Skip to content

Commit

Permalink
Merge pull request #1847 from embroider-build/refactor-watch-mode-tests
Browse files Browse the repository at this point in the history
Refactor watch mode tests
  • Loading branch information
mansona authored Mar 21, 2024
2 parents f30e685 + c7a7abc commit 433b22c
Showing 1 changed file with 78 additions and 177 deletions.
255 changes: 78 additions & 177 deletions tests/scenarios/watch-mode-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,200 +16,101 @@ let app = appScenarios.map('watch-mode', () => {
*/
});

abstract class Waiter {
readonly promise: Promise<void>;
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<void>((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<string> = {}): EmberCLI {
return new EmberCLI(execa('ember', args, { ...options, all: true }));
}

readonly completed: Promise<void>;

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<void>): Promise<void> {
if (this.currentWaiter) {
throw new Error(`bug: only one wait at a time`);
}
try {
await Promise.race(
[
timedOut,
new Promise<void>(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<void> {
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<void> {
let timedOut = new Promise<void>((_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<void> {
if (this.isErrored || this.isCompleted) {
if (this.exitCode != null) {
return;
}

Expand All @@ -223,7 +124,16 @@ class EmberCLI {
}
});

await this.completed;
await this.waitForExit();
}

async waitForExit(): Promise<number> {
while (true) {
if (this.exitCode != null) {
return this.exitCode;
}
await this.internalWait();
}
}
}

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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('<HelloWorld />');
server.clearOutput();

await appFile('app/components/hello-world.js').write(d`
import Component from '@glimmer/component';
Expand All @@ -506,11 +412,10 @@ app.forEachScenario(scenario => {
export default class extends Component {}
`);
await assertRewrittenFile('tests/integration/hello-world-test.js').includesContent('<HelloWorld />');
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');
Expand All @@ -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 () {
Expand All @@ -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';
Expand All @@ -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');
Expand All @@ -574,15 +477,13 @@ 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';
export default class extends Component {}
`);
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`
Expand Down

0 comments on commit 433b22c

Please sign in to comment.