@jsenv/test 2.1.3 → 3.0.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.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/dist/js/exception.js +908 -0
  3. package/dist/js/execute_using_dynamic_import.js +76 -167
  4. package/dist/js/v8_coverage.js +4 -482
  5. package/dist/jsenv_test.js +4608 -2917
  6. package/dist/node_child_process_controlled.mjs +95 -76
  7. package/dist/node_worker_thread_controlled.mjs +72 -54
  8. package/package.json +23 -14
  9. package/src/coverage/generate_coverage.js +186 -0
  10. package/src/coverage/list_files_not_covered.js +2 -2
  11. package/src/coverage/missing_coverage.js +2 -2
  12. package/src/coverage/report_coverage_as_html.js +45 -0
  13. package/src/coverage/report_coverage_as_json.js +20 -0
  14. package/src/coverage/{coverage_reporter_text_log.js → report_coverage_in_console.js} +13 -7
  15. package/src/coverage/v8_and_istanbul.js +4 -4
  16. package/src/coverage/v8_coverage.js +2 -2
  17. package/src/execution/exception.js +151 -68
  18. package/src/execution/execute.js +24 -11
  19. package/src/execution/execute_test_plan.js +834 -750
  20. package/src/execution/format_error_for_terminal.js +187 -0
  21. package/src/execution/github_annotation_from_error.js +3 -3
  22. package/src/execution/junit_xml_file/report_as_junit_xml.js +149 -0
  23. package/src/execution/junit_xml_file/xml_generator.js +141 -0
  24. package/src/execution/reporters/reporter_list.js +687 -0
  25. package/src/execution/run.js +92 -30
  26. package/src/execution/web_server/start_using_command.js +2 -2
  27. package/src/execution/web_server_param.js +5 -5
  28. package/src/helpers/cpu_usage.js +142 -0
  29. package/src/helpers/cpu_usage.test_manual.js +7 -0
  30. package/src/main.js +8 -0
  31. package/src/runtime_browsers/chromium.js +1 -3
  32. package/src/runtime_browsers/firefox.js +0 -3
  33. package/src/runtime_browsers/middleware_istanbul.js +2 -2
  34. package/src/runtime_browsers/using_playwright.js +296 -220
  35. package/src/runtime_browsers/webkit.js +0 -3
  36. package/src/runtime_node/execute_using_dynamic_import.js +75 -28
  37. package/src/runtime_node/node_child_process.js +250 -139
  38. package/src/runtime_node/node_child_process_controlled.mjs +94 -75
  39. package/src/runtime_node/node_worker_thread.js +206 -120
  40. package/src/runtime_node/node_worker_thread_controlled.mjs +70 -53
  41. package/src/coverage/coverage_reporter_html_directory.js +0 -32
  42. package/src/coverage/coverage_reporter_json_file.js +0 -17
  43. package/src/coverage/report_to_coverage.js +0 -198
  44. package/src/execution/execution_colors.js +0 -10
  45. package/src/execution/execution_steps.js +0 -65
  46. package/src/execution/logs_file_execution.js +0 -495
@@ -1,45 +1,31 @@
1
- import os from "node:os";
1
+ /*
2
+ *
3
+ */
4
+
5
+ import { cpus, availableParallelism, freemem, totalmem } from "node:os";
2
6
  import { existsSync } from "node:fs";
3
- import { memoryUsage } from "node:process";
4
7
  import { takeCoverage } from "node:v8";
5
8
  import stripAnsi from "strip-ansi";
6
9
  import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
7
10
  import { URL_META } from "@jsenv/url-meta";
8
- import { urlToFileSystemPath, urlToRelativeUrl } from "@jsenv/urls";
9
11
  import {
10
12
  ensureEmptyDirectory,
11
13
  assertAndNormalizeDirectoryUrl,
12
- assertAndNormalizeFileUrl,
13
- writeFileSync,
14
+ collectFiles,
14
15
  } from "@jsenv/filesystem";
15
- import {
16
- createLogger,
17
- createDetailedMessage,
18
- UNICODE,
19
- createLog,
20
- startSpinner,
21
- } from "@jsenv/log";
16
+ import { createLogger, createDetailedMessage, UNICODE } from "@jsenv/log";
22
17
  import {
23
18
  startGithubCheckRun,
24
19
  readGitHubWorkflowEnv,
25
20
  } from "@jsenv/github-check-run";
26
21
 
27
- import { createTeardown } from "../helpers/teardown.js";
22
+ import { startMeasuringCpuUsage } from "../helpers/cpu_usage.js";
28
23
  import { createCallOrderer } from "../helpers/call_orderer.js";
29
- import { reportToCoverage } from "../coverage/report_to_coverage.js";
30
- import { generateCoverageJsonFile } from "../coverage/coverage_reporter_json_file.js";
31
- import { generateCoverageHtmlDirectory } from "../coverage/coverage_reporter_html_directory.js";
32
- import { generateCoverageTextLog } from "../coverage/coverage_reporter_text_log.js";
24
+ import { generateCoverage } from "../coverage/generate_coverage.js";
33
25
  import { assertAndNormalizeWebServer } from "./web_server_param.js";
34
- import { executionStepsFromTestPlan } from "./execution_steps.js";
35
- import {
36
- createExecutionLog,
37
- formatSummaryLog,
38
- formatSummary,
39
- } from "./logs_file_execution.js";
40
26
  import { githubAnnotationFromError } from "./github_annotation_from_error.js";
41
27
  import { run } from "./run.js";
42
- import { ensureGlobalGc } from "./gc.js";
28
+ import { reporterList, renderOutro } from "./reporters/reporter_list.js";
43
29
 
44
30
  /**
45
31
  * Execute a list of files and log how it goes.
@@ -47,58 +33,29 @@ import { ensureGlobalGc } from "./gc.js";
47
33
  * @param {string|url} testPlanParameters.rootDirectoryUrl Directory containing test files;
48
34
  * @param {Object} [testPlanParameters.webServer] Web server info; required when executing test on browsers
49
35
  * @param {Object} testPlanParameters.testPlan Object associating files with runtimes where they will be executed
50
- * @param {boolean} [testPlanParameters.logShortForCompletedExecutions=false] Abbreviate completed execution information to shorten terminal output
51
- * @param {boolean} [testPlanParameters.logMergeForCompletedExecutions=false] Merge completed execution logs to shorten terminal output
52
- * @param {boolean|number} [testPlanParameters.concurrency=false] Maximum amount of execution running at the same time
36
+ * @param {Object|false} [testPlanParameters.parallel] Maximum amount of execution running at the same time
53
37
  * @param {number} [testPlanParameters.defaultMsAllocatedPerExecution=30000] Milliseconds after which execution is aborted and considered as failed by timeout
54
38
  * @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
55
- * @param {number} [testPlanParameters.cooldownBetweenExecutions=0] Millisecond to wait between each execution
56
- * @param {boolean} [testPlanParameters.logMemoryHeapUsage=false] Add memory heap usage during logs
57
- * @param {boolean} [testPlanParameters.coverageEnabled=false] Controls if coverage is collected during files executions
58
- * @param {boolean} [testPlanParameters.coverageV8ConflictWarning=true] Warn when coverage from 2 executions cannot be merged
39
+ * @param {Object|false} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
59
40
  * @return {Object} An object containing the result of all file executions
60
41
  */
