@jsenv/core 27.0.3 → 27.2.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 (37) hide show
  1. package/dist/controllable_child_process.mjs +139 -0
  2. package/dist/controllable_worker_thread.mjs +103 -0
  3. package/dist/js/execute_using_dynamic_import.js +169 -0
  4. package/dist/js/v8_coverage.js +539 -0
  5. package/dist/main.js +683 -818
  6. package/package.json +9 -8
  7. package/src/build/build.js +9 -12
  8. package/src/build/build_urls_generator.js +1 -1
  9. package/src/build/inject_global_version_mappings.js +3 -2
  10. package/src/build/inject_service_worker_urls.js +1 -2
  11. package/src/execute/run.js +50 -68
  12. package/src/execute/runtimes/browsers/chromium.js +1 -1
  13. package/src/execute/runtimes/browsers/firefox.js +1 -1
  14. package/src/execute/runtimes/browsers/from_playwright.js +13 -8
  15. package/src/execute/runtimes/browsers/webkit.js +1 -1
  16. package/src/execute/runtimes/node/{controllable_file.mjs → controllable_child_process.mjs} +18 -50
  17. package/src/execute/runtimes/node/controllable_worker_thread.mjs +103 -0
  18. package/src/execute/runtimes/node/execute_using_dynamic_import.js +49 -0
  19. package/src/execute/runtimes/node/exit_codes.js +9 -0
  20. package/src/execute/runtimes/node/{node_process.js → node_child_process.js} +56 -50
  21. package/src/execute/runtimes/node/node_worker_thread.js +268 -25
  22. package/src/execute/runtimes/node/profiler_v8_coverage.js +56 -0
  23. package/src/main.js +3 -1
  24. package/src/omega/kitchen.js +19 -6
  25. package/src/omega/server/file_service.js +2 -2
  26. package/src/omega/url_graph/url_graph_load.js +0 -1
  27. package/src/omega/url_graph.js +1 -0
  28. package/src/plugins/bundling/js_module/bundle_js_module.js +2 -5
  29. package/src/plugins/transpilation/as_js_classic/jsenv_plugin_as_js_classic.js +18 -15
  30. package/src/plugins/url_resolution/jsenv_plugin_url_resolution.js +2 -1
  31. package/src/test/coverage/report_to_coverage.js +16 -19
  32. package/src/test/coverage/v8_coverage.js +26 -0
  33. package/src/test/coverage/{v8_coverage_from_directory.js → v8_coverage_node_directory.js} +22 -26
  34. package/src/test/execute_plan.js +98 -91
  35. package/src/test/execute_test_plan.js +19 -13
  36. package/src/test/logs_file_execution.js +90 -13
  37. package/dist/js/controllable_file.mjs +0 -227
@@ -1,10 +1,9 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { memoryUsage } from "node:process"
3
+ import { takeCoverage } from "node:v8"
3
4
  import wrapAnsi from "wrap-ansi"
4
5
  import stripAnsi from "strip-ansi"
5
- import cuid from "cuid"
6
6
 
7
- import { URL_META } from "@jsenv/url-meta"
8
7
  import { urlToFileSystemPath } from "@jsenv/urls"
