diff --git a/tests/scenarios/watch-mode-test.ts b/tests/scenarios/watch-mode-test.ts index 8d87d5fbe..f0ec5a6bb 100644 --- a/tests/scenarios/watch-mode-test.ts +++ b/tests/scenarios/watch-mode-test.ts @@ -16,200 +16,101 @@ let app = appScenarios.map('watch-mode', () => { */ }); -abstract class Waiter { - readonly promise: Promise; - protected _resolve!: () => void; - protected _reject!: (error: unknown) => void; - private _timeout = (timeout: number) => this.onTimeout(timeout); - - constructor(timeout: number | null = DEFAULT_TIMEOUT) { - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - }); - - if (timeout !== null) { - setTimeout(() => this._timeout(timeout), timeout); - } - } - - abstract onOutputLine(data: string): boolean; - abstract onExit(code: number): void; - abstract onTimeout(timeout: number): void; - - protected resolve(): void { - const resolve = this._resolve; - this._resolve = this._reject = this._timeout = () => {}; - resolve(); - } - - protected reject(error: unknown): void { - const reject = this._reject; - this._resolve = this._reject = this._timeout = () => {}; - reject(error); - } -} - const DEFAULT_TIMEOUT = process.env.CI ? 90000 : 30000; -class OutputWaiter extends Waiter { - constructor(private process: EmberCLI, private output: string | RegExp, timeout?: number | null) { - super(timeout); - } - - onOutputLine(line: string): boolean { - if (this.matchLine(line)) { - this.resolve(); - return true; - } else { - return false; - } - } - - onExit(code: number): void { - try { - throw new Error( - 'Process exited with code ' + - code + - ' before output "' + - this.output + - '" was found. ' + - 'Recent output:\n\n' + - this.process.recentOutput - ); - } catch (error) { - this.reject(error); - } - } - - onTimeout(timeout: number): void { - try { - throw new Error( - 'Timed out after ' + - timeout + - 'ms before output "' + - this.output + - '" was found. ' + - 'Recent output:\n\n' + - this.process.recentOutput - ); - } catch (error) { - this.reject(error); - } - } - - private matchLine(line: string): boolean { - if (typeof this.output === 'string') { - return this.output === line; - } else { - return this.output.test(line); - } - } -} - -type Status = { type: 'running' } | { type: 'errored'; error: unknown } | { type: 'completed' }; - class EmberCLI { static launch(args: readonly string[], options: Options = {}): EmberCLI { return new EmberCLI(execa('ember', args, { ...options, all: true })); } - readonly completed: Promise; - - private status: Status = { type: 'running' }; - private waiters: Waiter[] = []; private lines: string[] = []; + private nextWaitedLine = 0; + private exitCode: number | null = null; + private currentWaiter: (() => void) | undefined; constructor(private process: ExecaChildProcess) { process.all!.on('data', data => { const lines = data.toString().split(/\r?\n/); this.lines.push(...lines); - for (const line of lines) { - this.waiters = this.waiters.filter(waiter => !waiter.onOutputLine(line)); - } + this.currentWaiter?.(); }); process.on('exit', code => { - for (const waiter of this.waiters) { - waiter.onExit(code ?? 0); - } - - this.waiters = []; + this.exitCode = code; + this.currentWaiter?.(); }); - - const exit = new (class ExitWaiter extends Waiter { - constructor(private process: EmberCLI) { - super(null); - } - - onOutputLine(): boolean { - return false; - } - - onExit(code: number): void { - if (code === 0) { - this.resolve(); - } else { - try { - throw new Error( - 'Process exited with code ' + code + '. ' + 'Recent output:\n\n' + this.process.recentOutput - ); - } catch (error) { - this.reject(error); - } - } - } - - onTimeout() {} - })(this); - - this.waiters.push(exit); - - this.completed = exit.promise - .then(() => { - this.status = { type: 'completed' }; - }) - .catch(error => { - this.status = { type: 'errored', error }; - throw error; - }); - } - - get isRunning(): boolean { - return this.status.type === 'running'; - } - - get isErrored(): boolean { - return this.status.type === 'errored'; } - get isCompleted(): boolean { - return this.status.type === 'completed'; + private async internalWait(timedOut?: Promise): Promise { + if (this.currentWaiter) { + throw new Error(`bug: only one wait at a time`); + } + try { + await Promise.race( + [ + timedOut, + new Promise(resolve => { + this.currentWaiter = resolve; + }), + ].filter(Boolean) + ); + } finally { + this.currentWaiter = undefined; + } } - get recentOutput(): string { - return this.lines.join('\n'); + private searchLines(output: string | RegExp): boolean { + while (this.nextWaitedLine < this.lines.length) { + let line = this.lines[this.nextWaitedLine++]; + if (typeof output === 'string') { + if (output === line) { + return true; + } + } else { + if (output.test(line)) { + return true; + } + } + } + return false; } - async waitFor(output: string | RegExp, timeout?: number | null): Promise { - const waiter = new OutputWaiter(this, output, timeout); - - for (const line of this.lines) { - if (waiter.onOutputLine(line)) { + async waitFor(output: string | RegExp, timeout = DEFAULT_TIMEOUT): Promise { + let timedOut = new Promise((_resolve, reject) => { + setTimeout(() => { + let err = new Error( + 'Timed out after ' + + timeout + + 'ms before output "' + + output + + '" was found. ' + + 'Output:\n\n' + + this.lines.join('\n') + ); + reject(err); + }, timeout); + }); + while (true) { + if (this.exitCode != null) { + throw new Error( + 'Process exited with code ' + + this.exitCode + + ' before output "' + + output + + '" was found. ' + + 'Output:\n\n' + + this.lines.join('\n') + ); + } + if (this.searchLines(output)) { return; } + await this.internalWait(timedOut); } - - this.waiters.push(waiter); - await waiter.promise; - } - - clearOutput(): void { - this.lines = []; } async shutdown(): Promise { - if (this.isErrored || this.isCompleted) { + if (this.exitCode != null) { return; } @@ -223,7 +124,16 @@ class EmberCLI { } }); - await this.completed; + await this.waitForExit(); + } + + async waitForExit(): Promise { + while (true) { + if (this.exitCode != null) { + return this.exitCode; + } + await this.internalWait(); + } } } @@ -393,7 +303,6 @@ app.forEachScenario(scenario => { app = await scenario.prepare(); server = EmberCLI.launch(['serve', '--port', '0'], { cwd: app.dir }); await waitFor(/Serving on http:\/\/localhost:[0-9]+\//, DEFAULT_TIMEOUT * 2); - server.clearOutput(); }); hooks.afterEach(async () => { @@ -410,7 +319,6 @@ app.forEachScenario(scenario => { await waitFor(/Build successful/); assert.true(await checkScripts(/js$/, originalContent), 'the file now exists'); - server.clearOutput(); const updatedContent = 'THREE IS A GREAT NUMBER TWO'; assert.false(await checkScripts(/js$/, updatedContent), 'file has not been created yet'); @@ -455,7 +363,6 @@ app.forEachScenario(scenario => { import templateOnlyComponent from '@ember/component/template-only'; export default templateOnlyComponent(); `); - server.clearOutput(); await appFile('app/components/hello-world.hbs').delete(); await deleted('components/hello-world.hbs'); @@ -492,7 +399,6 @@ app.forEachScenario(scenario => { await assertRewrittenFile('components/hello-world.hbs').doesNotExist(); await assertRewrittenFile('components/hello-world.js').doesNotExist(); await assertRewrittenFile('tests/integration/hello-world-test.js').includesContent(''); - server.clearOutput(); await appFile('app/components/hello-world.js').write(d` import Component from '@glimmer/component'; @@ -506,11 +412,10 @@ app.forEachScenario(scenario => { export default class extends Component {} `); await assertRewrittenFile('tests/integration/hello-world-test.js').includesContent(''); - server.clearOutput(); let test = await EmberCLI.launch(['test', '--filter', 'hello-world'], { cwd: app.dir }); await test.waitFor(/^not ok .+ Integration | hello-world: it renders/, DEFAULT_TIMEOUT * 2); - await assert.rejects(test.completed); + await assert.notStrictEqual(await test.waitForExit(), 0); await appFile('app/components/hello-world.hbs').write('hello world!'); await added('components/hello-world.hbs'); @@ -525,7 +430,7 @@ app.forEachScenario(scenario => { test = await EmberCLI.launch(['test', '--filter', 'hello-world'], { cwd: app.dir }); await test.waitFor(/^ok .+ Integration | hello-world: it renders/); - await test.completed; + await assert.strictEqual(await test.waitForExit(), 0); }); test('Scenario 3: deleting a co-located template', async function () { @@ -541,7 +446,6 @@ app.forEachScenario(scenario => { import templateOnlyComponent from '@ember/component/template-only'; export default templateOnlyComponent(); `); - server.clearOutput(); await appFile('app/components/hello-world.js').write(d` import Component from '@glimmer/component'; @@ -555,7 +459,6 @@ app.forEachScenario(scenario => { import Component from '@glimmer/component'; export default class extends Component {} `); - server.clearOutput(); await appFile('app/components/hello-world.hbs').delete(); await deleted('components/hello-world.hbs'); @@ -574,7 +477,6 @@ app.forEachScenario(scenario => { await appFile('app/components/hello-world.hbs').write('hello world!'); await added('components/hello-world.hbs'); await waitFor(/Build successful/); - server.clearOutput(); await appFile('app/components/hello-world.js').write(d` import Component from '@glimmer/component'; @@ -582,7 +484,6 @@ app.forEachScenario(scenario => { `); await added('components/hello-world.js'); await waitFor(/Build successful/); - server.clearOutput(); await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!'); await assertRewrittenFile('components/hello-world.js').hasContent(d`