@jsenv/test 1.0.0

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 (56) hide show
  1. package/dist/controllable_child_process.mjs +129 -0
  2. package/dist/controllable_worker_thread.mjs +91 -0
  3. package/dist/importmap_node_loader.mjs +551 -0
  4. package/dist/js/execute_using_dynamic_import.js +850 -0
  5. package/dist/js/v8_coverage.js +508 -0
  6. package/dist/jsenv_test.js +5993 -0
  7. package/dist/no_experimental_warnings.cjs +10 -0
  8. package/package.json +56 -0
  9. package/src/coverage/babel_plugin_instrument.js +48 -0
  10. package/src/coverage/coverage_reporter_html_directory.js +32 -0
  11. package/src/coverage/coverage_reporter_json_file.js +17 -0
  12. package/src/coverage/coverage_reporter_text_log.js +19 -0
  13. package/src/coverage/empty_coverage_factory.js +52 -0
  14. package/src/coverage/file_by_file_coverage.js +25 -0
  15. package/src/coverage/istanbul_coverage_composition.js +28 -0
  16. package/src/coverage/istanbul_coverage_map_from_coverage.js +16 -0
  17. package/src/coverage/list_files_not_covered.js +15 -0
  18. package/src/coverage/missing_coverage.js +41 -0
  19. package/src/coverage/report_to_coverage.js +198 -0
  20. package/src/coverage/v8_and_istanbul.js +37 -0
  21. package/src/coverage/v8_coverage.js +26 -0
  22. package/src/coverage/v8_coverage_composition.js +24 -0
  23. package/src/coverage/v8_coverage_node_directory.js +85 -0
  24. package/src/coverage/v8_coverage_to_istanbul.js +99 -0
  25. package/src/execution/execute.js +111 -0
  26. package/src/execution/execute_steps.js +423 -0
  27. package/src/execution/execute_test_plan.js +372 -0
  28. package/src/execution/execution_colors.js +10 -0
  29. package/src/execution/execution_steps.js +65 -0
  30. package/src/execution/gc.js +9 -0
  31. package/src/execution/logs_file_execution.js +427 -0
  32. package/src/execution/logs_file_execution.test.mjs +41 -0
  33. package/src/execution/run.js +160 -0
  34. package/src/execution/web_server_param.js +73 -0
  35. package/src/main.js +10 -0
  36. package/src/runtime_browsers/chromium.js +24 -0
  37. package/src/runtime_browsers/firefox.js +23 -0
  38. package/src/runtime_browsers/middleware_istanbul.js +65 -0
  39. package/src/runtime_browsers/middleware_js_supervisor.js +98 -0
  40. package/src/runtime_browsers/using_playwright.js +573 -0
  41. package/src/runtime_browsers/webkit.js +40 -0
  42. package/src/runtime_node/child_exec_options.js +166 -0
  43. package/src/runtime_node/controllable_child_process.mjs +135 -0
  44. package/src/runtime_node/controllable_worker_thread.mjs +103 -0
  45. package/src/runtime_node/exec_options.js +57 -0
  46. package/src/runtime_node/execute_using_dynamic_import.js +53 -0
  47. package/src/runtime_node/exit_codes.js +9 -0
  48. package/src/runtime_node/importmap_node_loader.mjs +51 -0
  49. package/src/runtime_node/importmap_node_loader_file_url.js +4 -0
  50. package/src/runtime_node/kill_process_tree.js +76 -0
  51. package/src/runtime_node/no_experimental_warnings.cjs +13 -0
  52. package/src/runtime_node/no_experimental_warnings_file_url.js +4 -0
  53. package/src/runtime_node/node_child_process.js +374 -0
  54. package/src/runtime_node/node_execution_performance.js +67 -0
  55. package/src/runtime_node/node_worker_thread.js +301 -0
  56. package/src/runtime_node/profiler_v8_coverage.js +56 -0
