From 39656f7d9aeb722f3bd480b0ed6b196faaa848c1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 1 Jul 2026 21:25:26 +0200 Subject: [PATCH] Add `--poll` option to `@tailwindcss/cli` (#20297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR re-adds the `--poll` option to the `@tailwindcss/cli` that we had in Tailwind CSS v3, but didn't in Tailwind CSS v4. In Tailwind CSS v4, we started using `@parcel/watcher` instead of chokidar for our watcher in the CLI. However, this currently doesn't support a `--poll` option. This PR implements our own `--poll` option such that you can use it in environments where fs events don't work properly (e.g. Docker). Polling can be enabled by using `--watch --poll`, in this case we will poll every `250ms` (I'm open for a different default value). You can also pick your own interval by using `--watch --poll 500` which is defined in milliseconds. The `--poll` option will be less efficient than a normal `--watch`. But if you are in a situation where you can't use `--watch` on its own then this is a good fallback. One thing you can do today is run the build command manually. If you do have some tooling that _does_ work on your machine (such as [`watchexec`](https://github.com/watchexec/watchexec)) then you can automatically perform a full build. The biggest downside of this approach is that you are doing a full build every time, instead of an incremental build. With this PR, we try to fix that by still allowing incremental builds. This should result in the same behavior as the normal `--watch` function: 1. First run, will trigger a full build 1. When any of the source files changes: - If all classes were already known, then it will be a no-op, but you will see a log in the terminal about it. - If a new class is detected, then the CSS will be updated, but it will be much more efficient than a full rebuild 1. When the input CSS file changes, or any of its dependencies, then a full rebuild will be triggered (such that your new `@utility` are available, and `@theme` values are updated). The implementation is a little bit more complex just because I didn't want to spam the terminal output even if we are polling every `250ms`. In the Oxide scanner we do track the modified times of each file. Every `250ms` we traverse the file system and skip the files that we know didn't change (since the mtime is the same). If the file was touched, then we will parse it again to extract possible Tailwind CSS classes. We will also track which files were scanned such that we can know whether we have to trigger a full-rebuild or not (in case the input.css file or any of its dependencies was changed). Fixes: #18109 Fixes: #18540 Fixes: #15750 ## Test plan 1. Existing tests pass 2. An integration test has been added for the `--poll` option 3. Tested it on the tailwindcss.com codebase: image Annotated: ``` ≈ tailwindcss v4.3.2 Done in 105ms Initial build Done in 3ms Saved a file that resulted in a no-op Done in 2ms Saved a file that resulted in a no-op Done in 3ms Saved a file that resulted in a no-op Done in 53ms Saved a file with a new class Done in 2ms Saved a file that resulted in a no-op Done in 2ms Saved a file that resulted in a no-op Done in 3ms Saved a file that resulted in a no-op Done in 88ms Saved a the input.css file Done in 3ms Saved a file that resulted in a no-op Polling for changes… ``` We check the file system every `250ms` by default, but we won't log to prevent spamming the terminal. --- CHANGELOG.md | 4 +- crates/node/src/lib.rs | 5 + crates/oxide/src/scanner/mod.rs | 63 ++-- crates/oxide/tests/scanner.rs | 59 ++++ integrations/cli/index.test.ts | 34 +++ .../src/commands/build/index.ts | 274 ++++++++++++++++-- packages/@tailwindcss-cli/src/index.ts | 2 +- .../@tailwindcss-cli/src/utils/args.test.ts | 31 ++ 8 files changed, 418 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe55aa6c..aa9356494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297)) ## [4.3.2] - 2026-06-26 diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 11ff25b0a..fb32a818c 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -165,6 +165,11 @@ impl Scanner { self.scanner.get_files() } + #[napi(getter)] + pub fn scanned_files(&self) -> Vec { + self.scanner.get_scanned_files() + } + #[napi(getter)] pub fn globs(&mut self) -> Vec { self diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 02830555d..3d89fbaf6 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -78,11 +78,12 @@ pub struct Scanner { /// Track unique set of candidates candidates: FxHashSet, - /// Track mtimes for files so re-scans can skip unchanged files. - /// Only populated after the first scan completes (to avoid unnecessary - /// metadata calls on initial build). + /// Track mtimes for files so incremental scans can skip unchanged files. mtimes: FxHashMap, + /// Files that were scanned during the last `scan()` call. + scanned_files: Vec, + /// Whether we've completed at least one full scan. When false, we skip /// mtime tracking entirely so the initial build stays fast. has_scanned_once: bool, @@ -122,9 +123,10 @@ impl Scanner { pub fn scan(&mut self) -> Vec { self.sources_scanned = false; - let (scanned_blobs, css_files) = self.discover_sources(); + let (scanned_blobs, css_files, files) = self.discover_sources(); self.extract_candidates(scanned_blobs, css_files); + self.scanned_files = files; // Return all candidates sorted let mut result = self.candidates.iter().cloned().collect::>(); @@ -256,6 +258,11 @@ impl Scanner { .collect() } + #[tracing::instrument(skip_all)] + pub fn get_scanned_files(&self) -> Vec { + self.scanned_files.clone() + } + #[tracing::instrument(skip_all)] pub fn get_globs(&mut self) -> Vec { if let Some(globs) = &self.globs { @@ -353,14 +360,14 @@ impl Scanner { } #[tracing::instrument(skip_all)] - fn discover_sources(&mut self) -> (Vec>, Vec) { + fn discover_sources(&mut self) -> (Vec>, Vec, Vec) { if self.sources_scanned { - return (vec![], vec![]); + return (vec![], vec![], vec![]); } self.sources_scanned = true; let Some(walker) = &mut self.walker else { - return (vec![], vec![]); + return (vec![], vec![], vec![]); }; // Use synchronous walk for the initial build (lower overhead) and parallel @@ -372,7 +379,8 @@ impl Scanner { }; let mut css_files: Vec = vec![]; - let mut content_paths: Vec<(PathBuf, String)> = Vec::new(); + let mut content_paths: Vec<(PathBuf, String)> = vec![]; + let mut changed_files = vec![]; // Fresh state self.files.clear(); @@ -380,7 +388,7 @@ impl Scanner { self.extensions.clear(); self.globs = None; - for (path, is_dir, extension) in all_entries { + for (path, is_dir, extension, mtime) in all_entries { if is_dir { self.dirs.insert(path); } else { @@ -390,13 +398,10 @@ impl Scanner { } self.extensions.insert(extension.clone()); - // On re-scans, check mtime to skip unchanged files. - // On the first scan we skip this entirely to avoid extra - // metadata syscalls. + // On incremental scans, check mtime to skip unchanged files. + // On the first scan, track mtimes while still scanning every file. let changed = if self.has_scanned_once { - let current_mtime = path.metadata().ok().and_then(|m| m.modified().ok()); - - match current_mtime { + match mtime { Some(mtime) => { let prev = self.mtimes.insert(path.clone(), mtime); prev.is_none_or(|prev| prev != mtime) @@ -404,6 +409,10 @@ impl Scanner { None => true, } } else { + if let Some(mtime) = mtime { + self.mtimes.insert(path.clone(), mtime); + } + true }; @@ -411,6 +420,10 @@ impl Scanner { continue; } + if let Ok(file) = path.clone().into_os_string().into_string() { + changed_files.push(file); + } + match extension.as_str() { // Special handing for CSS files, we don't want to extract candidates from // these files, but we do want to extract used CSS variables. @@ -442,7 +455,9 @@ impl Scanner { self.has_scanned_once = true; } - (scanned_blobs, css_files) + changed_files.par_sort_unstable(); + + (scanned_blobs, css_files, changed_files) } } @@ -547,27 +562,28 @@ where .collect() } -type WalkEntry = (PathBuf, bool, String); +type WalkEntry = (PathBuf, bool, String, Option); /// Walk the file system synchronously. Used for the initial build where the overhead of spawning /// parallel walker threads is not worth it. #[tracing::instrument(skip_all)] fn walk_synchronous(walker: &mut WalkBuilder) -> Vec { - let mut entries = Vec::new(); + let mut entries = vec![]; for entry in walker.build().filter_map(Result::ok) { let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let path = entry.into_path(); if is_dir { - entries.push((path, true, String::new())); + entries.push((path, true, String::new(), None)); } else { let ext = path .extension() .and_then(|x| x.to_str()) .unwrap_or_default() .to_owned(); - entries.push((path, false, ext)); + let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); + entries.push((path, false, ext, mtime)); } } @@ -591,7 +607,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { } } - let collected: Arc>> = Arc::new(Mutex::new(Vec::new())); + let collected: Arc>> = Arc::new(Mutex::new(vec![])); walker.build_parallel().run(|| { let mut buf = FlushOnDrop { @@ -608,14 +624,15 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { let path = entry.into_path(); if is_dir { - buf.local.push((path, true, String::new())); + buf.local.push((path, true, String::new(), None)); } else { let ext = path .extension() .and_then(|x| x.to_str()) .unwrap_or_default() .to_owned(); - buf.local.push((path, false, ext)); + let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); + buf.local.push((path, false, ext, mtime)); } if buf.local.len() >= 256 { diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 1139196e7..726ce5921 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -106,6 +106,18 @@ mod scanner { globs } + fn normalize_files(files: Vec, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut files = files + .iter() + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + files.sort(); + files + } + fn scan_with_globs( paths_with_content: &[(&str, &str)], source_directives: Vec<&str>, @@ -1045,6 +1057,53 @@ mod scanner { assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*"))); } + #[test] + fn it_should_track_files_scanned_by_the_last_scan() { + let dir = tempdir().unwrap().into_path(); + + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep.html", "content-['src/keep.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + assert_eq!( + scanner.scan(), + vec!["content-['src/index.html']", "content-['src/keep.html']"] + ); + + assert_eq!( + scanner.scan(), + vec!["content-['src/index.html']", "content-['src/keep.html']"] + ); + assert_eq!(scanner.get_scanned_files(), Vec::::new()); + + sleep(Duration::from_millis(10)); + fs::write(dir.join("src/index.html"), "content-['src/changed.html']").unwrap(); + + assert_eq!( + scanner.scan(), + vec![ + "content-['src/changed.html']", + "content-['src/index.html']", + "content-['src/keep.html']", + ] + ); + assert_eq!( + normalize_files(scanner.get_scanned_files(), &dir), + vec!["src/index.html"] + ); + } + #[test] fn it_should_ignore_negated_custom_sources() { let ScanResult { diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index ba11d546a..e742662be 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -375,6 +375,40 @@ describe.each([ }, ) + test( + 'watch mode with polling', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'src/index.css': css`@import 'tailwindcss/utilities';`, + 'src/index.html': html` +
+ `, + }, + }, + async ({ fs, spawn }) => { + let process = await spawn( + `${command} --input src/index.css --output dist/out.css --watch --poll=50`, + ) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [candidate`underline`]) + + await fs.write('src/index.html', html` +
+ `) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + }, + ) + test( "watch mode with unknown @source paths shouldn't crash on Windows", { diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 8ea5448a8..c06d02c50 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -26,6 +26,7 @@ import { drainStdin, outputFile } from './utils' const css = String.raw const DEBUG = env.DEBUG +const DEFAULT_POLL_INTERVAL_MS = 250 export function options() { return { @@ -47,6 +48,12 @@ export function options() { alias: '-w', values: ['always'], }, + '--poll': { + type: 'boolean | number', + description: 'Use polling instead of filesystem events when watching', + default: false, + values: ['ms'], + }, '--minify': { type: 'boolean', description: 'Optimize and minify the output', @@ -129,7 +136,17 @@ export async function handle(args: Result>) { // If the user passes `{bin} build --map -` then this likely means they want to output the map inline // this is the default behavior of `{bin build} --map` to inform the user of that if (args['--map'] === '-') { - eprintln(`Use --map without a value to inline the source map`) + eprintln(`Use --map without a value to inline the source map.`) + process.exit(1) + } + + if (args['--poll'] === undefined) { + eprintln(`Use --poll with a non-zero value in milliseconds.`) + process.exit(1) + } + let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll'] + if (pollInterval !== false && pollInterval <= 0) { + eprintln(`Specified polling interval must be a positive number.`) process.exit(1) } @@ -152,6 +169,7 @@ export async function handle(args: Result>) { let previous = { css: '', optimizedCss: '', + output: null as string | null, } async function write( @@ -208,13 +226,18 @@ export async function handle(args: Result>) { } } + let didChange = output !== previous.output + DEBUG && I.start('Write output') if (args['--output'] && args['--output'] !== '-') { await outputFile(args['--output'], output) - } else { + } else if (didChange) { println(output) } DEBUG && I.end('Write output') + + previous.output = output + return didChange } let inputFilePath = @@ -275,10 +298,10 @@ export async function handle(args: Result>) { } let [compiler, scanner] = await handleError(() => createCompiler(input, I)) + let cleanupWatchers: (() => Promise)[] = [] // Watch for changes - if (args['--watch']) { - let cleanupWatchers: (() => Promise)[] = [] + if (args['--watch'] && pollInterval === false) { cleanupWatchers.push( await createWatchers(await watchDirectories(scanner), async function handle(files) { try { @@ -292,36 +315,15 @@ export async function handle(args: Result>) { // Re-compile the input let start = process.hrtime.bigint() - let changedFiles: ChangedContent[] = [] - let rebuildStrategy: 'incremental' | 'full' = 'incremental' - let resolvedFullRebuildPaths = fullRebuildPaths - - for (let file of files) { - // If one of the changed files is related to the input CSS or JS - // config/plugin files, then we need to do a full rebuild because - // the theme might have changed. - if (resolvedFullRebuildPaths.includes(file)) { - rebuildStrategy = 'full' - - // No need to check the rest of the events, because we already know we - // need to do a full rebuild. - break - } - - // Track new and updated files for incremental rebuilds. - changedFiles.push({ - file, - extension: path.extname(file).slice(1), - } satisfies ChangedContent) - } + let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths) // Track the compiled CSS let compiledCss = '' let compiledMap: SourceMap | null = null // Scan the entire `base` directory for full rebuilds. - if (rebuildStrategy === 'full') { + if (rebuildStrategy.kind === 'full') { // Read the new `input`. let input = args['--input'] ? args['--input'] === '-' @@ -378,9 +380,9 @@ export async function handle(args: Result>) { } // Scan changed files only for incremental rebuilds. - else if (rebuildStrategy === 'incremental') { + else if (rebuildStrategy.kind === 'incremental') { DEBUG && I.start('Scan for candidates') - let newCandidates = scanner.scanFiles(changedFiles) + let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles) DEBUG && I.end('Scan for candidates') // No new candidates found which means we don't need to write to @@ -490,6 +492,170 @@ export async function handle(args: Result>) { let end = process.hrtime.bigint() if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) + + // Watch for changes, using polling + if (args['--watch'] && pollInterval !== false) { + let cleanupPollingIndicator = () => {} + + function logPollingMessage(message: string) { + if (!args['--silent']) { + process.stderr.write('\r\x1B[2K') + } + + eprintln(message) + + if (!args['--silent']) { + process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`) + } + } + + async function fullRebuild(I: Instrumentation) { + clearRequireCache(fullRebuildPaths) + backupRebuildPaths = fullRebuildPaths.slice() + fullRebuildPaths = inputFilePath ? [inputFilePath] : [] + + let input = args['--input'] + ? args['--input'] === '-' + ? await drainStdin() + : await fs.readFile(args['--input'], 'utf-8') + : css` + @import 'tailwindcss'; + ` + + ;[compiler, scanner] = await createCompiler(input, I) + backupRebuildPaths = fullRebuildPaths.slice() + + DEBUG && I.start('Scan for candidates') + let candidates = scanner.scan() + DEBUG && I.end('Scan for candidates') + + DEBUG && I.start('Build CSS') + let output = compiler.build(candidates) + DEBUG && I.end('Build CSS') + + let map: SourceMap | null = null + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + map = toSourceMap(compiler.buildSourceMap()) + DEBUG && I.end('Build Source Map') + } + + await write(output, map, args, I) + } + + if (!args['--silent']) { + let restored = false + + function restoreCursor() { + if (restored) return + restored = true + process.stderr.write('\r\x1B[2K\x1B[?25h') + process.off('exit', restoreCursor) + process.off('SIGINT', onSigint) + process.off('SIGTERM', onSigterm) + } + + function onSigint() { + restoreCursor() + process.exit(130) + } + + function onSigterm() { + restoreCursor() + process.exit(143) + } + + process.stderr.write('\x1B[?25l') + process.on('exit', restoreCursor) + process.on('SIGINT', onSigint) + process.on('SIGTERM', onSigterm) + + cleanupPollingIndicator = restoreCursor + cleanupWatchers.push(async () => cleanupPollingIndicator()) + } + + cleanupWatchers.push( + createPollingWatcher(async () => { + using I = new Instrumentation() + + DEBUG && I.start('Scan for candidates') + let candidates = scanner.scan() + DEBUG && I.end('Scan for candidates') + + let files = scanner.scannedFiles.filter( + (file) => file !== args['--output'] && file !== args['--map'], + ) + + if (files.length <= 0) return + + let start = process.hrtime.bigint() + + let strategy = getRebuildStrategy(files, fullRebuildPaths) + + if (strategy.kind === 'full') { + try { + await fullRebuild(I) + } catch (err) { + fullRebuildPaths = backupRebuildPaths + + let message = [red('Error:'), dim('\u250C')] + .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`)) + .concat(dim('\u2514')) + .join('\n') + + logPollingMessage(message) + } + + if (!args['--silent']) { + logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) + } + return + } + + // No candidates found which means we don't need to write to disk, and + // can return early. + if (candidates.length <= 0) { + if (!args['--silent']) { + logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) + } + return + } + + DEBUG && I.start('Build CSS') + let output = compiler.build(candidates) + DEBUG && I.end('Build CSS') + + let map: SourceMap | null = null + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + map = toSourceMap(compiler.buildSourceMap()) + DEBUG && I.end('Build Source Map') + } + + await write(output, map, args, I) + + if (!args['--silent']) { + logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) + } + }, pollInterval), + ) + + if (args['--watch'] !== 'always') { + process.stdin.on('end', () => { + Promise.all(cleanupWatchers.map((fn) => fn())).then( + () => process.exit(0), + () => process.exit(1), + ) + }) + } + + process.stdin.resume() + if (!args['--silent']) { + process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`) + } + } } async function createWatchers(dirs: string[], cb: (files: string[]) => void) { @@ -594,6 +760,56 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { } } +function getRebuildStrategy( + files: string[], + fullRebuildPaths: string[], +): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } { + let changedFiles: ChangedContent[] = [] + + for (let file of files) { + // If one of the changed files is related to the input CSS or JS + // config/plugin files, then we need to do a full rebuild because + // the theme might have changed. + if (fullRebuildPaths.includes(file)) { + return { kind: 'full' } + } + + // Track new and updated files for incremental rebuilds. + changedFiles.push({ + file, + extension: path.extname(file).slice(1), + } satisfies ChangedContent) + } + + return { kind: 'incremental', changedFiles } +} + +function createPollingWatcher(cb: () => Promise, pollInterval: number) { + let disposed = false + let timer: ReturnType | null = null + + async function poll() { + if (disposed) return + + try { + await cb() + } catch (err) { + console.error(err) + } finally { + if (!disposed) { + timer = setTimeout(poll, pollInterval) + } + } + } + + timer = setTimeout(poll, pollInterval) + + return async () => { + disposed = true + if (timer) clearTimeout(timer) + } +} + async function watchDirectories(scanner: Scanner) { let directories = ( await Promise.all( diff --git a/packages/@tailwindcss-cli/src/index.ts b/packages/@tailwindcss-cli/src/index.ts index 4b308e694..1543f7d13 100644 --- a/packages/@tailwindcss-cli/src/index.ts +++ b/packages/@tailwindcss-cli/src/index.ts @@ -11,7 +11,7 @@ const sharedOptions = { } satisfies Arg function buildUsage(command = 'tailwindcss') { - return `${command} [--input input.css] [--output output.css] [--watch] [options…]` + return `${command} [--input input.css] [--output output.css] [--watch] [--poll=ms] [options…]` } function rootHelp({ invalid }: { invalid?: string } = {}) { diff --git a/packages/@tailwindcss-cli/src/utils/args.test.ts b/packages/@tailwindcss-cli/src/utils/args.test.ts index 4ac2e8778..249fb23eb 100644 --- a/packages/@tailwindcss-cli/src/utils/args.test.ts +++ b/packages/@tailwindcss-cli/src/utils/args.test.ts @@ -153,3 +153,34 @@ it('should be possible to provide multiple types, and convert the value to that } `) }) + +it('should be possible to provide boolean or number arguments', () => { + let options = { + '--poll': { + type: 'boolean | number', + description: 'Use polling instead of filesystem events when watching', + default: false, + }, + } satisfies Arg + + expect(args(options, ['--poll'])).toMatchInlineSnapshot(` + { + "--poll": true, + "_": [], + } + `) + + expect(args(options, ['--poll=50'])).toMatchInlineSnapshot(` + { + "--poll": 50, + "_": [], + } + `) + + expect(args(options, ['--poll=foo'])).toMatchInlineSnapshot(` + { + "--poll": undefined, + "_": [], + } + `) +})