diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec6e07992..09e99f902 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic
Versioning](https://semver.org/spec/v2.0.0.html).
-
+## Unreleased
+
+### Added
+
+- Add support for forcing the use of filesystem polling instead of OS events in watch mode. Set the environment variable `WIREIT_WATCH_STRATEGY=poll`, and optionally the `WIREIT_WATCH_POLL_MS` (default `500`).
## [0.14.8] - 2024-08-22
diff --git a/README.md b/README.md
index ff4489927..3b1d6a2e7 100644
--- a/README.md
+++ b/README.md
@@ -447,6 +447,11 @@ The benefit of Wireit's watch mode over the built-in watch modes of Node and oth
simultaneously, such as build steps being triggered before all preceding steps
have finished.
+By default, watch mode uses whichever filesystem change API is available on your
+OS. This behavior can be changed with the `WIREIT_WATCH_STRATEGY` and
+`WIREIT_WATCH_POLL_MS` environment variables (see
+[below](#environment-variable-reference)).
+
## Environment variables
Use the `env` setting to either directly set environment variables, or to
@@ -874,12 +879,14 @@ The following environment variables affect the behavior of Wireit:
| Variable | Description |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `WIREIT_CACHE` | [Caching mode](#caching). Defaults to `local` unless `CI` is `true`, in which case defaults to `none`. Automatically set to `github` by the [`google/wireit@setup-github-actions-caching/v2`](#github-actions-caching) action. Options:
[`local`](#local-caching): Cache to local disk. [`github`](#github-actions-caching): Cache to GitHub Actions. `none`: Disable caching. |
| `WIREIT_FAILURES` | [How to handle script failures](#failures-and-errors). Options:[`no-new`](#failures-and-errors) (default): Allow running scripts to finish, but don't start new ones. [`continue`](#continue): Allow running scripts to continue, and start new ones unless any of their dependencies failed. [`kill`](#kill): Immediately kill running scripts, and don't start new ones. |
+| `WIREIT_LOGGER` | How to present progress and results on the command line. Options:`quiet` (default): writes a single dynamically updating line summarizing progress. Only passes along stdout and stderr from commands if there's a failure, or if the command is a service. The planned new default, please try it out. `simple` (default): A verbose logger that presents clear information about the work that Wireit is doing. `metrics`: Like `simple`, but also presents a summary table of results once a command is finished. `quiet-ci` (default when env.CI or !stdout.isTTY): like `quiet` but optimized for non-interactive environments, like GitHub Actions runners. |
+| `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits. |
| `WIREIT_PARALLEL` | [Maximum number of scripts to run at one time](#parallelism). Defaults to 2×logical CPU cores. Must be a positive integer or `infinity`. |
-| `WIREIT_CACHE` | [Caching mode](#caching). Defaults to `local` unless `CI` is `true`, in which case defaults to `none`. Automatically set to `github` by the [`google/wireit@setup-github-actions-caching/v2`](#github-actions-caching) action. Options:[`local`](#local-caching): Cache to local disk. [`github`](#github-actions-caching): Cache to GitHub Actions. `none`: Disable caching. |
+| `WIREIT_WATCH_STRATEGY` | How Wireit determines when a file has changed which warrants a new watch iteration. Options:`event` (default): Register OS file system watcher callbacks (using [chokidar](https://github.com/paulmillr/chokidar)). `poll`: Poll the filesystem every `WIREIT_WATCH_POLL_MS` milliseconds. Less responsive and worse performance than `event`, but a good fallback for when `event` does not work well or at all (e.g. filesystems that don't support filesystem events, or performance and memory problems with large file trees). |
+| `WIREIT_WATCH_POLL_MS` | When `WIREIT_WATCH_STRATEGY` is `poll`, how many milliseconds to wait between each filesystem poll. Defaults to `500`. |
| `CI` | Affects the default value of `WIREIT_CACHE`. Automatically set to `true` by [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables) and most other CI (continuous integration) services. Must be exactly `true`. If unset or any other value, interpreted as `false`. |
-| `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits. |
-| `WIREIT_LOGGER` | How to present progress and results on the command line. Options:`quiet` (default): writes a single dynamically updating line summarizing progress. Only passes along stdout and stderr from commands if there's a failure, or if the command is a service. The planned new default, please try it out. `simple` (default): A verbose logger that presents clear information about the work that Wireit is doing. `metrics`: Like `simple`, but also presents a summary table of results once a command is finished. `quiet-ci` (default when env.CI or !stdout.isTTY): like `quiet` but optimized for non-interactive environments, like GitHub Actions runners. |
### Glob patterns
diff --git a/src/cli-options.ts b/src/cli-options.ts
index 0d3f7b1a3..2181783df 100644
--- a/src/cli-options.ts
+++ b/src/cli-options.ts
@@ -50,7 +50,7 @@ export type Agent = 'npm' | 'nodeRun' | 'pnpm' | 'yarnClassic' | 'yarnBerry';
export interface Options {
script: ScriptReference;
- watch: boolean;
+ watch: false | {strategy: 'event'} | {strategy: 'poll'; interval: number};
extraArgs: string[] | undefined;
numWorkers: number;
cache: 'local' | 'github' | 'none';
@@ -277,7 +277,10 @@ function getArgvOptions(
// npm 8.11.0
// - Like npm 6, except there is no "npm_config_argv" environment variable.
return {
- watch: process.env['npm_config_watch'] !== undefined,
+ watch:
+ process.env['npm_config_watch'] !== undefined
+ ? readWatchConfigFromEnv()
+ : false,
extraArgs: process.argv.slice(2),
};
}
@@ -421,7 +424,7 @@ function findRemainingArgsFromNpmConfigArgv(
function parseRemainingArgs(
args: string[],
): Pick {
- let watch = false;
+ let watch: Options['watch'] = false;
let extraArgs: string[] = [];
const unrecognized = [];
for (let i = 0; i < args.length; i++) {
@@ -430,7 +433,7 @@ function parseRemainingArgs(
extraArgs = args.slice(i + 1);
break;
} else if (arg === '--watch') {
- watch = true;
+ watch = readWatchConfigFromEnv();
} else {
unrecognized.push(arg);
}
@@ -448,3 +451,47 @@ function parseRemainingArgs(
extraArgs,
};
}
+
+const DEFAULT_WATCH_STRATEGY = {strategy: 'event'} as const;
+const DEFAULT_WATCH_INTERVAL = 500;
+
+/**
+ * Interpret the WIREIT_WATCH_* environment variables.
+ */
+function readWatchConfigFromEnv(): Options['watch'] {
+ switch (process.env['WIREIT_WATCH_STRATEGY']) {
+ case 'event':
+ case '':
+ case undefined: {
+ return DEFAULT_WATCH_STRATEGY;
+ }
+ case 'poll': {
+ let interval = DEFAULT_WATCH_INTERVAL;
+ const intervalStr = process.env['WIREIT_WATCH_POLL_MS'];
+ if (intervalStr) {
+ const parsed = Number(intervalStr);
+ if (Number.isNaN(parsed) || parsed <= 0) {
+ console.error(
+ `⚠️ Expected WIREIT_WATCH_POLL_MS to be a positive integer, ` +
+ `got ${JSON.stringify(intervalStr)}. Using default interval of ` +
+ `${DEFAULT_WATCH_INTERVAL}ms.`,
+ );
+ } else {
+ interval = parsed;
+ }
+ }
+ return {
+ strategy: 'poll',
+ interval,
+ };
+ }
+ default: {
+ console.error(
+ `⚠️ Unrecognized WIREIT_WATCH_STRATEGY: ` +
+ `${JSON.stringify(process.env['WIREIT_WATCH_STRATEGY'])}. ` +
+ `Using default strategy of ${DEFAULT_WATCH_STRATEGY.strategy}.`,
+ );
+ return DEFAULT_WATCH_STRATEGY;
+ }
+ }
+}
diff --git a/src/cli.ts b/src/cli.ts
index 44ccb36a9..c7e49ac95 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {Result} from './error.js';
import {Analyzer} from './analyzer.js';
-import {Executor} from './executor.js';
-import {WorkerPool} from './util/worker-pool.js';
-import {unreachable} from './util/unreachable.js';
+import {getOptions, Options, packageDir} from './cli-options.js';
+import {Result} from './error.js';
import {Failure} from './event.js';
-import {packageDir, getOptions, Options} from './cli-options.js';
+import {Executor} from './executor.js';
import {DefaultLogger} from './logging/default-logger.js';
import {Console} from './logging/logger.js';
+import {unreachable} from './util/unreachable.js';
+import {WorkerPool} from './util/worker-pool.js';
const run = async (options: Options): Promise> => {
using logger = options.logger;
@@ -66,6 +66,7 @@ const run = async (options: Options): Promise> => {
cache,
options.failureMode,
options.agent,
+ options.watch,
);
process.on('SIGINT', () => {
watcher.abort();
diff --git a/src/test/cli-options.test.ts b/src/test/cli-options.test.ts
index eb4e95451..7e0fe90cd 100644
--- a/src/test/cli-options.test.ts
+++ b/src/test/cli-options.test.ts
@@ -161,7 +161,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
packageDir: rig.temp,
name: 'main',
},
- watch: true,
+ watch: {strategy: 'event'},
});
}),
);
@@ -179,7 +179,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
name: 'main',
},
extraArgs: ['--extra'],
- watch: true,
+ watch: {strategy: 'event'},
},
);
}),
@@ -199,7 +199,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
name: 'other',
},
extraArgs: [],
- watch: true,
+ watch: {strategy: 'event'},
},
undefined,
{
@@ -269,7 +269,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
name: 'other',
},
extraArgs: ['--extra'],
- watch: true,
+ watch: {strategy: 'event'},
},
undefined,
{
@@ -318,7 +318,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
packageDir: rig.temp,
name: 'test',
},
- watch: true,
+ watch: {strategy: 'event'},
});
}),
);
@@ -334,7 +334,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
name: 'test',
},
extraArgs: ['--extra'],
- watch: true,
+ watch: {strategy: 'event'},
});
}),
);
@@ -377,7 +377,7 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
packageDir: rig.temp,
name: 'start',
},
- watch: true,
+ watch: {strategy: 'event'},
});
}),
);
@@ -392,11 +392,87 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
name: 'start',
},
extraArgs: ['--extra'],
- watch: true,
+ watch: {strategy: 'event'},
});
}),
);
}
+
+ test(
+ `${agent} --watch WIREIT_WATCH_STRATEGY=poll`,
+ rigTest(async ({rig}) => {
+ await assertOptions(
+ rig,
+ `${runCmd} main ${extraDashes} --watch`,
+ {
+ agent,
+ script: {
+ packageDir: rig.temp,
+ name: 'main',
+ },
+ logger: 'QuietLogger',
+ watch: {
+ strategy: 'poll',
+ interval: 500,
+ },
+ },
+ {
+ WIREIT_WATCH_STRATEGY: 'poll',
+ },
+ );
+ }),
+ );
+
+ test(
+ `${agent} --watch WIREIT_WATCH_STRATEGY=poll WIREIT_WATCH_POLL_MS=74`,
+ rigTest(async ({rig}) => {
+ await assertOptions(
+ rig,
+ `${runCmd} main ${extraDashes} --watch`,
+ {
+ agent,
+ script: {
+ packageDir: rig.temp,
+ name: 'main',
+ },
+ logger: 'QuietLogger',
+ watch: {
+ strategy: 'poll',
+ interval: 74,
+ },
+ },
+ {
+ WIREIT_WATCH_STRATEGY: 'poll',
+ WIREIT_WATCH_POLL_MS: '74',
+ },
+ );
+ }),
+ );
+
+ test(
+ `${agent} WIREIT_WATCH_STRATEGY=poll WIREIT_WATCH_POLL_MS=74`,
+ rigTest(async ({rig}) => {
+ await assertOptions(
+ rig,
+ `${runCmd} main ${extraDashes}`,
+ {
+ agent,
+ script: {
+ packageDir: rig.temp,
+ name: 'main',
+ },
+ logger: 'QuietLogger',
+ // This is just testing that the WIREIT_WATCH environment variables
+ // don't actually turn on watch mode. Only the --watch flag does that.
+ watch: false,
+ },
+ {
+ WIREIT_WATCH_STRATEGY: 'poll',
+ WIREIT_WATCH_POLL_MS: '74',
+ },
+ );
+ }),
+ );
}
test.run();
diff --git a/src/test/glob.test.ts b/src/test/glob.test.ts
index 7afd3f9bb..d3b1d8b95 100644
--- a/src/test/glob.test.ts
+++ b/src/test/glob.test.ts
@@ -5,12 +5,12 @@
*/
import * as pathlib from 'path';
-import * as assert from 'uvu/assert';
import {suite} from 'uvu';
+import * as assert from 'uvu/assert';
import {glob} from '../util/glob.js';
-import {FilesystemTestRig} from './util/filesystem-test-rig.js';
-import {makeWatcher} from '../watcher.js';
import {IS_WINDOWS} from '../util/windows.js';
+import {makeWatcher} from '../watcher.js';
+import {FilesystemTestRig} from './util/filesystem-test-rig.js';
interface Symlink {
/** Where the symlink file points to. */
@@ -116,6 +116,7 @@ test.before.each(async (ctx) => {
// events to find out what chokidar has found (we usually only care
// about changes, not initial files).
false,
+ {strategy: 'event'},
);
const watcher = fsWatcher.watcher;
watcher.on('add', (path) => {
diff --git a/src/test/watch.test.ts b/src/test/watch.test.ts
index 5e675d94d..c29cf37b9 100644
--- a/src/test/watch.test.ts
+++ b/src/test/watch.test.ts
@@ -7,166 +7,234 @@
import {suite} from 'uvu';
import * as assert from 'uvu/assert';
import {rigTest} from './util/rig-test.js';
-
-const test = suite();
-
-test(
- 'runs initially and waits for SIGINT',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
+import type {WireitTestRig} from './util/test-rig.js';
+
+tests('WIREIT_WATCH_STRATEGY=');
+
+tests('WIREIT_WATCH_STRATEGY=poll', (rig: WireitTestRig) => {
+ rig.env.WIREIT_WATCH_STRATEGY = 'poll';
+ // Default is 500, let's speed up the tests.
+ rig.env.WIREIT_WATCH_POLL_MS = '50';
+});
+
+function tests(
+ suiteName: string,
+ // TODO(aomarks) There should be a better way to prepare a rig without having
+ // to remember to call it in every test case. We should refactor to use the
+ // node test runner, maybe can do this as part of that.
+ prepareRig: (rig: WireitTestRig) => void | Promise = () => {},
+) {
+ const test = suite(suiteName);
+
+ test(
+ 'runs initially and waits for SIGINT',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
},
- },
- },
- });
-
- // Initial execution.
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
-
- // It's important in these test cases that after we tell a script process to
- // exit, we wait for its socket to close, indicating that it received the
- // message and has exited (or is in the process of exiting). Otherwise, when
- // we then send a kill signal to the parent Wireit process, the Wireit
- // process might kill the script child process before our message has been
- // transferred, which will raise an uncaught ECONNRESET error in these
- // tests.
- //
- // TODO(aomarks) Waiting for the socket write callback seems like it should
- // be sufficient to prevent this error, but it isn't. Investigate why that
- // is, and consider instead sending explicit ACK messages back from the
- // child process.
- await inv.closed;
-
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- // Wait a while to check that the Wireit process remains running, waiting
- // for file changes or a signal.
- await new Promise((resolve) => setTimeout(resolve, 100));
- assert.ok(exec.running);
-
- // Should exit after a SIGINT signal (i.e. Ctrl-C).
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 1);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'runs again when input file changes after execution',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['input.txt'],
+ wireit: {
+ a: {
+ command: cmdA.command,
+ },
},
},
- },
- 'input.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ });
- // Initial run.
- {
+ // Initial execution.
+ const exec = rig.exec('npm run a --watch');
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
const inv = await cmdA.nextInvocation();
inv.exit(0);
+
+ // It's important in these test cases that after we tell a script process to
+ // exit, we wait for its socket to close, indicating that it received the
+ // message and has exited (or is in the process of exiting). Otherwise, when
+ // we then send a kill signal to the parent Wireit process, the Wireit
+ // process might kill the script child process before our message has been
+ // transferred, which will raise an uncaught ECONNRESET error in these
+ // tests.
+ //
+ // TODO(aomarks) Waiting for the socket write callback seems like it should
+ // be sufficient to prevent this error, but it isn't. Investigate why that
+ // is, and consider instead sending explicit ACK messages back from the
+ // child process.
+ await inv.closed;
+
await exec.waitForLog(/Ran 1 script and skipped 0/);
- }
+ // Wait a while to check that the Wireit process remains running, waiting
+ // for file changes or a signal.
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ assert.ok(exec.running);
- // Changing an input file should cause another run.
- {
+ // Should exit after a SIGINT signal (i.e. Ctrl-C).
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 1);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'runs again when input file changes after execution',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
await rig.writeAtomic({
- 'input.txt': 'v1',
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['input.txt'],
+ },
+ },
+ },
+ 'input.txt': 'v0',
});
+
+ const exec = rig.exec('npm run a --watch');
await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- await inv.closed;
- }
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'runs again when new input file created',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['input*.txt'],
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ }
+
+ // Changing an input file should cause another run.
+ {
+ await rig.writeAtomic({
+ 'input.txt': 'v1',
+ });
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'runs again when new input file created',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['input*.txt'],
+ },
},
},
- },
- 'input1.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ 'input1.txt': 'v0',
+ });
- // Initial run.
- {
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- }
+ const exec = rig.exec('npm run a --watch');
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- // Adding another input file should cause another run.
- {
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ }
+
+ // Adding another input file should cause another run.
+ {
+ await rig.writeAtomic({
+ 'input2.txt': 'v0',
+ });
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'runs again when input file deleted',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
await rig.writeAtomic({
- 'input2.txt': 'v0',
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['input'],
+ },
+ },
+ },
+ input: 'v0',
});
+
+ const exec = rig.exec('npm run a --watch');
await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- await inv.closed;
- }
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'runs again when input file deleted',
- rigTest(
- async ({rig}) => {
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ }
+
+ // Deleting the input file should cause another run.
+ {
+ await rig.delete('input');
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'runs again when input file changes in the middle of execution',
+ rigTest(async ({rig}) => {
+ await prepareRig(rig);
const cmdA = await rig.newCommand();
await rig.writeAtomic({
'package.json': {
@@ -176,26 +244,28 @@ test(
wireit: {
a: {
command: cmdA.command,
- files: ['input'],
+ files: ['input.txt'],
},
},
},
- input: 'v0',
+ 'input.txt': 'v0',
});
const exec = rig.exec('npm run a --watch');
await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
-
// Initial run.
{
const inv = await cmdA.nextInvocation();
+ // Change the input while the first invocation is still running.
+ await rig.writeAtomic({
+ 'input.txt': 'v1',
+ });
inv.exit(0);
await exec.waitForLog(/Ran 1 script and skipped 0/);
}
- // Deleting the input file should cause another run.
+ // Expect another invocation to have been queued up.
{
- await rig.delete('input');
await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
const inv = await cmdA.nextInvocation();
inv.exit(0);
@@ -206,90 +276,250 @@ test(
exec.kill();
await exec.exit;
assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'runs again when input file changes in the middle of execution',
- rigTest(async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['input.txt'],
+ }),
+ );
+
+ test(
+ 'reloads config when package.json changes and runs again',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA1 = await rig.newCommand();
+ const cmdA2 = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA1.command,
+ },
+ },
},
- },
+ });
+
+ const exec = rig.exec('npm run a --watch');
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ // Initial run.
+ {
+ const inv = await cmdA1.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ }
+
+ // Change the command of the script we are running by re-writing the
+ // package.json. That change should be detected, the new config should be
+ // analyzed, and the new command should run.
+ {
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA2.command,
+ },
+ },
+ },
+ });
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ const inv = await cmdA2.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA1.numInvocations, 1);
+ assert.equal(cmdA2.numInvocations, 1);
},
- 'input.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- // Initial run.
- {
- const inv = await cmdA.nextInvocation();
- // Change the input while the first invocation is still running.
- await rig.writeAtomic({
- 'input.txt': 'v1',
- });
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- }
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'changes are detected in same-package dependencies',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ const cmdB = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ b: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ dependencies: ['b'],
+ files: ['a.txt'],
+ output: [],
+ },
+ b: {
+ command: cmdB.command,
+ files: ['b.txt'],
+ output: [],
+ },
+ },
+ },
+ 'a.txt': 'v0',
+ 'b.txt': 'v0',
+ });
- // Expect another invocation to have been queued up.
- {
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- await inv.closed;
- }
-
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- }),
-);
-
-test(
- 'reloads config when package.json changes and runs again',
- rigTest(
- async ({rig}) => {
- const cmdA1 = await rig.newCommand();
- const cmdA2 = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
+ const exec = rig.exec('npm run a --watch');
+ await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] b/);
+
+ // Both scripts run initially.
+ {
+ const invB = await cmdB.nextInvocation();
+ invB.exit(0);
+ await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ const invA = await cmdA.nextInvocation();
+ invA.exit(0);
+ assert.equal(cmdA.numInvocations, 1);
+ assert.equal(cmdB.numInvocations, 1);
+ await exec.waitForLog(/Ran 2 scripts and skipped 0/);
+ }
+
+ // Changing an input of A should cause A to run again, but not B.
+ {
+ await rig.writeAtomic({
+ 'a.txt': 'v1',
+ });
+ await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ const invA = await cmdA.nextInvocation();
+ invA.exit(0);
+ assert.equal(cmdA.numInvocations, 2);
+ assert.equal(cmdB.numInvocations, 1);
+ await exec.waitForLog(/Ran 1 script and skipped 1/);
+ }
+
+ // Changing an input of B should cause both scripts to run.
+ {
+ await rig.writeAtomic({
+ 'b.txt': 'v1',
+ });
+ await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] b/);
+ const invB = await cmdB.nextInvocation();
+ invB.exit(0);
+ await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ const invA = await cmdA.nextInvocation();
+ invA.exit(0);
+ await invA.closed;
+ await invB.closed;
+ await exec.waitForLog(/Ran 2 scripts and skipped 0/);
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 3);
+ assert.equal(cmdB.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'changes are detected in cross-package dependencies',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ const cmdB = await rig.newCommand();
+ await rig.writeAtomic({
+ 'foo/package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ dependencies: ['../bar:b'],
+ files: ['a.txt'],
+ output: [],
+ },
+ },
},
- wireit: {
- a: {
- command: cmdA1.command,
+ 'foo/a.txt': 'v0',
+ 'bar/package.json': {
+ scripts: {
+ b: 'wireit',
+ },
+ wireit: {
+ b: {
+ command: cmdB.command,
+ files: ['b.txt'],
+ output: [],
+ },
},
},
- },
- });
+ 'bar/b.txt': 'v0',
+ });
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- // Initial run.
- {
- const inv = await cmdA1.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- }
+ const exec = rig.exec('npm run a --watch', {cwd: 'foo'});
+ await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] \.\.\/bar:b/);
- // Change the command of the script we are running by re-writing the
- // package.json. That change should be detected, the new config should be
- // analyzed, and the new command should run.
- {
+ // Both scripts run initially.
+ {
+ const invB = await cmdB.nextInvocation();
+ invB.exit(0);
+ await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ const invA = await cmdA.nextInvocation();
+ invA.exit(0);
+ assert.equal(cmdA.numInvocations, 1);
+ assert.equal(cmdB.numInvocations, 1);
+ await exec.waitForLog(/Ran 2 scripts and skipped 0/);
+ }
+
+ // Changing an input of A should cause A to run again, but not B.
+ {
+ await rig.writeAtomic({
+ 'foo/a.txt': 'v1',
+ });
+ await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ const invA = await cmdA.nextInvocation();
+ invA.exit(0);
+ assert.equal(cmdA.numInvocations, 2);
+ assert.equal(cmdB.numInvocations, 1);
+ await exec.waitForLog(/Ran 1 script and skipped 1/);
+ }
+
+ // Changing an input of B should cause both scripts to run.
+ {
+ await rig.writeAtomic({
+ 'bar/b.txt': 'v1',
+ });
+ await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] \.\.\/bar:b/);
+ const invB = await cmdB.nextInvocation();
+ invB.exit(0);
+ await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ const invA = await cmdA.nextInvocation();
+ invA.exit(0);
+ await invA.closed;
+ await invB.closed;
+ await exec.waitForLog(/Ran 2 scripts and skipped 0/);
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 3);
+ assert.equal(cmdB.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'error from script is not fatal',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
await rig.writeAtomic({
'package.json': {
scripts: {
@@ -297,742 +527,547 @@ test(
},
wireit: {
a: {
- command: cmdA2.command,
+ command: cmdA.command,
+ files: ['a.txt'],
},
},
},
+ 'a.txt': 'v0',
});
+
+ const exec = rig.exec('npm run a --watch');
await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
- const inv = await cmdA2.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- await inv.closed;
- }
- exec.kill();
- await exec.exit;
- assert.equal(cmdA1.numInvocations, 1);
- assert.equal(cmdA2.numInvocations, 1);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'changes are detected in same-package dependencies',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- const cmdB = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
+ // Script fails initially.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(1);
+ assert.equal(cmdA.numInvocations, 1);
+ await exec.waitForLog(/1 script failed/);
+ }
+
+ // Changing input file triggers another run. Script succeeds this time.
+ {
+ await rig.writeAtomic({
+ 'a.txt': 'v1',
+ });
+ await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await inv.closed;
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'recovers from analysis errors',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ // In this test we do very fast sequences of writes, which causes chokidar
+ // to sometimes not report events, possibly caused by some internal
+ // throttling it apparently does:
+ // https://github.com/paulmillr/chokidar/issues/1084. It seems to affect
+ // Linux and Windows but not macOS. Add a short pause to force it to notice
+ // the write.
+ const pauseToWorkAroundChokidarEventThrottling = () =>
+ new Promise((resolve) => setTimeout(resolve, 50));
+
+ // We use `writeAtomic` in this test because it is otherwise possible for
+ // chokidar to emit a "change" event before the write has completed,
+ // generating JSON syntax errors at unexpected times. The chokidar
+ // `awaitWriteFinish` option can address this problem, but it introduces
+ // latency because it polls until file size has been stable. Since this only
+ // seems to be a problem on CI where the filesystem is slower, we just
+ // workaround it in this test using atomic writes. If it happened to a user
+ // in practice, either chokidar would emit another event when the write
+ // finished and we'd automatically do another run, or the user could save
+ // the file again.
+
+ // The minimum to get npm to invoke Wireit at all.
+ await rig.writeAtomic('package.json', {
scripts: {
a: 'wireit',
- b: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- dependencies: ['b'],
- files: ['a.txt'],
- output: [],
- },
- b: {
- command: cmdB.command,
- files: ['b.txt'],
- output: [],
- },
},
- },
- 'a.txt': 'v0',
- 'b.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] b/);
-
- // Both scripts run initially.
- {
- const invB = await cmdB.nextInvocation();
- invB.exit(0);
- await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- const invA = await cmdA.nextInvocation();
- invA.exit(0);
- assert.equal(cmdA.numInvocations, 1);
- assert.equal(cmdB.numInvocations, 1);
- await exec.waitForLog(/Ran 2 scripts and skipped 0/);
- }
-
- // Changing an input of A should cause A to run again, but not B.
- {
- await rig.writeAtomic({
- 'a.txt': 'v1',
});
- await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- const invA = await cmdA.nextInvocation();
- invA.exit(0);
- assert.equal(cmdA.numInvocations, 2);
- assert.equal(cmdB.numInvocations, 1);
- await exec.waitForLog(/Ran 1 script and skipped 1/);
- }
+ const wireit = rig.exec('npm run a --watch');
+ await wireit.waitForLog(/no config in the wireit section/);
+ await wireit.waitForLog(/❌ 1 script failed\./);
- // Changing an input of B should cause both scripts to run.
- {
- await rig.writeAtomic({
- 'b.txt': 'v1',
+ // Add a wireit section but without a command.
+ await pauseToWorkAroundChokidarEventThrottling();
+ await rig.writeAtomic('package.json', {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {},
+ },
});
- await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] b/);
- const invB = await cmdB.nextInvocation();
- invB.exit(0);
- await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- const invA = await cmdA.nextInvocation();
- invA.exit(0);
- await invA.closed;
- await invB.closed;
- await exec.waitForLog(/Ran 2 scripts and skipped 0/);
- }
+ await wireit.waitForLog(/nothing for wireit to do/);
+ await wireit.waitForLog(/❌ 1 script failed\./);
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 3);
- assert.equal(cmdB.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'changes are detected in cross-package dependencies',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- const cmdB = await rig.newCommand();
- await rig.writeAtomic({
- 'foo/package.json': {
+ // Add the command.
+ const a = await rig.newCommand();
+ await pauseToWorkAroundChokidarEventThrottling();
+ await rig.writeAtomic('package.json', {
scripts: {
a: 'wireit',
},
wireit: {
a: {
- command: cmdA.command,
- dependencies: ['../bar:b'],
- files: ['a.txt'],
- output: [],
+ command: a.command,
},
},
- },
- 'foo/a.txt': 'v0',
- 'bar/package.json': {
- scripts: {
- b: 'wireit',
- },
- wireit: {
- b: {
- command: cmdB.command,
- files: ['b.txt'],
- output: [],
- },
- },
- },
- 'bar/b.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch', {cwd: 'foo'});
- await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] \.\.\/bar:b/);
-
- // Both scripts run initially.
- {
- const invB = await cmdB.nextInvocation();
- invB.exit(0);
- await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- const invA = await cmdA.nextInvocation();
- invA.exit(0);
- assert.equal(cmdA.numInvocations, 1);
- assert.equal(cmdB.numInvocations, 1);
- await exec.waitForLog(/Ran 2 scripts and skipped 0/);
- }
-
- // Changing an input of A should cause A to run again, but not B.
- {
- await rig.writeAtomic({
- 'foo/a.txt': 'v1',
- });
- await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- const invA = await cmdA.nextInvocation();
- invA.exit(0);
- assert.equal(cmdA.numInvocations, 2);
- assert.equal(cmdB.numInvocations, 1);
- await exec.waitForLog(/Ran 1 script and skipped 1/);
- }
-
- // Changing an input of B should cause both scripts to run.
- {
- await rig.writeAtomic({
- 'bar/b.txt': 'v1',
- });
- await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] \.\.\/bar:b/);
- const invB = await cmdB.nextInvocation();
- invB.exit(0);
- await exec.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- const invA = await cmdA.nextInvocation();
- invA.exit(0);
- await invA.closed;
- await invB.closed;
- await exec.waitForLog(/Ran 2 scripts and skipped 0/);
- }
-
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 3);
- assert.equal(cmdB.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'error from script is not fatal',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
+ });
+ (await a.nextInvocation()).exit(0);
+ await wireit.waitForLog(/Ran 1 script and skipped 0/);
+
+ // Add a dependency on another package, but the other package.json has
+ // invalid JSON.
+ await pauseToWorkAroundChokidarEventThrottling();
+ await rig.writeAtomic('other/package.json', 'potato');
+ await rig.writeAtomic('package.json', {
scripts: {
a: 'wireit',
},
wireit: {
a: {
- command: cmdA.command,
- files: ['a.txt'],
+ command: a.command,
+ dependencies: ['./other:b'],
},
},
- },
- 'a.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
-
- // Script fails initially.
- {
- const inv = await cmdA.nextInvocation();
- inv.exit(1);
- assert.equal(cmdA.numInvocations, 1);
- await exec.waitForLog(/1 script failed/);
- }
-
- // Changing input file triggers another run. Script succeeds this time.
- {
- await rig.writeAtomic({
- 'a.txt': 'v1',
});
- await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/);
+ await wireit.waitForLog(/JSON syntax error/);
+ await wireit.waitForLog(/❌ 1 script failed\./);
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await inv.closed;
- await exec.waitForLog(/Ran 1 script and skipped 0/);
- }
-
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'recovers from analysis errors',
- rigTest(
- async ({rig}) => {
- // In this test we do very fast sequences of writes, which causes chokidar
- // to sometimes not report events, possibly caused by some internal
- // throttling it apparently does:
- // https://github.com/paulmillr/chokidar/issues/1084. It seems to affect
- // Linux and Windows but not macOS. Add a short pause to force it to notice
- // the write.
- const pauseToWorkAroundChokidarEventThrottling = () =>
- new Promise((resolve) => setTimeout(resolve, 50));
-
- // We use `writeAtomic` in this test because it is otherwise possible for
- // chokidar to emit a "change" event before the write has completed,
- // generating JSON syntax errors at unexpected times. The chokidar
- // `awaitWriteFinish` option can address this problem, but it introduces
- // latency because it polls until file size has been stable. Since this only
- // seems to be a problem on CI where the filesystem is slower, we just
- // workaround it in this test using atomic writes. If it happened to a user
- // in practice, either chokidar would emit another event when the write
- // finished and we'd automatically do another run, or the user could save
- // the file again.
-
- // The minimum to get npm to invoke Wireit at all.
- await rig.writeAtomic('package.json', {
- scripts: {
- a: 'wireit',
- },
- });
- const wireit = rig.exec('npm run a --watch');
- await wireit.waitForLog(/no config in the wireit section/);
- await wireit.waitForLog(/❌ 1 script failed\./);
-
- // Add a wireit section but without a command.
- await pauseToWorkAroundChokidarEventThrottling();
- await rig.writeAtomic('package.json', {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {},
- },
- });
- await wireit.waitForLog(/nothing for wireit to do/);
- await wireit.waitForLog(/❌ 1 script failed\./);
-
- // Add the command.
- const a = await rig.newCommand();
- await pauseToWorkAroundChokidarEventThrottling();
- await rig.writeAtomic('package.json', {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: a.command,
- },
- },
- });
- (await a.nextInvocation()).exit(0);
- await wireit.waitForLog(/Ran 1 script and skipped 0/);
-
- // Add a dependency on another package, but the other package.json has
- // invalid JSON.
- await pauseToWorkAroundChokidarEventThrottling();
- await rig.writeAtomic('other/package.json', 'potato');
- await rig.writeAtomic('package.json', {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: a.command,
- dependencies: ['./other:b'],
- },
- },
- });
- await wireit.waitForLog(/JSON syntax error/);
- await wireit.waitForLog(/❌ 1 script failed\./);
-
- // Make the other package config valid.
- await pauseToWorkAroundChokidarEventThrottling();
- const b = await rig.newCommand();
- await rig.writeAtomic('other/package.json', {
- scripts: {
- b: 'wireit',
- },
- wireit: {
- b: {
- command: b.command,
- },
- },
- });
- await wireit.waitForLog(/0% \[0 \/ 2\] \[1 running\]/);
- (await b.nextInvocation()).exit(0);
- await wireit.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
- (await a.nextInvocation()).exit(0);
- await wireit.waitForLog(/Ran 2 scripts and skipped 0/);
-
- wireit.kill();
- await wireit.exit;
- },
- {flaky: true},
- ),
-);
-
-test(
- 'watchers understand negations',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
+ // Make the other package config valid.
+ await pauseToWorkAroundChokidarEventThrottling();
+ const b = await rig.newCommand();
+ await rig.writeAtomic('other/package.json', {
scripts: {
- a: 'wireit',
+ b: 'wireit',
},
wireit: {
- a: {
- command: cmdA.command,
- files: ['*.txt', '!excluded.txt'],
+ b: {
+ command: b.command,
},
},
- },
- 'included.txt': 'v0',
- 'excluded.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
-
- // Initial run.
- {
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- assert.equal(cmdA.numInvocations, 1);
- }
-
- // Changing an excluded file should not trigger a run.
- {
- await rig.writeAtomic({
- 'excluded.txt': 'v1',
});
- // Wait a while to ensure the command doesn't run.
- await new Promise((resolve) => setTimeout(resolve, 100));
- // TODO(aomarks) This would fail if the command runs, but it wouldn't fail
- // if the executor ran. The watcher could be triggering the executor too
- // often, but the executor would be smart enough not to actually execute
- // the command. To confirm that the executor is not running too often, we
- // will need to test for some logged output.
- assert.equal(cmdA.numInvocations, 1);
- }
-
- // Changing an included file should trigger a run.
- {
+ await wireit.waitForLog(/0% \[0 \/ 2\] \[1 running\]/);
+ (await b.nextInvocation()).exit(0);
+ await wireit.waitForLog(/50% \[1 \/ 2\] \[1 running\] a/);
+ (await a.nextInvocation()).exit(0);
+ await wireit.waitForLog(/Ran 2 scripts and skipped 0/);
+
+ wireit.kill();
+ await wireit.exit;
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'watchers understand negations',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
await rig.writeAtomic({
- 'included.txt': 'v1',
- });
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await inv.closed;
- }
-
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- '.dotfiles are watched',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['*.txt'],
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['*.txt', '!excluded.txt'],
+ },
},
},
- },
- '.dotfile.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
-
- // Initial run.
- {
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- assert.equal(cmdA.numInvocations, 1);
- }
+ 'included.txt': 'v0',
+ 'excluded.txt': 'v0',
+ });
- // Changing input file should trigger another run.
- {
+ const exec = rig.exec('npm run a --watch');
+
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ assert.equal(cmdA.numInvocations, 1);
+ }
+
+ // Changing an excluded file should not trigger a run.
+ {
+ await rig.writeAtomic({
+ 'excluded.txt': 'v1',
+ });
+ // Wait a while to ensure the command doesn't run.
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ // TODO(aomarks) This would fail if the command runs, but it wouldn't fail
+ // if the executor ran. The watcher could be triggering the executor too
+ // often, but the executor would be smart enough not to actually execute
+ // the command. To confirm that the executor is not running too often, we
+ // will need to test for some logged output.
+ assert.equal(cmdA.numInvocations, 1);
+ }
+
+ // Changing an included file should trigger a run.
+ {
+ await rig.writeAtomic({
+ 'included.txt': 'v1',
+ });
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ '.dotfiles are watched',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
await rig.writeAtomic({
- '.dotfile.txt': 'v1',
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['*.txt'],
+ },
+ },
+ },
+ '.dotfile.txt': 'v0',
});
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await inv.closed;
- }
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'package-lock.json files are watched',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'foo/package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: [],
+ const exec = rig.exec('npm run a --watch');
+
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ assert.equal(cmdA.numInvocations, 1);
+ }
+
+ // Changing input file should trigger another run.
+ {
+ await rig.writeAtomic({
+ '.dotfile.txt': 'v1',
+ });
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'package-lock.json files are watched',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ await rig.writeAtomic({
+ 'foo/package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: [],
+ },
},
},
- },
- 'foo/package-lock.json': 'v0',
- // No parent dir package-lock.json initially.
- });
+ 'foo/package-lock.json': 'v0',
+ // No parent dir package-lock.json initially.
+ });
- const exec = rig.exec('npm run a --watch', {cwd: 'foo'});
+ const exec = rig.exec('npm run a --watch', {cwd: 'foo'});
- // Initial run.
- {
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- assert.equal(cmdA.numInvocations, 1);
- }
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ assert.equal(cmdA.numInvocations, 1);
+ }
- // Change foo's package-lock.json file. Expect another run.
- {
- await rig.writeAtomic({'foo/package-lock.json': 'v1'});
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- }
+ // Change foo's package-lock.json file. Expect another run.
+ {
+ await rig.writeAtomic({'foo/package-lock.json': 'v1'});
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ }
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'debounces when two scripts are watching the same file',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- const cmdB = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- b: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- dependencies: ['b'],
- files: ['input.txt'],
- // Note it's important for this test that we don't have output set,
- // because otherwise the potential third run would be restored from
- // cache, and we wouldn't detect it anyway.
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'debounces when two scripts are watching the same file',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ const cmdB = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ b: 'wireit',
},
- b: {
- command: cmdB.command,
- files: ['input.txt'],
+ wireit: {
+ a: {
+ command: cmdA.command,
+ dependencies: ['b'],
+ files: ['input.txt'],
+ // Note it's important for this test that we don't have output set,
+ // because otherwise the potential third run would be restored from
+ // cache, and we wouldn't detect it anyway.
+ },
+ b: {
+ command: cmdB.command,
+ files: ['input.txt'],
+ },
},
},
- },
- 'input.txt': 'v0',
- });
+ 'input.txt': 'v0',
+ });
- const exec = rig.exec('npm run a --watch');
+ const exec = rig.exec('npm run a --watch');
- // Initial run.
- {
- (await cmdB.nextInvocation()).exit(0);
- (await cmdA.nextInvocation()).exit(0);
- }
+ // Initial run.
+ {
+ (await cmdB.nextInvocation()).exit(0);
+ (await cmdA.nextInvocation()).exit(0);
+ }
- // Wait until wireit is in the "watching" state, otherwise the double file
- // change events would occur in the "running" state, which wouldn't trigger
- // the double runs.
- await exec.waitForLog(/Ran 2 scripts and skipped 0/);
+ // Wait until wireit is in the "watching" state, otherwise the double file
+ // change events would occur in the "running" state, which wouldn't trigger
+ // the double runs.
+ await exec.waitForLog(/Ran 2 scripts and skipped 0/);
- // Changing an input file should cause one more run.
- {
- await rig.writeAtomic({
- 'input.txt': 'v1',
- });
- (await cmdB.nextInvocation()).exit(0);
- (await cmdA.nextInvocation()).exit(0);
- }
+ // Changing an input file should cause one more run.
+ {
+ await rig.writeAtomic({
+ 'input.txt': 'v1',
+ });
+ (await cmdB.nextInvocation()).exit(0);
+ (await cmdA.nextInvocation()).exit(0);
+ }
- await exec.waitForLog(/Ran 2 scripts and skipped 0/);
+ await exec.waitForLog(/Ran 2 scripts and skipped 0/);
- // Wait a moment to ensure a third run doesn't occur.
- await new Promise((resolve) => setTimeout(resolve, 100));
+ // Wait a moment to ensure a third run doesn't occur.
+ await new Promise((resolve) => setTimeout(resolve, 100));
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- assert.equal(cmdB.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'strips leading slash from watch paths',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['/input.txt'],
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ assert.equal(cmdB.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'strips leading slash from watch paths',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['/input.txt'],
+ },
},
},
- },
- 'input.txt': 'v0',
- });
-
- const exec = rig.exec('npm run a --watch');
-
- // Initial run.
- {
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- }
-
- // Changing an input file should cause another run.
- {
- await rig.writeAtomic({
- 'input.txt': 'v1',
+ 'input.txt': 'v0',
});
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await inv.closed;
- }
-
- exec.kill();
- await exec.exit;
- assert.equal(cmdA.numInvocations, 2);
- },
- {flaky: true},
- ),
-);
-
-test(
- 'script fails but still emits output consumed by another script',
- rigTest(
- async ({rig}) => {
- // This test relies on the simple logger.
- rig.env['WIREIT_LOGGER'] = 'simple';
- const cmdA = await rig.newCommand();
- const cmdB = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- b: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['b.out'],
- output: ['a.out'],
- dependencies: ['b'],
+ const exec = rig.exec('npm run a --watch');
+
+ // Initial run.
+ {
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ }
+
+ // Changing an input file should cause another run.
+ {
+ await rig.writeAtomic({
+ 'input.txt': 'v1',
+ });
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await inv.closed;
+ }
+
+ exec.kill();
+ await exec.exit;
+ assert.equal(cmdA.numInvocations, 2);
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'script fails but still emits output consumed by another script',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ // This test relies on the simple logger.
+ rig.env['WIREIT_LOGGER'] = 'simple';
+
+ const cmdA = await rig.newCommand();
+ const cmdB = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ b: 'wireit',
},
- b: {
- command: cmdB.command,
- files: ['b.in'],
- output: ['b.out'],
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['b.out'],
+ output: ['a.out'],
+ dependencies: ['b'],
+ },
+ b: {
+ command: cmdB.command,
+ files: ['b.in'],
+ output: ['b.out'],
+ },
},
},
- },
- });
+ });
- const exec = rig.exec('npm run a --watch');
+ const exec = rig.exec('npm run a --watch');
- // B fails, but still emits an output file.
- const invB = await cmdB.nextInvocation();
- await rig.write('b.out', 'v0');
- invB.exit(1);
-
- // Since the output file was emitted while B was running, and A directly
- // consumes that input file, another execution iteration is going to get
- // queued up.
- //
- // However, it doesn't make sense to re-run B, because none of its input
- // files changed. If we do, and it emits another copy of its output file,
- // we'll get into an infinite loop.
- //
- // The standard Wireit behavior for non-watch mode is to not keep any memory
- // of failures, so that the next time the user runs wireit failed scripts
- // will always be retried. In watch mode, however, we do need to store a
- // record of failures to prevent this kind of loop.
- //
- // Wait a moment to ensure the second run of B doesn't occur.
- await new Promise((resolve) => setTimeout(resolve, 100));
+ // B fails, but still emits an output file.
+ const invB = await cmdB.nextInvocation();
+ await rig.write('b.out', 'v0');
+ invB.exit(1);
+
+ // Since the output file was emitted while B was running, and A directly
+ // consumes that input file, another execution iteration is going to get
+ // queued up.
+ //
+ // However, it doesn't make sense to re-run B, because none of its input
+ // files changed. If we do, and it emits another copy of its output file,
+ // we'll get into an infinite loop.
+ //
+ // The standard Wireit behavior for non-watch mode is to not keep any memory
+ // of failures, so that the next time the user runs wireit failed scripts
+ // will always be retried. In watch mode, however, we do need to store a
+ // record of failures to prevent this kind of loop.
+ //
+ // Wait a moment to ensure the second run of B doesn't occur.
+ await new Promise((resolve) => setTimeout(resolve, 100));
- exec.kill();
- const {stdout, stderr} = await exec.exit;
- assert.equal(cmdA.numInvocations, 0);
- assert.equal(cmdB.numInvocations, 1);
-
- // Also check that we don't log anything for the second iteration which
- // ultimately doesn't do anything new.
- assert.equal([...stdout.matchAll(/Running command/gi)].length, 1);
- const count = [...stdout.matchAll(/Watching for file changes/gi)].length;
- assert.equal(
- [1, 2].includes(count),
- true,
- `Expected to see one or two "Watching for file changes" but found ${count}`,
- );
- const failureCount = [...stderr.matchAll(/Failed/gi)].length;
- assert.equal(
- [1, 2].includes(failureCount),
- true,
- `Expected to see one or two "Failed" lines but found ${failureCount}`,
- );
- },
- {flaky: true},
- ),
-);
-
-test(
- 'input file changes but the contents are the same',
- rigTest(
- async ({rig}) => {
- const cmdA = await rig.newCommand();
- await rig.writeAtomic({
- 'package.json': {
- scripts: {
- a: 'wireit',
- },
- wireit: {
- a: {
- command: cmdA.command,
- files: ['input'],
- output: [],
+ exec.kill();
+ const {stdout, stderr} = await exec.exit;
+ assert.equal(cmdA.numInvocations, 0);
+ assert.equal(cmdB.numInvocations, 1);
+
+ // Also check that we don't log anything for the second iteration which
+ // ultimately doesn't do anything new.
+ assert.equal([...stdout.matchAll(/Running command/gi)].length, 1);
+ const count = [...stdout.matchAll(/Watching for file changes/gi)]
+ .length;
+ assert.equal(
+ [1, 2].includes(count),
+ true,
+ `Expected to see one or two "Watching for file changes" but found ${count}`,
+ );
+ const failureCount = [...stderr.matchAll(/Failed/gi)].length;
+ assert.equal(
+ [1, 2].includes(failureCount),
+ true,
+ `Expected to see one or two "Failed" lines but found ${failureCount}`,
+ );
+ },
+ {flaky: true},
+ ),
+ );
+
+ test(
+ 'input file changes but the contents are the same',
+ rigTest(
+ async ({rig}) => {
+ await prepareRig(rig);
+ const cmdA = await rig.newCommand();
+ await rig.writeAtomic({
+ 'package.json': {
+ scripts: {
+ a: 'wireit',
+ },
+ wireit: {
+ a: {
+ command: cmdA.command,
+ files: ['input'],
+ output: [],
+ },
},
},
- },
- input: 'foo',
- });
+ input: 'foo',
+ });
- const exec = rig.exec('npm run a --watch');
- const inv = await cmdA.nextInvocation();
- inv.exit(0);
- await exec.waitForLog(/Ran 1 script and skipped 0/);
+ const exec = rig.exec('npm run a --watch');
+ const inv = await cmdA.nextInvocation();
+ inv.exit(0);
+ await exec.waitForLog(/Ran 1 script and skipped 0/);
- // Write an input file, but it's the same content. This will cause the file
- // watcher to trigger, and will start an execution, but the execution will
- // ultimately do nothing interesting because the fingerprint is the same, so
- // we shouldn't actually expect any logging.
- await rig.writeAtomic('input', 'foo');
- await exec.waitForLog(/Ran 0 scripts and skipped 1/);
+ // Write an input file, but it's the same content. This will cause the file
+ // watcher to trigger, and will start an execution, but the execution will
+ // ultimately do nothing interesting because the fingerprint is the same, so
+ // we shouldn't actually expect any logging.
+ await rig.writeAtomic('input', 'foo');
+ await exec.waitForLog(/Ran 0 scripts and skipped 1/);
- exec.kill();
- assert.equal(cmdA.numInvocations, 1);
- },
- {flaky: true},
- ),
-);
+ exec.kill();
+ assert.equal(cmdA.numInvocations, 1);
+ },
+ {flaky: true},
+ ),
+ );
-test.run();
+ test.run();
+}
diff --git a/src/watcher.ts b/src/watcher.ts
index d443a39b1..8bfa8f2d9 100644
--- a/src/watcher.ts
+++ b/src/watcher.ts
@@ -7,19 +7,19 @@
import chokidar from 'chokidar';
import {Analyzer} from './analyzer.js';
import {Cache} from './caching/cache.js';
-import {Executor, FailureMode, ServiceMap} from './executor.js';
-import {Logger} from './logging/logger.js';
-import {Deferred} from './util/deferred.js';
-import {WorkerPool} from './util/worker-pool.js';
-import './util/dispose.js';
import {
ScriptConfig,
ScriptReference,
ScriptReferenceString,
scriptReferenceToString,
} from './config.js';
+import {Executor, FailureMode, ServiceMap} from './executor.js';
+import {Logger} from './logging/logger.js';
+import {Deferred} from './util/deferred.js';
+import './util/dispose.js';
+import {WorkerPool} from './util/worker-pool.js';
-import type {Agent} from './cli-options.js';
+import type {Agent, Options} from './cli-options.js';
import type {Fingerprint} from './fingerprint.js';
/**
@@ -126,6 +126,8 @@ export class Watcher {
*/
readonly #finished = new Deferred();
+ readonly #watchOptions: Exclude;
+
constructor(
rootScript: ScriptReference,
extraArgs: string[] | undefined,
@@ -134,6 +136,7 @@ export class Watcher {
cache: Cache | undefined,
failureMode: FailureMode,
agent: Agent,
+ watchOptions: Exclude,
) {
this.#rootScript = rootScript;
this.#extraArgs = extraArgs;
@@ -142,6 +145,7 @@ export class Watcher {
this.#failureMode = failureMode;
this.#cache = cache;
this.#agent = agent;
+ this.#watchOptions = watchOptions;
}
watch(): Promise {
@@ -245,6 +249,8 @@ export class Watcher {
configFiles,
'/',
this.#onConfigFileChanged,
+ true,
+ this.#watchOptions,
);
if (oldWatcher !== undefined) {
void oldWatcher[Symbol.asyncDispose]();
@@ -380,6 +386,8 @@ export class Watcher {
newInputFiles,
script.packageDir,
this.#fileChanged,
+ true,
+ this.#watchOptions,
);
this.#inputFileWatchers.set(key, newWatcher);
}
@@ -479,7 +487,8 @@ export const makeWatcher = (
patterns: string[],
cwd: string,
callback: () => void,
- ignoreInitial = true,
+ ignoreInitial: boolean,
+ watchOptions: Exclude,
): FileWatcher => {
// TODO(aomarks) chokidar doesn't work exactly like fast-glob, so there are
// currently various differences in what gets watched vs what actually affects
@@ -491,6 +500,9 @@ export const makeWatcher = (
{
cwd,
ignoreInitial,
+ usePolling: watchOptions.strategy === 'poll',
+ interval:
+ watchOptions.strategy === 'poll' ? watchOptions.interval : undefined,
},
);
watcher.on('all', callback);