@@ -0,0 +1,85 @@
1
+ import { readFileSync, readdirSync } from "node:fs"
2
+ import { assertAndNormalizeDirectoryUrl } from "@jsenv/filesystem"
3
+ import { createDetailedMessage } from "@jsenv/log"
4
+ import { Abort } from "@jsenv/abort"
5
+
6
+ export const readNodeV8CoverageDirectory = async ({
7
+ logger,
8
+ signal,
9
+ onV8Coverage,
10
+ maxMsWaitingForNodeToWriteCoverageFile = 2000,
11
+ }) => {
12
+ const NODE_V8_COVERAGE = process.env.NODE_V8_COVERAGE
13
+ const operation = Abort.startOperation()
14
+ operation.addAbortSignal(signal)
15
+
16
+ let timeSpentTrying = 0
17
+ const tryReadDirectory = async () => {
18
+ const dirContent = readdirSync(NODE_V8_COVERAGE)
19
+ if (dirContent.length > 0) {
20
+ return dirContent
21
+ }
22
+ if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
23
+ await new Promise((resolve) => setTimeout(resolve, 200))
24
+ timeSpentTrying += 200
25
+ logger.debug("retry to read coverage directory")
26
+ return tryReadDirectory()
27
+ }
28
+ logger.warn(`v8 coverage directory is empty at ${NODE_V8_COVERAGE}`)
29
+ return dirContent
30
+ }
31
+
32
+ try {
33
+ operation.throwIfAborted()
34
+ const dirContent = await tryReadDirectory()
35
+
36
+ const coverageDirectoryUrl = assertAndNormalizeDirectoryUrl(
37
+ NODE_V8_COVERAGE,
38
+ "NODE_V8_COVERAGE",
39
+ )
40
+
41
+ await dirContent.reduce(async (previous, dirEntry) => {
42
+ operation.throwIfAborted()
43
+ await previous
44
+
45
+ const dirEntryUrl = new URL(dirEntry, coverageDirectoryUrl)
46
+ const tryReadJsonFile = async () => {
47
+ const fileContent = String(readFileSync(dirEntryUrl))
48
+ if (fileContent === "") {
49
+ if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
50
+ await new Promise((resolve) => setTimeout(resolve, 200))
51
+ timeSpentTrying += 200
52
+ return tryReadJsonFile()
53
+ }
54
+ console.warn(`Coverage JSON file is empty at ${dirEntryUrl}`)
55
+ return null
56
+ }
57
+
58
+ try {
59
+ const fileAsJson = JSON.parse(fileContent)
60
+ return fileAsJson
61
+ } catch (e) {
62
+ if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
63
+ await new Promise((resolve) => setTimeout(resolve, 200))
64
+ timeSpentTrying += 200
65
+ return tryReadJsonFile()
66
+ }
67
+ console.warn(
68
+ createDetailedMessage(`Error while reading coverage file`, {
69
+ "error stack": e.stack,
70
+ "file": dirEntryUrl,
71
+ }),
72
+ )
73
+ return null
74
+ }
75
+ }
76
+
77
+ const fileContent = await tryReadJsonFile()
78
+ if (fileContent) {
79
+ await onV8Coverage(fileContent)
80
+ }
81
+ }, Promise.resolve())
82
+ } finally {
83
+ await operation.end()
84
+ }
85
+ }
@@ -0,0 +1,99 @@
1
+ import { urlToFileSystemPath } from "@jsenv/urls"
2
+ import { Abort } from "@jsenv/abort"
3
+ import { requireFromJsenv } from "@jsenv/core/src/helpers/require_from_jsenv.js"
4
+
5
+ import { composeTwoFileByFileIstanbulCoverages } from "./istanbul_coverage_composition.js"
6
+
7
+ export const v8CoverageToIstanbul = async (v8Coverage, { signal }) => {
8
+ const operation = Abort.startOperation()
9
+ operation.addAbortSignal(signal)
10
+
11
+ try {
12
+ const v8ToIstanbul = requireFromJsenv("v8-to-istanbul")
13
+ const sourcemapCache = v8Coverage["source-map-cache"]
14
+ let istanbulCoverageComposed = null
15
+
16
+ await v8Coverage.result.reduce(async (previous, fileV8Coverage) => {
17
+ operation.throwIfAborted()
18
+ await previous
19
+
20
+ const { source } = fileV8Coverage
21
+ let sources
22
+ // when v8 coverage comes from playwright (chromium) v8Coverage.source is set
23
+ if (typeof source === "string") {
24
+ sources = { source }
25
+ }
26
+ // when v8 coverage comes from Node.js, the source can be read from sourcemapCache
27
+ else if (sourcemapCache) {
28
+ sources = sourcesFromSourceMapCache(fileV8Coverage.url, sourcemapCache)
29
+ }
30
+ const path = urlToFileSystemPath(fileV8Coverage.url)
31
+
32
+ const converter = v8ToIstanbul(
33
+ path,
34
+ // wrapperLength is undefined we don't need it
35
+ // https://github.com/istanbuljs/v8-to-istanbul/blob/2b54bc97c5edf8a37b39a171ec29134ba9bfd532/lib/v8-to-istanbul.js#L27
36
+ undefined,
37
+ sources,
38
+ )
39
+ await converter.load()
40
+
41
+ converter.applyCoverage(fileV8Coverage.functions)
42
+ const istanbulCoverage = converter.toIstanbul()
43
+
44
+ istanbulCoverageComposed = istanbulCoverageComposed
45
+ ? composeTwoFileByFileIstanbulCoverages(
46
+ istanbulCoverageComposed,
47
+ istanbulCoverage,
48
+ )
49
+ : istanbulCoverage
50
+ }, Promise.resolve())
51
+
52
+ if (!istanbulCoverageComposed) {
53
+ return {}
54
+ }
55
+ istanbulCoverageComposed = markAsConvertedFromV8(istanbulCoverageComposed)
56
+ return istanbulCoverageComposed
57
+ } finally {
58
+ await operation.end()
59
+ }
60
+ }
61
+
62
+ const markAsConvertedFromV8 = (fileByFileCoverage) => {
63
+ const fileByFileMarked = {}
64
+ Object.keys(fileByFileCoverage).forEach((key) => {
65
+ const fileCoverage = fileByFileCoverage[key]
66
+ fileByFileMarked[key] = {
67
+ ...fileCoverage,
68
+ fromV8: true,
69
+ }
70
+ })
71
+ return fileByFileMarked
72
+ }
73
+
74
+ const sourcesFromSourceMapCache = (url, sourceMapCache) => {
75
+ const sourceMapAndLineLengths = sourceMapCache[url]
76
+ if (!sourceMapAndLineLengths) {
77
+ return {}
78
+ }
79
+
80
+ const { data, lineLengths } = sourceMapAndLineLengths
81
+ // See: https://github.com/nodejs/node/pull/34305
82
+ if (!data) {
83
+ return undefined
84
+ }
85
+
86
+ const sources = {
87
+ sourcemap: data,
88
+ ...(lineLengths ? { source: sourcesFromLineLengths(lineLengths) } : {}),
89
+ }
90
+ return sources
91
+ }
92
+
93
+ const sourcesFromLineLengths = (lineLengths) => {
94
+ let source = ""
95
+ lineLengths.forEach((length) => {
96
+ source += `${"".padEnd(length, ".")}\n`
97
+ })
98
+ return source
99
+ }
@@ -0,0 +1,111 @@
1
+ /*
2
+ * Export a function capable to execute a file on a runtime (browser or node) and return how it goes.
3
+ *
4
+ * - can be useful to execute a file in a browser/node.js programmatically
5
+ * - not documented
6
+ * - the most importants parts:
7
+ * - fileRelativeUrl: the file to execute inside rootDirectoryUrl
8
+ * - runtime: an object with a "run" method.
9
+ * The run method will start a browser/node process and execute file in it
10
+ * - Most of the logic lives in "./run.js" used by executeTestPlan to run tests
11
+ */
12
+
13
+ import { Abort, raceProcessTeardownEvents } from "@jsenv/abort"
14
+ import { assertAndNormalizeDirectoryUrl } from "@jsenv/filesystem"
15
+ import { createLogger } from "@jsenv/log"
16
+
17
+ import { assertAndNormalizeWebServer } from "./web_server_param.js"
18
+ import { run } from "./run.js"
19
+
20
+ export const execute = async ({
21
+ signal = new AbortController().signal,
22
+ handleSIGINT = true,
23
+ logLevel,
24
+ rootDirectoryUrl,
25
+ webServer,
26
+ importMap,
27
+
28
+ fileRelativeUrl,
29
+ allocatedMs,
30
+ mirrorConsole = true,
31
+ keepRunning = false,
32
+
33
+ collectConsole,
34
+ collectCoverage,
35
+ coverageTempDirectoryUrl,
36
+ collectPerformance = false,
37
+ runtime,
38
+ runtimeParams,
39
+
40
+ ignoreError = false,
41
+ }) => {
42
+ const logger = createLogger({ logLevel })
43
+ rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
44
+ rootDirectoryUrl,
45
+ "rootDirectoryUrl",
46
+ )
47
+ const executeOperation = Abort.startOperation()
48
+ executeOperation.addAbortSignal(signal)
49
+ if (handleSIGINT) {
50
+ executeOperation.addAbortSource((abort) => {
51
+ return raceProcessTeardownEvents(
52
+ {
53
+ SIGINT: true,
54
+ },
55
+ abort,
56
+ )
57
+ })
58
+ }
59
+
60
+ if (runtime.type === "browser") {
61
+ await assertAndNormalizeWebServer(webServer)
62
+ }
63
+
64
+ let resultTransformer = (result) => result
65
+ runtimeParams = {
66
+ rootDirectoryUrl,
67
+ webServer,
68
+ fileRelativeUrl,
69
+ importMap,
70
+ ...runtimeParams,
71
+ }
72
+
73
+ let result = await run({
74
+ signal: executeOperation.signal,
75
+ logger,
76
+ allocatedMs,
77
+ keepRunning,
78
+ mirrorConsole,
79
+ collectConsole,
80
+ collectCoverage,
81
+ coverageTempDirectoryUrl,
82
+ collectPerformance,
83
+ runtime,
84
+ runtimeParams,
85
+ })
86
+ result = resultTransformer(result)
87
+
88
+ try {
89
+ if (result.status === "failed") {
90
+ if (ignoreError) {
91
+ return result
92
+ }
93
+ /*
94
+ Warning: when node launched with --unhandled-rejections=strict, despites
95
+ this promise being rejected by throw result.error node will completely ignore it.
96
+
97
+ The error can be logged by doing
98
+ ```js
99
+ process.setUncaughtExceptionCaptureCallback((error) => {
100
+ console.error(error.stack)
101
+ })
102
+ ```
103
+ But it feels like a hack.
104
+ */
105
+ throw result.errors[result.errors.length - 1]
106
+ }
107
+ return result
108
+ } finally {
109
+ await executeOperation.end()
110
+ }
111
+ }
@@ -0,0 +1,423 @@
1
+ import { existsSync } from "node:fs"
2
+ import { memoryUsage } from "node:process"
3
+ import { takeCoverage } from "node:v8"
4
+ import stripAnsi from "strip-ansi"
5
+ import { urlToFileSystemPath } from "@jsenv/urls"
6
+ import { createLog, startSpinner } from "@jsenv/log"
7
+ import { Abort, raceProcessTeardownEvents } from "@jsenv/abort"
8
+ import { ensureEmptyDirectory, writeFileSync } from "@jsenv/filesystem"
9
+
10
+ import { reportToCoverage } from "../coverage/report_to_coverage.js"
11
+ import { run } from "./run.js"
12
+ import { ensureGlobalGc } from "./gc.js"
13
+ import { createExecutionLog, createSummaryLog } from "./logs_file_execution.js"
14
+
15
+ export const executeSteps = async (
16
+ executionSteps,
17
+ {
18
+ signal,
19
+ handleSIGINT,
20
+ logger,
21
+ logRefresh,
22
+ logRuntime,
23
+ logEachDuration,
24
+ logSummary,
25
+ logTimeUsage,
26
+ logMemoryHeapUsage,
27
+ logFileRelativeUrl,
28
+ completedExecutionLogMerging,
29
+ completedExecutionLogAbbreviation,
30
+ rootDirectoryUrl,
31
+ webServer,
32
+
33
+ keepRunning,
34
+ defaultMsAllocatedPerExecution,
35
+ maxExecutionsInParallel,
36
+ failFast,
37
+ gcBetweenExecutions,
38
+ cooldownBetweenExecutions,
39
+
40
+ coverageEnabled,
41
+ coverageConfig,
42
+ coverageIncludeMissing,
43
+ coverageMethodForBrowsers,
44
+ coverageMethodForNodeJs,
45
+ coverageV8ConflictWarning,
46
+ coverageTempDirectoryUrl,
47
+
48
+ beforeExecutionCallback = () => {},
49
+ afterExecutionCallback = () => {},
50
+ } = {},
51
+ ) => {
52
+ const executePlanReturnValue = {}
53
+ const report = {}
54
+ const callbacks = []
55
+ const stopAfterAllSignal = { notify: () => {} }
56
+
57
+ const multipleExecutionsOperation = Abort.startOperation()
58
+ multipleExecutionsOperation.addAbortSignal(signal)
59
+ if (handleSIGINT) {
60
+ multipleExecutionsOperation.addAbortSource((abort) => {
61
+ return raceProcessTeardownEvents(
62
+ {
63
+ SIGINT: true,
64
+ },
65
+ () => {
66
+ logger.debug(`SIGINT abort`)
67
+ abort()
68
+ },
69
+ )
70
+ })
71
+ }
72
+ const failFastAbortController = new AbortController()
73
+ if (failFast) {
74
+ multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal)
75
+ }
76
+
77
+ try {
78
+ if (gcBetweenExecutions) {
79
+ ensureGlobalGc()
80
+ }
81
+
82
+ if (coverageEnabled) {
83
+ // when runned multiple times, we don't want to keep previous files in this directory
84
+ await ensureEmptyDirectory(coverageTempDirectoryUrl)
85
+ callbacks.push(async () => {
86
+ if (multipleExecutionsOperation.signal.aborted) {
87
+ // don't try to do the coverage stuff
88
+ return
89
+ }
90
+ try {
91
+ if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
92
+ takeCoverage()
93
+ // conceptually we don't need coverage anymore so it would be
94
+ // good to call v8.stopCoverage()
95
+ // but it logs a strange message about "result is not an object"
96
+ }
97
+ const planCoverage = await reportToCoverage(report, {
98
+ signal: multipleExecutionsOperation.signal,
99
+ logger,
100
+ rootDirectoryUrl,
101
+ coverageConfig,
102
+ coverageIncludeMissing,
103
+ coverageMethodForBrowsers,
104
+ coverageV8ConflictWarning,
105
+ })
106
+ executePlanReturnValue.planCoverage = planCoverage
107
+ } catch (e) {
108
+ if (Abort.isAbortError(e)) {
109
+ return
110
+ }
111
+ throw e
112
+ }
113
+ })
114
+ }
115
+
116
+ const runtimeParams = {
117
+ rootDirectoryUrl,
118
+ webServer,
119
+
120
+ coverageEnabled,
121
+ coverageConfig,
122
+ coverageMethodForBrowsers,
123
+ coverageMethodForNodeJs,
124
+ stopAfterAllSignal,
125
+ }
126
+
127
+ if (completedExecutionLogMerging && !process.stdout.isTTY) {
128
+ completedExecutionLogMerging = false
129
+ logger.debug(
130
+ `Force completedExecutionLogMerging to false because process.stdout.isTTY is false`,
131
+ )
132
+ }
133
+ const debugLogsEnabled = logger.levels.debug
134
+ const executionLogsEnabled = logger.levels.info
135
+ const executionSpinner =
136
+ logRefresh &&
137
+ !debugLogsEnabled &&
138
+ executionLogsEnabled &&
139
+ process.stdout.isTTY &&
140
+ // if there is an error during execution npm will mess up the output
141
+ // (happens when npm runs several command in a workspace)
142
+ // so we enable spinner only when !process.exitCode (no error so far)
143
+ process.exitCode !== 1
144
+
145
+ const startMs = Date.now()
146
+ let rawOutput = ""
147
+ let executionLog = createLog({ newLine: "" })
148
+ const counters = {
149
+ total: executionSteps.length,
150
+ aborted: 0,
151
+ timedout: 0,
152
+ failed: 0,
153
+ completed: 0,
154
+ done: 0,
155
+ }
156
+ await executeInParallel({
157
+ multipleExecutionsOperation,
158
+ maxExecutionsInParallel,
159
+ cooldownBetweenExecutions,
160
+ executionSteps,
161
+ start: async (paramsFromStep) => {
162
+ const executionIndex = executionSteps.indexOf(paramsFromStep)
163
+ const { executionName, fileRelativeUrl, runtime } = paramsFromStep
164
+ const runtimeType = runtime.type
165
+ const runtimeName = runtime.name
166
+ const runtimeVersion = runtime.version
167
+ const executionParams = {
168
+ measurePerformance: false,
169
+ collectPerformance: false,
170
+ collectConsole: true,
171
+ allocatedMs: defaultMsAllocatedPerExecution,
172
+ ...paramsFromStep,
173
+ runtimeParams: {
174
+ fileRelativeUrl,
175
+ ...paramsFromStep.runtimeParams,
176
+ },
177
+ }
178
+ const beforeExecutionInfo = {
179
+ fileRelativeUrl,
180
+ runtimeType,
181
+ runtimeName,
182
+ runtimeVersion,
183
+ executionIndex,
184
+ executionParams,
185
+ startMs: Date.now(),
186
+ executionResult: {
187
+ status: "executing",
188
+ },
189
+ }
190
+ let spinner
191
+ if (executionSpinner) {
192
+ spinner = startSpinner({
193
+ log: executionLog,
194
+ render: () => {
195
+ return createExecutionLog(beforeExecutionInfo, {
196
+ counters,
197
+ logRuntime,
198
+ logEachDuration,
199
+ ...(logTimeUsage
200
+ ? {
201
+ timeEllapsed: Date.now() - startMs,
202
+ }
203
+ : {}),
204
+ ...(logMemoryHeapUsage
205
+ ? { memoryHeap: memoryUsage().heapUsed }
206
+ : {}),
207
+ })
208
+ },
209
+ })
210
+ }
211
+ beforeExecutionCallback(beforeExecutionInfo)
212
+
213
+ const fileUrl = `${rootDirectoryUrl}${fileRelativeUrl}`
214
+ let executionResult
215
+ if (existsSync(new URL(fileUrl))) {
216
+ executionResult = await run({
217
+ signal: multipleExecutionsOperation.signal,
218
+ logger,
219
+ allocatedMs:
220
+ typeof executionParams.allocatedMs === "function"
221
+ ? executionParams.allocatedMs(beforeExecutionInfo)
222
+ : executionParams.allocatedMs,
223
+ keepRunning,
224
+ mirrorConsole: false, // file are executed in parallel, log would be a mess to read
225
+ collectConsole: executionParams.collectConsole,
226
+ coverageEnabled,
227
+ coverageTempDirectoryUrl,
228
+ runtime: executionParams.runtime,
229
+ runtimeParams: {
230
+ ...runtimeParams,
231
+ ...executionParams.runtimeParams,
232
+ },
233
+ })
234
+ } else {
235
+ executionResult = {
236
+ status: "failed",
237
+ errors: [
238
+ new Error(
239
+ `No file at ${fileRelativeUrl} for execution "${executionName}"`,
240
+ ),
241
+ ],
242
+ }
243
+ }
244
+ counters.done++
245
+ const fileReport = report[fileRelativeUrl]
246
+ if (fileReport) {
247
+ fileReport[executionName] = executionResult
248
+ } else {
249
+ report[fileRelativeUrl] = {
250
+ [executionName]: executionResult,
251
+ }
252
+ }
253
+
254
+ const afterExecutionInfo = {
255
+ ...beforeExecutionInfo,
256
+ runtimeVersion: runtime.version,
257
+ endMs: Date.now(),
258
+ executionResult,
259
+ }
260
+ afterExecutionCallback(afterExecutionInfo)
261
+
262
+ if (executionResult.status === "aborted") {
263
+ counters.aborted++
264
+ } else if (executionResult.status === "timedout") {
265
+ counters.timedout++
266
+ } else if (executionResult.status === "failed") {
267
+ counters.failed++
268
+ } else if (executionResult.status === "completed") {
269
+ counters.completed++
270
+ }
271
+ if (gcBetweenExecutions) {
272
+ global.gc()
273
+ }
274
+ if (executionLogsEnabled) {
275
+ const log = createExecutionLog(afterExecutionInfo, {
276
+ completedExecutionLogAbbreviation,
277
+ counters,
278
+ logRuntime,
279
+ logEachDuration,
280
+ ...(logTimeUsage
281
+ ? {
282
+ timeEllapsed: Date.now() - startMs,
283
+ }
284
+ : {}),
285
+ ...(logMemoryHeapUsage
286
+ ? { memoryHeap: memoryUsage().heapUsed }
287
+ : {}),
288
+ })
289
+ // replace spinner with this execution result
290
+ if (spinner) spinner.stop()
291
+ executionLog.write(log)
292
+ rawOutput += stripAnsi(log)
293
+
294
+ const canOverwriteLog = canOverwriteLogGetter({
295
+ completedExecutionLogMerging,
296
+ executionResult,
297
+ })
298
+ if (canOverwriteLog) {
299
+ // nothing to do, we reuse the current executionLog object
300
+ } else {
301
+ executionLog.destroy()
302
+ executionLog = createLog({ newLine: "" })
303
+ }
304
+ }
305
+ const isLastExecutionLog = executionIndex === executionSteps.length - 1
306
+ const cancelRemaining =
307
+ failFast &&
308
+ executionResult.status !== "completed" &&
309
+ counters.done < counters.total
310
+ if (isLastExecutionLog && logger.levels.info) {
311
+ executionLog.write("\n")
312
+ }
313
+
314
+ if (cancelRemaining) {
315
+ logger.info(`"failFast" enabled -> cancel remaining executions`)
316
+ failFastAbortController.abort()
317
+ }
318
+ },
319
+ })
320
+ if (!keepRunning) {
321
+ logger.debug("stopAfterAllSignal.notify()")
322
+ await stopAfterAllSignal.notify()
323
+ }
324
+
325
+ counters.cancelled = counters.total - counters.done
326
+ const summary = {
327
+ counters,
328
+ // when execution is aborted, the remaining executions are "cancelled"
329
+ duration: Date.now() - startMs,
330
+ }
331
+ if (logSummary) {
332
+ const summaryLog = createSummaryLog(summary)
333
+ rawOutput += stripAnsi(summaryLog)
334
+ logger.info(summaryLog)
335
+ }
336
+ if (summary.counters.total !== summary.counters.completed) {
337
+ const logFileUrl = new URL(logFileRelativeUrl, rootDirectoryUrl).href
338
+ writeFileSync(logFileUrl, rawOutput)
339
+ logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`)
340
+ }
341
+ executePlanReturnValue.aborted = multipleExecutionsOperation.signal.aborted
342
+ executePlanReturnValue.planSummary = summary
343
+ executePlanReturnValue.planReport = report
344
+ await callbacks.reduce(async (previous, callback) => {
345
+ await previous
346
+ await callback()
347
+ }, Promise.resolve())
348
+ return executePlanReturnValue
349
+ } finally {
350
+ await multipleExecutionsOperation.end()
351
+ }
352
+ }
353
+
354
+ const canOverwriteLogGetter = ({
355
+ completedExecutionLogMerging,
356
+ executionResult,
357
+ }) => {
358
+ if (!completedExecutionLogMerging) {
359
+ return false
360
+ }
361
+ if (executionResult.status === "aborted") {
362
+ return true
363
+ }
364
+ if (executionResult.status !== "completed") {
365
+ return false
366
+ }
367
+ const { consoleCalls = [] } = executionResult
368
+ if (consoleCalls.length > 0) {
369
+ return false
370
+ }
371
+ return true
372
+ }
373
+
374
+ const executeInParallel = async ({
375
+ multipleExecutionsOperation,
376
+ maxExecutionsInParallel,
377
+ cooldownBetweenExecutions,
378
+ executionSteps,
379
+ start,
380
+ }) => {
381
+ const executionResults = []
382
+ let progressionIndex = 0
383
+ let remainingExecutionCount = executionSteps.length
384
+
385
+ const nextChunk = async () => {
386
+ if (multipleExecutionsOperation.signal.aborted) {
387
+ return
388
+ }
389
+ const outputPromiseArray = []
390
+ while (
391
+ remainingExecutionCount > 0 &&
392
+ outputPromiseArray.length < maxExecutionsInParallel
393
+ ) {
394
+ remainingExecutionCount--
395
+ const outputPromise = executeOne(progressionIndex)
396
+ progressionIndex++
397
+ outputPromiseArray.push(outputPromise)
398
+ }
399
+ if (outputPromiseArray.length) {
400
+ await Promise.all(outputPromiseArray)
401
+ if (remainingExecutionCount > 0) {
402
+ await nextChunk()
403
+ }
404
+ }
405
+ }
406
+
407
+ const executeOne = async (index) => {
408
+ const input = executionSteps[index]
409
+ const output = await start(input)
410
+ if (!multipleExecutionsOperation.signal.aborted) {
411
+ executionResults[index] = output
412
+ }
413
+ if (cooldownBetweenExecutions) {
414
+ await new Promise((resolve) =>
415
+ setTimeout(resolve, cooldownBetweenExecutions),
416
+ )
417
+ }
418
+ }
419
+
420
+ await nextChunk()
421
+
422
+ return executionResults
423
+ }