@jsenv/test 1.5.2 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/test",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,6 +14,7 @@ import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
14
14
  import { assertAndNormalizeDirectoryUrl } from "@jsenv/filesystem";
15
15
  import { createLogger } from "@jsenv/log";
16
16
 
17
+ import { createTeardown } from "../helpers/teardown.js";
17
18
  import { assertAndNormalizeWebServer } from "./web_server_param.js";
18
19
  import { run } from "./run.js";
19
20
 
@@ -44,6 +45,7 @@ export const execute = async ({
44
45
  rootDirectoryUrl,
45
46
  "rootDirectoryUrl",
46
47
  );
48
+ const teardown = createTeardown();
47
49
  const executeOperation = Abort.startOperation();
48
50
  executeOperation.addAbortSignal(signal);
49
51
  if (handleSIGINT) {
@@ -58,7 +60,7 @@ export const execute = async ({
58
60
  }
59
61
 
60
62
  if (runtime.type === "browser") {
61
- await assertAndNormalizeWebServer(webServer);
63
+ await assertAndNormalizeWebServer(webServer, { signal, teardown, logger });
62
64
  }
63
65
 
64
66
  let resultTransformer = (result) => result;
@@ -67,6 +69,7 @@ export const execute = async ({
67
69
  webServer,
68
70
  fileRelativeUrl,
69
71
  importMap,
72
+ teardown,
70
73
  ...runtimeParams,
71
74
  };
72
75
 
@@ -106,6 +109,7 @@ export const execute = async ({
106
109
  }
107
110
  return result;
108
111
  } finally {
112
+ await teardown.trigger();
109
113
  await executeOperation.end();
110
114
  }
111
115
  };
@@ -4,7 +4,7 @@ import { takeCoverage } from "node:v8";
4
4
  import stripAnsi from "strip-ansi";
5
5
  import { urlToFileSystemPath } from "@jsenv/urls";
6
6
  import { createLog, startSpinner } from "@jsenv/log";
7
- import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
7
+ import { Abort } from "@jsenv/abort";
8
8
  import { ensureEmptyDirectory, writeFileSync } from "@jsenv/filesystem";
9
9
 
10
10
  import { reportToCoverage } from "../coverage/report_to_coverage.js";
@@ -16,7 +16,7 @@ export const executeSteps = async (
16
16
  executionSteps,
17
17
  {
18
18
  signal,
19
- handleSIGINT,
19
+ teardown,
20
20
  logger,
21
21
  logRefresh,
22
22
  logRuntime,
@@ -56,23 +56,9 @@ export const executeSteps = async (
56
56
  const executePlanReturnValue = {};
57
57
  const report = {};
58
58
  const callbacks = [];
59
- const stopAfterAllSignal = { notify: () => {} };
60
59
 
61
60
  const multipleExecutionsOperation = Abort.startOperation();
62
61
  multipleExecutionsOperation.addAbortSignal(signal);
63
- if (handleSIGINT) {
64
- multipleExecutionsOperation.addAbortSource((abort) => {
65
- return raceProcessTeardownEvents(
66
- {
67
- SIGINT: true,
68
- },
69
- () => {
70
- logger.debug(`SIGINT abort`);
71
- abort();
72
- },
73
- );
74
- });
75
- }
76
62
  const failFastAbortController = new AbortController();
77
63
  if (failFast) {
78
64
  multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal);
@@ -125,7 +111,8 @@ export const executeSteps = async (
125
111
  coverageConfig,
126
112
  coverageMethodForBrowsers,
127
113
  coverageMethodForNodeJs,
128
- stopAfterAllSignal,
114
+ isTestPlan: true,
115
+ teardown,
129
116
  };
130
117
 
131
118
  if (logMergeForCompletedExecutions && !process.stdout.isTTY) {
@@ -323,8 +310,8 @@ export const executeSteps = async (
323
310
  },
324
311
  });
325
312
  if (!keepRunning) {
326
- logger.debug("stopAfterAllSignal.notify()");
327
- await stopAfterAllSignal.notify();
313
+ logger.debug("trigger test plan teardown");
314
+ await teardown.trigger();
328
315
  }
329
316
 
330
317
  counters.cancelled = counters.total - counters.done;
@@ -346,10 +333,9 @@ export const executeSteps = async (
346
333
  executePlanReturnValue.aborted = multipleExecutionsOperation.signal.aborted;
347
334
  executePlanReturnValue.planSummary = summary;
348
335
  executePlanReturnValue.planReport = report;
349
- await callbacks.reduce(async (previous, callback) => {
350
- await previous;
336
+ for (const callback of callbacks) {
351
337
  await callback();
352
- }, Promise.resolve());
338
+ }
353
339
  return executePlanReturnValue;
354
340
  } finally {
355
341
  await multipleExecutionsOperation.end();
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
2
3
  import { URL_META } from "@jsenv/url-meta";
3
4
  import { urlToFileSystemPath, urlToRelativeUrl } from "@jsenv/urls";
4
5
  import {
@@ -8,6 +9,7 @@ import {
8
9
  } from "@jsenv/filesystem";
9
10
  import { createLogger, createDetailedMessage } from "@jsenv/log";
10
11
 
12
+ import { createTeardown } from "../helpers/teardown.js";
11
13
  import { generateCoverageJsonFile } from "../coverage/coverage_reporter_json_file.js";
12
14
  import { generateCoverageHtmlDirectory } from "../coverage/coverage_reporter_html_directory.js";
13
15
  import { generateCoverageTextLog } from "../coverage/coverage_reporter_text_log.js";
@@ -103,6 +105,25 @@ export const executeTestPlan = async ({
103
105
  coverageReportHtmlDirectoryUrl,
104
106
  ...rest
105
107
  }) => {
108
+ const teardown = createTeardown();
109
+
110
+ const operation = Abort.startOperation();
111
+ operation.addAbortSignal(signal);
112
+ if (handleSIGINT) {
113
+ operation.addAbortSource((abort) => {
114
+ return raceProcessTeardownEvents(
115
+ {
116
+ SIGINT: true,
117
+ },
118
+ () => {
119
+ logger.debug(`SIGINT abort`);
120
+ abort();
121
+ },
122
+ );
123
+ });
124
+ }
125
+
126
+ let logger;
106
127
  let someNeedsServer = false;
107
128
  let someHasCoverageV8 = false;
108
129
  let someNodeRuntime = false;
@@ -126,6 +147,8 @@ export const executeTestPlan = async ({
126
147
  throw new Error(`testPlan must be an object, got ${testPlan}`);
127
148
  }
128
149
 
150
+ logger = createLogger({ logLevel });
151
+
129
152
  Object.keys(testPlan).forEach((filePattern) => {
130
153
  const filePlan = testPlan[filePattern];
131
154
  if (!filePlan) return;
@@ -148,7 +171,11 @@ export const executeTestPlan = async ({
148
171
  });
149
172
 
150
173
  if (someNeedsServer) {
151
- await assertAndNormalizeWebServer(webServer);
174
+ await assertAndNormalizeWebServer(webServer, {
175
+ signal: operation.signal,
176
+ teardown,
177
+ logger,
178
+ });
152
179
  }
153
180
 
154
181
  if (coverageEnabled) {
@@ -233,7 +260,6 @@ export const executeTestPlan = async ({
233
260
  }
234
261
  }
235
262
 
236
- const logger = createLogger({ logLevel });
237
263
  logger.debug(
238
264
  createDetailedMessage(`Prepare executing plan`, {
239
265
  runtimes: JSON.stringify(runtimes, null, " "),
@@ -288,7 +314,7 @@ export const executeTestPlan = async ({
288
314
 
289
315
  const result = await executeSteps(executionSteps, {
290
316
  signal,
291
- handleSIGINT,
317
+ teardown,
292
318
  logger,
293
319
  logRefresh,
294
320
  logSummary,
@@ -0,0 +1,132 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { Abort } from "@jsenv/abort";
3
+
4
+ import { pingServer } from "../../helpers/ping_server.js";
5
+
6
+ export const startServerUsingCommand = async (
7
+ webServer,
8
+ { signal, allocatedMs, logger, teardown },
9
+ ) => {
10
+ const spawnedProcess = spawn(webServer.command, [], {
11
+ // On non-windows platforms, `detached: true` makes child process a leader of a new
12
+ // process group, making it possible to kill child process tree with `.kill(-pid)` command.
13
+ // @see https://nodejs.org/api/child_process.html#child_process_options_detached
14
+ detached: process.platform !== "win32",
15
+ stdio: ["pipe", "pipe", "pipe"],
16
+ shell: true,
17
+ cwd: webServer.cwd,
18
+ });
19
+ if (!spawnedProcess.pid) {
20
+ await new Promise((resolve, reject) => {
21
+ spawnedProcess.once("error", (error) => {
22
+ reject(new Error(`Failed to launch: ${error}`));
23
+ });
24
+ });
25
+ }
26
+
27
+ let errorReceived = false;
28
+ const errorPromise = new Promise((resolve, reject) => {
29
+ spawnedProcess.on("error", (e) => {
30
+ errorReceived = true;
31
+ reject(e);
32
+ });
33
+ });
34
+
35
+ // const stdout = readline.createInterface({ input: spawnedProcess.stdout });
36
+ // stdout.on("line", () => {
37
+ // logger.debug(`[pid=${spawnedProcess.pid}][out] ${data}`);
38
+ // });
39
+ // const stderr = readline.createInterface({ input: spawnedProcess.stderr });
40
+ // stderr.on("line", (data) => {
41
+ // logger.debug(`[pid=${spawnedProcess.pid}][err] ${data}`);
42
+ // });
43
+ let processClosed = false;
44
+ const closedPromise = new Promise((resolve) => {
45
+ spawnedProcess.once("exit", (exitCode, signal) => {
46
+ logger.info(
47
+ `[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`,
48
+ );
49
+ processClosed = true;
50
+ resolve();
51
+ });
52
+ });
53
+ const killProcess = async () => {
54
+ logger.info(`[pid=${spawnedProcess.pid}] <kill>`);
55
+ if (!spawnedProcess.pid || spawnedProcess.killed || processClosed) {
56
+ logger.info(
57
+ `[pid=${spawnedProcess.pid}] <skipped force kill spawnedProcess.killed=${spawnedProcess.killed} processClosed=${processClosed}>`,
58
+ );
59
+ return;
60
+ }
61
+ logger.info(`[pid=${spawnedProcess.pid}] <will force kill>`);
62
+ // Force kill the browser.
63
+ try {
64
+ if (process.platform === "win32") {
65
+ const taskkillProcess = spawnSync(
66
+ `taskkill /pid ${spawnedProcess.pid} /T /F`,
67
+ { shell: true },
68
+ );
69
+ const [stdout, stderr] = [
70
+ taskkillProcess.stdout.toString(),
71
+ taskkillProcess.stderr.toString(),
72
+ ];
73
+ if (stdout)
74
+ logger.info(`[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout}`);
75
+ if (stderr)
76
+ logger.info(`[pid=${spawnedProcess.pid}] taskkill stderr: ${stderr}`);
77
+ } else {
78
+ process.kill(-spawnedProcess.pid, "SIGKILL");
79
+ }
80
+ } catch (e) {
81
+ logger.info(
82
+ `[pid=${spawnedProcess.pid}] exception while trying to kill process: ${e}`,
83
+ );
84
+ // the process might have already stopped
85
+ }
86
+ await closedPromise;
87
+ };
88
+
89
+ const startOperation = Abort.startOperation();
90
+ startOperation.addAbortSignal(signal);
91
+ const timeoutAbortSource = startOperation.timeout(allocatedMs);
92
+ startOperation.addAbortCallback(killProcess);
93
+ teardown.addCallback(killProcess);
94
+
95
+ const startedPromise = (async () => {
96
+ const logScale = [100, 250, 500];
97
+ // eslint-disable-next-line no-constant-condition
98
+ while (true) {
99
+ if (errorReceived) {
100
+ break;
101
+ }
102
+ const connected = await pingServer(webServer.origin);
103
+ if (connected) {
104
+ break;
105
+ }
106
+ startOperation.throwIfAborted();
107
+ const delay = logScale.shift() || 1000;
108
+ logger.debug(`Waiting ${delay}ms`);
109
+ await new Promise((x) => setTimeout(x, delay));
110
+ }
111
+ })();
112
+
113
+ try {
114
+ await Promise.race([errorPromise, startedPromise]);
115
+ } catch (e) {
116
+ if (Abort.isAbortError(e)) {
117
+ if (timeoutAbortSource.signal.aborted) {
118
+ // aborted by timeout
119
+ throw new Error(
120
+ `"${webServer.command}" command did not start a server in less than ${allocatedMs}ms (webServer.command)`,
121
+ );
122
+ }
123
+ if (signal.aborted) {
124
+ // aborted from outside
125
+ return;
126
+ }
127
+ }
128
+ throw e;
129
+ } finally {
130
+ await startOperation.end();
131
+ }
132
+ };
@@ -0,0 +1,79 @@
1
+ import { existsSync } from "node:fs";
2
+ import { Worker } from "node:worker_threads";
3
+ import { Abort } from "@jsenv/abort";
4
+
5
+ import { pingServer } from "../../helpers/ping_server.js";
6
+
7
+ export const startServerUsingModuleUrl = async (
8
+ webServer,
9
+ { signal, teardown, logger, allocatedMs },
10
+ ) => {
11
+ if (!existsSync(new URL(webServer.moduleUrl))) {
12
+ throw new Error(
13
+ `webServer.moduleUrl does not lead to a file at "${webServer.moduleUrl}"`,
14
+ );
15
+ }
16
+ const worker = new Worker(webServer.moduleUrl, {
17
+ env: {
18
+ // IMPORTED_BY_TEST_PLAN: "1",
19
+ },
20
+ stdin: true,
21
+ stdout: true,
22
+ });
23
+ let errorReceived = false;
24
+ const errorPromise = new Promise((resolve, reject) => {
25
+ worker.on("error", (e) => {
26
+ errorReceived = true;
27
+ reject(e);
28
+ });
29
+ });
30
+
31
+ const killWorker = async () => {
32
+ await worker.terminate();
33
+ };
34
+
35
+ const startOperation = Abort.startOperation();
36
+ startOperation.addAbortSignal(signal);
37
+ const timeoutAbortSource = startOperation.timeout(allocatedMs);
38
+ startOperation.addAbortCallback(killWorker);
39
+ teardown.addCallback(killWorker);
40
+
41
+ const startedPromise = (async () => {
42
+ const logScale = [100, 250, 500];
43
+ // eslint-disable-next-line no-constant-condition
44
+ while (true) {
45
+ if (errorReceived) {
46
+ break;
47
+ }
48
+ const connected = await pingServer(webServer.origin);
49
+ if (connected) {
50
+ break;
51
+ }
52
+
53
+ startOperation.throwIfAborted();
54
+ const delay = logScale.shift() || 1000;
55
+ logger.debug(`Waiting ${delay}ms`);
56
+ await new Promise((x) => setTimeout(x, delay));
57
+ }
58
+ })();
59
+
60
+ try {
61
+ await Promise.race([errorPromise, startedPromise]);
62
+ } catch (e) {
63
+ if (Abort.isAbortError(e)) {
64
+ if (timeoutAbortSource.signal.aborted) {
65
+ // aborted by timeout
66
+ throw new Error(
67
+ `"${webServer.moduleUrl}" did not start a server in less than ${allocatedMs}ms (webServer.moduleUrl)`,
68
+ );
69
+ }
70
+ if (signal.aborted) {
71
+ // aborted from outside
72
+ return;
73
+ }
74
+ }
75
+ throw e;
76
+ } finally {
77
+ await startOperation.end();
78
+ }
79
+ };
@@ -0,0 +1,5 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+
3
+ const { url } = workerData;
4
+ await import(url);
5
+ parentPort.postMessage("done");
@@ -1,53 +1,43 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import { assertAndNormalizeDirectoryUrl } from "@jsenv/filesystem";
3
2
 
3
+ import { startServerUsingModuleUrl } from "./web_server/start_using_module_url.js";
4
+ import { startServerUsingCommand } from "./web_server/start_using_command.js";
4
5
  import { pingServer } from "../helpers/ping_server.js";
5
6
  import { basicFetch } from "../helpers/basic_fetch.js";
6
7
 
7
- export const assertAndNormalizeWebServer = async (webServer) => {
8
+ export const assertAndNormalizeWebServer = async (
9
+ webServer,
10
+ { signal, logger, teardown },
11
+ ) => {
8
12
  if (!webServer) {
9
13
  throw new TypeError(
10
14
  `webServer is required when running tests on browser(s)`,
11
15
  );
12
16
  }
13
17
  const unexpectedParamNames = Object.keys(webServer).filter((key) => {
14
- return !["origin", "moduleUrl", "rootDirectoryUrl"].includes(key);
18
+ return ![
19
+ "origin",
20
+ "moduleUrl",
21
+ "command",
22
+ "cwd",
23
+ "rootDirectoryUrl",
24
+ ].includes(key);
15
25
  });
16
26
  if (unexpectedParamNames.length > 0) {
17
27
  throw new TypeError(
18
28
  `${unexpectedParamNames.join(",")}: there is no such param to webServer`,
19
29
  );
20
30
  }
21
-
22
- let aServerIsListening = await pingServer(webServer.origin);
23
- if (!aServerIsListening) {
24
- if (!webServer.moduleUrl) {
25
- throw new TypeError(
26
- `webServer.moduleUrl is required as there is no server listening "${webServer.origin}"`,
27
- );
28
- }
29
- try {
30
- process.env.IMPORTED_BY_TEST_PLAN = "1";
31
- await import(webServer.moduleUrl);
32
- delete process.env.IMPORTED_BY_TEST_PLAN;
33
- } catch (e) {
34
- if (
35
- e.code === "ERR_MODULE_NOT_FOUND" &&
36
- e.message.includes(fileURLToPath(webServer.moduleUrl))
37
- ) {
38
- throw new Error(
39
- `webServer.moduleUrl does not lead to a file at "${webServer.moduleUrl}"`,
40
- );
41
- }
42
- throw e;
43
- }
44
- aServerIsListening = await pingServer(webServer.origin);
45
- if (!aServerIsListening) {
46
- throw new Error(
47
- `webServer.moduleUrl did not start a server listening at "${webServer.origin}", check file at "${webServer.moduleUrl}"`,
48
- );
49
- }
31
+ if (typeof webServer.origin !== "string") {
32
+ throw new TypeError(
33
+ `webServer.origin must be a string, got ${webServer.origin}`,
34
+ );
50
35
  }
36
+ await ensureWebServerIsStarted(webServer, {
37
+ signal,
38
+ teardown,
39
+ logger,
40
+ });
51
41
  const { headers } = await basicFetch(webServer.origin, {
52
42
  method: "GET",
53
43
  rejectUnauthorized: false,
@@ -76,3 +66,34 @@ export const assertAndNormalizeWebServer = async (webServer) => {
76
66
  );
77
67
  }
78
68
  };
69
+
70
+ export const ensureWebServerIsStarted = async (
71
+ webServer,
72
+ { signal, teardown, logger, allocatedMs = 5_000 },
73
+ ) => {
74
+ const aServerIsListening = await pingServer(webServer.origin);
75
+ if (aServerIsListening) {
76
+ return;
77
+ }
78
+ if (webServer.moduleUrl) {
79
+ await startServerUsingModuleUrl(webServer, {
80
+ signal,
81
+ allocatedMs,
82
+ teardown,
83
+ logger,
84
+ });
85
+ return;
86
+ }
87
+ if (webServer.command) {
88
+ await startServerUsingCommand(webServer, {
89
+ signal,
90
+ allocatedMs,
91
+ teardown,
92
+ logger,
93
+ });
94
+ return;
95
+ }
96
+ throw new TypeError(
97
+ `webServer.moduleUrl or webServer.command is required as there is no server listening "${webServer.origin}"`,
98
+ );
99
+ };
@@ -0,0 +1,15 @@
1
+ export const createTeardown = () => {
2
+ const teardownCallbackSet = new Set();
3
+ return {
4
+ addCallback: (callback) => {
5
+ teardownCallbackSet.add(callback);
6
+ },
7
+ trigger: async () => {
8
+ await Promise.all(
9
+ Array.from(teardownCallbackSet.values()).map(async (callback) => {
10
+ await callback();
11
+ }),
12
+ );
13
+ },
14
+ };
15
+ };
@@ -52,7 +52,8 @@ export const createRuntimeUsingPlaywright = ({
52
52
  coverageMethodForBrowsers,
53
53
  coverageFileUrl,
54
54
 
55
- stopAfterAllSignal,
55
+ teardown,
56
+ isTestPlan,
56
57
  stopSignal,
57
58
  keepRunning,
58
59
  onConsole,
@@ -71,7 +72,7 @@ export const createRuntimeUsingPlaywright = ({
71
72
  await cleanupCallbackList.notify({ reason });
72
73
  });
73
74
 
74
- const isBrowserDedicatedToExecution = isolatedTab || !stopAfterAllSignal;
75
+ const isBrowserDedicatedToExecution = isolatedTab || !isTestPlan;
75
76
  let browserAndContextPromise = isBrowserDedicatedToExecution
76
77
  ? null
77
78
  : browserPromiseCache.get(label);
@@ -320,15 +321,11 @@ export const createRuntimeUsingPlaywright = ({
320
321
  cleanupCallbackList.add(() => {
321
322
  browser.removeListener("disconnected", disconnectedCallback);
322
323
  });
323
- const notifyPrevious = stopAfterAllSignal.notify;
324
- stopAfterAllSignal.notify = async () => {
325
- await notifyPrevious();
324
+ teardown.addCallback(async () => {
326
325
  browser.removeListener("disconnected", disconnectedCallback);
327
- logger.debug(
328
- `stopAfterAllSignal notified -> closing ${browserName}`,
329
- );
326
+ logger.debug(`testPlan teardown -> closing ${browserName}`);
330
327
  await closeBrowser();
331
- };
328
+ });
332
329
  }
333
330
  },
334
331
  response: async (cb) => {