diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d22408c..7956dc3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -110,7 +110,9 @@ export async function startServer({ log, server, command, exitOnError }: ServerO print(displayClient(ws), color("DISCONNECTION"), `${msg} (code = ${code})`); if (code !== 1000 && exitOnError) { print(displayClient(ws), chalk.red("EXITTING"), "after an abnormal disconnect"); - process.exitCode = 1; + if (typeof process.exitCode !== "number") { + process.exitCode = 1; + } cleanup(); } }); @@ -132,7 +134,9 @@ export async function startServer({ log, server, command, exitOnError }: ServerO } // Exit right away if (exitOnError) { - process.exitCode = 1; + if (typeof process.exitCode !== "number") { + process.exitCode = 1; + } cleanup(); } }); @@ -343,7 +347,9 @@ export function run(args = hideBin(process.argv)): void { if (!argv.watch) { // Run once and exit with the failures as exit code server.run(failures => { - process.exitCode = failures; + if (typeof process.exitCode !== "number") { + process.exitCode = failures; + } cleanup(); }); } @@ -351,7 +357,9 @@ export function run(args = hideBin(process.argv)): void { err => { /* eslint-disable-next-line no-console */ console.error(chalk.red("ERROR"), err.message); - process.exitCode = 1; + if (typeof process.exitCode !== "number") { + process.exitCode = 1; + } } ); }) diff --git a/packages/integration-tests/fixtures/client.ts b/packages/integration-tests/fixtures/client.ts new file mode 100644 index 0000000..80f30e2 --- /dev/null +++ b/packages/integration-tests/fixtures/client.ts @@ -0,0 +1,12 @@ +import { Client } from "mocha-remote-client" + +const wait = parseInt(process.argv.pop() || "0", 10); + +new Client({ + autoConnect: true, + tests: () => { + it("should reject", (done) => { + setTimeout(done, wait); + }); + } +}); diff --git a/packages/integration-tests/src/disconnecting.test.ts b/packages/integration-tests/src/disconnecting.test.ts new file mode 100644 index 0000000..2329755 --- /dev/null +++ b/packages/integration-tests/src/disconnecting.test.ts @@ -0,0 +1,112 @@ +import cp from "child_process"; +import { expect } from "chai"; +import { resolve } from "path"; + +import { Server } from "mocha-remote-server"; + +const TEST_CLIENT_PATH = resolve(__dirname, "../fixtures/client.ts"); + +/** + * @returns a promise of a child process for a client resolving when the client connects to the server. + */ +function startClient(server: Server, timeout: number) { + const clientProcess = cp.spawn( + process.execPath, + ["--import", "tsx", TEST_CLIENT_PATH, timeout.toString()], + { stdio: "inherit", env: { ...process.env, FORCE_COLOR: "false" }, timeout: 5_000 }, + ); + return new Promise((resolve) => { + server.once("connection", () => resolve(clientProcess)); + }); +} + +/** + * Stop a client previously started with {@link startClient}. + * @returns a promise of the client process exiting. + */ +function stopClient(clientProcess: cp.ChildProcess) { + const result = new Promise((resolve) => clientProcess.once("exit", resolve)); + clientProcess.kill(); + return result; +} + +describe("disconnecting a client", () => { + it("should be able to start again, when the server is stopped during a run", async function() { + this.timeout(10000); + // Create and start the server + const server = new Server({ + port: 8090, + reporter: "base", + autoRun: false, + }); + + await server.start(); + const running = new Promise(resolve => server.once("running", resolve)); + + // Start a client waiting longer then the test timeout + const childClientProcess = await startClient(server, this.timeout()); + + // Starting a run, which will be stopped before it completes naturally + let completed = false; + server.run(() => { + completed = true; + }); + + // Wait for the tests to start running + await running; + + // Stop and restart the server + await server.stop(); + await server.start(); + + // Finally stop the server and client + await server.stop(); + await stopClient(childClientProcess); + + // Stopping the server will call the callback passed to run + expect(completed).to.equal(false); + }); + + it("should be able to start again, on client disconnection while running", async function() { + this.timeout(10000); + // Create and start the server + const server = new Server({ + port: 8090, + reporter: "base", + autoRun: false, + }); + + await server.start(); + { + // Start a client waiting longer then the test timeout + const childClientProcess = await startClient(server, this.timeout() * 2); + + // Abort the run by disconnecting the client + let completed = false; + server.run(() => { + completed = true + }); + // Wait for the server to start running + await new Promise(resolve => server.once("running", resolve)); + // Disconnect the client while running + await stopClient(childClientProcess); + // Expect no completion + expect(completed).to.equal(false); + } + + { + // Connect with a new client + // Start a client completing fast + const childClientProcess = await startClient(server, 0); + + // Run the tests again to completion + const result = await new Promise((resolve) => server.run(resolve)); + expect(result).to.equal(0); + + // Stop the server and the client + await stopClient(childClientProcess); + } + + await server.stop(); + }); +}); \ No newline at end of file diff --git a/packages/server/src/Server.ts b/packages/server/src/Server.ts index b5d731f..3cf5929 100644 --- a/packages/server/src/Server.ts +++ b/packages/server/src/Server.ts @@ -149,6 +149,7 @@ export class Server extends ServerEventEmitter { } // Close the server this.wss.close(err => { + this.handleReset(); // Forget about the server delete this.wss; // Reject or resolve the promise @@ -198,11 +199,6 @@ export class Server extends ServerEventEmitter { // Attach event listeners to update stats createStatsCollector(this.runner as Mocha.Runner); - // Bind listeners to the runner, re-emitting events on the server itself - this.runner.on("end", () => { - this.emit("end"); - }); - // Set the client options, to be passed to the next running client this.clientOptions = { grep: this.config.grep, @@ -238,15 +234,17 @@ export class Server extends ServerEventEmitter { } }; + // Emit event when the tests starts running + this.runner.once(FakeRunner.constants.EVENT_RUN_BEGIN, () => { + this.emit("running", this.runner as Mocha.Runner); + }); + // Attach a listener to the run ending this.runner.once(FakeRunner.constants.EVENT_RUN_END, () => { const failures = this.runner ? this.runner.failures : 0; - // Delete the runner to allow another run - delete this.runner; - // Get rid of the client options - delete this.clientOptions; // Call any callbacks to signal completion done(failures); + this.handleReset(); }); // If we already have a client, tell it to run @@ -336,16 +334,17 @@ export class Server extends ServerEventEmitter { } if (this.client) { this.debug("A client was already connected"); - this.client.removeAllListeners(); this.client.close( 1013 /* try again later */, "Got a connection from another client" ); - delete this.client; + // Reset the server to prepare for the incoming client + this.handleReset(); } // Hang onto the client this.client = ws; this.client.on("message", this.handleMessage.bind(this, this.client)); + this.client.once("close", this.handleReset); // If we already have a runner, it can run now that we have a client if (this.runner) { if (this.clientOptions) { @@ -398,6 +397,28 @@ export class Server extends ServerEventEmitter { } }; + /** + * Resets the server for another test run. + */ + private handleReset = () => { + // Forget everything about the runner and the client + const { runner, client } = this; + delete this.runner; + delete this.client; + delete this.clientOptions; + if (runner) { + runner.removeAllListeners(); + // Relay this onto the server itself + this.emit("end"); + } + if (client) { + if (client.readyState !== WebSocket.CLOSED) { + client.terminate(); + } + client.removeAllListeners(); + } + }; + /** * @param reporter A constructor or a string containing the name of a builtin reporter or the module name or relative path of one. * @returns A constructor for the reporter. diff --git a/packages/server/src/ServerEventEmitter.ts b/packages/server/src/ServerEventEmitter.ts index 07944f8..dc80159 100644 --- a/packages/server/src/ServerEventEmitter.ts +++ b/packages/server/src/ServerEventEmitter.ts @@ -5,9 +5,11 @@ import type http from "http"; import type { Debugger } from "debug"; import type { Server } from "./Server"; +import type { Runner } from "mocha"; export enum ServerEvents { STARTED = "started", + RUNNING = "running", CONNECTION = "connection", DISCONNECTION = "disconnection", ERROR = "error", @@ -15,6 +17,7 @@ export enum ServerEvents { } export type StartedListener = (server: Server) => void; +export type RunningListener = (runner: Runner) => void; export type ConnectionListener = (ws: WebSocket, req: http.IncomingMessage) => void; export type DisconnectionListener = (ws: WebSocket, code: number, reason: string) => void; export type ErrorListener = (error: Error) => void; @@ -26,8 +29,8 @@ export type MessageEvents = { disconnection: DisconnectionListener, error: ErrorListener, end: EndListener, - /* running: RunningListener, + /* test: TestListener, */ }