From 57aefbf89421385720aee367d04ebb3ce35c6c9d Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 13:58:18 -0800 Subject: [PATCH 1/7] Convert watch test to node:test (4/24) --- src/test/util/rig-test.ts | 21 +- src/test/watch.test.ts | 1824 ++++++++++++++++++------------------- 2 files changed, 928 insertions(+), 917 deletions(-) diff --git a/src/test/util/rig-test.ts b/src/test/util/rig-test.ts index ed1cc3e47..a7fd2a83e 100644 --- a/src/test/util/rig-test.ts +++ b/src/test/util/rig-test.ts @@ -100,9 +100,22 @@ export const rigTest = ( }; export function rigTestNode( - fn: (args: {rig: WireitTestRig}) => unknown, + handler: (args: {rig: WireitTestRig}) => unknown, + options?: {flaky?: boolean}, ): TestFn { - return async function () { - await fn({rig: await WireitTestRig.setup()}); - }; + if (options?.flaky) { + return async () => { + try { + await handler({rig: await WireitTestRig.setup()}); + return; + } catch { + console.log('Test failed, retrying...'); + } + await handler({rig: await WireitTestRig.setup()}); + }; + } else { + return async () => { + await handler({rig: await WireitTestRig.setup()}); + }; + } } diff --git a/src/test/watch.test.ts b/src/test/watch.test.ts index c29cf37b9..c25c21464 100644 --- a/src/test/watch.test.ts +++ b/src/test/watch.test.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {suite} from 'uvu'; -import * as assert from 'uvu/assert'; -import {rigTest} from './util/rig-test.js'; +import {test, suite} from 'node:test'; +import * as assert from 'node:assert'; +import {rigTestNode as rigTest} from './util/rig-test.js'; import type {WireitTestRig} from './util/test-rig.js'; tests('WIREIT_WATCH_STRATEGY='); @@ -24,168 +24,215 @@ function tests( // 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', - }, - wireit: { - a: { - command: cmdA.command, + 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}) => { - await prepareRig(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}) => { - await prepareRig(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({ @@ -196,26 +243,28 @@ function tests( 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); @@ -226,92 +275,250 @@ function tests( 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': { - 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}) => { - await prepareRig(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: { @@ -319,755 +526,546 @@ function tests( }, 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}) => { - await prepareRig(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', + }, + }); + 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', - b: 'wireit', }, wireit: { a: { - command: cmdA.command, - dependencies: ['b'], - files: ['a.txt'], - output: [], + command: a.command, }, - b: { - command: cmdB.command, - files: ['b.txt'], - output: [], + }, + }); + (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'], }, }, - }, - '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/); - } + await wireit.waitForLog(/JSON syntax error/); + await wireit.waitForLog(/❌ 1 script failed\./); - // 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: [], - }, - }, - }, - 'foo/a.txt': 'v0', - 'bar/package.json': { + // 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: cmdB.command, - files: ['b.txt'], - output: [], + command: b.command, }, }, - }, - '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 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({ - '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: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['a.txt'], + 'package.json': { + scripts: { + a: 'wireit', }, - }, - }, - '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/); - - 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', - }, - }); - 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}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); - await rig.writeAtomic({ - 'package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['*.txt', '!excluded.txt'], + wireit: { + a: { + command: cmdA.command, + files: ['*.txt', '!excluded.txt'], + }, }, }, - }, - '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', + 'included.txt': 'v0', + 'excluded.txt': 'v0', }); - // 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. - { + 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({ - '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({ - 'package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['*.txt'], + 'package.json': { + scripts: { + a: 'wireit', + }, + wireit: { + a: { + command: cmdA.command, + files: ['*.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); - } - - // Changing input file should trigger another run. - { - await rig.writeAtomic({ - '.dotfile.txt': 'v1', + '.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}) => { - await prepareRig(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}) => { - 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: ['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}) => { - await prepareRig(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}) => { - 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', - }, - 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}) => { - await prepareRig(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', - }); - - 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/); + input: 'foo', + }); - // 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/); + 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/); - exec.kill(); - assert.equal(cmdA.numInvocations, 1); - }, - {flaky: true}, - ), - ); + // 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/); - test.run(); + exec.kill(); + assert.equal(cmdA.numInvocations, 1); + }, + {flaky: true}, + ), + ); + }); } From 775823f2c8d677e0a442f717ed565f40f5fb3ddc Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 13:59:54 -0800 Subject: [PATCH 2/7] Update runner too --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8ea0940f6..b6ed94636 100644 --- a/package.json +++ b/package.json @@ -392,7 +392,7 @@ "output": [] }, "test:watch": { - "command": "uvu lib/test \"^watch\\.test\\.js$\"", + "command": "node --test --test-reporter=dot lib/test/watch.test.js", "env": { "NODE_OPTIONS": "--enable-source-maps" }, From cb0b2cbf6de3c5434904cafa91424b6f58aa3c3b Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 14:01:42 -0800 Subject: [PATCH 3/7] Make linter happy --- src/test/watch.test.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/test/watch.test.ts b/src/test/watch.test.ts index c25c21464..d9ae6da1e 100644 --- a/src/test/watch.test.ts +++ b/src/test/watch.test.ts @@ -24,8 +24,8 @@ function tests( // node test runner, maybe can do this as part of that. prepareRig: (rig: WireitTestRig) => void | Promise = () => {}, ) { - suite(suiteName, () => { - test( + void suite(suiteName, () => { + void test( 'runs initially and waits for SIGINT', rigTest( async ({rig}) => { @@ -79,7 +79,7 @@ function tests( ), ); - test( + void test( 'runs again when input file changes after execution', rigTest( async ({rig}) => { @@ -130,7 +130,7 @@ function tests( ), ); - test( + void test( 'runs again when new input file created', rigTest( async ({rig}) => { @@ -181,7 +181,7 @@ function tests( ), ); - test( + void test( 'runs again when input file deleted', rigTest( async ({rig}) => { @@ -230,7 +230,7 @@ function tests( ), ); - test( + void test( 'runs again when input file changes in the middle of execution', rigTest(async ({rig}) => { await prepareRig(rig); @@ -278,7 +278,7 @@ function tests( }), ); - test( + void test( 'reloads config when package.json changes and runs again', rigTest( async ({rig}) => { @@ -339,7 +339,7 @@ function tests( ), ); - test( + void test( 'changes are detected in same-package dependencies', rigTest( async ({rig}) => { @@ -423,7 +423,7 @@ function tests( ), ); - test( + void test( 'changes are detected in cross-package dependencies', rigTest( async ({rig}) => { @@ -513,7 +513,7 @@ function tests( ), ); - test( + void test( 'error from script is not fatal', rigTest( async ({rig}) => { @@ -566,7 +566,7 @@ function tests( ), ); - test( + void test( 'recovers from analysis errors', rigTest( async ({rig}) => { @@ -674,7 +674,7 @@ function tests( ), ); - test( + void test( 'watchers understand negations', rigTest( async ({rig}) => { @@ -738,7 +738,7 @@ function tests( ), ); - test( + void test( '.dotfiles are watched', rigTest( async ({rig}) => { @@ -786,7 +786,7 @@ function tests( ), ); - test( + void test( 'package-lock.json files are watched', rigTest( async ({rig}) => { @@ -832,7 +832,7 @@ function tests( ), ); - test( + void test( 'debounces when two scripts are watching the same file', rigTest( async ({rig}) => { @@ -899,7 +899,7 @@ function tests( ), ); - test( + void test( 'strips leading slash from watch paths', rigTest( async ({rig}) => { @@ -946,7 +946,7 @@ function tests( ), ); - test( + void test( 'script fails but still emits output consumed by another script', rigTest( async ({rig}) => { @@ -1027,7 +1027,7 @@ function tests( ), ); - test( + void test( 'input file changes but the contents are the same', rigTest( async ({rig}) => { From 7d7802bc5e6480b9d0ecc4c4fb1b52df149bc0a5 Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 14:10:38 -0800 Subject: [PATCH 4/7] Downgrade node types to 18 --- package-lock.json | 25 +++++++++---------------- package.json | 2 +- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index a68666a8e..c8cabbf8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "devDependencies": { "@eslint/js": "^9.10.0", "@types/brace-expansion": "^1.1.2", - "@types/node": "^22.5.4", + "@types/node": "^18.19.130", "@types/node-forge": "^1.3.0", "@types/proper-lockfile": "^4.1.2", "@types/selfsigned": "^2.0.1", @@ -1505,13 +1505,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, - "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~5.26.4" } }, "node_modules/@types/node-forge": { @@ -1614,7 +1613,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2052,7 +2050,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3085,7 +3082,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6329,7 +6325,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6467,7 +6462,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6525,11 +6519,10 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true }, "node_modules/unicorn-magic": { "version": "0.3.0", diff --git a/package.json b/package.json index b6ed94636..4155e6f21 100644 --- a/package.json +++ b/package.json @@ -417,7 +417,7 @@ "devDependencies": { "@eslint/js": "^9.10.0", "@types/brace-expansion": "^1.1.2", - "@types/node": "^22.5.4", + "@types/node": "^18.19.130", "@types/node-forge": "^1.3.0", "@types/proper-lockfile": "^4.1.2", "@types/selfsigned": "^2.0.1", From 638c0fb39cc4711c9394cadd072e1cca410dbd5a Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 14:12:18 -0800 Subject: [PATCH 5/7] Use describe instead of suite for node 18 --- src/test/watch.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/watch.test.ts b/src/test/watch.test.ts index d9ae6da1e..43789de12 100644 --- a/src/test/watch.test.ts +++ b/src/test/watch.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {test, suite} from 'node:test'; +import {test, describe} from 'node:test'; import * as assert from 'node:assert'; import {rigTestNode as rigTest} from './util/rig-test.js'; import type {WireitTestRig} from './util/test-rig.js'; @@ -24,7 +24,7 @@ function tests( // node test runner, maybe can do this as part of that. prepareRig: (rig: WireitTestRig) => void | Promise = () => {}, ) { - void suite(suiteName, () => { + void describe(suiteName, () => { void test( 'runs initially and waits for SIGINT', rigTest( From 82f525601ffe5554ce806769e60fce4bb31b5542 Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 14:17:46 -0800 Subject: [PATCH 6/7] Do cleanup in rigTestNode --- src/test/util/rig-test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/test/util/rig-test.ts b/src/test/util/rig-test.ts index a7fd2a83e..20a8df352 100644 --- a/src/test/util/rig-test.ts +++ b/src/test/util/rig-test.ts @@ -103,19 +103,20 @@ export function rigTestNode( handler: (args: {rig: WireitTestRig}) => unknown, options?: {flaky?: boolean}, ): TestFn { + const runTest = async () => { + await using rig = await WireitTestRig.setup(); + await handler({rig}); + }; if (options?.flaky) { return async () => { try { - await handler({rig: await WireitTestRig.setup()}); - return; + return await runTest(); } catch { console.log('Test failed, retrying...'); } - await handler({rig: await WireitTestRig.setup()}); + return await runTest(); }; } else { - return async () => { - await handler({rig: await WireitTestRig.setup()}); - }; + return runTest; } } From adcdf43d0a1ebf1a297200513b1d8b1e8039adb5 Mon Sep 17 00:00:00 2001 From: Al Marks Date: Tue, 23 Dec 2025 14:22:00 -0800 Subject: [PATCH 7/7] Clean up intendation --- src/test/watch.test.ts | 1830 ++++++++++++++++++++-------------------- 1 file changed, 914 insertions(+), 916 deletions(-) diff --git a/src/test/watch.test.ts b/src/test/watch.test.ts index 43789de12..1d1b17457 100644 --- a/src/test/watch.test.ts +++ b/src/test/watch.test.ts @@ -9,230 +9,181 @@ import * as assert from 'node:assert'; import {rigTestNode as rigTest} from './util/rig-test.js'; import type {WireitTestRig} from './util/test-rig.js'; -tests('WIREIT_WATCH_STRATEGY='); +void describe('WIREIT_WATCH_STRATEGY=', () => tests()); -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'; -}); +void describe('WIREIT_WATCH_STRATEGY=poll', () => + tests((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 = () => {}, ) { - void describe(suiteName, () => { - void 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', + void 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', + }, + wireit: { + a: { + command: cmdA.command, }, - wireit: { - a: { - command: cmdA.command, - }, + }, + }, + }); + + // 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}, + ), + ); + + void test( + 'runs again when input file changes after execution', + 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', + }); - // Initial execution. - const exec = rig.exec('npm run a --watch'); - await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/); + const exec = rig.exec('npm run a --watch'); + await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/); + + // Initial run. + { 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}, - ), - ); - - void test( - 'runs again when input file changes after execution', - rigTest( - async ({rig}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); + // Changing an input file should cause another run. + { await rig.writeAtomic({ - 'package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['input.txt'], - }, - }, - }, - 'input.txt': 'v0', + 'input.txt': 'v1', }); - - 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; + } - // 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}, - ), - ); - - void 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'], - }, + exec.kill(); + await exec.exit; + assert.equal(cmdA.numInvocations, 2); + }, + {flaky: true}, + ), + ); + + void 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', - }); + }, + 'input1.txt': 'v0', + }); - const exec = rig.exec('npm run a --watch'); - await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/); + const exec = rig.exec('npm run a --watch'); + await exec.waitForLog(/0% \[0 \/ 1\] \[1 running\] a/); - // 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}, - ), - ); - - void test( - 'runs again when input file deleted', - rigTest( - async ({rig}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); + // 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({ - 'package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['input'], - }, - }, - }, - input: 'v0', + 'input2.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; + } - // 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}, - ), - ); - - void test( - 'runs again when input file changes in the middle of execution', - rigTest(async ({rig}) => { + exec.kill(); + await exec.exit; + assert.equal(cmdA.numInvocations, 2); + }, + {flaky: true}, + ), + ); + + void test( + 'runs again when input file deleted', + rigTest( + async ({rig}) => { await prepareRig(rig); const cmdA = await rig.newCommand(); await rig.writeAtomic({ @@ -243,28 +194,26 @@ function tests( wireit: { a: { command: cmdA.command, - files: ['input.txt'], + files: ['input'], }, }, }, - 'input.txt': 'v0', + input: '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/); } - // Expect another invocation to have been queued up. + // 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); @@ -275,250 +224,92 @@ function tests( exec.kill(); await exec.exit; assert.equal(cmdA.numInvocations, 2); - }), - ); - - void 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, - }, - }, + }, + {flaky: true}, + ), + ); + + void 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': { + scripts: { + a: 'wireit', + }, + wireit: { + a: { + command: cmdA.command, + files: ['input.txt'], }, - }); - - 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); + }, }, - {flaky: true}, - ), - ); - - void 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', - }); - - const exec = rig.exec('npm run a --watch'); - await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] b/); + '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/); + } - // 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}, - ), - ); - - void 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: [], - }, - }, + // 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); + }), + ); + + void 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', }, - 'foo/a.txt': 'v0', - 'bar/package.json': { - scripts: { - b: 'wireit', - }, - wireit: { - b: { - command: cmdB.command, - files: ['b.txt'], - output: [], - }, + wireit: { + a: { + command: cmdA1.command, }, }, - 'bar/b.txt': 'v0', - }); + }, + }); - const exec = rig.exec('npm run a --watch', {cwd: 'foo'}); - await exec.waitForLog(/0% \[0 \/ 2\] \[1 running\] \.\.\/bar:b/); + 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/); + } - // 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}, - ), - ); - - void test( - 'error from script is not fatal', - rigTest( - async ({rig}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); + // 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: { @@ -526,546 +317,753 @@ function tests( }, wireit: { a: { - command: cmdA.command, - files: ['a.txt'], + command: cmdA2.command, }, }, }, - '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; + } - // 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}, - ), - ); - - void 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', - }, - }); - 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', { + exec.kill(); + await exec.exit; + assert.equal(cmdA1.numInvocations, 1); + assert.equal(cmdA2.numInvocations, 1); + }, + {flaky: true}, + ), + ); + + void 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: a.command, + command: cmdA.command, + dependencies: ['b'], + files: ['a.txt'], + output: [], }, - }, - }); - (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'], + 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 wireit.waitForLog(/JSON syntax error/); - await wireit.waitForLog(/❌ 1 script failed\./); + 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/); + } - // Make the other package config valid. - await pauseToWorkAroundChokidarEventThrottling(); - const b = await rig.newCommand(); - await rig.writeAtomic('other/package.json', { - scripts: { + // 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}, + ), + ); + + void 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: [], + }, + }, + }, + 'foo/a.txt': 'v0', + 'bar/package.json': { + scripts: { b: 'wireit', }, wireit: { b: { - command: b.command, + 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 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}, - ), - ); - - void test( - 'watchers understand negations', - rigTest( - async ({rig}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); + 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({ - 'package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['*.txt', '!excluded.txt'], - }, + '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}, + ), + ); + + void test( + 'error from script is not fatal', + 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: ['a.txt'], }, }, - 'included.txt': 'v0', - 'excluded.txt': 'v0', - }); + }, + 'a.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 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}, - ), - ); - - void test( - '.dotfiles are watched', - rigTest( - async ({rig}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); + 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({ - 'package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: ['*.txt'], - }, + '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}, + ), + ); + + void 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', + }, + }); + 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}, + ), + ); + + void test( + 'watchers understand negations', + 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: ['*.txt', '!excluded.txt'], }, }, - '.dotfile.txt': 'v0', + }, + '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); + } - 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}, - ), - ); - - void test( - 'package-lock.json files are watched', - rigTest( - async ({rig}) => { - await prepareRig(rig); - const cmdA = await rig.newCommand(); + // Changing an included file should trigger a run. + { await rig.writeAtomic({ - 'foo/package.json': { - scripts: { - a: 'wireit', - }, - wireit: { - a: { - command: cmdA.command, - files: [], - }, + '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}, + ), + ); + + void test( + '.dotfiles are watched', + 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: ['*.txt'], }, }, - 'foo/package-lock.json': 'v0', - // No parent dir package-lock.json initially. + }, + '.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); + } + + // 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}, + ), + ); + + void 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. + }); - 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}, - ), - ); - - void 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', + exec.kill(); + await exec.exit; + assert.equal(cmdA.numInvocations, 2); + }, + {flaky: true}, + ), + ); + + void 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', + }, + 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. }, - 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'], - }, + 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}, - ), - ); - - void 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'], - }, + exec.kill(); + await exec.exit; + assert.equal(cmdA.numInvocations, 2); + assert.equal(cmdB.numInvocations, 2); + }, + {flaky: true}, + ), + ); + + void 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', - }); + }, + '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', - }); - const inv = await cmdA.nextInvocation(); - inv.exit(0); - await inv.closed; - } - - exec.kill(); - await exec.exit; - assert.equal(cmdA.numInvocations, 2); - }, - {flaky: true}, - ), - ); - - void 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(); + 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({ - 'package.json': { - scripts: { - a: 'wireit', - b: 'wireit', + '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}, + ), + ); + + void 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', + }, + wireit: { + a: { + command: cmdA.command, + files: ['b.out'], + output: ['a.out'], + dependencies: ['b'], }, - wireit: { - a: { - command: cmdA.command, - files: ['b.out'], - output: ['a.out'], - dependencies: ['b'], - }, - b: { - command: cmdB.command, - files: ['b.in'], - output: ['b.out'], - }, + b: { + command: cmdB.command, + files: ['b.in'], + output: ['b.out'], }, }, - }); - - 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)); + const exec = rig.exec('npm run a --watch'); - exec.kill(); - const {stdout, stderr} = await exec.exit; - assert.equal(cmdA.numInvocations, 0); - assert.equal(cmdB.numInvocations, 1); + // 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)); - // 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}, - ), - ); - - void 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: [], - }, + 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}, + ), + ); + + void 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}, + ), + ); }