@jsenv/core 25.4.9 → 25.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "25.4.9",
3
+ "version": "25.5.0",
4
4
  "description": "Tool to develop, test and build js projects",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -102,6 +102,7 @@
102
102
  "rollup-plugin-node-globals": "1.4.0",
103
103
  "rollup-plugin-polyfill-node": "0.8.0",
104
104
  "source-map": "0.7.3",
105
+ "strip-ansi": "7.0.1",
105
106
  "systemjs": "6.11.0",
106
107
  "terser": "5.10.0",
107
108
  "v8-to-istanbul": "8.1.0",
@@ -28,6 +28,7 @@ import { jsenvCoverageConfig } from "./jsenvCoverageConfig.js"
28
28
  * @param {boolean} [testPlanParameters.completedExecutionLogMerging=false] Merge completed execution logs to shorten terminal output
29
29
  * @param {number} [testPlanParameters.maxExecutionsInParallel=1] Maximum amount of execution in parallel
30
30
  * @param {number} [testPlanParameters.defaultMsAllocatedPerExecution=30000] Milliseconds after which execution is aborted and considered as failed by timeout
31
+ * @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
31
32
  * @param {number} [testPlanParameters.cooldownBetweenExecutions=0] Millisecond to wait between each execution
32
33
  * @param {boolean} [testPlanParameters.logMemoryHeapUsage=false] Add memory heap usage during logs
33
34
  * @param {boolean} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
