diff --git a/tests/specs/watch.ts b/tests/specs/watch.ts index 35bd4f7e..8960a9f4 100644 --- a/tests/specs/watch.ts +++ b/tests/specs/watch.ts @@ -323,37 +323,36 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => { const negativeSignal = 'fail'; - await processInteract( - tsxProcess.stdout!, - [ - async (data) => { - if (data.includes(negativeSignal)) { - throw new Error('should not log ignored file'); - } + await expect( + processInteract( + tsxProcess.stdout!, + [ + async (data) => { + if (data !== 'logA logB logC\n') { + return; + } - if (data === 'logA logB logC\n') { // These changes should not trigger a re-run await Promise.all([ fixtureGlob.writeFile(fileA, `export default "${negativeSignal}"`), fixtureGlob.writeFile(fileB, `export default "${negativeSignal}"`), fixtureGlob.writeFile(depA, `export default "${negativeSignal}"`), ]); - - await setTimeout(1000); - fixtureGlob.writeFile(entryFile, 'console.log("TERMINATE")'); return true; - } - }, - data => data === 'TERMINATE\n', - ], - 9000, - ); + }, + (data) => { + if (data.includes(negativeSignal)) { + throw new Error('Unexpected re-run'); + } + }, + ], + 2000, + ), + ).rejects.toThrow('Timeout'); // Watch should not trigger tsxProcess.kill(); - const p = await tsxProcess; - expect(p.all).not.toMatch('fail'); - expect(p.stderr).toBe(''); + await tsxProcess; }, 10_000); }); }); diff --git a/tests/utils/process-interact.ts b/tests/utils/process-interact.ts index 84b6d091..5e570f02 100644 --- a/tests/utils/process-interact.ts +++ b/tests/utils/process-interact.ts @@ -2,36 +2,80 @@ import type { Readable } from 'node:stream'; import { on } from 'node:events'; import { setTimeout } from 'node:timers/promises'; -type MaybePromise = T | Promise; +type OnTimeoutCallback = () => void; -export const processInteract = async ( - stdout: Readable, - actions: ((data: string) => MaybePromise)[], +type Api = { + startTime: number; + onTimeout: (callback: OnTimeoutCallback) => void; +}; + +const enforceTimeout = ( timeout: number, -) => { + function_: (api: Api) => ReturnType, +): ReturnType => { const startTime = Date.now(); - const logs: [time: number, string][] = []; + let onTimeoutCallback: OnTimeoutCallback; - let currentAction = actions.shift(); + const runFunction = function_({ + startTime, + onTimeout: (callback) => { + onTimeoutCallback = callback; + }, + }); + + if (!(runFunction instanceof Promise)) { + return runFunction; + } const ac = new AbortController(); - setTimeout(timeout, true, ac).then( - () => { - if (currentAction) { - console.error(`Timeout ${timeout}ms exceeded:`); - console.log(logs); + const timer = setTimeout(timeout, true, ac).then( + async () => { + if (onTimeoutCallback) { + await onTimeoutCallback(); } + + throw new Error('Timeout'); }, - () => {}, + () => { /* Timeout aborted */ }, ); + return Promise.race([ + runFunction.finally(() => ac.abort()), + timer, + ]) as ReturnType; +}; + +type MaybePromise = T | Promise; + +export const processInteract = async ( + stdout: Readable, + actions: ((data: string) => MaybePromise)[], + timeout: number, +) => enforceTimeout(timeout, async ({ startTime, onTimeout }) => { + const logs: { + time: number; + stdout: string; + }[] = []; + + let currentAction = actions.shift(); + + onTimeout(() => { + if (currentAction) { + const error = Object.assign( + new Error(`Timeout ${timeout}ms exceeded:`), + { logs }, + ); + throw error; + } + }); + while (currentAction) { for await (const [chunk] of on(stdout, 'data')) { const chunkString = chunk.toString(); - logs.push([ - Date.now() - startTime, - chunkString, - ]); + logs.push({ + time: Date.now() - startTime, + stdout: chunkString, + }); const gotoNextAction = await currentAction(chunkString); if (gotoNextAction) { @@ -40,5 +84,4 @@ export const processInteract = async ( } } } - ac.abort(); -}; +});