61
- export const executeTestPlan = async ({
62
- signal = new AbortController().signal,
63
- handleSIGINT = true,
64
- logLevel = "info",
65
- logRefresh = true,
66
- logRuntime = true,
67
- logEachDuration = true,
68
- logSummary = true,
69
- logTimeUsage = false,
70
- logMemoryHeapUsage = false,
71
- logFileRelativeUrl = ".jsenv/test_plan_debug.txt",
72
- logShortForCompletedExecutions = false,
73
- logMergeForCompletedExecutions = false,
74
-
75
- rootDirectoryUrl,
76
- webServer,
77
- testPlan,
78
- updateProcessExitCode = true,
79
- concurrency = false,
80
- defaultMsAllocatedPerExecution = 30_000,
81
- failFast = false,
82
- // keepRunning: false to ensure runtime is stopped once executed
83
- // because we have what we wants: execution is completed and
84
- // we have associated coverage and console output
85
- // passsing true means all node process and browsers launched stays opened
86
- // (can eventually be used for debug)
87
- keepRunning = false,
88
- cooldownBetweenExecutions = 0,
89
- gcBetweenExecutions = logMemoryHeapUsage,
90
-
91
- githubCheckEnabled = Boolean(process.env.GITHUB_WORKFLOW),
92
- githubCheckLogLevel,
93
- githubCheckName = "Jsenv tests",
94
- githubCheckTitle = "Tests executions",
95
- githubCheckToken,
96
- githubCheckRepositoryOwner,
97
- githubCheckRepositoryName,
98
- githubCheckCommitSha,
99
-
100
- coverageEnabled = process.argv.includes("--coverage"),
101
- coverageConfig = {
42
+ const logsDefault = {
43
+ level: "info",
44
+ type: "list",
45
+ animated: true,
46
+ fileUrl: undefined,
47
+ };
48
+ const githubCheckDefault = {
49
+ logLevel: "info",
50
+ name: "Jsenv tests",
51
+ title: "Tests execution",
52
+ token: undefined,
53
+ repositoryOwner: undefined,
54
+ repositoryName: undefined,
55
+ commitSha: undefined,
56
+ };
57
+ const coverageDefault = {
58
+ include: {
102
59
  "file:///**/node_modules/": false,
103
60
  "./**/.*": false,
104
61
  "./**/.*/": false,
@@ -112,9 +69,9 @@ export const executeTestPlan = async ({
112
69
  "./**/*.test.js": false,
113
70
  "./**/*.test.mjs": false,
114
71
  },
115
- coverageIncludeMissing = true,
116
- coverageAndExecutionAllowed = false,
117
- coverageMethodForNodeJs = process.env.NODE_V8_COVERAGE
72
+ includeMissing: true,
73
+ coverageAndExecutionAllowed: false,
74
+ methodForNodeJs: process.env.NODE_V8_COVERAGE
118
75
  ? "NODE_V8_COVERAGE"
119
76
  : "Profiler",
120
77
  // - When chromium only -> coverage generated by v8
@@ -124,765 +81,892 @@ export const executeTestPlan = async ({
124
81
  // -> by default only coverage from chromium is used
125
82
  // and a warning is logged according to coverageV8ConflictWarning
126
83
  // -> to collect coverage from both browsers, pass coverageMethodForBrowsers: "istanbul"
127
- coverageMethodForBrowsers, // undefined | "playwright" | "istanbul"
128
- coverageV8ConflictWarning = true,
129
- coverageTempDirectoryUrl,
130
- // skip empty means empty files won't appear in the coverage reports (json and html)
131
- coverageReportSkipEmpty = false,
132
- // skip full means file with 100% coverage won't appear in coverage reports (json and html)
133
- coverageReportSkipFull = false,
134
- coverageReportTextLog = true,
135
- coverageReportJson = process.env.CI,
136
- coverageReportJsonFileUrl,
137
- coverageReportHtml = !process.env.CI,
138
- coverageReportHtmlDirectoryUrl,
139
-
140
- beforeExecutionCallback = () => {},
141
- afterExecutionCallback = () => {},
142
- afterAllExecutionCallback = () => {},
84
+ methodForBrowsers: undefined, // undefined | "playwright" | "istanbul"
85
+ v8ConflictWarning: true,
86
+ tempDirectoryUrl: undefined,
87
+ };
88
+ const parallelDefault = {
89
+ max: "80%", // percentage resolved against the available cpus
90
+ maxCpu: "90%",
91
+ maxMemory: "90%",
92
+ };
93
+
94
+ export const executeTestPlan = async ({
95
+ logs = logsDefault,
96
+
97
+ rootDirectoryUrl,
98
+ webServer,
99
+ testPlan,
100
+
101
+ signal = new AbortController().signal,
102
+ handleSIGINT = true,
103
+ handleSIGUP = true,
104
+ handleSIGTERM = true,
105
+ updateProcessExitCode = true,
106
+ parallel = parallelDefault,
107
+ defaultMsAllocatedPerExecution = 30_000,
108
+ failFast = false,
109
+ // keepRunning: false to ensure runtime is stopped once executed
110
+ // because we have what we wants: execution is completed and
111
+ // we have associated coverage and console output
112
+ // passsing true means all node process and browsers launched stays opened
113
+ // (can eventually be used for debug)
114
+ keepRunning = false,
115
+
116
+ githubCheck = process.env.GITHUB_WORKFLOW ? githubCheckDefault : null,
117
+ coverage = process.argv.includes("--coverage") ? coverageDefault : null,
118
+
119
+ reporters = [],
143
120
  ...rest
144
121
  }) => {
145
- const teardown = createTeardown();
146
-
147
- const beforeExecutionCallbackSet = new Set();
148
- const afterExecutionCallbackSet = new Set();
149
- const afterAllExecutionCallbackSet = new Set();
150
- beforeExecutionCallbackSet.add(beforeExecutionCallback);
151
- afterExecutionCallbackSet.add(afterExecutionCallback);
152
- afterAllExecutionCallbackSet.add(afterAllExecutionCallback);
122
+ const teardownCallbackSet = new Set();
153
123
 
154
124
  const operation = Abort.startOperation();
155
125
  operation.addAbortSignal(signal);
156
- if (handleSIGINT) {
126
+ if (handleSIGINT || handleSIGUP || handleSIGTERM) {
157
127
  operation.addAbortSource((abort) => {
158
128
  return raceProcessTeardownEvents(
159
129
  {
160
- SIGINT: true,
130
+ SIGINT: handleSIGINT,
131
+ SIGHUP: handleSIGUP,
132
+ SIGTERM: handleSIGTERM,
161
133
  },
162
- () => {
163
- logger.debug(`SIGINT abort`);
134
+ ({ name }) => {
135
+ console.log(`${name} -> abort`);
164
136
  abort();
165
137
  },
166
138
  );
167
139
  });
168
140
  }
169
141
 
170
- let logger;
171
- let someNeedsServer = false;
172
- let someHasCoverageV8 = false;
173
- let someNodeRuntime = false;
174
- const runtimes = {};
175
- // param validation
176
- {
177
- const unexpectedParamNames = Object.keys(rest);
178
- if (unexpectedParamNames.length > 0) {
179
- throw new TypeError(
180
- `${unexpectedParamNames.join(",")}: there is no such param`,
181
- );
182
- }
183
- rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
184
- rootDirectoryUrl,
185
- "rootDirectoryUrl",
186
- );
187
- if (!existsSync(new URL(rootDirectoryUrl))) {
188
- throw new Error(`ENOENT on rootDirectoryUrl at ${rootDirectoryUrl}`);
189
- }
190
- if (typeof testPlan !== "object") {
191
- throw new Error(`testPlan must be an object, got ${testPlan}`);
192
- }
142
+ const timingOrigin = Date.now();
143
+ const takeTiming = () => {
144
+ return Date.now() - timingOrigin;
145
+ };
146
+ const testPlanResult = {
147
+ rootDirectoryUrl: String(rootDirectoryUrl),
148
+ groups: {},
149
+ counters: {
150
+ planified: 0,
151
+ remaining: 0,
152
+ waiting: 0,
153
+ executing: 0,
154
+ executed: 0,
193
155
 
194
- logger = createLogger({ logLevel });
195
-
196
- Object.keys(testPlan).forEach((filePattern) => {
197
- const filePlan = testPlan[filePattern];
198
- if (!filePlan) return;
199
- Object.keys(filePlan).forEach((executionName) => {
200
- const executionConfig = filePlan[executionName];
201
- const { runtime } = executionConfig;
202
- if (runtime) {
203
- runtimes[runtime.name] = runtime.version;
204
- if (runtime.type === "browser") {
205
- if (runtime.capabilities && runtime.capabilities.coverageV8) {
206
- someHasCoverageV8 = true;
207
- }
208
- someNeedsServer = true;
156
+ aborted: 0,
157
+ cancelled: 0,
158
+ timedout: 0,
159
+ failed: 0,
160
+ completed: 0,
161
+ },
162
+ aborted: false,
163
+ failed: false,
164
+ timings: {
165
+ origin: timingOrigin,
166
+ executionStart: null,
167
+ executionEnd: null,
168
+ teardownEnd: null,
169
+ coverageTeardownEnd: null,
170
+ end: null,
171
+ },
172
+ coverage: null,
173
+ results: {},
174
+ };
175
+ const timings = testPlanResult.timings;
176
+ const groups = testPlanResult.groups;
177
+ const counters = testPlanResult.counters;
178
+ const countersInOrder = { ...counters };
179
+ const results = testPlanResult.results;
180
+
181
+ const beforeEachCallbackSet = new Set();
182
+ const beforeEachInOrderCallbackSet = new Set();
183
+ const afterEachCallbackSet = new Set();
184
+ const afterEachInOrderCallbackSet = new Set();
185
+ const afterAllCallbackSet = new Set();
186
+ let finalizeCoverage;
187
+
188
+ try {
189
+ const cpuUsage = startMeasuringCpuUsage();
190
+ operation.addEndCallback(cpuUsage.stop);
191
+
192
+ let logger;
193
+ const runtimeInfo = {
194
+ someNeedsServer: false,
195
+ someHasCoverageV8: false,
196
+ someNodeRuntime: false,
197
+ };
198
+ // param validation and normalization
199
+ {
200
+ const unexpectedParamNames = Object.keys(rest);
201
+ if (unexpectedParamNames.length > 0) {
202
+ throw new TypeError(`${unexpectedParamNames.join(",")}: no such param`);
203
+ }
204
+ // logs
205
+ {
206
+ if (typeof logs !== "object") {
207
+ throw new TypeError(`logs must be an object, got ${logs}`);
208
+ }
209
+ const unexpectedLogsKeys = Object.keys(logs).filter(
210
+ (key) => !Object.hasOwn(logsDefault, key),
211
+ );
212
+ if (unexpectedLogsKeys.length > 0) {
213
+ throw new TypeError(
214
+ `${unexpectedLogsKeys.join(",")}: no such key on logs`,
215
+ );
216
+ }
217
+ logs = { ...logsDefault, ...logs };
218
+ logger = createLogger({ logLevel: logs.level });
219
+
220
+ if (logs.type === "list" && logger.levels.info) {
221
+ const listReporterOptions = {
222
+ animated: logger.levels.debug ? false : logs.animated,
223
+ fileUrl:
224
+ logs.fileUrl === undefined
225
+ ? new URL("./.jsenv/jsenv_tests_output.txt", rootDirectoryUrl)
226
+ : logs.fileUrl,
227
+ };
228
+ reporters.push(reporterList(listReporterOptions));
229
+ }
230
+ }
231
+ // rootDirectoryUrl
232
+ {
233
+ rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
234
+ rootDirectoryUrl,
235
+ "rootDirectoryUrl",
236
+ );
237
+ if (!existsSync(new URL(rootDirectoryUrl))) {
238
+ throw new Error(`ENOENT on rootDirectoryUrl at ${rootDirectoryUrl}`);
239
+ }
240
+ }
241
+ // parallel
242
+ {
243
+ if (parallel === true) {
244
+ parallel = {};
245
+ }
246
+ if (parallel === false) {
247
+ parallel = { max: 1 };
248
+ }
249
+ if (typeof parallel !== "object") {
250
+ throw new TypeError(`parallel must be an object, got ${parallel}`);
251
+ }
252
+ const unexpectedParallelKeys = Object.keys(parallel).filter(
253
+ (key) => !Object.hasOwn(parallelDefault, key),
254
+ );
255
+ if (unexpectedParallelKeys.length > 0) {
256
+ throw new TypeError(
257
+ `${unexpectedParallelKeys.join(",")}: no such key on parallel`,
258
+ );
259
+ }
260
+ parallel = { ...parallelDefault, ...parallel };
261
+ const assertPercentageAndConvertToRatio = (string) => {
262
+ const lastChar = string[string.length - 1];
263
+ if (lastChar !== "%") {
264
+ throw new TypeError(`string is not a percentage, got ${string}`);
265
+ }
266
+ const percentageString = string.slice(0, -1);
267
+ const percentageNumber = parseInt(percentageString);
268
+ if (percentageNumber <= 0) {
269
+ return 0;
209
270
  }
210
- if (runtime.type === "node") {
211
- someNodeRuntime = true;
271
+ if (percentageNumber >= 100) {
272
+ return 1;
212
273
  }
274
+ const ratio = percentageNumber / 100;
275
+ return ratio;
276
+ };
277
+ const max = parallel.max;
278
+ if (typeof max === "string") {
279
+ const maxAsRatio = assertPercentageAndConvertToRatio(max);
280
+ const availableCpus = countAvailableCpus();
281
+ parallel.max = Math.round(maxAsRatio * availableCpus) || 1;
282
+ } else if (typeof max === "number") {
283
+ if (max < 1) {
284
+ parallel.max = 1;
285
+ }
286
+ } else {
287
+ throw new TypeError(
288
+ `parallel.max must be a number or a percentage, got ${max}`,
289
+ );
213
290
  }
214
- });
215
- });
216
-
217
- if (someNeedsServer) {
218
- await assertAndNormalizeWebServer(webServer, {
219
- signal: operation.signal,
220
- teardown,
221
- logger,
222
- });
223
- }
224
291
 
225
- if (githubCheckEnabled && !process.env.GITHUB_TOKEN) {
226
- githubCheckEnabled = false;
227
- const suggestions = [];
228
- if (process.env.GITHUB_WORKFLOW_REF) {
229
- const workflowFileRef = process.env.GITHUB_WORKFLOW_REF;
230
- const refsIndex = workflowFileRef.indexOf("@refs/");
231
- // see "GITHUB_WORKFLOW_REF" in https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
232
- const workflowFilePath =
233
- refsIndex === -1
234
- ? workflowFileRef
235
- : workflowFileRef.slice(0, refsIndex);
236
- suggestions.push(`Pass github token in ${workflowFilePath} during job "${process.env.GITHUB_JOB}"
292
+ const maxMemory = parallel.maxMemory;
293
+ if (typeof maxMemory === "string") {
294
+ const maxMemoryAsRatio = assertPercentageAndConvertToRatio(maxMemory);
295
+ parallel.maxMemory = Math.round(maxMemoryAsRatio * totalmem());
296
+ } else if (typeof maxMemory !== "number") {
297
+ throw new TypeError(
298
+ `parallel.maxMemory must be a number or a percentage, got ${maxMemory}`,
299
+ );
300
+ }
301
+ }
302
+ // testPlan
303
+ {
304
+ if (typeof testPlan !== "object") {
305
+ throw new Error(`testPlan must be an object, got ${testPlan}`);
306
+ }
307
+ for (const filePattern of Object.keys(testPlan)) {
308
+ const filePlan = testPlan[filePattern];
309
+ if (!filePlan) continue;
310
+ for (const executionName of Object.keys(filePlan)) {
311
+ const executionConfig = filePlan[executionName];
312
+ if (executionConfig === null) {
313
+ continue;
314
+ }
315
+ const { runtime } = executionConfig;
316
+ if (!runtime || runtime.disabled) {
317
+ continue;
318
+ }
319
+ if (runtime.type === "browser") {
320
+ if (runtime.capabilities && runtime.capabilities.coverageV8) {
321
+ runtimeInfo.someHasCoverageV8 = true;
322
+ }
323
+ runtimeInfo.someNeedsServer = true;
324
+ }
325
+ if (runtime.type === "node") {
326
+ runtimeInfo.someNodeRuntime = true;
327
+ }
328
+ }
329
+ }
330
+ testPlan = {
331
+ "file:///**/node_modules/": null,
332
+ "**/*./": null,
333
+ ...testPlan,
334
+ "**/.jsenv/": null, // ensure it's impossible to look for ".jsenv/"
335
+ };
336
+ }
337
+ // webServer
338
+ if (runtimeInfo.someNeedsServer) {
339
+ await assertAndNormalizeWebServer(webServer, {
340
+ signal: operation.signal,
341
+ teardownCallbackSet,
342
+ logger,
343
+ });
344
+ }
345
+ // githubCheck
346
+ {
347
+ if (githubCheck && !process.env.GITHUB_TOKEN) {
348
+ githubCheck = false;
349
+ const suggestions = [];
350
+ if (process.env.GITHUB_WORKFLOW_REF) {
351
+ const workflowFileRef = process.env.GITHUB_WORKFLOW_REF;
352
+ const refsIndex = workflowFileRef.indexOf("@refs/");
353
+ // see "GITHUB_WORKFLOW_REF" in https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
354
+ const workflowFilePath =
355
+ refsIndex === -1
356
+ ? workflowFileRef
357
+ : workflowFileRef.slice(0, refsIndex);
358
+ suggestions.push(`Pass github token in ${workflowFilePath} during job "${process.env.GITHUB_JOB}"
237
359
  \`\`\`yml
238
360
  env:
239
361
  GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
240
362
  \`\`\``);
241
- }
242
- suggestions.push(`Disable github check with githubCheckEnabled: false`);
243
- logger.warn(
244
- `${
245
- UNICODE.WARNING
246
- } githubCheckEnabled but process.env.GITHUB_TOKEN is missing.
363
+ }
364
+ suggestions.push(`Disable github check with githubCheck: false`);
365
+ logger.warn(
366
+ `${UNICODE.WARNING} githubCheck requires process.env.GITHUB_TOKEN.
247
367
  Integration with Github check API is disabled
248
368
  To fix this warning:
249
369
  - ${suggestions.join("\n- ")}
250
370
  `,
251
- );
252
- }
253
- if (githubCheckEnabled) {
254
- const githubCheckInfoFromEnv = process.env.GITHUB_WORKFLOW
255
- ? readGitHubWorkflowEnv()
256
- : {};
257
- githubCheckToken = githubCheckToken || githubCheckInfoFromEnv.githubToken;
258
- githubCheckRepositoryOwner =
259
- githubCheckRepositoryOwner || githubCheckInfoFromEnv.repositoryOwner;
260
- githubCheckRepositoryName =
261
- githubCheckRepositoryName || githubCheckInfoFromEnv.repositoryName;
262
- githubCheckCommitSha =
263
- githubCheckCommitSha || githubCheckInfoFromEnv.commitSha;
264
- }
371
+ );
372
+ }
373
+ if (githubCheck) {
374
+ if (githubCheck === true) {
375
+ githubCheck = {};
376
+ }
377
+ if (typeof githubCheck !== "object") {
378
+ throw new TypeError(
379
+ `githubCheck must be an object, got ${githubCheck}`,
380
+ );
381
+ }
382
+ const unexpectedKeys = Object.keys(githubCheck).filter(
383
+ (key) => !Object.hasOwn(githubCheckDefault, key),
384
+ );
385
+ if (unexpectedKeys.length > 0) {
386
+ throw new TypeError(
387
+ `${unexpectedKeys.join(",")}: no such key on githubCheck`,
388
+ );
389
+ }
265
390
 
266
- if (coverageEnabled) {
267
- if (coverageMethodForBrowsers === undefined) {
268
- coverageMethodForBrowsers = someHasCoverageV8
269
- ? "playwright"
270
- : "istanbul";
271
- }
272
- if (typeof coverageConfig !== "object") {
273
- throw new TypeError(
274
- `coverageConfig must be an object, got ${coverageConfig}`,
275
- );
391
+ const githubCheckInfoFromEnv = process.env.GITHUB_WORKFLOW
392
+ ? readGitHubWorkflowEnv()
393
+ : {};
394
+ githubCheck = { ...githubCheckDefault, githubCheck };
395
+ if (githubCheck.token === undefined) {
396
+ githubCheck.token = githubCheckInfoFromEnv.githubToken;
397
+ }
398
+ if (githubCheck.repositoryOwner === undefined) {
399
+ githubCheck.repositoryOwner =
400
+ githubCheckInfoFromEnv.repositoryOwner;
401
+ }
402
+ if (githubCheck.repositoryName === undefined) {
403
+ githubCheck.repositoryName = githubCheckInfoFromEnv.repositoryName;
404
+ }
405
+ if (githubCheck.commitSha === undefined) {
406
+ githubCheck.commitSha = githubCheckInfoFromEnv.commitSha;
407
+ }
408
+ }
276
409
  }
277
- if (!coverageAndExecutionAllowed) {
278
- const associationsForExecute = URL_META.resolveAssociations(
279
- { execute: testPlan },
280
- "file:///",
281
- );
282
- const associationsForCover = URL_META.resolveAssociations(
283
- { cover: coverageConfig },
284
- "file:///",
410
+ // coverage
411
+ if (coverage) {
412
+ if (coverage === true) {
413
+ coverage = {};
414
+ }
415
+ if (typeof coverage !== "object") {
416
+ throw new TypeError(`coverage must be an object, got ${coverage}`);
417
+ }
418
+ const unexpectedKeys = Object.keys(coverage).filter(
419
+ (key) => !Object.hasOwn(coverageDefault, key),
285
420
  );
286
- const patternsMatchingCoverAndExecute = Object.keys(
287
- associationsForExecute.execute,
288
- ).filter((testPlanPattern) => {
289
- const { cover } = URL_META.applyAssociations({
290
- url: testPlanPattern,
291
- associations: associationsForCover,
292
- });
293
- return cover;
294
- });
295
- if (patternsMatchingCoverAndExecute.length) {
296
- // It would be strange, for a given file to be both covered and executed
297
- throw new Error(
298
- createDetailedMessage(
299
- `some file will be both covered and executed`,
300
- {
301
- patterns: patternsMatchingCoverAndExecute,
302
- },
303
- ),
421
+ if (unexpectedKeys.length > 0) {
422
+ throw new TypeError(
423
+ `${unexpectedKeys.join(",")}: no such key on coverage`,
304
424
  );
305
425
  }
306
- }
307
-
308
- if (coverageTempDirectoryUrl === undefined) {
309
- coverageTempDirectoryUrl = new URL(
310
- "./.coverage/tmp/",
311
- rootDirectoryUrl,
312
- );
313
- } else {
314
- coverageTempDirectoryUrl = assertAndNormalizeDirectoryUrl(
315
- coverageTempDirectoryUrl,
316
- "coverageTempDirectoryUrl",
317
- );
318
- }
319
- if (coverageReportJson) {
320
- if (coverageReportJsonFileUrl === undefined) {
321
- coverageReportJsonFileUrl = new URL(
322
- "./.coverage/coverage.json",
323
- rootDirectoryUrl,
426
+ coverage = { ...coverageDefault, ...coverage };
427
+ if (typeof coverage.include !== "object") {
428
+ throw new TypeError(
429
+ `coverage.include must be an object, got ${coverage.include}`,
324
430
  );
325
- } else {
326
- coverageReportJsonFileUrl = assertAndNormalizeFileUrl(
327
- coverageReportJsonFileUrl,
328
- "coverageReportJsonFileUrl",
431
+ }
432
+ if (Object.keys(coverage.include).length === 0) {
433
+ logger.warn(
434
+ `coverage.include is an empty object. Nothing will be instrumented for coverage so your coverage will be empty`,
329
435
  );
330
436
  }
331
- }
332
- if (coverageReportHtml) {
333
- if (coverageReportHtmlDirectoryUrl === undefined) {
334
- coverageReportHtmlDirectoryUrl = new URL(
335
- "./.coverage/",
437
+ if (coverage.methodForBrowsers === undefined) {
438
+ coverage.methodForBrowsers = runtimeInfo.someHasCoverageV8
439
+ ? "playwright"
440
+ : "istanbul";
441
+ }
442
+ if (
443
+ runtimeInfo.someNodeRuntime &&
444
+ coverage.methodForNodeJs === "NODE_V8_COVERAGE"
445
+ ) {
446
+ if (process.env.NODE_V8_COVERAGE) {
447
+ // when runned multiple times, we don't want to keep previous files in this directory
448
+ await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE);
449
+ } else {
450
+ coverage.methodForNodeJs = "Profiler";
451
+ logger.warn(
452
+ createDetailedMessage(
453
+ `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
454
+ {
455
+ "suggestion": `set process.env.NODE_V8_COVERAGE`,
456
+ "suggestion 2": `use coverage.methodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
457
+ },
458
+ ),
459
+ );
460
+ }
461
+ }
462
+ if (!coverage.coverageAndExecutionAllowed) {
463
+ const associationsForExecute = URL_META.resolveAssociations(
464
+ { execute: testPlan },
465
+ "file:///",
466
+ );
467
+ const associationsForCover = URL_META.resolveAssociations(
468
+ { cover: coverage.include },
469
+ "file:///",
470
+ );
471
+ const patternsMatchingCoverAndExecute = Object.keys(
472
+ associationsForExecute.execute,
473
+ ).filter((testPlanPattern) => {
474
+ const { cover } = URL_META.applyAssociations({
475
+ url: testPlanPattern,
476
+ associations: associationsForCover,
477
+ });
478
+ return cover;
479
+ });
480
+ if (patternsMatchingCoverAndExecute.length) {
481
+ // It would be strange, for a given file to be both covered and executed
482
+ throw new Error(
483
+ createDetailedMessage(
484
+ `some file will be both covered and executed`,
485
+ {
486
+ patterns: patternsMatchingCoverAndExecute,
487
+ },
488
+ ),
489
+ );
490
+ }
491
+ }
492
+ if (coverage.tempDirectoryUrl === undefined) {
493
+ coverage.tempDirectoryUrl = new URL(
494
+ "./.coverage/tmp/",
336
495
  rootDirectoryUrl,
337
496
  );
338
497
  } else {
339
- coverageReportHtmlDirectoryUrl = assertAndNormalizeDirectoryUrl(
340
- coverageReportHtmlDirectoryUrl,
341
- "coverageReportHtmlDirectoryUrl",
498
+ coverage.tempDirectoryUrl = assertAndNormalizeDirectoryUrl(
499
+ coverage.tempDirectoryUrl,
500
+ "coverageTempDirectoryUrl",
342
501
  );
343
502
  }
344
503
  }
345
504
  }
346
505
 
347
- if (typeof concurrency !== "boolean" && typeof concurrency !== "number") {
348
- throw new TypeError(`concurrency must be a boolean or a number`);
349
- }
350
- }
506
+ const executionPlanifiedSet = new Set();
351
507
 
352
- logger.debug(
353
- createDetailedMessage(`Prepare executing plan`, {
354
- runtimes: JSON.stringify(runtimes, null, " "),
355
- }),
356
- );
357
-
358
- // param normalization
359
- {
360
- if (coverageEnabled) {
361
- if (Object.keys(coverageConfig).length === 0) {
362
- logger.warn(
363
- `coverageConfig is an empty object. Nothing will be instrumented for coverage so your coverage will be empty`,
364
- );
365
- }
366
- if (
367
- someNodeRuntime &&
368
- coverageEnabled &&
369
- coverageMethodForNodeJs === "NODE_V8_COVERAGE"
370
- ) {
371
- if (process.env.NODE_V8_COVERAGE) {
372
- // when runned multiple times, we don't want to keep previous files in this directory
373
- await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE);
374
- } else {
375
- coverageMethodForNodeJs = "Profiler";
376
- logger.warn(
377
- createDetailedMessage(
378
- `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
379
- {
380
- "suggestion": `set process.env.NODE_V8_COVERAGE`,
381
- "suggestion 2": `use coverageMethodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
382
- },
383
- ),
384
- );
508
+ // collect files to execute + fill executionPlanifiedSet
509
+ {
510
+ const fileResultArray = await collectFiles({
511
+ signal,
512
+ directoryUrl: rootDirectoryUrl,
513
+ associations: { testPlan },
514
+ predicate: ({ testPlan }) => testPlan,
515
+ });
516
+ let index = 0;
517
+ let lastExecution;
518
+ const fileExecutionCountMap = new Map();
519
+ for (const { relativeUrl, meta } of fileResultArray) {
520
+ const filePlan = meta.testPlan;
521
+ for (const groupName of Object.keys(filePlan)) {
522
+ const stepConfig = filePlan[groupName];
523
+ if (stepConfig === null || stepConfig === undefined) {
524
+ continue;
525
+ }
526
+ if (typeof stepConfig !== "object") {
527
+ throw new TypeError(
528
+ createDetailedMessage(
529
+ `found unexpected value in plan, they must be object`,
530
+ {
531
+ ["file relative path"]: relativeUrl,
532
+ ["group"]: groupName,
533
+ ["value"]: stepConfig,
534
+ },
535
+ ),
536
+ );
537
+ }
538
+ if (stepConfig.runtime?.disabled) {
539
+ continue;
540
+ }
541
+
542
+ const {
543
+ runtime,
544
+ runtimeParams,
545
+ allocatedMs = defaultMsAllocatedPerExecution,
546
+ } = stepConfig;
547
+ const params = {
548
+ measureMemoryUsage: true,
549
+ measurePerformance: false,
550
+ collectPerformance: false,
551
+ collectConsole: true,
552
+ allocatedMs,
553
+ runtime,
554
+ runtimeParams: {
555
+ rootDirectoryUrl,
556
+ webServer,
557
+ teardownCallbackSet,
558
+
559
+ coverageEnabled: Boolean(coverage),
560
+ coverageInclude: coverage?.include,
561
+ coverageMethodForBrowsers: coverage?.methodForBrowsers,
562
+ coverageMethodForNodeJs: coverage?.methodForNodeJs,
563
+ isTestPlan: true,
564
+ fileRelativeUrl: relativeUrl,
565
+ ...runtimeParams,
566
+ },
567
+ };
568
+ const runtimeType = runtime.type;
569
+ const runtimeName = runtime.name;
570
+ const runtimeVersion = runtime.version;
571
+
572
+ let fileExecutionCount;
573
+ if (fileExecutionCountMap.has(relativeUrl)) {
574
+ fileExecutionCount = fileExecutionCountMap.get(relativeUrl) + 1;
575
+ fileExecutionCountMap.set(relativeUrl, fileExecutionCount);
576
+ } else {
577
+ fileExecutionCount = 1;
578
+ fileExecutionCountMap.set(relativeUrl, fileExecutionCount);
579
+ }
580
+
581
+ const execution = {
582
+ counters,
583
+ countersInOrder,
584
+ index,
585
+ isLast: false,
586
+ group: groupName,
587
+ rootDirectoryUrl: String(rootDirectoryUrl),
588
+ fileRelativeUrl: relativeUrl,
589
+ fileExecutionIndex: fileExecutionCount - 1,
590
+ fileExecutionCount: null,
591
+ runtimeType,
592
+ runtimeName,
593
+ runtimeVersion,
594
+ params,
595
+
596
+ // will be set by run()
597
+ status: "planified",
598
+ result: {},
599
+ };
600
+ if (typeof params.allocatedMs === "function") {
601
+ params.allocatedMs = params.allocatedMs(execution);
602
+ }
603
+
604
+ lastExecution = execution;
605
+ executionPlanifiedSet.add(execution);
606
+ const existingResults = results[relativeUrl];
607
+ if (existingResults) {
608
+ existingResults[groupName] = execution.result;
609
+ } else {
610
+ results[relativeUrl] = {
611
+ [groupName]: execution.result,
612
+ };
613
+ }
614
+ const existingGroup = groups[groupName];
615
+ if (existingGroup) {
616
+ groups[groupName].count++;
617
+ } else {
618
+ groups[groupName] = {
619
+ count: 1,
620
+ runtimeType,
621
+ runtimeName,
622
+ runtimeVersion,
623
+ };
624
+ }
625
+ index++;
385
626
  }
386
627
  }
387
- }
388
- if (cooldownBetweenExecutions) {
389
- if (cooldownBetweenExecutions < 0) {
390
- cooldownBetweenExecutions = 0;
628
+ fileResultArray.length = 0;
629
+ fileExecutionCountMap.clear();
630
+ if (lastExecution) {
631
+ lastExecution.isLast = true;
391
632
  }
392
- if (cooldownBetweenExecutions === Infinity) {
393
- cooldownBetweenExecutions = 0;
394
- }
395
- }
396
- if (concurrency === true) {
397
- const availableCpus = os.cpus().length;
398
- concurrency =
399
- availableCpus > 2
400
- ? // One CPU is used to run this code so we do availableCpus - 1
401
- availableCpus - 1
402
- : 1;
403
633
  }
404
- if (concurrency === false) {
405
- concurrency = 1;
634
+
635
+ counters.planified =
636
+ counters.remaining =
637
+ counters.waiting =
638
+ executionPlanifiedSet.size;
639
+ countersInOrder.planified =
640
+ countersInOrder.remaining =
641
+ countersInOrder.waiting =
642
+ executionPlanifiedSet.size;
643
+ if (githubCheck) {
644
+ const githubCheckRun = await startGithubCheckRun({
645
+ logLevel: githubCheck.logLevel,
646
+ githubToken: githubCheck.token,
647
+ repositoryOwner: githubCheck.repositoryOwner,
648
+ repositoryName: githubCheck.repositoryName,
649
+ commitSha: githubCheck.commitSha,
650
+ checkName: githubCheck.name,
651
+ checkTitle: githubCheck.title,
652
+ checkSummary: `${executionPlanifiedSet.size} files will be executed`,
653
+ });
654
+ const annotations = [];
655
+ reporters.push({
656
+ beforeAll: (testPlanResult) => {
657
+ return {
658
+ afterEach: (execution) => {
659
+ const { result } = execution;
660
+ const { errors = [] } = result;
661
+ for (const error of errors) {
662
+ const annotation = githubAnnotationFromError(error, {
663
+ rootDirectoryUrl,
664
+ execution,
665
+ });
666
+ annotations.push(annotation);
667
+ }
668
+ },
669
+ afterAll: async () => {
670
+ const title = "Jsenv test results";
671
+ const summaryText = stripAnsi(renderOutro(testPlanResult));
672
+ if (testPlanResult.failed) {
673
+ await githubCheckRun.fail({
674
+ title,
675
+ summary: summaryText,
676
+ annotations,
677
+ });
678
+ return;
679
+ }
680
+ await githubCheckRun.pass({
681
+ title,
682
+ summary: summaryText,
683
+ annotations,
684
+ });
685
+ },
686
+ };
687
+ },
688
+ });
406
689
  }
407
- }
408
690
 
409
- testPlan = {
410
- "file:///**/node_modules/": null,
411
- "**/*./": null,
412
- ...testPlan,
413
- "**/.jsenv/": null,
414
- };
415
- logger.debug(`Generate executions`);
416
- let executionSteps = await executionStepsFromTestPlan({
417
- signal,
418
- testPlan,
419
- rootDirectoryUrl,
420
- });
421
- logger.debug(`${executionSteps.length} executions planned`);
422
- if (githubCheckEnabled) {
423
- const githubCheckRun = await startGithubCheckRun({
424
- logLevel: githubCheckLogLevel,
425
- githubToken: githubCheckToken,
426
- repositoryOwner: githubCheckRepositoryOwner,
427
- repositoryName: githubCheckRepositoryName,
428
- commitSha: githubCheckCommitSha,
429
- checkName: githubCheckName,
430
- checkTitle: githubCheckTitle,
431
- checkSummary: `${executionSteps.length} files will be executed`,
432
- });
433
- const annotations = [];
434
- afterExecutionCallbackSet.add((afterExecutionInfo) => {
435
- const { executionResult } = afterExecutionInfo;
436
- const { errors = [] } = executionResult;
437
- for (const error of errors) {
438
- const annotation = githubAnnotationFromError(error, {
439
- rootDirectoryUrl,
440
- executionInfo: afterExecutionInfo,
441
- });
442
- annotations.push(annotation);
691
+ timings.executionStart = takeTiming();
692
+ // execute all
693
+ {
694
+ const failFastAbortController = new AbortController();
695
+ if (failFast) {
696
+ operation.addAbortSignal(failFastAbortController.signal);
443
697
  }
444
- });
445
- afterAllExecutionCallbackSet.add(async (returnValue) => {
446
- const { summary } = returnValue;
447
- const title = "Jsenv test results";
448
- const summaryText = stripAnsi(formatSummary(summary));
449
- if (summary.counters.total !== summary.counters.completed) {
450
- await githubCheckRun.fail({
451
- title,
452
- summary: summaryText,
453
- annotations,
454
- });
455
- return;
698
+
699
+ if (coverage) {
700
+ // when runned multiple times, we don't want to keep previous files in this directory
701
+ await ensureEmptyDirectory(coverage.tempDirectoryUrl);
702
+ finalizeCoverage = async () => {
703
+ if (operation.signal.aborted) {
704
+ // don't try to do the coverage stuff
705
+ return;
706
+ }
707
+ try {
708
+ if (coverage.methodForNodeJs === "NODE_V8_COVERAGE") {
709
+ takeCoverage();
710
+ // conceptually we don't need coverage anymore so it would be
711
+ // good to call v8.stopCoverage()
712
+ // but it logs a strange message about "result is not an object"
713
+ }
714
+ const testPlanCoverage = await generateCoverage(testPlanResult, {
715
+ signal: operation.signal,
716
+ logger,
717
+ rootDirectoryUrl,
718
+ coverage,
719
+ });
720
+ testPlanResult.coverage = testPlanCoverage;
721
+ } catch (e) {
722
+ if (Abort.isAbortError(e)) {
723
+ return;
724
+ }
725
+ throw e;
726
+ }
727
+ };
456
728
  }
457
- await githubCheckRun.pass({
458
- title,
459
- summary: summaryText,
460
- annotations,
461
- });
462
- });
463
- }
464
729
 
465
- executionSteps = executionSteps.filter(
466
- (executionStep) => !executionStep.runtime?.disabled,
467
- );
730
+ const callWhenPreviousExecutionAreDone = createCallOrderer();
731
+
732
+ const executionRemainingSet = new Set(executionPlanifiedSet);
733
+ const executionExecutingSet = new Set();
734
+ const start = async (execution) => {
735
+ execution.fileExecutionCount = Object.keys(
736
+ testPlanResult.results[execution.fileRelativeUrl],
737
+ ).length;
738
+ mutateCountersBeforeExecutionStarts(counters, execution);
739
+ mutateCountersBeforeExecutionStarts(countersInOrder, execution);
740
+
741
+ execution.status = "executing";
742
+ executionRemainingSet.delete(execution);
743
+ executionExecutingSet.add(execution);
744
+ for (const beforeEachCallback of beforeEachCallbackSet) {
745
+ const returnValue = beforeEachCallback(execution, testPlanResult);
746
+ if (typeof returnValue === "function") {
747
+ const callback = (...args) => {
748
+ afterEachCallbackSet.delete(callback);
749
+ return returnValue(...args);
750
+ };
751
+ afterEachCallbackSet.add(callback);
752
+ }
753
+ }
468
754
 
469
- const returnValue = {
470
- aborted: false,
471
- summary: null,
472
- report: null,
473
- coverage: null,
474
- };
475
- const report = {};
476
- const callbacks = [];
477
-
478
- const multipleExecutionsOperation = Abort.startOperation();
479
- multipleExecutionsOperation.addAbortSignal(signal);
480
- const failFastAbortController = new AbortController();
481
- if (failFast) {
482
- multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal);
483
- }
755
+ for (const beforeEachInOrderCallback of beforeEachInOrderCallbackSet) {
756
+ const returnValue = beforeEachInOrderCallback(
757
+ execution,
758
+ testPlanResult,
759
+ );
760
+ if (typeof returnValue === "function") {
761
+ const callback = (...args) => {
762
+ afterEachInOrderCallbackSet.delete(callback);
763
+ return returnValue(...args);
764
+ };
765
+ afterEachInOrderCallbackSet.add(callback);
766
+ }
767
+ }
768
+ const executionResult = await run({
769
+ ...execution.params,
770
+ signal: operation.signal,
771
+ logger,
772
+ keepRunning,
773
+ mirrorConsole: false, // might be executed in parallel: log would be a mess to read
774
+ coverageEnabled: Boolean(coverage),
775
+ coverageTempDirectoryUrl: coverage?.tempDirectoryUrl,
776
+ });
777
+ Object.assign(execution.result, executionResult);
778
+ execution.status = "executed";
779
+ executionExecutingSet.delete(execution);
780
+ mutateCountersAfterExecutionEnds(counters, execution);
781
+ if (execution.result.status !== "completed") {
782
+ testPlanResult.failed = true;
783
+ if (updateProcessExitCode) {
784
+ process.exitCode = 1;
785
+ }
786
+ }
484
787
 
485
- try {
486
- if (gcBetweenExecutions) {
487
- ensureGlobalGc();
488
- }
788
+ for (const afterEachCallback of afterEachCallbackSet) {
789
+ afterEachCallback(execution, testPlanResult);
790
+ }
791
+ callWhenPreviousExecutionAreDone(execution.index, () => {
792
+ mutateCountersAfterExecutionEnds(countersInOrder, execution);
793
+ for (const afterEachInOrderCallback of afterEachInOrderCallbackSet) {
794
+ afterEachInOrderCallback(execution, testPlanResult);
795
+ }
796
+ });
489
797
 
490
- if (coverageEnabled) {
491
- // when runned multiple times, we don't want to keep previous files in this directory
492
- await ensureEmptyDirectory(coverageTempDirectoryUrl);
493
- callbacks.push(async () => {
494
- if (multipleExecutionsOperation.signal.aborted) {
495
- // don't try to do the coverage stuff
798
+ if (testPlanResult.failed && failFast && counters.remaining) {
799
+ logger.info(`"failFast" enabled -> cancel remaining executions`);
800
+ failFastAbortController.abort();
496
801
  return;
497
802
  }
498
- try {
499
- if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
500
- takeCoverage();
501
- // conceptually we don't need coverage anymore so it would be
502
- // good to call v8.stopCoverage()
503
- // but it logs a strange message about "result is not an object"
803
+ await startAsMuchAsPossible();
804
+ };
805
+ const startAsMuchAsPossible = async () => {
806
+ const promises = [];
807
+ // eslint-disable-next-line no-constant-condition
808
+ while (true) {
809
+ operation.throwIfAborted();
810
+ if (executionRemainingSet.size === 0) {
811
+ break;
504
812
  }
505
- const coverage = await reportToCoverage(report, {
506
- signal: multipleExecutionsOperation.signal,
507
- logger,
508
- rootDirectoryUrl,
509
- coverageConfig,
510
- coverageIncludeMissing,
511
- coverageMethodForNodeJs,
512
- coverageV8ConflictWarning,
513
- });
514
- returnValue.coverage = coverage;
515
- } catch (e) {
516
- if (Abort.isAbortError(e)) {
517
- return;
813
+ if (executionExecutingSet.size >= parallel.max) {
814
+ break;
518
815
  }
519
- throw e;
520
- }
521
- });
522
- }
523
-
524
- const runtimeParams = {
525
- rootDirectoryUrl,
526
- webServer,
527
816
 
528
- coverageEnabled,
529
- coverageConfig,
530
- coverageMethodForBrowsers,
531
- coverageMethodForNodeJs,
532
- isTestPlan: true,
533
- teardown,
534
- };
817
+ if (
818
+ // starting execution in parallel is limited by
819
+ // cpu and memory only when trying to parallelize
820
+ // if nothing is executing these limitations don't apply
821
+ executionExecutingSet.size > 0
822
+ ) {
823
+ const availableMemory = freemem();
824
+ const totalMemory = totalmem();
825
+ const usedMemory = totalMemory - availableMemory;
826
+ if (usedMemory > parallel.maxMemory) {
827
+ // retry after Xms in case memory usage decreases
828
+ const promise = (async () => {
829
+ await operation.wait(200);
830
+ await startAsMuchAsPossible();
831
+ })();
832
+ promises.push(promise);
833
+ }
535
834
 
536
- if (logMergeForCompletedExecutions && !process.stdout.isTTY) {
537
- logMergeForCompletedExecutions = false;
538
- logger.debug(
539
- `Force logMergeForCompletedExecutions to false because process.stdout.isTTY is false`,
540
- );
541
- }
542
- const debugLogsEnabled = logger.levels.debug;
543
- const executionLogsEnabled = logger.levels.info;
544
- const executionSpinner =
545
- logRefresh &&
546
- concurrency === 1 &&
547
- !debugLogsEnabled &&
548
- executionLogsEnabled &&
549
- process.stdout.isTTY &&
550
- // if there is an error during execution npm will mess up the output
551
- // (happens when npm runs several command in a workspace)
552
- // so we enable spinner only when !process.exitCode (no error so far)
553
- process.exitCode !== 1;
554
-
555
- const startMs = Date.now();
556
- let rawOutput = "";
557
- let executionLog = createLog({ newLine: "" });
558
- const counters = {
559
- total: executionSteps.length,
560
- aborted: 0,
561
- timedout: 0,
562
- failed: 0,
563
- completed: 0,
564
- done: 0,
565
- };
835
+ if (cpuUsage.overall.active > parallel.maxCpu) {
836
+ // retry after Xms in case cpu usage decreases
837
+ const promise = (async () => {
838
+ await operation.wait(200);
839
+ await startAsMuchAsPossible();
840
+ })();
841
+ promises.push(promise);
842
+ break;
843
+ }
844
+ }
566
845
 
567
- const callWhenPreviousExecutionAreDone = createCallOrderer();
568
-
569
- await executeConcurrently({
570
- multipleExecutionsOperation,
571
- concurrency,
572
- cooldownBetweenExecutions,
573
- executionSteps,
574
- start: async (paramsFromStep) => {
575
- const executionIndex = executionSteps.indexOf(paramsFromStep);
576
- const { executionName, fileRelativeUrl, runtime } = paramsFromStep;
577
- const runtimeType = runtime.type;
578
- const runtimeName = runtime.name;
579
- const runtimeVersion = runtime.version;
580
- const executionParams = {
581
- measurePerformance: false,
582
- collectPerformance: false,
583
- collectConsole: true,
584
- allocatedMs: defaultMsAllocatedPerExecution,
585
- ...paramsFromStep,
586
- runtimeParams: {
587
- fileRelativeUrl,
588
- ...paramsFromStep.runtimeParams,
589
- },
590
- };
591
- const beforeExecutionInfo = {
592
- fileRelativeUrl,
593
- runtimeType,
594
- runtimeName,
595
- runtimeVersion,
596
- executionIndex,
597
- executionParams,
598
- startMs: Date.now(),
599
- executionResult: {
600
- status: "executing",
601
- },
602
- counters,
603
- timeEllapsed: Date.now() - startMs,
604
- memoryHeap: memoryUsage().heapUsed,
605
- };
606
- if (typeof executionParams.allocatedMs === "function") {
607
- executionParams.allocatedMs =
608
- executionParams.allocatedMs(beforeExecutionInfo);
609
- }
610
- let spinner;
611
- if (executionSpinner) {
612
- spinner = startSpinner({
613
- log: executionLog,
614
- render: () => {
615
- return createExecutionLog(beforeExecutionInfo, {
616
- logRuntime,
617
- logEachDuration,
618
- logTimeUsage,
619
- logMemoryHeapUsage,
620
- });
621
- },
622
- });
623
- }
624
- for (const beforeExecutionCallback of beforeExecutionCallbackSet) {
625
- beforeExecutionCallback(beforeExecutionInfo);
626
- }
627
- const fileUrl = `${rootDirectoryUrl}${fileRelativeUrl}`;
628
- let executionResult;
629
- if (existsSync(new URL(fileUrl))) {
630
- executionResult = await run({
631
- signal: multipleExecutionsOperation.signal,
632
- logger,
633
- allocatedMs: executionParams.allocatedMs,
634
- keepRunning,
635
- mirrorConsole: false, // might be executed in parallel: log would be a mess to read
636
- collectConsole: executionParams.collectConsole,
637
- coverageEnabled,
638
- coverageTempDirectoryUrl,
639
- runtime: executionParams.runtime,
640
- runtimeParams: {
641
- ...runtimeParams,
642
- ...executionParams.runtimeParams,
643
- },
644
- });
645
- } else {
646
- executionResult = {
647
- status: "failed",
648
- errors: [
649
- new Error(
650
- `No file at ${fileRelativeUrl} for execution "${executionName}"`,
651
- ),
652
- ],
653
- };
846
+ let execution;
847
+ for (const executionCandidate of executionRemainingSet) {
848
+ // TODO: this is where we'll check if it can be executed
849
+ // according to upcoming "using" execution param
850
+ execution = executionCandidate;
851
+ break;
852
+ }
853
+ if (execution) {
854
+ promises.push(start(execution));
855
+ }
654
856
  }
655
- const fileReport = report[fileRelativeUrl];
656
- if (fileReport) {
657
- fileReport[executionName] = executionResult;
658
- } else {
659
- report[fileRelativeUrl] = {
660
- [executionName]: executionResult,
661
- };
857
+ if (promises.length) {
858
+ await Promise.all(promises);
859
+ promises.length = 0;
662
860
  }
663
-
664
- const afterExecutionInfo = {
665
- ...beforeExecutionInfo,
666
- runtimeVersion: runtime.version,
667
- endMs: Date.now(),
668
- executionResult,
669
- };
670
-
671
- if (gcBetweenExecutions) {
672
- global.gc();
673
- }
674
-
675
- const timeEllapsed = Date.now() - startMs;
676
- const memoryHeap = memoryUsage().heapUsed;
677
- callWhenPreviousExecutionAreDone(executionIndex, () => {
678
- counters.done++;
679
- if (executionResult.status === "aborted") {
680
- counters.aborted++;
681
- } else if (executionResult.status === "timedout") {
682
- counters.timedout++;
683
- } else if (executionResult.status === "failed") {
684
- counters.failed++;
685
- } else if (executionResult.status === "completed") {
686
- counters.completed++;
687
- }
688
-
689
- if (executionLogsEnabled) {
690
- // replace spinner with this execution result
691
- if (spinner) {
692
- spinner.stop();
693
- spinner = null;
861
+ };
862
+
863
+ reporters = reporters.flat(Infinity);
864
+ for (const reporter of reporters) {
865
+ const {
866
+ beforeAll,
867
+ // TODO: if defined add them too
868
+ // beforeEach,
869
+ // afterEach,
870
+ // beforeEachInOrder,
871
+ // afterEachInOrder,
872
+ // afterAll,
873
+ } = reporter;
874
+ if (beforeAll) {
875
+ const returnValue = await beforeAll(testPlanResult);
876
+ if (returnValue) {
877
+ const {
878
+ beforeEach,
879
+ beforeEachInOrder,
880
+ afterEach,
881
+ afterEachInOrder,
882
+ afterAll,
883
+ } = returnValue;
884
+ if (beforeEach) {
885
+ beforeEachCallbackSet.add(beforeEach);
694
886
  }
695
-
696
- const log = createExecutionLog(afterExecutionInfo, {
697
- logShortForCompletedExecutions,
698
- logRuntime,
699
- logEachDuration,
700
- ...(logTimeUsage ? { timeEllapsed } : {}),
701
- ...(logMemoryHeapUsage ? { memoryHeap } : {}),
702
- });
703
-
704
- executionLog.write(log);
705
- rawOutput += stripAnsi(log);
706
- const canOverwriteLog = canOverwriteLogGetter({
707
- logMergeForCompletedExecutions,
708
- executionResult,
709
- });
710
- if (canOverwriteLog) {
711
- // nothing to do, we reuse the current executionLog object
712
- } else {
713
- executionLog.destroy();
714
- executionLog = createLog({ newLine: "" });
887
+ if (beforeEachInOrder) {
888
+ beforeEachInOrderCallbackSet.add(beforeEachInOrder);
889
+ }
890
+ if (afterEach) {
891
+ afterEachCallbackSet.add(afterEach);
892
+ }
893
+ if (afterEachInOrder) {
894
+ afterEachInOrderCallbackSet.add(afterEachInOrder);
715
895
  }
716
- const isLastExecutionLog =
717
- executionIndex === executionSteps.length - 1;
718
- if (isLastExecutionLog && logger.levels.info) {
719
- executionLog.write("\n");
896
+ if (afterAll) {
897
+ afterAllCallbackSet.add(afterAll);
720
898
  }
721
899
  }
722
- });
723
- for (const afterExecutionCallback of afterExecutionCallbackSet) {
724
- afterExecutionCallback(afterExecutionInfo);
725
- }
726
- const cancelRemaining =
727
- failFast &&
728
- executionResult.status !== "completed" &&
729
- counters.done < counters.total;
730
- if (cancelRemaining) {
731
- logger.info(`"failFast" enabled -> cancel remaining executions`);
732
- failFastAbortController.abort();
733
900
  }
734
- },
735
- });
736
- if (!keepRunning) {
737
- logger.debug("trigger test plan teardown");
738
- await teardown.trigger();
739
- }
740
-
741
- counters.cancelled = counters.total - counters.done;
742
- const summary = {
743
- counters,
744
- // when execution is aborted, the remaining executions are "cancelled"
745
- duration: Date.now() - startMs,
746
- };
747
- if (logSummary) {
748
- const summaryLog = formatSummaryLog(summary);
749
- rawOutput += stripAnsi(summaryLog);
750
- logger.info(summaryLog);
751
- }
752
- if (summary.counters.total !== summary.counters.completed) {
753
- const logFileUrl = new URL(logFileRelativeUrl, rootDirectoryUrl).href;
754
- writeFileSync(logFileUrl, rawOutput);
755
- logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`);
901
+ }
902
+ await startAsMuchAsPossible();
756
903
  }
757
- returnValue.aborted = multipleExecutionsOperation.signal.aborted;
758
- returnValue.summary = summary;
759
- returnValue.report = report;
760
- for (const callback of callbacks) {
761
- await callback();
904
+ timings.executionEnd = takeTiming();
905
+ } catch (e) {
906
+ if (Abort.isAbortError(e)) {
907
+ } else {
908
+ throw e;
762
909
  }
763
910
  } finally {
764
- await multipleExecutionsOperation.end();
765
- }
911
+ testPlanResult.aborted = operation.signal.aborted;
912
+ if (testPlanResult.aborted) {
913
+ // when execution is aborted, the remaining executions are "cancelled"
914
+ counters.cancelled = counters.planified - counters.executed;
915
+ counters.remaining = 0;
916
+ countersInOrder.cancelled =
917
+ countersInOrder.planified - countersInOrder.executed;
918
+ countersInOrder.remaining = 0;
919
+ }
766
920
 
767
- const hasFailed =
768
- returnValue.summary.counters.total !==
769
- returnValue.summary.counters.completed;
770
- if (updateProcessExitCode && hasFailed) {
771
- process.exitCode = 1;
772
- }
773
- const coverage = returnValue.coverage;
774
- // planCoverage can be null when execution is aborted
775
- if (coverage) {
776
- const promises = [];
777
- // keep this one first because it does ensureEmptyDirectory
778
- // and in case coverage json file gets written in the same directory
779
- // it must be done before
780
- if (coverageEnabled && coverageReportHtml) {
781
- await ensureEmptyDirectory(coverageReportHtmlDirectoryUrl);
782
- const htmlCoverageDirectoryIndexFileUrl = `${coverageReportHtmlDirectoryUrl}index.html`;
783
- logger.info(
784
- `-> ${urlToFileSystemPath(htmlCoverageDirectoryIndexFileUrl)}`,
785
- );
786
- promises.push(
787
- generateCoverageHtmlDirectory(coverage, {
788
- rootDirectoryUrl,
789
- coverageHtmlDirectoryRelativeUrl: urlToRelativeUrl(
790
- coverageReportHtmlDirectoryUrl,
791
- rootDirectoryUrl,
792
- ),
793
- coverageReportSkipEmpty,
794
- coverageReportSkipFull,
795
- }),
796
- );
921
+ if (!keepRunning) {
922
+ for (const teardownCallback of teardownCallbackSet) {
923
+ await teardownCallback();
924
+ }
925
+ teardownCallbackSet.clear();
797
926
  }
798
- if (coverageEnabled && coverageReportJson) {
799
- promises.push(
800
- generateCoverageJsonFile({
801
- coverage,
802
- coverageJsonFileUrl: coverageReportJsonFileUrl,
803
- logger,
804
- }),
805
- );
927
+ timings.teardownEnd = takeTiming();
928
+
929
+ if (finalizeCoverage) {
930
+ await finalizeCoverage();
806
931
  }
807
- if (coverageEnabled && coverageReportTextLog) {
808
- promises.push(
809
- generateCoverageTextLog(coverage, {
810
- coverageReportSkipEmpty,
811
- coverageReportSkipFull,
812
- }),
813
- );
932
+ timings.coverageTeardownEnd = takeTiming();
933
+ timings.end = takeTiming();
934
+
935
+ afterEachCallbackSet.clear();
936
+ afterEachInOrderCallbackSet.clear();
937
+ for (const afterAllCallback of afterAllCallbackSet) {
938
+ await afterAllCallback(testPlanResult);
814
939
  }
815
- await Promise.all(promises);
940
+ afterAllCallbackSet.clear();
941
+ await operation.end();
816
942
  }
817
943
 
818
- for (const afterAllExecutionCallback of afterAllExecutionCallbackSet) {
819
- await afterAllExecutionCallback(returnValue);
820
- }
821
- return returnValue;
944
+ return testPlanResult;
822
945
  };
823
946
 
824
- const canOverwriteLogGetter = ({
825
- logMergeForCompletedExecutions,
826
- executionResult,
827
- }) => {
828
- if (!logMergeForCompletedExecutions) {
829
- return false;
947
+ const countAvailableCpus = () => {
948
+ if (typeof availableParallelism === "function") {
949
+ return availableParallelism();
830
950
  }
831
- if (executionResult.status === "aborted") {
832
- return true;
833
- }
834
- if (executionResult.status !== "completed") {
835
- return false;
836
- }
837
- const { consoleCalls = [] } = executionResult;
838
- if (consoleCalls.length > 0) {
839
- return false;
840
- }
841
- return true;
951
+ const cpuArray = cpus();
952
+ return cpuArray.length || 1;
842
953
  };
843
954
 
844
- const executeConcurrently = async ({
845
- multipleExecutionsOperation,
846
- concurrency,
847
- cooldownBetweenExecutions,
848
- executionSteps,
849
- start,
850
- }) => {
851
- let progressionIndex = 0;
852
- let remainingExecutionToStart = executionSteps.length;
853
-
854
- const startNext = async () => {
855
- if (remainingExecutionToStart === 0) {
856
- return;
857
- }
858
- remainingExecutionToStart--;
859
- const input = executionSteps[progressionIndex];
860
- progressionIndex++;
861
- await start(input);
862
- if (multipleExecutionsOperation.signal.aborted) {
863
- return;
864
- }
865
- if (cooldownBetweenExecutions) {
866
- await new Promise((resolve) => {
867
- const timeoutId = setTimeout(() => {
868
- removeClearTimeoutOnAbort();
869
- resolve();
870
- }, cooldownBetweenExecutions);
871
- const removeClearTimeoutOnAbort =
872
- multipleExecutionsOperation.signal.addAbortCallback(() => {
873
- clearTimeout(timeoutId);
874
- });
875
- });
876
- if (multipleExecutionsOperation.signal.aborted) {
877
- return;
878
- }
879
- }
880
- await startNext();
881
- };
882
-
883
- const promises = [];
884
- while (concurrency--) {
885
- promises.push(startNext());
955
+ const mutateCountersBeforeExecutionStarts = (counters) => {
956
+ counters.executing++;
957
+ counters.waiting--;
958
+ };
959
+ const mutateCountersAfterExecutionEnds = (counters, execution) => {
960
+ counters.executing--;
961
+ counters.executed++;
962
+ counters.remaining--;
963
+ if (execution.result.status === "aborted") {
964
+ counters.aborted++;
965
+ } else if (execution.result.status === "timedout") {
966
+ counters.timedout++;
967
+ } else if (execution.result.status === "failed") {
968
+ counters.failed++;
969
+ } else if (execution.result.status === "completed") {
970
+ counters.completed++;
886
971
  }
887
- await Promise.all(promises);
888
972
  };