@@ -48,15 +49,17 @@ export const executeTestPlan = async ({
48
49
 
49
50
  testPlan,
50
51
 
52
+ logSummary = true,
51
53
  logMemoryHeapUsage = false,
54
+ logFileRelativeUrl = ".jsenv/test_plan_debug.txt",
52
55
  completedExecutionLogAbbreviation = false,
53
56
  completedExecutionLogMerging = false,
54
- logSummary = true,
55
57
  updateProcessExitCode = true,
56
58
  windowsProcessExitFix = true,
57
59
 
58
60
  maxExecutionsInParallel = 1,
59
61
  defaultMsAllocatedPerExecution = 30000,
62
+ failFast = false,
60
63
  // stopAfterExecute: true to ensure runtime is stopped once executed
61
64
  // because we have what we wants: execution is completed and
62
65
  // we have associated coverage and capturedConsole
@@ -103,14 +106,11 @@ export const executeTestPlan = async ({
103
106
  jsenvDirectoryClean,
104
107
  }) => {
105
108
  const logger = createLogger({ logLevel })
106
-
107
109
  projectDirectoryUrl = assertProjectDirectoryUrl({ projectDirectoryUrl })
108
110
  await assertProjectDirectoryExists({ projectDirectoryUrl })
109
-
110
111
  if (typeof testPlan !== "object") {
111
112
  throw new Error(`testPlan must be an object, got ${testPlan}`)
112
113
  }
113
-
114
114
  if (coverage) {
115
115
  if (typeof coverageConfig !== "object") {
116
116
  throw new TypeError(
@@ -155,7 +155,6 @@ export const executeTestPlan = async ({
155
155
  }
156
156
  }
157
157
  }
158
-
159
158
  const result = await executePlan(testPlan, {
160
159
  signal,
161
160
  handleSIGINT,
@@ -171,13 +170,15 @@ export const executeTestPlan = async ({
171
170
  importResolutionMethod,
172
171
  importDefaultExtension,
173
172
 
173
+ logSummary,
174
174
  logMemoryHeapUsage,
175
+ logFileRelativeUrl,
175
176
  completedExecutionLogMerging,
176
177
  completedExecutionLogAbbreviation,
177
- logSummary,
178
178
 
179
- defaultMsAllocatedPerExecution,
180
179
  maxExecutionsInParallel,
180
+ defaultMsAllocatedPerExecution,
181
+ failFast,
181
182
  stopAfterExecute,
182
183
  cooldownBetweenExecutions,
183
184
  gcBetweenExecutions,
@@ -205,11 +206,9 @@ export const executeTestPlan = async ({
205
206
  importMapInWebWorkers,
206
207
  customCompilers,
207
208
  })
208
-
209
209
  if (updateProcessExitCode && !executionIsPassed(result)) {
210
210
  process.exitCode = 1
211
211
  }
212
-
213
212
  const planCoverage = result.planCoverage
214
213
  // planCoverage can be null when execution is aborted
215
214
  if (planCoverage) {
@@ -260,7 +259,6 @@ export const executeTestPlan = async ({
260
259
  }
261
260
  await Promise.all(promises)
262
261
  }
263
-
264
262
  // Sometimes on windows test plan scripts never ends
265
263
  // I suspect it's some node process keeping the process alive
266
264
  // because not properly killed for some reason.
@@ -271,7 +269,6 @@ export const executeTestPlan = async ({
271
269
  process.exit()
272
270
  }, 2000).unref()
273
271
  }
274
-
275
272
  return {
276
273
  testPlanAborted: result.aborted,
277
274
  testPlanSummary: result.planSummary,
@@ -1,11 +1,34 @@
1
- import { Abort, raceProcessTeardownEvents } from "@jsenv/abort"
2
- import { createDetailedMessage } from "@jsenv/logger"
1
+ import { existsSync } from "node:fs"
2
+ import { memoryUsage } from "node:process"
3
+ import wrapAnsi from "wrap-ansi"
4
+ import stripAnsi from "strip-ansi"
5
+ import cuid from "cuid"
6
+ import { createDetailedMessage, loggerToLevels } from "@jsenv/logger"
7
+ import { createLog, startSpinner } from "@jsenv/log"
8
+ import {
9
+ Abort,
10
+ raceProcessTeardownEvents,
11
+ createCallbackListNotifiedOnce,
12
+ } from "@jsenv/abort"
13
+ import {
14
+ urlToFileSystemPath,
15
+ resolveUrl,
16
+ writeDirectory,
17
+ ensureEmptyDirectory,
18
+ normalizeStructuredMetaMap,
19
+ urlToMeta,
20
+ writeFile,
21
+ } from "@jsenv/filesystem"
3
22
 
4
- import { mergeRuntimeSupport } from "@jsenv/core/src/internal/runtime_support/runtime_support.js"
5
23
  import { startCompileServer } from "../compiling/startCompileServer.js"
6
24
  import { babelPluginInstrument } from "./coverage/babel_plugin_instrument.js"
7
25
  import { generateExecutionSteps } from "./generateExecutionSteps.js"
8
- import { executeConcurrently } from "./executeConcurrently.js"
26
+
27
+ import { launchAndExecute } from "../executing/launchAndExecute.js"
28
+ import { reportToCoverage } from "./coverage/reportToCoverage.js"
29
+ import { formatExecuting, formatExecutionResult } from "./executionLogs.js"
30
+ import { createSummaryLog } from "./createSummaryLog.js"
31
+ import { ensureGlobalGc } from "./gc.js"
9
32
 
10
33
  export const executePlan = async (
11
34
  plan,
@@ -26,11 +49,13 @@ export const executePlan = async (
26
49
 
27
50
  logSummary,
28
51
  logMemoryHeapUsage,
52
+ logFileRelativeUrl,
29
53
  completedExecutionLogMerging,
30
54
  completedExecutionLogAbbreviation,
31
55
 
32
56
  defaultMsAllocatedPerExecution,
33
57
  maxExecutionsInParallel,
58
+ failFast,
34
59
  gcBetweenExecutions,
35
60
  stopAfterExecute,
36
61
  cooldownBetweenExecutions,
@@ -56,6 +81,9 @@ export const executePlan = async (
56
81
  serviceWorkers,
57
82
  importMapInWebWorkers,
58
83
  customCompilers,
84
+
85
+ beforeExecutionCallback = () => {},
86
+ afterExecutionCallback = () => {},
59
87
  } = {},
60
88
  ) => {
61
89
  if (coverage) {
@@ -67,27 +95,22 @@ export const executePlan = async (
67
95
  ],
68
96
  }
69
97
  }
70
-
71
- const runtimeSupport = {}
98
+ const runtimes = {}
72
99
  Object.keys(plan).forEach((filePattern) => {
73
100
  const filePlan = plan[filePattern]
74
101
  Object.keys(filePlan).forEach((executionName) => {
75
102
  const executionConfig = filePlan[executionName]
76
103
  const { runtime } = executionConfig
77
104
  if (runtime) {
78
- mergeRuntimeSupport(runtimeSupport, {
79
- [runtime.name]: runtime.version,
80
- })
105
+ runtimes[runtime.name] = runtime.version
81
106
  }
82
107
  })
83
108
  })
84
-
85
109
  logger.debug(
86
110
  createDetailedMessage(`Prepare executing plan`, {
87
- runtimeSupport: JSON.stringify(runtimeSupport, null, " "),
111
+ runtimes: JSON.stringify(runtimes, null, " "),
88
112
  }),
89
113
  )
90
-
91
114
  const multipleExecutionsOperation = Abort.startOperation()
92
115
  multipleExecutionsOperation.addAbortSignal(signal)
93
116
  if (handleSIGINT) {
@@ -103,6 +126,10 @@ export const executePlan = async (
103
126
  )
104
127
  })
105
128
  }
129
+ const failFastAbortController = new AbortController()
130
+ if (failFast) {
131
+ multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal)
132
+ }
106
133
 
107
134
  try {
108
135
  const compileServer = await startCompileServer({
@@ -131,74 +158,303 @@ export const executePlan = async (
131
158
  serviceWorkers,
132
159
  importMapInWebWorkers,
133
160
  customCompilers,
134
- runtimeSupport,
135
161
  })
136
-
162
+ babelPluginMap = compileServer.babelPluginMap
137
163
  multipleExecutionsOperation.addEndCallback(async () => {
138
164
  await compileServer.stop()
139
165
  })
140
-
141
166
  logger.debug(`Generate executions`)
167
+ const executionSteps = await getExecutionAsSteps({
168
+ plan,
169
+ compileServer,
170
+ multipleExecutionsOperation,
171
+ projectDirectoryUrl,
172
+ })
173
+ logger.debug(`${executionSteps.length} executions planned`)
142
174
 
143
- let executionSteps
144
- try {
145
- executionSteps = await generateExecutionSteps(
146
- {
147
- ...plan,
148
- [compileServer.jsenvDirectoryRelativeUrl]: null,
149
- },
150
- {
151
- signal: multipleExecutionsOperation.signal,
152
- projectDirectoryUrl,
153
- },
175
+ if (completedExecutionLogMerging && !process.stdout.isTTY) {
176
+ completedExecutionLogMerging = false
177
+ logger.debug(
178
+ `Force completedExecutionLogMerging to false because process.stdout.isTTY is false`,
154
179
  )
155
- } catch (e) {
156
- if (Abort.isAbortError(e)) {
157
- return {
158
- aborted: true,
159
- planSummary: {},
160
- planReport: {},
161
- planCoverage: null,
162
- }
163
- }
164
- throw e
165
180
  }
166
- logger.debug(`${executionSteps.length} executions planned`)
181
+ const executionLogsEnabled = loggerToLevels(logger).info
182
+ const executionSpinner = executionLogsEnabled && process.stdout.isTTY
167
183
 
168
- const result = await executeConcurrently(executionSteps, {
169
- multipleExecutionsOperation,
170
- logger,
171
- launchAndExecuteLogLevel,
184
+ const startMs = Date.now()
185
+ const report = {}
186
+ const executionCount = executionSteps.length
187
+ let rawOutput = ""
188
+
189
+ let transformReturnValue = (value) => value
172
190
 
191
+ if (gcBetweenExecutions) {
192
+ ensureGlobalGc()
193
+ }
194
+
195
+ const coverageTempDirectoryUrl = resolveUrl(
196
+ coverageTempDirectoryRelativeUrl,
173
197
  projectDirectoryUrl,
174
- compileServer,
198
+ )
199
+ const structuredMetaMapForCover = normalizeStructuredMetaMap(
200
+ {
201
+ cover: coverageConfig,
202
+ },
203
+ projectDirectoryUrl,
204
+ )
205
+ const coverageIgnorePredicate = (url) => {
206
+ return !urlToMeta({
207
+ url: resolveUrl(url, projectDirectoryUrl),
208
+ structuredMetaMap: structuredMetaMapForCover,
209
+ }).cover
210
+ }
175
211
 
176
- // not sure we actually have to pass import params to executeConcurrently
177
- importResolutionMethod,
178
- importDefaultExtension,
212
+ if (coverage) {
213
+ // in case runned multiple times, we don't want to keep writing lot of files in this directory
214
+ if (!process.env.NODE_V8_COVERAGE) {
215
+ await ensureEmptyDirectory(coverageTempDirectoryUrl)
216
+ }
217
+ if (runtimes.node) {
218
+ // v8 coverage is written in a directoy and auto propagate to subprocesses
219
+ // through process.env.NODE_V8_COVERAGE.
220
+ if (!coverageForceIstanbul && !process.env.NODE_V8_COVERAGE) {
221
+ const v8CoverageDirectory = resolveUrl(
222
+ `./node_v8/${cuid()}`,
223
+ coverageTempDirectoryUrl,
224
+ )
225
+ await writeDirectory(v8CoverageDirectory, { allowUseless: true })
226
+ process.env.NODE_V8_COVERAGE =
227
+ urlToFileSystemPath(v8CoverageDirectory)
228
+ }
229
+ }
230
+
231
+ transformReturnValue = async (value) => {
232
+ if (multipleExecutionsOperation.signal.aborted) {
233
+ // don't try to do the coverage stuff
234
+ return value
235
+ }
179
236
 
180
- babelPluginMap: compileServer.babelPluginMap,
237
+ try {
238
+ value.coverage = await reportToCoverage(value.report, {
239
+ signal: multipleExecutionsOperation.signal,
240
+ logger,
241
+ projectDirectoryUrl,
242
+ babelPluginMap,
243
+ coverageConfig,
244
+ coverageIncludeMissing,
245
+ coverageForceIstanbul,
246
+ coverageIgnorePredicate,
247
+ coverageV8ConflictWarning,
248
+ })
249
+ } catch (e) {
250
+ if (Abort.isAbortError(e)) {
251
+ return value
252
+ }
253
+ throw e
254
+ }
255
+ return value
256
+ }
257
+ }
181
258
 
182
- logSummary,
183
- logMemoryHeapUsage,
184
- completedExecutionLogMerging,
185
- completedExecutionLogAbbreviation,
259
+ logger.info("")
260
+ let executionLog = createLog({ newLine: "" })
261
+ let abortedCount = 0
262
+ let timedoutCount = 0
263
+ let erroredCount = 0
264
+ let completedCount = 0
265
+ const stopAfterAllExecutionCallbackList = createCallbackListNotifiedOnce()
186
266
 
187
- defaultMsAllocatedPerExecution,
267
+ let executionDoneCount = 0
268
+ await executeInParallel({
269
+ multipleExecutionsOperation,
188
270
  maxExecutionsInParallel,
189
- stopAfterExecute,
190
- gcBetweenExecutions,
191
271
  cooldownBetweenExecutions,
272
+ executionSteps,
273
+ start: async (paramsFromStep) => {
274
+ const executionIndex = executionSteps.indexOf(paramsFromStep)
275
+ const { executionName, fileRelativeUrl, runtime } = paramsFromStep
276
+ const runtimeName = runtime.name
277
+ const runtimeVersion = runtime.version
192
278
 
193
- coverage,
194
- coverageConfig,
195
- coverageIncludeMissing,
196
- coverageForceIstanbul,
197
- coverageV8ConflictWarning,
198
- coverageTempDirectoryRelativeUrl,
199
- runtimeSupport,
279
+ const executionParams = {
280
+ // the params below can be overriden by executionDefaultParams
281
+ measurePerformance: false,
282
+ collectPerformance: false,
283
+ captureConsole: true,
284
+ stopAfterExecute,
285
+ stopAfterExecuteReason: "execution-done",
286
+ allocatedMs: defaultMsAllocatedPerExecution,
287
+ ...paramsFromStep,
288
+ runtime,
289
+ // mirrorConsole: false because file will be executed in parallel
290
+ // so log would be a mess to read
291
+ mirrorConsole: false,
292
+ }
293
+
294
+ const beforeExecutionInfo = {
295
+ fileRelativeUrl,
296
+ runtimeName,
297
+ runtimeVersion,
298
+ executionIndex,
299
+ executionParams,
300
+ }
301
+
302
+ let spinner
303
+ if (executionSpinner) {
304
+ spinner = startSpinner({
305
+ log: executionLog,
306
+ text: formatExecuting(beforeExecutionInfo, {
307
+ executionCount,
308
+ abortedCount,
309
+ timedoutCount,
310
+ erroredCount,
311
+ completedCount,
312
+ ...(logMemoryHeapUsage
313
+ ? { memoryHeap: memoryUsage().heapUsed }
314
+ : {}),
315
+ }),
316
+ })
317
+ }
318
+ beforeExecutionCallback(beforeExecutionInfo)
319
+
320
+ const filePath = urlToFileSystemPath(
321
+ `${projectDirectoryUrl}${fileRelativeUrl}`,
322
+ )
323
+ let executionResult
324
+ if (existsSync(filePath)) {
325
+ executionResult = await launchAndExecute({
326
+ signal: multipleExecutionsOperation.signal,
327
+ launchAndExecuteLogLevel,
328
+
329
+ ...executionParams,
330
+ collectCoverage: coverage,
331
+ coverageTempDirectoryUrl,
332
+ runtimeParams: {
333
+ projectDirectoryUrl,
334
+ compileServerOrigin: compileServer.origin,
335
+ compileServerId: compileServer.id,
336
+ jsenvDirectoryRelativeUrl:
337
+ compileServer.jsenvDirectoryRelativeUrl,
338
+
339
+ collectCoverage: coverage,
340
+ coverageIgnorePredicate,
341
+ coverageForceIstanbul,
342
+ stopAfterAllExecutionCallbackList,
343
+ ...executionParams.runtimeParams,
344
+ },
345
+ executeParams: {
346
+ fileRelativeUrl,
347
+ ...executionParams.executeParams,
348
+ },
349
+ coverageV8ConflictWarning,
350
+ })
351
+ } else {
352
+ executionResult = {
353
+ status: "errored",
354
+ error: new Error(
355
+ `No file at ${fileRelativeUrl} for execution "${executionName}"`,
356
+ ),
357
+ }
358
+ }
359
+ executionDoneCount++
360
+ if (fileRelativeUrl in report === false) {
361
+ report[fileRelativeUrl] = {}
362
+ }
363
+ report[fileRelativeUrl][executionName] = executionResult
364
+ const afterExecutionInfo = {
365
+ ...beforeExecutionInfo,
366
+ endMs: Date.now(),
367
+ executionResult,
368
+ }
369
+ afterExecutionCallback(afterExecutionInfo)
370
+
371
+ if (executionResult.status === "aborted") {
372
+ abortedCount++
373
+ } else if (executionResult.status === "timedout") {
374
+ timedoutCount++
375
+ } else if (executionResult.status === "errored") {
376
+ erroredCount++
377
+ } else if (executionResult.status === "completed") {
378
+ completedCount++
379
+ }
380
+ if (gcBetweenExecutions) {
381
+ global.gc()
382
+ }
383
+ if (executionLogsEnabled) {
384
+ let log = formatExecutionResult(afterExecutionInfo, {
385
+ completedExecutionLogAbbreviation,
386
+ executionCount,
387
+ abortedCount,
388
+ timedoutCount,
389
+ erroredCount,
390
+ completedCount,
391
+ ...(logMemoryHeapUsage
392
+ ? { memoryHeap: memoryUsage().heapUsed }
393
+ : {}),
394
+ })
395
+ log = `${log}
396
+
397
+ `
398
+ const { columns = 80 } = process.stdout
399
+ log = wrapAnsi(log, columns, {
400
+ trim: false,
401
+ hard: true,
402
+ wordWrap: false,
403
+ })
404
+
405
+ // replace spinner with this execution result
406
+ if (spinner) spinner.stop()
407
+ executionLog.write(log)
408
+ rawOutput += stripAnsi(log)
409
+
410
+ const canOverwriteLog = canOverwriteLogGetter({
411
+ completedExecutionLogMerging,
412
+ executionResult,
413
+ })
414
+ if (canOverwriteLog) {
415
+ // nothing to do, we reuse the current executionLog object
416
+ } else {
417
+ executionLog.destroy()
418
+ executionLog = createLog({ newLine: "" })
419
+ }
420
+ }
421
+ if (
422
+ failFast &&
423
+ executionResult.status !== "completed" &&
424
+ executionDoneCount < executionCount
425
+ ) {
426
+ logger.info(`"failFast" enabled -> cancel remaining executions`)
427
+ failFastAbortController.abort()
428
+ }
429
+ },
200
430
  })
201
431
 
432
+ if (stopAfterExecute) {
433
+ stopAfterAllExecutionCallbackList.notify()
434
+ }
435
+
436
+ const summaryCounts = reportToSummary(report)
437
+ const summary = {
438
+ executionCount,
439
+ ...summaryCounts,
440
+ // when execution is aborted, the remaining executions are "cancelled"
441
+ cancelledCount: executionCount - executionDoneCount,
442
+ duration: Date.now() - startMs,
443
+ }
444
+ if (logSummary) {
445
+ const summaryLog = createSummaryLog(summary)
446
+ rawOutput += stripAnsi(summaryLog)
447
+ logger.info(summaryLog)
448
+ }
449
+ if (summary.executionCount !== summary.completedCount) {
450
+ const logFileUrl = new URL(logFileRelativeUrl, projectDirectoryUrl)
451
+ writeFile(logFileUrl, rawOutput)
452
+ logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`)
453
+ }
454
+ const result = await transformReturnValue({
455
+ summary,
456
+ report,
457
+ })
202
458
  return {
203
459
  aborted: multipleExecutionsOperation.signal.aborted,
204
460
  planSummary: result.summary,
@@ -209,3 +465,137 @@ export const executePlan = async (
209
465
  await multipleExecutionsOperation.end()
210
466
  }
211
467
  }
468
+
469
+ const getExecutionAsSteps = async ({
470
+ plan,
471
+ compileServer,
472
+ multipleExecutionsOperation,
473
+ projectDirectoryUrl,
474
+ }) => {
475
+ try {
476
+ const executionSteps = await generateExecutionSteps(
477
+ {
478
+ ...plan,
479
+ [compileServer.jsenvDirectoryRelativeUrl]: null,
480
+ },
481
+ {
482
+ signal: multipleExecutionsOperation.signal,
483
+ projectDirectoryUrl,
484
+ },
485
+ )
486
+ return executionSteps
487
+ } catch (e) {
488
+ if (Abort.isAbortError(e)) {
489
+ return {
490
+ aborted: true,
491
+ planSummary: {},
492
+ planReport: {},
493
+ planCoverage: null,
494
+ }
495
+ }
496
+ throw e
497
+ }
498
+ }
499
+
500
+ const canOverwriteLogGetter = ({
501
+ completedExecutionLogMerging,
502
+ executionResult,
503
+ }) => {
504
+ if (!completedExecutionLogMerging) {
505
+ return false
506
+ }
507
+ if (executionResult.status === "aborted") {
508
+ return true
509
+ }
510
+ if (executionResult.status !== "completed") {
511
+ return false
512
+ }
513
+ const { consoleCalls = [] } = executionResult
514
+ if (consoleCalls.length > 0) {
515
+ return false
516
+ }
517
+ return true
518
+ }
519
+
520
+ const executeInParallel = async ({
521
+ multipleExecutionsOperation,
522
+ maxExecutionsInParallel,
523
+ cooldownBetweenExecutions,
524
+ executionSteps,
525
+ start,
526
+ }) => {
527
+ const executionResults = []
528
+ let progressionIndex = 0
529
+ let remainingExecutionCount = executionSteps.length
530
+
531
+ const nextChunk = async () => {
532
+ if (multipleExecutionsOperation.signal.aborted) {
533
+ return
534
+ }
535
+ const outputPromiseArray = []
536
+ while (
537
+ remainingExecutionCount > 0 &&
538
+ outputPromiseArray.length < maxExecutionsInParallel
539
+ ) {
540
+ remainingExecutionCount--
541
+ const outputPromise = executeOne(progressionIndex)
542
+ progressionIndex++
543
+ outputPromiseArray.push(outputPromise)
544
+ }
545
+ if (outputPromiseArray.length) {
546
+ await Promise.all(outputPromiseArray)
547
+ if (remainingExecutionCount > 0) {
548
+ await nextChunk()
549
+ }
550
+ }
551
+ }
552
+
553
+ const executeOne = async (index) => {
554
+ const input = executionSteps[index]
555
+ const output = await start(input)
556
+ if (!multipleExecutionsOperation.signal.aborted) {
557
+ executionResults[index] = output
558
+ }
559
+ if (cooldownBetweenExecutions) {
560
+ await new Promise((resolve) =>
561
+ setTimeout(resolve, cooldownBetweenExecutions),
562
+ )
563
+ }
564
+ }
565
+
566
+ await nextChunk()
567
+
568
+ return executionResults
569
+ }
570
+
571
+ const reportToSummary = (report) => {
572
+ const fileNames = Object.keys(report)
573
+ const countResultMatching = (predicate) => {
574
+ return fileNames.reduce((previous, fileName) => {
575
+ const fileExecutionResult = report[fileName]
576
+
577
+ return (
578
+ previous +
579
+ Object.keys(fileExecutionResult).filter((executionName) => {
580
+ const fileExecutionResultForRuntime =
581
+ fileExecutionResult[executionName]
582
+ return predicate(fileExecutionResultForRuntime)
583
+ }).length
584
+ )
585
+ }, 0)
586
+ }
587
+ const abortedCount = countResultMatching(({ status }) => status === "aborted")
588
+ const timedoutCount = countResultMatching(
589
+ ({ status }) => status === "timedout",
590
+ )
591
+ const erroredCount = countResultMatching(({ status }) => status === "errored")
592
+ const completedCount = countResultMatching(
593
+ ({ status }) => status === "completed",
594
+ )
595
+ return {
596
+ abortedCount,
597
+ timedoutCount,
598
+ erroredCount,
599
+ completedCount,
600
+ }
601
+ }
@@ -56,7 +56,7 @@ const normalizeRuntimeVersion = (version) => {
56
56
  return version
57
57
  }
58
58
 
59
- export const mergeRuntimeSupport = (runtimeSupport, childRuntimeSupport) => {
59
+ const mergeRuntimeSupport = (runtimeSupport, childRuntimeSupport) => {
60
60
  Object.keys(childRuntimeSupport).forEach((runtimeName) => {
61
61
  const childRuntimeVersion = normalizeRuntimeVersion(
62
62
  childRuntimeSupport[runtimeName],
@@ -1,440 +0,0 @@
1
- import { existsSync } from "node:fs"
2
- import { memoryUsage } from "node:process"
3
- import wrapAnsi from "wrap-ansi"
4
- import cuid from "cuid"
5
- import { loggerToLevels } from "@jsenv/logger"
6
- import { createLog, startSpinner } from "@jsenv/log"
7
- import {
8
- urlToFileSystemPath,
9
- resolveUrl,
10
- writeDirectory,
11
- ensureEmptyDirectory,
12
- normalizeStructuredMetaMap,
13
- urlToMeta,
14
- } from "@jsenv/filesystem"
15
- import { Abort, createCallbackListNotifiedOnce } from "@jsenv/abort"
16
-
17
- import { launchAndExecute } from "../executing/launchAndExecute.js"
18
- import { reportToCoverage } from "./coverage/reportToCoverage.js"
19
- import { formatExecuting, formatExecutionResult } from "./executionLogs.js"
20
- import { createSummaryLog } from "./createSummaryLog.js"
21
- import { ensureGlobalGc } from "./gc.js"
22
-
23
- export const executeConcurrently = async (
24
- executionSteps,
25
- {
26
- multipleExecutionsOperation,
27
-
28
- logger,
29
- launchAndExecuteLogLevel,
30
-
31
- projectDirectoryUrl,
32
- compileServer,
33
- babelPluginMap,
34
-
35
- logSummary,
36
- logMemoryHeapUsage,
37
- completedExecutionLogMerging,
38
- completedExecutionLogAbbreviation,
39
-
40
- maxExecutionsInParallel,
41
- defaultMsAllocatedPerExecution,
42
- stopAfterExecute,
43
- cooldownBetweenExecutions,
44
- gcBetweenExecutions,
45
-
46
- coverage,
47
- coverageConfig,
48
- coverageIncludeMissing,
49
- coverageForceIstanbul,
50
- coverageV8ConflictWarning,
51
- coverageTempDirectoryRelativeUrl,
52
- runtimeSupport,
53
-
54
- beforeExecutionCallback = () => {},
55
- afterExecutionCallback = () => {},
56
- },
57
- ) => {
58
- if (completedExecutionLogMerging && !process.stdout.isTTY) {
59
- completedExecutionLogMerging = false
60
- logger.debug(
61
- `Force completedExecutionLogMerging to false because process.stdout.isTTY is false`,
62
- )
63
- }
64
- const executionLogsEnabled = loggerToLevels(logger).info
65
- const executionSpinner = executionLogsEnabled && process.stdout.isTTY
66
-
67
- const startMs = Date.now()
68
- const report = {}
69
- const executionCount = executionSteps.length
70
-
71
- let transformReturnValue = (value) => value
72
-
73
- if (gcBetweenExecutions) {
74
- ensureGlobalGc()
75
- }
76
-
77
- const coverageTempDirectoryUrl = resolveUrl(
78
- coverageTempDirectoryRelativeUrl,
79
- projectDirectoryUrl,
80
- )
81
-
82
- const structuredMetaMapForCover = normalizeStructuredMetaMap(
83
- {
84
- cover: coverageConfig,
85
- },
86
- projectDirectoryUrl,
87
- )
88
- const coverageIgnorePredicate = (url) => {
89
- return !urlToMeta({
90
- url: resolveUrl(url, projectDirectoryUrl),
91
- structuredMetaMap: structuredMetaMapForCover,
92
- }).cover
93
- }
94
-
95
- if (coverage) {
96
- // in case runned multiple times, we don't want to keep writing lot of files in this directory
97
- if (!process.env.NODE_V8_COVERAGE) {
98
- await ensureEmptyDirectory(coverageTempDirectoryUrl)
99
- }
100
-
101
- if (runtimeSupport.node) {
102
- // v8 coverage is written in a directoy and auto propagate to subprocesses
103
- // through process.env.NODE_V8_COVERAGE.
104
- if (!coverageForceIstanbul && !process.env.NODE_V8_COVERAGE) {
105
- const v8CoverageDirectory = resolveUrl(
106
- `./node_v8/${cuid()}`,
107
- coverageTempDirectoryUrl,
108
- )
109
- await writeDirectory(v8CoverageDirectory, { allowUseless: true })
110
- process.env.NODE_V8_COVERAGE = urlToFileSystemPath(v8CoverageDirectory)
111
- }
112
- }
113
-
114
- transformReturnValue = async (value) => {
115
- if (multipleExecutionsOperation.signal.aborted) {
116
- // don't try to do the coverage stuff
117
- return value
118
- }
119
-
120
- try {
121
- value.coverage = await reportToCoverage(value.report, {
122
- signal: multipleExecutionsOperation.signal,
123
- logger,
124
- projectDirectoryUrl,
125
- babelPluginMap,
126
- coverageConfig,
127
- coverageIncludeMissing,
128
- coverageForceIstanbul,
129
- coverageIgnorePredicate,
130
- coverageV8ConflictWarning,
131
- })
132
- } catch (e) {
133
- if (Abort.isAbortError(e)) {
134
- return value
135
- }
136
- throw e
137
- }
138
- return value
139
- }
140
- }
141
-
142
- logger.info("")
143
- let executionLog = createLog({ newLine: "" })
144
- let abortedCount = 0
145
- let timedoutCount = 0
146
- let erroredCount = 0
147
- let completedCount = 0
148
- const stopAfterAllExecutionCallbackList = createCallbackListNotifiedOnce()
149
-
150
- const executionsDone = await executeInParallel({
151
- multipleExecutionsOperation,
152
- maxExecutionsInParallel,
153
- cooldownBetweenExecutions,
154
- executionSteps,
155
- start: async (paramsFromStep) => {
156
- const executionIndex = executionSteps.indexOf(paramsFromStep)
157
- const { executionName, fileRelativeUrl, runtime } = paramsFromStep
158
- const runtimeName = runtime.name
159
- const runtimeVersion = runtime.version
160
-
161
- const executionParams = {
162
- // the params below can be overriden by executionDefaultParams
163
- measurePerformance: false,
164
- collectPerformance: false,
165
- captureConsole: true,
166
- stopAfterExecute,
167
- stopAfterExecuteReason: "execution-done",
168
- allocatedMs: defaultMsAllocatedPerExecution,
169
- ...paramsFromStep,
170
- runtime,
171
- // mirrorConsole: false because file will be executed in parallel
172
- // so log would be a mess to read
173
- mirrorConsole: false,
174
- }
175
-
176
- const beforeExecutionInfo = {
177
- fileRelativeUrl,
178
- runtimeName,
179
- runtimeVersion,
180
- executionIndex,
181
- executionParams,
182
- }
183
-
184
- let spinner
185
- if (executionSpinner) {
186
- spinner = startSpinner({
187
- log: executionLog,
188
- text: formatExecuting(beforeExecutionInfo, {
189
- executionCount,
190
- abortedCount,
191
- timedoutCount,
192
- erroredCount,
193
- completedCount,
194
- ...(logMemoryHeapUsage
195
- ? { memoryHeap: memoryUsage().heapUsed }
196
- : {}),
197
- }),
198
- })
199
- }
200
- beforeExecutionCallback(beforeExecutionInfo)
201
-
202
- const filePath = urlToFileSystemPath(
203
- `${projectDirectoryUrl}${fileRelativeUrl}`,
204
- )
205
- let executionResult
206
- if (existsSync(filePath)) {
207
- executionResult = await launchAndExecute({
208
- signal: multipleExecutionsOperation.signal,
209
- launchAndExecuteLogLevel,
210
-
211
- ...executionParams,
212
- collectCoverage: coverage,
213
- coverageTempDirectoryUrl,
214
- runtimeParams: {
215
- projectDirectoryUrl,
216
- compileServerOrigin: compileServer.origin,
217
- compileServerId: compileServer.id,
218
- jsenvDirectoryRelativeUrl: compileServer.jsenvDirectoryRelativeUrl,
219
-
220
- collectCoverage: coverage,
221
- coverageIgnorePredicate,
222
- coverageForceIstanbul,
223
- stopAfterAllExecutionCallbackList,
224
- ...executionParams.runtimeParams,
225
- },
226
- executeParams: {
227
- fileRelativeUrl,
228
- ...executionParams.executeParams,
229
- },
230
- coverageV8ConflictWarning,
231
- })
232
- } else {
233
- executionResult = {
234
- status: "errored",
235
- error: new Error(
236
- `No file at ${fileRelativeUrl} for execution "${executionName}"`,
237
- ),
238
- }
239
- }
240
- if (fileRelativeUrl in report === false) {
241
- report[fileRelativeUrl] = {}
242
- }
243
- report[fileRelativeUrl][executionName] = executionResult
244
- const afterExecutionInfo = {
245
- ...beforeExecutionInfo,
246
- endMs: Date.now(),
247
- executionResult,
248
- }
249
- afterExecutionCallback(afterExecutionInfo)
250
-
251
- if (executionResult.status === "aborted") {
252
- abortedCount++
253
- } else if (executionResult.status === "timedout") {
254
- timedoutCount++
255
- } else if (executionResult.status === "errored") {
256
- erroredCount++
257
- } else if (executionResult.status === "completed") {
258
- completedCount++
259
- }
260
-
261
- if (gcBetweenExecutions) {
262
- global.gc()
263
- }
264
-
265
- if (executionLogsEnabled) {
266
- let log = formatExecutionResult(afterExecutionInfo, {
267
- completedExecutionLogAbbreviation,
268
- executionCount,
269
- abortedCount,
270
- timedoutCount,
271
- erroredCount,
272
- completedCount,
273
- ...(logMemoryHeapUsage ? { memoryHeap: memoryUsage().heapUsed } : {}),
274
- })
275
- log = `${log}
276
-
277
- `
278
- const { columns = 80 } = process.stdout
279
- log = wrapAnsi(log, columns, {
280
- trim: false,
281
- hard: true,
282
- wordWrap: false,
283
- })
284
-
285
- // replace spinner with this execution result
286
- if (spinner) spinner.stop()
287
- executionLog.write(log)
288
-
289
- const canOverwriteLog = canOverwriteLogGetter({
290
- completedExecutionLogMerging,
291
- executionResult,
292
- })
293
- if (canOverwriteLog) {
294
- // nothing to do, we reuse the current executionLog object
295
- } else {
296
- executionLog.destroy()
297
- executionLog = createLog({ newLine: "" })
298
- }
299
- }
300
- },
301
- })
302
-
303
- if (stopAfterExecute) {
304
- stopAfterAllExecutionCallbackList.notify()
305
- }
306
-
307
- const summaryCounts = reportToSummary(report)
308
-
309
- const summary = {
310
- executionCount,
311
- ...summaryCounts,
312
- // when execution is aborted, the remaining executions are "cancelled"
313
- cancelledCount:
314
- executionCount -
315
- executionsDone.length -
316
- // we substract abortedCount because they are not pushed into executionsDone
317
- summaryCounts.abortedCount,
318
- duration: Date.now() - startMs,
319
- }
320
- if (logSummary) {
321
- logger.info(createSummaryLog(summary))
322
- }
323
-
324
- return transformReturnValue({
325
- summary,
326
- report,
327
- })
328
- }
329
-
330
- const canOverwriteLogGetter = ({
331
- completedExecutionLogMerging,
332
- executionResult,
333
- }) => {
334
- if (!completedExecutionLogMerging) {
335
- return false
336
- }
337
-
338
- if (executionResult.status === "aborted") {
339
- return true
340
- }
341
-
342
- if (executionResult.status !== "completed") {
343
- return false
344
- }
345
-
346
- const { consoleCalls = [] } = executionResult
347
- if (consoleCalls.length > 0) {
348
- return false
349
- }
350
-
351
- return true
352
- }
353
-
354
- const executeInParallel = async ({
355
- multipleExecutionsOperation,
356
- executionSteps,
357
- start,
358
- maxExecutionsInParallel = 1,
359
- cooldownBetweenExecutions,
360
- }) => {
361
- const executionResults = []
362
- let progressionIndex = 0
363
- let remainingExecutionCount = executionSteps.length
364
-
365
- const nextChunk = async () => {
366
- if (multipleExecutionsOperation.signal.aborted) {
367
- return
368
- }
369
-
370
- const outputPromiseArray = []
371
- while (
372
- remainingExecutionCount > 0 &&
373
- outputPromiseArray.length < maxExecutionsInParallel
374
- ) {
375
- remainingExecutionCount--
376
- const outputPromise = executeOne(progressionIndex)
377
- progressionIndex++
378
- outputPromiseArray.push(outputPromise)
379
- }
380
-
381
- if (outputPromiseArray.length) {
382
- await Promise.all(outputPromiseArray)
383
- if (remainingExecutionCount > 0) {
384
- await nextChunk()
385
- }
386
- }
387
- }
388
-
389
- const executeOne = async (index) => {
390
- const input = executionSteps[index]
391
- const output = await start(input)
392
- if (!multipleExecutionsOperation.signal.aborted) {
393
- executionResults[index] = output
394
- }
395
- if (cooldownBetweenExecutions) {
396
- await new Promise((resolve) =>
397
- setTimeout(resolve, cooldownBetweenExecutions),
398
- )
399
- }
400
- }
401
-
402
- await nextChunk()
403
-
404
- return executionResults
405
- }
406
-
407
- const reportToSummary = (report) => {
408
- const fileNames = Object.keys(report)
409
-
410
- const countResultMatching = (predicate) => {
411
- return fileNames.reduce((previous, fileName) => {
412
- const fileExecutionResult = report[fileName]
413
-
414
- return (
415
- previous +
416
- Object.keys(fileExecutionResult).filter((executionName) => {
417
- const fileExecutionResultForRuntime =
418
- fileExecutionResult[executionName]
419
- return predicate(fileExecutionResultForRuntime)
420
- }).length
421
- )
422
- }, 0)
423
- }
424
-
425
- const abortedCount = countResultMatching(({ status }) => status === "aborted")
426
- const timedoutCount = countResultMatching(
427
- ({ status }) => status === "timedout",
428
- )
429
- const erroredCount = countResultMatching(({ status }) => status === "errored")
430
- const completedCount = countResultMatching(
431
- ({ status }) => status === "completed",
432
- )
433
-
434
- return {
435
- abortedCount,
436
- timedoutCount,
437
- erroredCount,
438
- completedCount,
439
- }
440
- }