9
8
  import {
10
9
  createDetailedMessage,
@@ -13,11 +12,7 @@ import {
13
12
  startSpinner,
14
13
  } from "@jsenv/log"
15
14
  import { Abort, raceProcessTeardownEvents } from "@jsenv/abort"
16
- import {
17
- writeDirectory,
18
- ensureEmptyDirectory,
19
- writeFileSync,
20
- } from "@jsenv/filesystem"
15
+ import { ensureEmptyDirectory, writeFileSync } from "@jsenv/filesystem"
21
16
 
22
17
  import { babelPluginInstrument } from "./coverage/babel_plugin_instrument.js"
23
18
  import { reportToCoverage } from "./coverage/report_to_coverage.js"
@@ -37,6 +32,8 @@ export const executePlan = async (
37
32
  signal,
38
33
  handleSIGINT,
39
34
  logger,
35
+ logRuntime,
36
+ logEachDuration,
40
37
  logSummary,
41
38
  logTimeUsage,
42
39
  logMemoryHeapUsage,
@@ -52,10 +49,11 @@ export const executePlan = async (
52
49
  gcBetweenExecutions,
53
50
  cooldownBetweenExecutions,
54
51
 
55
- coverage,
52
+ coverageEnabled,
56
53
  coverageConfig,
57
54
  coverageIncludeMissing,
58
- coverageForceIstanbul,
55
+ coverageMethodForBrowsers,
56
+ coverageMethodForNodeJs,
59
57
  coverageV8ConflictWarning,
60
58
  coverageTempDirectoryRelativeUrl,
61
59
 
@@ -77,9 +75,13 @@ export const executePlan = async (
77
75
  afterExecutionCallback = () => {},
78
76
  } = {},
79
77
  ) => {
78
+ const executePlanReturnValue = {}
79
+ const report = {}
80
+ const callbacks = []
80
81
  const stopAfterAllSignal = { notify: () => {} }
81
82
 
82
83
  let someNeedsServer = false
84
+ let someNodeRuntime = false
83
85
  const runtimes = {}
84
86
  Object.keys(plan).forEach((filePattern) => {
85
87
  const filePlan = plan[filePattern]
@@ -91,6 +93,9 @@ export const executePlan = async (
91
93
  if (runtime.needsServer) {
92
94
  someNeedsServer = true
93
95
  }
96
+ if (runtime.type === "node") {
97
+ someNodeRuntime = true
98
+ }
94
99
  }
95
100
  })
96
101
  })
@@ -120,10 +125,76 @@ export const executePlan = async (
120
125
  }
121
126
 
122
127
  try {
128
+ const coverageTempDirectoryUrl = new URL(
129
+ coverageTempDirectoryRelativeUrl,
130
+ rootDirectoryUrl,
131
+ ).href
132
+ if (
133
+ someNodeRuntime &&
134
+ coverageEnabled &&
135
+ coverageMethodForNodeJs === "NODE_V8_COVERAGE"
136
+ ) {
137
+ if (process.env.NODE_V8_COVERAGE) {
138
+ // when runned multiple times, we don't want to keep previous files in this directory
139
+ await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE)
140
+ } else {
141
+ coverageMethodForNodeJs = "Profiler"
142
+ logger.warn(
143
+ createDetailedMessage(
144
+ `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
145
+ {
146
+ "suggestion": `Preprend NODE_V8_COVERAGE=.coverage/node to the command executing this process`,
147
+ "suggestion 2": `use coverageMethodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
148
+ },
149
+ ),
150
+ )
151
+ }
152
+ }
153
+
154
+ if (gcBetweenExecutions) {
155
+ ensureGlobalGc()
156
+ }
157
+
158
+ if (coverageEnabled) {
159
+ // when runned multiple times, we don't want to keep previous files in this directory
160
+ await ensureEmptyDirectory(coverageTempDirectoryUrl)
161
+ callbacks.push(async () => {
162
+ if (multipleExecutionsOperation.signal.aborted) {
163
+ // don't try to do the coverage stuff
164
+ return
165
+ }
166
+ try {
167
+ if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
168
+ takeCoverage()
169
+ // conceptually we don't need coverage anymore so it would be
170
+ // good to call v8.stopCoverage()
171
+ // but it logs a strange message about "result is not an object"
172
+ }
173
+ const planCoverage = await reportToCoverage(report, {
174
+ signal: multipleExecutionsOperation.signal,
175
+ logger,
176
+ rootDirectoryUrl,
177
+ coverageConfig,
178
+ coverageIncludeMissing,
179
+ coverageMethodForBrowsers,
180
+ coverageV8ConflictWarning,
181
+ })
182
+ executePlanReturnValue.planCoverage = planCoverage
183
+ } catch (e) {
184
+ if (Abort.isAbortError(e)) {
185
+ return
186
+ }
187
+ throw e
188
+ }
189
+ })
190
+ }
191
+
123
192
  let runtimeParams = {
124
193
  rootDirectoryUrl,
125
- collectCoverage: coverage,
126
- coverageForceIstanbul,
194
+ coverageEnabled,
195
+ coverageConfig,
196
+ coverageMethodForBrowsers,
197
+ coverageMethodForNodeJs,
127
198
  stopAfterAllSignal,
128
199
  }
