From 9c64d9047501f3ad099a4703c934d0dcb8798881 Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 3 Sep 2024 11:35:31 -0700 Subject: [PATCH] Add support for filesystem polling (#1176) Adds WIREIT_WATCH_STRATEGY and WIREIT_WATCH_POLL_MS environment variables which can be used to switch filesystem detection from event-based to polling: export WIREIT_WATCH_STRATEGY=poll export WIREIT_WATCH_POLL_MS= Currently this just sets the chokidar usePolling and interval options. --- CHANGELOG.md | 6 +- README.md | 13 +- src/cli-options.ts | 55 +- src/cli.ts | 11 +- src/test/cli-options.test.ts | 92 +- src/test/glob.test.ts | 7 +- src/test/watch.test.ts | 1807 +++++++++++++++++----------------- src/watcher.ts | 26 +- 8 files changed, 1100 insertions(+), 917 deletions(-) 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: | | `WIREIT_FAILURES` | [How to handle script failures](#failures-and-errors).

Options:
| +| `WIREIT_LOGGER` | How to present progress and results on the command line.

Options:
| +| `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: | +| `WIREIT_WATCH_STRATEGY` | How Wireit determines when a file has changed which warrants a new watch iteration.

Options:
| +| `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:
| ### 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);