Skip to content

Commit eb92905

Browse files
Merge pull request #1116 from gemini-testing/TESTPLANE-645.reuse_devserver
feat: implement devServer reuseExisting option
2 parents 91d0db0 + 1dbd6f2 commit eb92905

7 files changed

Lines changed: 334 additions & 36 deletions

File tree

src/config/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ module.exports = {
106106
env: {},
107107
args: [],
108108
logs: true,
109+
reuseExisting: false,
109110
readinessProbe: {
110111
url: null,
111112
isReady: null,

src/config/options.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const rootSection = section(
183183
args: options.optionalArray("devServer.args"),
184184
cwd: options.optionalString("devServer.cwd"),
185185
logs: options.optionalBoolean("devServer.logs"),
186+
reuseExisting: options.optionalBoolean("devServer.reuseExisting"),
186187
readinessProbe: option({
187188
defaultValue: defaults.devServer.readinessProbe,
188189
validate: value => {

src/config/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ export interface CommonConfig {
347347
args: Array<string>;
348348
logs: boolean;
349349
readinessProbe: ReadinessProbe;
350+
reuseExisting: boolean;
350351
};
351352

352353
timeTravel: TimeTravelConfig;

src/dev-server/index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import _ from "lodash";
22
import { spawn } from "child_process";
33
import debug from "debug";
44
import { Config } from "../config";
5-
import { findCwd, pipeLogsWithPrefix, waitDevServerReady } from "./utils";
5+
import { findCwd, pipeLogsWithPrefix, probeServer, waitDevServerReady } from "./utils";
66
import * as logger from "../utils/logger";
77
import type { Testplane } from "../testplane";
88

@@ -15,6 +15,22 @@ export const initDevServer: InitDevServer = async ({ testplane, devServerConfig,
1515
return;
1616
}
1717

18+
if (devServerConfig.reuseExisting) {
19+
if (typeof devServerConfig.readinessProbe === "function" || !devServerConfig.readinessProbe.url) {
20+
throw new Error(
21+
"When 'reuseExisting' option is set in 'devServer' config, it is required to set 'devServer.readinessProbe.url' option",
22+
);
23+
}
24+
25+
const isReady = await probeServer(devServerConfig.readinessProbe);
26+
27+
if (isReady) {
28+
logger.log("Reusing existing dev server");
29+
30+
return;
31+
}
32+
}
33+
1834
logger.log("Starting dev server with command", `"${devServerConfig.command}"`);
1935

2036
const debugLog = debug("testplane:dev-server");
@@ -38,7 +54,15 @@ export const initDevServer: InitDevServer = async ({ testplane, devServerConfig,
3854
pipeLogsWithPrefix(devServer, "[dev server] ");
3955
}
4056

57+
const killDevServerOnProcessExitCb = (): void => {
58+
devServer.kill("SIGINT");
59+
};
60+
61+
process.on("exit", killDevServerOnProcessExitCb);
62+
4163
devServer.once("exit", (code, signal) => {
64+
process.removeListener("exit", killDevServerOnProcessExitCb);
65+
4266
if (signal !== "SIGINT") {
4367
const errorMessage = [
4468
"An error occured while launching dev server",
@@ -48,9 +72,5 @@ export const initDevServer: InitDevServer = async ({ testplane, devServerConfig,
4872
}
4973
});
5074

51-
process.once("exit", () => {
52-
devServer.kill("SIGINT");
53-
});
54-
5575
await waitDevServerReady(devServer, devServerConfig.readinessProbe);
5676
};

src/dev-server/utils.ts

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,38 @@ const defaultIsReadyFn = (response: Awaited<ReturnType<typeof globalThis.fetch>>
7979
return response.status >= 200 && response.status < 300;
8080
};
8181

82+
export const probeServer = async (
83+
// eslint-disable-next-line @typescript-eslint/ban-types
84+
readinessProbe: Exclude<Config["devServer"]["readinessProbe"], Function>,
85+
): Promise<boolean> => {
86+
if (typeof readinessProbe.url !== "string") {
87+
throw new Error("devServer.readinessProbe.url should be set to url");
88+
}
89+
90+
const isReadyFn = readinessProbe.isReady || defaultIsReadyFn;
91+
92+
try {
93+
const signal = AbortSignal.timeout(readinessProbe.timeouts.probeRequestTimeout);
94+
const response = await fetch(readinessProbe.url!, { signal });
95+
const isReady = await isReadyFn(response);
96+
97+
if (!isReady) {
98+
return false;
99+
}
100+
101+
return true;
102+
} catch (error) {
103+
const err = error as { cause?: { code?: string } };
104+
const errorMessage = err && err.cause && (err.cause.code || err.cause);
105+
106+
if (errorMessage && errorMessage !== "ECONNREFUSED") {
107+
logger.warn("Dev server ready probe failed:", errorMessage);
108+
}
109+
110+
return false;
111+
}
112+
};
113+
82114
export const waitDevServerReady = async (
83115
devServer: ChildProcessWithoutNullStreams,
84116
readinessProbe: Config["devServer"]["readinessProbe"],
@@ -99,8 +131,6 @@ export const waitDevServerReady = async (
99131
});
100132
}
101133

102-
const isReadyFn = readinessProbe.isReady || defaultIsReadyFn;
103-
104134
let isSuccess = false;
105135
let isError = false;
106136

@@ -115,33 +145,18 @@ export const waitDevServerReady = async (
115145

116146
const readyPromise = new Promise<void>(resolve => {
117147
const tryToFetch = async (): Promise<void> => {
118-
const signal = AbortSignal.timeout(readinessProbe.timeouts.probeRequestTimeout);
119-
120-
try {
121-
const response = await fetch(readinessProbe.url!, { signal });
122-
const isReady = await isReadyFn(response);
148+
const isReady = await probeServer(readinessProbe);
123149

124-
if (!isReady) {
125-
throw new Error("Dev server is not ready yet");
126-
}
127-
128-
if (!isError && !isSuccess) {
129-
isSuccess = true;
130-
logger.log("Dev server is ready");
131-
resolve();
132-
}
133-
} catch (error) {
134-
const err = error as { cause?: { code?: string } };
135-
136-
if (!isError && !isSuccess) {
137-
setTimeout(tryToFetch, readinessProbe.timeouts.probeRequestInterval).unref();
138-
139-
const errorMessage = err && err.cause && (err.cause.code || err.cause);
150+
if (isError || isSuccess) {
151+
return;
152+
}
140153

141-
if (errorMessage && errorMessage !== "ECONNREFUSED") {
142-
logger.warn("Dev server ready probe failed:", errorMessage);
143-
}
144-
}
154+
if (isReady) {
155+
isSuccess = true;
156+
logger.log("Dev server is ready");
157+
resolve();
158+
} else {
159+
setTimeout(tryToFetch, readinessProbe.timeouts.probeRequestInterval).unref();
145160
}
146161
};
147162

test/src/dev-server/index.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ describe("dev-server", () => {
2121
let childProcessStub: EventEmitter & { kill: SinonStub };
2222
let pipeLogsWithPrefixStub: SinonStub;
2323
let waitDevServerReadyStub: SinonStub;
24-
let loggerStub: { log: SinonStub };
24+
let probeServerStub: SinonStub;
25+
let loggerStub: { log: SinonStub; warn: SinonStub };
2526
let debugLog: SinonStub;
2627
let testplaneStub: Testplane & { halt: SinonStub };
2728
let findCwdStub: SinonStub;
@@ -49,9 +50,10 @@ describe("dev-server", () => {
4950
spawnStub = sandbox.stub().returns(childProcessStub);
5051
pipeLogsWithPrefixStub = sandbox.stub();
5152
waitDevServerReadyStub = sandbox.stub();
53+
probeServerStub = sandbox.stub();
5254
findCwdStub = sandbox.stub();
5355

54-
loggerStub = { log: sandbox.stub() };
56+
loggerStub = { log: sandbox.stub(), warn: sandbox.stub() };
5557
debugLog = sandbox.stub();
5658

5759
devServer = proxyquire("src/dev-server", {
@@ -61,6 +63,7 @@ describe("dev-server", () => {
6163
"./utils": {
6264
pipeLogsWithPrefix: pipeLogsWithPrefixStub,
6365
waitDevServerReady: waitDevServerReadyStub,
66+
probeServer: probeServerStub,
6467
findCwd: findCwdStub,
6568
},
6669
});
@@ -169,4 +172,134 @@ describe("dev-server", () => {
169172
assert.calledWith(debugLog, "Dev server env:", JSON.stringify({ bar: "baz" }, null, 4));
170173
});
171174
});
175+
176+
describe("reuseExisting", () => {
177+
const defaultReadinessProbe = {
178+
url: "http://localhost:3000",
179+
isReady: null,
180+
timeouts: {
181+
waitServerTimeout: 30000,
182+
probeRequestTimeout: 1000,
183+
probeRequestInterval: 500,
184+
},
185+
};
186+
187+
it("should throw error when reuseExisting is true but readinessProbe is a function", async () => {
188+
const readinessProbe = sandbox.stub();
189+
190+
try {
191+
await initDevServer_({
192+
command: "foo",
193+
reuseExisting: true,
194+
readinessProbe,
195+
});
196+
assert.fail("Expected error to be thrown");
197+
} catch (error) {
198+
assert.match(
199+
(error as Error).message,
200+
/When 'reuseExisting' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'/,
201+
);
202+
}
203+
204+
assert.notCalled(spawnStub);
205+
assert.notCalled(probeServerStub);
206+
});
207+
208+
it("should throw error when reuseExisting is true but readinessProbe.url is not set", async () => {
209+
try {
210+
await initDevServer_({
211+
command: "foo",
212+
reuseExisting: true,
213+
readinessProbe: {
214+
...defaultReadinessProbe,
215+
url: null,
216+
},
217+
});
218+
assert.fail("Expected error to be thrown");
219+
} catch (error) {
220+
assert.match(
221+
(error as Error).message,
222+
/When 'reuseExisting' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'/,
223+
);
224+
}
225+
226+
assert.notCalled(spawnStub);
227+
assert.notCalled(probeServerStub);
228+
});
229+
230+
it("should reuse existing server when reuseExisting is true and server is ready", async () => {
231+
probeServerStub.resolves(true);
232+
233+
await initDevServer_({
234+
command: "foo",
235+
reuseExisting: true,
236+
readinessProbe: defaultReadinessProbe,
237+
});
238+
239+
assert.calledOnceWith(probeServerStub, defaultReadinessProbe);
240+
assert.calledWith(loggerStub.log, "Reusing existing dev server");
241+
assert.notCalled(spawnStub);
242+
assert.notCalled(waitDevServerReadyStub);
243+
});
244+
245+
it("should start new server when reuseExisting is true but server is not ready", async () => {
246+
probeServerStub.resolves(false);
247+
248+
await initDevServer_({
249+
command: "foo",
250+
reuseExisting: true,
251+
readinessProbe: defaultReadinessProbe,
252+
});
253+
254+
assert.calledOnceWith(probeServerStub, defaultReadinessProbe);
255+
assert.calledWith(loggerStub.log, "Starting dev server with command", '"foo"');
256+
assert.calledOnceWith(spawnStub, "foo");
257+
assert.calledOnce(waitDevServerReadyStub);
258+
});
259+
260+
it("should work with custom isReady function when reusing existing server", async () => {
261+
probeServerStub.resolves(true);
262+
const customIsReady = sandbox.stub();
263+
264+
const customReadinessProbe = {
265+
...defaultReadinessProbe,
266+
isReady: customIsReady,
267+
};
268+
269+
await initDevServer_({
270+
command: "foo",
271+
reuseExisting: true,
272+
readinessProbe: customReadinessProbe,
273+
});
274+
275+
assert.calledOnceWith(probeServerStub, customReadinessProbe);
276+
assert.calledWith(loggerStub.log, "Reusing existing dev server");
277+
assert.notCalled(spawnStub);
278+
});
279+
280+
it("should not probe server when reuseExisting is false", async () => {
281+
await initDevServer_({
282+
command: "foo",
283+
reuseExisting: false,
284+
readinessProbe: defaultReadinessProbe,
285+
});
286+
287+
assert.notCalled(probeServerStub);
288+
assert.calledWith(loggerStub.log, "Starting dev server with command", '"foo"');
289+
assert.calledOnceWith(spawnStub, "foo");
290+
assert.calledOnce(waitDevServerReadyStub);
291+
});
292+
293+
it("should not probe server when reuseExisting is not set (default false)", async () => {
294+
await initDevServer_({
295+
command: "foo",
296+
readinessProbe: defaultReadinessProbe,
297+
});
298+
299+
assert.notCalled(probeServerStub);
300+
assert.calledWith(loggerStub.log, "Starting dev server with command", '"foo"');
301+
assert.calledOnceWith(spawnStub, "foo");
302+
assert.calledOnce(waitDevServerReadyStub);
303+
});
304+
});
172305
});

0 commit comments

Comments
 (0)