129
200
  if (someNeedsServer) {
@@ -152,7 +223,7 @@ export const executePlan = async (
152
223
  ...transpilation,
153
224
  getCustomBabelPlugins: ({ clientRuntimeCompat }) => {
154
225
  if (
155
- coverage &&
226
+ coverageEnabled &&
156
227
  Object.keys(clientRuntimeCompat)[0] !== "chrome"
157
228
  ) {
158
229
  return {
@@ -219,76 +290,8 @@ export const executePlan = async (
219
290
  process.exitCode !== 1
220
291
 
221
292
  const startMs = Date.now()
222
- const report = {}
223
293
  let rawOutput = ""
224
294
 
225
- let transformReturnValue = (value) => value
226
- if (gcBetweenExecutions) {
227
- ensureGlobalGc()
228
- }
229
-
230
- const coverageTempDirectoryUrl = new URL(
231
- coverageTempDirectoryRelativeUrl,
232
- rootDirectoryUrl,
233
- ).href
234
-
235
- if (coverage) {
236
- const associations = URL_META.resolveAssociations(
237
- { cover: coverageConfig },
238
- rootDirectoryUrl,
239
- )
240
- const urlShouldBeCovered = (url) => {
241
- const { cover } = URL_META.applyAssociations({
242
- url: new URL(url, rootDirectoryUrl).href,
243
- associations,
244
- })
245
- return cover
246
- }
247
- runtimeParams.urlShouldBeCovered = urlShouldBeCovered
248
-
249
- // in case runned multiple times, we don't want to keep writing lot of files in this directory
250
- if (!process.env.NODE_V8_COVERAGE) {
251
- await ensureEmptyDirectory(coverageTempDirectoryUrl)
252
- }
253
- if (runtimes.node) {
254
- // v8 coverage is written in a directoy and auto propagate to subprocesses
255
- // through process.env.NODE_V8_COVERAGE.
256
- if (!coverageForceIstanbul && !process.env.NODE_V8_COVERAGE) {
257
- const v8CoverageDirectory = new URL(
258
- `./node_v8/${cuid()}`,
259
- coverageTempDirectoryUrl,
260
- ).href
261
- await writeDirectory(v8CoverageDirectory, { allowUseless: true })
262
- process.env.NODE_V8_COVERAGE =
263
- urlToFileSystemPath(v8CoverageDirectory)
264
- }
265
- }
266
- transformReturnValue = async (value) => {
267
- if (multipleExecutionsOperation.signal.aborted) {
268
- // don't try to do the coverage stuff
269
- return value
270
- }
271
- try {
272
- value.coverage = await reportToCoverage(value.report, {
273
- signal: multipleExecutionsOperation.signal,
274
- logger,
275
- rootDirectoryUrl,
276
- coverageConfig,
277
- coverageIncludeMissing,
278
- coverageForceIstanbul,
279
- urlShouldBeCovered,
280
- coverageV8ConflictWarning,
281
- })
282
- } catch (e) {
283
- if (Abort.isAbortError(e)) {
284
- return value
285
- }
286
- throw e
287
- }
288
- return value
289
- }
290
- }
291
-
292
295
  logger.info("")
293
296
  let executionLog = createLog({ newLine: "" })
294
297
  const counters = {
@@ -307,6 +310,7 @@ export const executePlan = async (
307
310
  start: async (paramsFromStep) => {
308
311
  const executionIndex = executionSteps.indexOf(paramsFromStep)
309
312
  const { executionName, fileRelativeUrl, runtime } = paramsFromStep
313
+ const runtimeType = runtime.type
310
314
  const runtimeName = runtime.name
311
315
  const runtimeVersion = runtime.version
312
316
  const executionParams = {
@@ -322,6 +326,7 @@ export const executePlan = async (
322
326
  }
323
327
  const beforeExecutionInfo = {
324
328
  fileRelativeUrl,
329
+ runtimeType,
325
330
  runtimeName,
326
331
  runtimeVersion,
327
332
  executionIndex,
@@ -338,6 +343,8 @@ export const executePlan = async (
338
343
  render: () => {
339
344
  return createExecutionLog(beforeExecutionInfo, {
340
345
  counters,
346
+ logRuntime,
347
+ logEachDuration,
341
348
  ...(logTimeUsage
342
349
  ? {
343
350
  timeEllapsed: Date.now() - startMs,
@@ -362,7 +369,7 @@ export const executePlan = async (
362
369
  keepRunning,
363
370
  mirrorConsole: false, // file are executed in parallel, log would be a mess to read
364
371
  collectConsole: executionParams.collectConsole,
365
- collectCoverage: coverage,
372
+ coverageEnabled,
366
373
  coverageTempDirectoryUrl,
367
374
  runtime: executionParams.runtime,
368
375
  runtimeParams: {
@@ -411,6 +418,8 @@ export const executePlan = async (
411
418
  let log = createExecutionLog(afterExecutionInfo, {
412
419
  completedExecutionLogAbbreviation,
413
420
  counters,
421
+ logRuntime,
422
+ logEachDuration,
414
423
  ...(logTimeUsage
415
424
  ? {
416
425
  timeEllapsed: Date.now() - startMs,
@@ -477,16 +486,14 @@ export const executePlan = async (
477
486
  writeFileSync(logFileUrl, rawOutput)
478
487
  logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`)
479
488
  }
480
- const result = await transformReturnValue({
481
- summary,
482
- report,
483
- })
484
- return {
485
- aborted: multipleExecutionsOperation.signal.aborted,
486
- planSummary: result.summary,
487
- planReport: result.report,
488
- planCoverage: result.coverage,
489
- }
489
+ executePlanReturnValue.aborted = multipleExecutionsOperation.signal.aborted
490
+ executePlanReturnValue.planSummary = summary
491
+ executePlanReturnValue.planReport = report
492
+ await callbacks.reduce(async (previous, callback) => {
493
+ await previous
494
+ await callback()
495
+ }, Promise.resolve())
496
+ return executePlanReturnValue
490
497
  } finally {
491
498
  await multipleExecutionsOperation.end()
492
499
  }
@@ -28,7 +28,7 @@ import { executePlan } from "./execute_plan.js"
28
28
  * @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
29
29
  * @param {number} [testPlanParameters.cooldownBetweenExecutions=0] Millisecond to wait between each execution
30
30
  * @param {boolean} [testPlanParameters.logMemoryHeapUsage=false] Add memory heap usage during logs
31
- * @param {boolean} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
31
+ * @param {boolean} [testPlanParameters.coverageEnabled=false] Controls if coverage is collected during files executions
32
32
  * @param {boolean} [testPlanParameters.coverageV8ConflictWarning=true] Warn when coverage from 2 executions cannot be merged
33
33
  * @return {Object} An object containing the result of all file executions
34
34
  */
@@ -36,6 +36,8 @@ export const executeTestPlan = async ({
36
36
  signal = new AbortController().signal,
37
37
  handleSIGINT = true,
38
38
  logLevel = "info",
39
+ logRuntime = true,
40
+ logEachDuration = true,
39
41
  logSummary = true,
40
42
  logTimeUsage = false,
41
43
  logMemoryHeapUsage = false,
@@ -58,23 +60,24 @@ export const executeTestPlan = async ({
58
60
  cooldownBetweenExecutions = 0,
59
61
  gcBetweenExecutions = logMemoryHeapUsage,
60
62
 
61
- coverage = process.argv.includes("--cover") ||
63
+ coverageEnabled = process.argv.includes("--cover") ||
62
64
  process.argv.includes("--coverage"),
63
- coverageTempDirectoryRelativeUrl = "./.coverage/tmp/",
64
65
  coverageConfig = {
65
66
  "./src/": true,
66
67
  },
67
68
  coverageIncludeMissing = true,
68
69
  coverageAndExecutionAllowed = false,
69
- coverageForceIstanbul = false,
70
+ coverageMethodForNodeJs = "NODE_V8_COVERAGE", // "Profiler" also accepted
71
+ coverageMethodForBrowsers = "playwright_api", // "istanbul" also accepted
70
72
  coverageV8ConflictWarning = true,
71
- coverageReportTextLog = true,
72
- coverageReportJsonFile = process.env.CI ? null : "./.coverage/coverage.json",
73
- coverageReportHtmlDirectory = process.env.CI ? "./.coverage/" : null,
73
+ coverageTempDirectoryRelativeUrl = "./.coverage/tmp/",
74
74
  // skip empty means empty files won't appear in the coverage reports (json and html)
75
75
  coverageReportSkipEmpty = false,
76
76
  // skip full means file with 100% coverage won't appear in coverage reports (json and html)
77
77
  coverageReportSkipFull = false,
78
+ coverageReportTextLog = true,
79
+ coverageReportJsonFile = process.env.CI ? null : "./.coverage/coverage.json",
80
+ coverageReportHtmlDirectory = process.env.CI ? "./.coverage/" : null,
78
81
 
79
82
  sourcemaps = "inline",
80
83
  plugins = [],
@@ -93,7 +96,7 @@ export const executeTestPlan = async ({
93
96
  if (typeof testPlan !== "object") {
94
97
  throw new Error(`testPlan must be an object, got ${testPlan}`)
95
98
  }
96
- if (coverage) {
99
+ if (coverageEnabled) {
97
100
  if (typeof coverageConfig !== "object") {
98
101
  throw new TypeError(
99
102
  `coverageConfig must be an object, got ${coverageConfig}`,
@@ -138,6 +141,8 @@ export const executeTestPlan = async ({
138
141
  logger,
139
142
  logLevel,
140
143
  logSummary,
144
+ logRuntime,
145
+ logEachDuration,
141
146
  logTimeUsage,
142
147
  logMemoryHeapUsage,
143
148
  logFileRelativeUrl,
@@ -152,10 +157,11 @@ export const executeTestPlan = async ({
152
157
  cooldownBetweenExecutions,
153
158
  gcBetweenExecutions,
154
159
 
155
- coverage,
160
+ coverageEnabled,
156
161
  coverageConfig,
157
162
  coverageIncludeMissing,
158
- coverageForceIstanbul,
163
+ coverageMethodForBrowsers,
164
+ coverageMethodForNodeJs,
159
165
  coverageV8ConflictWarning,
160
166
  coverageTempDirectoryRelativeUrl,
161
167
 
@@ -185,7 +191,7 @@ export const executeTestPlan = async ({
185
191
  // keep this one first because it does ensureEmptyDirectory
186
192
  // and in case coverage json file gets written in the same directory
187
193
  // it must be done before
188
- if (coverage && coverageReportHtmlDirectory) {
194
+ if (coverageEnabled && coverageReportHtmlDirectory) {
189
195
  const coverageHtmlDirectoryUrl = resolveDirectoryUrl(
190
196
  coverageReportHtmlDirectory,
191
197
  rootDirectoryUrl,
@@ -212,7 +218,7 @@ export const executeTestPlan = async ({
212
218
  }),
213
219
  )
214
220
  }
215
- if (coverage && coverageReportJsonFile) {
221
+ if (coverageEnabled && coverageReportJsonFile) {
216
222
  const coverageJsonFileUrl = new URL(
217
223
  coverageReportJsonFile,
218
224
  rootDirectoryUrl,
@@ -225,7 +231,7 @@ export const executeTestPlan = async ({
225
231
  }),
226
232
  )
227
233
  }
228
- if (coverage && coverageReportTextLog) {
234
+ if (coverageEnabled && coverageReportTextLog) {
229
235
  promises.push(
230
236
  generateCoverageTextLog(result.planCoverage, {
231
237
  coverageReportSkipEmpty,
@@ -19,7 +19,14 @@ export const createExecutionLog = (
19
19
  startMs,
20
20
  endMs,
21
21
  },
22
- { completedExecutionLogAbbreviation, counters, timeEllapsed, memoryHeap },
22
+ {
23
+ completedExecutionLogAbbreviation,
24
+ counters,
25
+ logRuntime,
26
+ logEachDuration,
27
+ timeEllapsed,
28
+ memoryHeap,
29
+ },
23
30
  ) => {
24
31
  const { status } = executionResult
25
32
  const descriptionFormatter = descriptionFormatters[status]
@@ -43,11 +50,15 @@ export const createExecutionLog = (
43
50
  label: `${description}${summary}`,
44
51
  details: {
45
52
  file: fileRelativeUrl,
46
- runtime: `${runtimeName}/${runtimeVersion}`,
47
- duration:
48
- status === "executing"
49
- ? msAsEllapsedTime(Date.now() - startMs)
50
- : msAsDuration(endMs - startMs),
53
+ ...(logRuntime ? { runtime: `${runtimeName}/${runtimeVersion}` } : {}),
54
+ ...(logEachDuration
55
+ ? {
56
+ duration:
57
+ status === "executing"
58
+ ? msAsEllapsedTime(Date.now() - startMs)
59
+ : msAsDuration(endMs - startMs),
60
+ }
61
+ : {}),
51
62
  ...(error ? { error: error.stack || error.message || error } : {}),
52
63
  },
53
64
  consoleOutput,
@@ -203,18 +214,84 @@ const descriptionFormatters = {
203
214
  }
204
215
 
205
216
  const formatConsoleCalls = (consoleCalls) => {
206
- const consoleOutput = consoleCalls.reduce((previous, { text }) => {
207
- return `${previous}${text}`
208
- }, "")
209
- const consoleOutputTrimmed = consoleOutput.trim()
210
- if (consoleOutputTrimmed === "") {
217
+ if (consoleCalls.length === 0) {
211
218
  return ""
212
219
  }
213
- return `${ANSI.color(`-------- console output --------`, ANSI.GREY)}
214
- ${consoleOutputTrimmed}
220
+
221
+ const repartition = {
222
+ debug: 0,
223
+ info: 0,
224
+ warning: 0,
225
+ error: 0,
226
+ log: 0,
227
+ }
228
+ let consoleOutput = ``
229
+ consoleCalls.forEach((consoleCall) => {
230
+ repartition[consoleCall.type]++
231
+ const text = consoleCall.text
232
+ const textFormatted = prefixFirstAndIndentRemainingLines({
233
+ prefix: CONSOLE_ICONS[consoleCall.type],
234
+ text,
235
+ trimLastLine: consoleCall === consoleCalls[consoleCalls.length - 1],
236
+ })
237
+ consoleOutput += textFormatted
238
+ })
239
+
240
+ return `${ANSI.color(
241
+ `-------- ${formatConsoleSummary(repartition)} --------`,
242
+ ANSI.GREY,
243
+ )}
244
+ ${consoleOutput}
215
245
  ${ANSI.color(`-------------------------`, ANSI.GREY)}`
216
246
  }
217
247
 
248
+ const CONSOLE_ICONS = {
249
+ debug: UNICODE.DEBUG,
250
+ info: UNICODE.INFO,
251
+ warning: UNICODE.WARNING,
252
+ error: UNICODE.FAILURE,
253
+ log: " ",
254
+ }
255
+
256
+ const formatConsoleSummary = (repartition) => {
257
+ const { debug, info, warning, error } = repartition
258
+ const parts = []
259
+ if (error) {
260
+ parts.push(`${CONSOLE_ICONS.error} ${error}`)
261
+ }
262
+ if (warning) {
263
+ parts.push(`${CONSOLE_ICONS.warning} ${warning}`)
264
+ }
265
+ if (info) {
266
+ parts.push(`${CONSOLE_ICONS.info} ${info}`)
267
+ }
268
+ if (debug) {
269
+ parts.push(`${CONSOLE_ICONS.debug} ${debug}`)
270
+ }
271
+ if (parts.length === 0) {
272
+ return `console`
273
+ }
274
+ return `console (${parts.join(" ")})`
275
+ }
276
+
277
+ const prefixFirstAndIndentRemainingLines = ({ prefix, text, trimLastLine }) => {
278
+ const lines = text.split(/\r?\n/)
279
+ const firstLine = lines.shift()
280
+ let result = `${prefix} ${firstLine}`
281
+ let i = 0
282
+ const indentation = ` `
283
+ while (i < lines.length) {
284
+ const line = lines[i].trim()
285
+ i++
286
+ result += line.length
287
+ ? `\n${indentation}${line}`
288
+ : trimLastLine && i === lines.length
289
+ ? ""
290
+ : `\n`
291
+ }
292
+ return result
293
+ }
294
+
218
295
  const formatExecution = ({ label, details = {}, consoleOutput }) => {
219
296
  let message = ``
220
297
  message += label