Skip to content

Commit 8807a26

Browse files
feat: implement devServer reuseExisting option
1 parent 91d0db0 commit 8807a26

6 files changed

Lines changed: 259 additions & 34 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' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'",
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: 189 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,188 @@ describe("dev-server", () => {
169172
assert.calledWith(debugLog, "Dev server env:", JSON.stringify({ bar: "baz" }, null, 4));
170173
});
171174
});
175+
176+
describe("reuseExisting", () => {
177+
it("should throw error when reuseExisting is true but readinessProbe is a function", async () => {
178+
const readinessProbe = sandbox.stub();
179+
180+
try {
181+
await initDevServer_({
182+
command: "foo",
183+
reuseExisting: true,
184+
readinessProbe,
185+
});
186+
assert.fail("Expected error to be thrown");
187+
} catch (error) {
188+
assert.match(
189+
(error as Error).message,
190+
/When 'reuseExisting' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'/,
191+
);
192+
}
193+
194+
assert.notCalled(spawnStub);
195+
assert.notCalled(probeServerStub);
196+
});
197+
198+
it("should throw error when reuseExisting is true but readinessProbe.url is not set", async () => {
199+
try {
200+
await initDevServer_({
201+
command: "foo",
202+
reuseExisting: true,
203+
readinessProbe: {
204+
url: null,
205+
isReady: null,
206+
timeouts: {
207+
waitServerTimeout: 30000,
208+
probeRequestTimeout: 1000,
209+
probeRequestInterval: 500,
210+
},
211+
},
212+
});
213+
assert.fail("Expected error to be thrown");
214+
} catch (error) {
215+
assert.match(
216+
(error as Error).message,
217+
/When 'reuseExisting' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'/,
218+
);
219+
}
220+
221+
assert.notCalled(spawnStub);
222+
assert.notCalled(probeServerStub);
223+
});
224+
225+
it("should reuse existing server when reuseExisting is true and server is ready", async () => {
226+
probeServerStub.resolves(true);
227+
228+
await initDevServer_({
229+
command: "foo",
230+
reuseExisting: true,
231+
readinessProbe: {
232+
url: "http://localhost:3000",
233+
isReady: null,
234+
timeouts: {
235+
waitServerTimeout: 30000,
236+
probeRequestTimeout: 1000,
237+
probeRequestInterval: 500,
238+
},
239+
},
240+
});
241+
242+
assert.calledOnceWith(probeServerStub, {
243+
url: "http://localhost:3000",
244+
isReady: null,
245+
timeouts: {
246+
waitServerTimeout: 30000,
247+
probeRequestTimeout: 1000,
248+
probeRequestInterval: 500,
249+
},
250+
});
251+
assert.calledWith(loggerStub.log, "Reusing existing dev server");
252+
assert.notCalled(spawnStub);
253+
assert.notCalled(waitDevServerReadyStub);
254+
});
255+
256+
it("should start new server when reuseExisting is true but server is not ready", async () => {
257+
probeServerStub.resolves(false);
258+
259+
await initDevServer_({
260+
command: "foo",
261+
reuseExisting: true,
262+
readinessProbe: {
263+
url: "http://localhost:3000",
264+
isReady: null,
265+
timeouts: {
266+
waitServerTimeout: 30000,
267+
probeRequestTimeout: 1000,
268+
probeRequestInterval: 500,
269+
},
270+
},
271+
});
272+
273+
assert.calledOnceWith(probeServerStub, {
274+
url: "http://localhost:3000",
275+
isReady: null,
276+
timeouts: {
277+
waitServerTimeout: 30000,
278+
probeRequestTimeout: 1000,
279+
probeRequestInterval: 500,
280+
},
281+
});
282+
assert.calledWith(loggerStub.log, "Starting dev server with command", '"foo"');
283+
assert.calledOnceWith(spawnStub, "foo");
284+
assert.calledOnce(waitDevServerReadyStub);
285+
});
286+
287+
it("should work with custom isReady function when reusing existing server", async () => {
288+
probeServerStub.resolves(true);
289+
const customIsReady = sandbox.stub();
290+
291+
await initDevServer_({
292+
command: "foo",
293+
reuseExisting: true,
294+
readinessProbe: {
295+
url: "http://localhost:3000",
296+
isReady: customIsReady,
297+
timeouts: {
298+
waitServerTimeout: 30000,
299+
probeRequestTimeout: 1000,
300+
probeRequestInterval: 500,
301+
},
302+
},
303+
});
304+
305+
assert.calledOnceWith(probeServerStub, {
306+
url: "http://localhost:3000",
307+
isReady: customIsReady,
308+
timeouts: {
309+
waitServerTimeout: 30000,
310+
probeRequestTimeout: 1000,
311+
probeRequestInterval: 500,
312+
},
313+
});
314+
assert.calledWith(loggerStub.log, "Reusing existing dev server");
315+
assert.notCalled(spawnStub);
316+
});
317+
318+
it("should not probe server when reuseExisting is false", async () => {
319+
await initDevServer_({
320+
command: "foo",
321+
reuseExisting: false,
322+
readinessProbe: {
323+
url: "http://localhost:3000",
324+
isReady: null,
325+
timeouts: {
326+
waitServerTimeout: 30000,
327+
probeRequestTimeout: 1000,
328+
probeRequestInterval: 500,
329+
},
330+
},
331+
});
332+
333+
assert.notCalled(probeServerStub);
334+
assert.calledWith(loggerStub.log, "Starting dev server with command", '"foo"');
335+
assert.calledOnceWith(spawnStub, "foo");
336+
assert.calledOnce(waitDevServerReadyStub);
337+
});
338+
339+
it("should not probe server when reuseExisting is not set (default false)", async () => {
340+
await initDevServer_({
341+
command: "foo",
342+
readinessProbe: {
343+
url: "http://localhost:3000",
344+
isReady: null,
345+
timeouts: {
346+
waitServerTimeout: 30000,
347+
probeRequestTimeout: 1000,
348+
probeRequestInterval: 500,
349+
},
350+
},
351+
});
352+
353+
assert.notCalled(probeServerStub);
354+
assert.calledWith(loggerStub.log, "Starting dev server with command", '"foo"');
355+
assert.calledOnceWith(spawnStub, "foo");
356+
assert.calledOnce(waitDevServerReadyStub);
357+
});
358+
});
172359
});

0 commit comments

Comments
 (0)