Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Randomize port and improve watch mode test #1629

Merged
merged 1 commit into from
Oct 7, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 252 additions & 29 deletions tests/scenarios/watch-mode-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import QUnit from 'qunit';
import globby from 'globby';
import fs from 'fs/promises';
import path from 'path';
import execa from 'execa';
import execa, { type Options, type ExecaChildProcess } from 'execa';

const { module: Qmodule, test } = QUnit;

Expand All @@ -15,20 +15,237 @@ 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: 'starting' } | { type: 'ready' } | { type: 'errored'; error: unknown } | { type: 'completed' };
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It kinda feels like this whole util belongs in ember-cli and should have its own tests.
But that would mean ember-cli would need to formally support a js api, which could probably be implemented without stream peaking
🙃 idk.

Looks solid tho!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I called it EmberCLI but it really is just a stateful child process thing with facilities for testing outputs, which I am sure could be useful for testing the vite integrations and what not as well


class EmberCLI {
static launch(args: readonly string[], options: Options<string>): EmberCLI {
return new EmberCLI(execa('ember', args, { ...options, all: true }));
}

readonly ready: Promise<void>;
readonly completed: Promise<void>;

private status: Status = { type: 'starting' };
private waiters: Waiter[] = [];
private lines: string[] = [];

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));
}
});

process.on('exit', code => {
for (const waiter of this.waiters) {
waiter.onExit(code ?? 0);
}

this.waiters = [];
});

const ready = new OutputWaiter(this, /Serving on http:\/\/localhost:[0-9]+\//, DEFAULT_TIMEOUT * 2);

this.waiters.push(ready);

this.ready = ready.promise.then(() => {
this.status = { type: 'ready' };
});

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 isStarting(): boolean {
return this.status.type === 'starting';
}

get isReady(): boolean {
return this.status.type === 'ready';
}

get isErrored(): boolean {
return this.status.type === 'errored';
}

get isCompleted(): boolean {
return this.status.type === 'completed';
}

get recentOutput(): string {
return this.lines.join('\n');
}

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)) {
return;
}
}

this.waiters.push(waiter);
await waiter.promise;
}

clearOutput(): void {
this.lines = [];
}

async shutdown(): Promise<void> {
if (this.isErrored || this.isCompleted) {
return;
}

this.process.kill();

// on windows the subprocess won't close if you don't end all the sockets
// we don't just end stdout because when you register a listener for stdout it auto registers stdin and stderr... for some reason :(
this.process.stdio.forEach((socket: any) => {
if (socket) {
socket.end();
}
});

await this.completed;
}
}

app.forEachScenario(scenario => {
Qmodule(scenario.name, function (hooks) {
let app: PreparedApp;
let watchProcess: ReturnType<any>;

function waitFor(stdoutContent: string) {
return new Promise<void>(resolve => {
watchProcess.stdout.on('data', (data: Buffer) => {
let str = data.toString();
if (str.includes(stdoutContent)) {
resolve();
}
});
});
let cli: EmberCLI;

async function waitFor(...args: Parameters<EmberCLI['waitFor']>): Promise<void> {
await cli.waitFor(...args);
}

async function checkScripts(distPattern: RegExp, needle: string) {
Expand All @@ -46,31 +263,37 @@ app.forEachScenario(scenario => {

hooks.beforeEach(async () => {
app = await scenario.prepare();
watchProcess = execa('ember', ['s'], { cwd: app.dir });
cli = EmberCLI.launch(['serve', '--port', '0'], { cwd: app.dir });
await cli.ready;
cli.clearOutput();
});

hooks.afterEach(async () => {
watchProcess.kill();

// on windows the subprocess won't close if you don't end all the sockets
// we don't just end stdout because when you register a listener for stdout it auto registers stdin and stderr... for some reason :(
watchProcess.stdio.forEach((socket: any) => {
if (socket) {
socket.end();
}
});
await cli.shutdown();
});

test(`pnpm ember test`, async function (assert) {
await waitFor('Serving on');
const content = 'TWO IS A GREAT NUMBER< I LKE IT A LOT< IT IS THE POWER OF ALL OF ELECTRONICS, MATH, ETC';
test(`ember serve`, async function (assert) {
const originalContent =
'TWO IS A GREAT NUMBER< I LKE IT A LOT< IT IS THE POWER OF ALL OF ELECTRONICS, MATH, ETC';
assert.false(await checkScripts(/js$/, originalContent), 'file has not been created yet');

await fs.writeFile(path.join(app.dir, 'app/simple-file.js'), `export const two = "${originalContent}";`);
await waitFor('file added simple-file.js');
await waitFor(/Build successful/);

assert.true(await checkScripts(/js$/, originalContent), 'the file now exists');
cli.clearOutput();

assert.false(await checkScripts(/js$/, content), 'file has not been created yet');
const updatedContent = 'THREE IS A GREAT NUMBER TWO';
assert.false(await checkScripts(/js$/, updatedContent), 'file has not been created yet');

fs.writeFile(path.join(app.dir, 'app/simple-file.js'), `export const two = "${content}";`);
await waitFor('Build successful');
await fs.writeFile(path.join(app.dir, 'app/simple-file.js'), `export const two = "${updatedContent}";`);
await waitFor('file changed simple-file.js');
await waitFor(/Build successful/);

assert.true(await checkScripts(/js$/, content), 'the file now exists');
// TODO: find a better way to test this; this seems to linger around
// assert.false(await checkScripts(/js$/, originalContent), 'the original file does not exists');
assert.true(await checkScripts(/js$/, updatedContent), 'the updated file now exists');
});
});
});