@jsenv/test 1.7.8 → 1.8.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/test",
3
- "version": "1.7.8",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,10 +34,10 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@jsenv/abort": "4.2.4",
37
- "@jsenv/ast": "6.0.0",
37
+ "@jsenv/ast": "6.0.1",
38
38
  "@jsenv/filesystem": "4.3.2",
39
39
  "@jsenv/log": "3.4.2",
40
- "@jsenv/plugin-supervisor": "1.3.8",
40
+ "@jsenv/plugin-supervisor": "1.3.9",
41
41
  "@jsenv/urls": "2.2.1",
42
42
  "@jsenv/url-meta": "8.1.0",
43
43
  "@jsenv/utils": "2.0.1",
@@ -1,4 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { memoryUsage } from "node:process";
3
+ import { takeCoverage } from "node:v8";
2
4
  import stripAnsi from "strip-ansi";
3
5
  import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
4
6
  import { URL_META } from "@jsenv/url-meta";
@@ -7,22 +9,35 @@ import {
7
9
  ensureEmptyDirectory,
8
10
  assertAndNormalizeDirectoryUrl,
9
11
  assertAndNormalizeFileUrl,
12
+ writeFileSync,
10
13
  } from "@jsenv/filesystem";
11
- import { createLogger, createDetailedMessage, UNICODE } from "@jsenv/log";
14
+ import {
15
+ createLogger,
16
+ createDetailedMessage,
17
+ UNICODE,
18
+ createLog,
19
+ startSpinner,
20
+ } from "@jsenv/log";
12
21
  import {
13
22
  startGithubCheckRun,
14
23
  readGitHubWorkflowEnv,
15
24
  } from "@jsenv/github-check-run";
16
25
 
17
26
  import { createTeardown } from "../helpers/teardown.js";
27
+ import { reportToCoverage } from "../coverage/report_to_coverage.js";
18
28
  import { generateCoverageJsonFile } from "../coverage/coverage_reporter_json_file.js";
19
29
  import { generateCoverageHtmlDirectory } from "../coverage/coverage_reporter_html_directory.js";
20
30
  import { generateCoverageTextLog } from "../coverage/coverage_reporter_text_log.js";
21
31
  import { assertAndNormalizeWebServer } from "./web_server_param.js";
22
32
  import { executionStepsFromTestPlan } from "./execution_steps.js";
23
- import { executeSteps } from "./execute_steps.js";
24
- import { formatSummary } from "./logs_file_execution.js";
33
+ import {
34
+ createExecutionLog,
35
+ formatSummaryLog,
36
+ formatSummary,
37
+ } from "./logs_file_execution.js";
25
38
  import { githubAnnotationFromError } from "./github_annotation_from_error.js";
39
+ import { run } from "./run.js";
40
+ import { ensureGlobalGc } from "./gc.js";
26
41
 
27
42
  /**
28
43
  * Execute a list of files and log how it goes.
@@ -74,7 +89,7 @@ export const executeTestPlan = async ({
74
89
  githubCheckEnabled = Boolean(process.env.GITHUB_WORKFLOW),
75
90
  githubCheckLogLevel,
76
91
  githubCheckName = "Jsenv tests",
77
- githubCheckTitle,
92
+ githubCheckTitle = "Tests executions",
78
93
  githubCheckToken,
79
94
  githubCheckRepositoryOwner,
80
95
  githubCheckRepositoryName,
@@ -119,6 +134,10 @@ export const executeTestPlan = async ({
119
134
  coverageReportJsonFileUrl,
120
135
  coverageReportHtml = !process.env.CI,
121
136
  coverageReportHtmlDirectoryUrl,
137
+
138
+ beforeExecutionCallback = () => {},
139
+ afterExecutionCallback = () => {},
140
+ afterAllExecutionCallback = () => {},
122
141
  ...rest
123
142
  }) => {
124
143
  const teardown = createTeardown();
@@ -362,15 +381,12 @@ To fix this warning:
362
381
  "**/.jsenv/": null,
363
382
  };
364
383
  logger.debug(`Generate executions`);
365
- const executionSteps = await executionStepsFromTestPlan({
384
+ let executionSteps = await executionStepsFromTestPlan({
366
385
  signal,
367
386
  testPlan,
368
387
  rootDirectoryUrl,
369
388
  });
370
389
  logger.debug(`${executionSteps.length} executions planned`);
371
- let beforeExecutionCallback;
372
- let afterExecutionCallback;
373
- let afterAllExecutionCallback = () => {};
374
390
  if (githubCheckEnabled) {
375
391
  const githubCheckRun = await startGithubCheckRun({
376
392
  logLevel: githubCheckLogLevel,
@@ -379,11 +395,13 @@ To fix this warning:
379
395
  repositoryName: githubCheckRepositoryName,
380
396
  commitSha: githubCheckCommitSha,
381
397
  checkName: githubCheckName,
382
- checkTitle: `Tests executions`,
398
+ checkTitle: githubCheckTitle,
383
399
  checkSummary: `${executionSteps.length} files will be executed`,
384
400
  });
385
401
  const annotations = [];
402
+ const afterExecutionCallbackPrevious = afterExecutionCallback;
386
403
  afterExecutionCallback = (afterExecutionInfo) => {
404
+ afterExecutionCallbackPrevious(afterExecutionInfo);
387
405
  const { executionResult } = afterExecutionInfo;
388
406
  const { errors = [] } = executionResult;
389
407
  for (const error of errors) {
@@ -394,78 +412,328 @@ To fix this warning:
394
412
  annotations.push(annotation);
395
413
  }
396
414
  };
397
- afterAllExecutionCallback = async ({ testPlanSummary }) => {
415
+ const afterAllExecutionCallbackPrevious = afterAllExecutionCallback;
416
+ afterAllExecutionCallback = async (returnValue) => {
417
+ afterAllExecutionCallbackPrevious(returnValue);
418
+ const { summary } = returnValue;
398
419
  const title = "Jsenv test results";
399
- const summary = stripAnsi(formatSummary(testPlanSummary));
400
- if (
401
- testPlanSummary.counters.total !== testPlanSummary.counters.completed
402
- ) {
420
+ const summaryText = stripAnsi(formatSummary(summary));
421
+ if (summary.counters.total !== summary.counters.completed) {
403
422
  await githubCheckRun.fail({
404
423
  title,
405
- summary,
424
+ summary: summaryText,
406
425
  annotations,
407
426
  });
408
427
  return;
409
428
  }
410
429
  await githubCheckRun.pass({
411
430
  title,
412
- summary,
431
+ summary: summaryText,
413
432
  annotations,
414
433
  });
415
434
  };
416
435
  }
417
436
 
418
- const result = await executeSteps(executionSteps, {
419
- signal,
420
- teardown,
421
- logger,
422
- logRefresh,
423
- logSummary,
424
- logRuntime,
425
- logEachDuration,
426
- logTimeUsage,
427
- logMemoryHeapUsage,
428
- logFileRelativeUrl,
429
- logShortForCompletedExecutions,
430
- logMergeForCompletedExecutions,
431
- rootDirectoryUrl,
432
- webServer,
433
-
434
- maxExecutionsInParallel,
435
- defaultMsAllocatedPerExecution,
436
- failFast,
437
- keepRunning,
438
- cooldownBetweenExecutions,
439
- gcBetweenExecutions,
440
-
441
- githubCheckEnabled,
442
- githubCheckName,
443
- githubCheckTitle,
444
- githubCheckToken,
445
- githubCheckRepositoryOwner,
446
- githubCheckRepositoryName,
447
- githubCheckCommitSha,
448
-
449
- coverageEnabled,
450
- coverageConfig,
451
- coverageIncludeMissing,
452
- coverageMethodForBrowsers,
453
- coverageMethodForNodeJs,
454
- coverageV8ConflictWarning,
455
- coverageTempDirectoryUrl,
456
-
457
- beforeExecutionCallback,
458
- afterExecutionCallback,
459
- });
437
+ executionSteps = executionSteps.filter(
438
+ (executionStep) => !executionStep.runtime?.disabled,
439
+ );
440
+
441
+ const returnValue = {
442
+ aborted: false,
443
+ summary: null,
444
+ report: null,
445
+ coverage: null,
446
+ };
447
+ const report = {};
448
+ const callbacks = [];
449
+
450
+ const multipleExecutionsOperation = Abort.startOperation();
451
+ multipleExecutionsOperation.addAbortSignal(signal);
452
+ const failFastAbortController = new AbortController();
453
+ if (failFast) {
454
+ multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal);
455
+ }
456
+
457
+ try {
458
+ if (gcBetweenExecutions) {
459
+ ensureGlobalGc();
460
+ }
461
+
462
+ if (coverageEnabled) {
463
+ // when runned multiple times, we don't want to keep previous files in this directory
464
+ await ensureEmptyDirectory(coverageTempDirectoryUrl);
465
+ callbacks.push(async () => {
466
+ if (multipleExecutionsOperation.signal.aborted) {
467
+ // don't try to do the coverage stuff
468
+ return;
469
+ }
470
+ try {
471
+ if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
472
+ takeCoverage();
473
+ // conceptually we don't need coverage anymore so it would be
474
+ // good to call v8.stopCoverage()
475
+ // but it logs a strange message about "result is not an object"
476
+ }
477
+ const coverage = await reportToCoverage(report, {
478
+ signal: multipleExecutionsOperation.signal,
479
+ logger,
480
+ rootDirectoryUrl,
481
+ coverageConfig,
482
+ coverageIncludeMissing,
483
+ coverageMethodForNodeJs,
484
+ coverageV8ConflictWarning,
485
+ });
486
+ returnValue.coverage = coverage;
487
+ } catch (e) {
488
+ if (Abort.isAbortError(e)) {
489
+ return;
490
+ }
491
+ throw e;
492
+ }
493
+ });
494
+ }
495
+
496
+ const runtimeParams = {
497
+ rootDirectoryUrl,
498
+ webServer,
499
+
500
+ coverageEnabled,
501
+ coverageConfig,
502
+ coverageMethodForBrowsers,
503
+ coverageMethodForNodeJs,
504
+ isTestPlan: true,
505
+ teardown,
506
+ };
507
+
508
+ if (logMergeForCompletedExecutions && !process.stdout.isTTY) {
509
+ logMergeForCompletedExecutions = false;
510
+ logger.debug(
511
+ `Force logMergeForCompletedExecutions to false because process.stdout.isTTY is false`,
512
+ );
513
+ }
514
+ const debugLogsEnabled = logger.levels.debug;
515
+ const executionLogsEnabled = logger.levels.info;
516
+ const executionSpinner =
517
+ logRefresh &&
518
+ !debugLogsEnabled &&
519
+ executionLogsEnabled &&
520
+ process.stdout.isTTY &&
521
+ // if there is an error during execution npm will mess up the output
522
+ // (happens when npm runs several command in a workspace)
523
+ // so we enable spinner only when !process.exitCode (no error so far)
524
+ process.exitCode !== 1;
525
+
526
+ const startMs = Date.now();
527
+ let rawOutput = "";
528
+ let executionLog = createLog({ newLine: "" });
529
+ const counters = {
530
+ total: executionSteps.length,
531
+ aborted: 0,
532
+ timedout: 0,
533
+ failed: 0,
534
+ completed: 0,
535
+ done: 0,
536
+ };
537
+ await executeInParallel({
538
+ multipleExecutionsOperation,
539
+ maxExecutionsInParallel,
540
+ cooldownBetweenExecutions,
541
+ executionSteps,
542
+ start: async (paramsFromStep) => {
543
+ const executionIndex = executionSteps.indexOf(paramsFromStep);
544
+ const { executionName, fileRelativeUrl, runtime } = paramsFromStep;
545
+ const runtimeType = runtime.type;
546
+ const runtimeName = runtime.name;
547
+ const runtimeVersion = runtime.version;
548
+ const executionParams = {
549
+ measurePerformance: false,
550
+ collectPerformance: false,
551
+ collectConsole: true,
552
+ allocatedMs: defaultMsAllocatedPerExecution,
553
+ ...paramsFromStep,
554
+ runtimeParams: {
555
+ fileRelativeUrl,
556
+ ...paramsFromStep.runtimeParams,
557
+ },
558
+ };
559
+ const beforeExecutionInfo = {
560
+ fileRelativeUrl,
561
+ runtimeType,
562
+ runtimeName,
563
+ runtimeVersion,
564
+ executionIndex,
565
+ executionParams,
566
+ startMs: Date.now(),
567
+ executionResult: {
568
+ status: "executing",
569
+ },
570
+ counters,
571
+ timeEllapsed: Date.now() - startMs,
572
+ memoryHeap: memoryUsage().heapUsed,
573
+ };
574
+ if (typeof executionParams.allocatedMs === "function") {
575
+ executionParams.allocatedMs =
576
+ executionParams.allocatedMs(beforeExecutionInfo);
577
+ }
578
+ let spinner;
579
+ if (executionSpinner) {
580
+ spinner = startSpinner({
581
+ log: executionLog,
582
+ render: () => {
583
+ return createExecutionLog(beforeExecutionInfo, {
584
+ logRuntime,
585
+ logEachDuration,
586
+ logTimeUsage,
587
+ logMemoryHeapUsage,
588
+ });
589
+ },
590
+ });
591
+ }
592
+ beforeExecutionCallback(beforeExecutionInfo);
593
+
594
+ const fileUrl = `${rootDirectoryUrl}${fileRelativeUrl}`;
595
+ let executionResult;
596
+ if (existsSync(new URL(fileUrl))) {
597
+ executionResult = await run({
598
+ signal: multipleExecutionsOperation.signal,
599
+ logger,
600
+ allocatedMs: executionParams.allocatedMs,
601
+ keepRunning,
602
+ mirrorConsole: false, // file are executed in parallel, log would be a mess to read
603
+ collectConsole: executionParams.collectConsole,
604
+ coverageEnabled,
605
+ coverageTempDirectoryUrl,
606
+ runtime: executionParams.runtime,
607
+ runtimeParams: {
608
+ ...runtimeParams,
609
+ ...executionParams.runtimeParams,
610
+ },
611
+ });
612
+ } else {
613
+ executionResult = {
614
+ status: "failed",
615
+ errors: [
616
+ new Error(
617
+ `No file at ${fileRelativeUrl} for execution "${executionName}"`,
618
+ ),
619
+ ],
620
+ };
621
+ }
622
+ counters.done++;
623
+ const fileReport = report[fileRelativeUrl];
624
+ if (fileReport) {
625
+ fileReport[executionName] = executionResult;
626
+ } else {
627
+ report[fileRelativeUrl] = {
628
+ [executionName]: executionResult,
629
+ };
630
+ }
631
+
632
+ const afterExecutionInfo = {
633
+ ...beforeExecutionInfo,
634
+ runtimeVersion: runtime.version,
635
+ endMs: Date.now(),
636
+ executionResult,
637
+ };
638
+
639
+ if (executionResult.status === "aborted") {
640
+ counters.aborted++;
641
+ } else if (executionResult.status === "timedout") {
642
+ counters.timedout++;
643
+ } else if (executionResult.status === "failed") {
644
+ counters.failed++;
645
+ } else if (executionResult.status === "completed") {
646
+ counters.completed++;
647
+ }
648
+ if (gcBetweenExecutions) {
649
+ global.gc();
650
+ }
651
+ if (executionLogsEnabled) {
652
+ const log = createExecutionLog(afterExecutionInfo, {
653
+ logShortForCompletedExecutions,
654
+ logRuntime,
655
+ logEachDuration,
656
+ ...(logTimeUsage
657
+ ? {
658
+ timeEllapsed: Date.now() - startMs,
659
+ }
660
+ : {}),
661
+ ...(logMemoryHeapUsage
662
+ ? { memoryHeap: memoryUsage().heapUsed }
663
+ : {}),
664
+ });
665
+ // replace spinner with this execution result
666
+ if (spinner) spinner.stop();
667
+ executionLog.write(log);
668
+ rawOutput += stripAnsi(log);
669
+
670
+ const canOverwriteLog = canOverwriteLogGetter({
671
+ logMergeForCompletedExecutions,
672
+ executionResult,
673
+ });
674
+ if (canOverwriteLog) {
675
+ // nothing to do, we reuse the current executionLog object
676
+ } else {
677
+ executionLog.destroy();
678
+ executionLog = createLog({ newLine: "" });
679
+ }
680
+ }
681
+ afterExecutionCallback(afterExecutionInfo);
682
+ const isLastExecutionLog = executionIndex === executionSteps.length - 1;
683
+ const cancelRemaining =
684
+ failFast &&
685
+ executionResult.status !== "completed" &&
686
+ counters.done < counters.total;
687
+ if (isLastExecutionLog && logger.levels.info) {
688
+ executionLog.write("\n");
689
+ }
690
+
691
+ if (cancelRemaining) {
692
+ logger.info(`"failFast" enabled -> cancel remaining executions`);
693
+ failFastAbortController.abort();
694
+ }
695
+ },
696
+ });
697
+ if (!keepRunning) {
698
+ logger.debug("trigger test plan teardown");
699
+ await teardown.trigger();
700
+ }
701
+
702
+ counters.cancelled = counters.total - counters.done;
703
+ const summary = {
704
+ counters,
705
+ // when execution is aborted, the remaining executions are "cancelled"
706
+ duration: Date.now() - startMs,
707
+ };
708
+ if (logSummary) {
709
+ const summaryLog = formatSummaryLog(summary);
710
+ rawOutput += stripAnsi(summaryLog);
711
+ logger.info(summaryLog);
712
+ }
713
+ if (summary.counters.total !== summary.counters.completed) {
714
+ const logFileUrl = new URL(logFileRelativeUrl, rootDirectoryUrl).href;
715
+ writeFileSync(logFileUrl, rawOutput);
716
+ logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`);
717
+ }
718
+ returnValue.aborted = multipleExecutionsOperation.signal.aborted;
719
+ returnValue.summary = summary;
720
+ returnValue.report = report;
721
+ for (const callback of callbacks) {
722
+ await callback();
723
+ }
724
+ } finally {
725
+ await multipleExecutionsOperation.end();
726
+ }
460
727
 
461
728
  const hasFailed =
462
- result.planSummary.counters.total !== result.planSummary.counters.completed;
729
+ returnValue.summary.counters.total !==
730
+ returnValue.summary.counters.completed;
463
731
  if (updateProcessExitCode && hasFailed) {
464
732
  process.exitCode = 1;
465
733
  }
466
- const planCoverage = result.planCoverage;
734
+ const coverage = returnValue.coverage;
467
735
  // planCoverage can be null when execution is aborted
468
- if (planCoverage) {
736
+ if (coverage) {
469
737
  const promises = [];
470
738
  // keep this one first because it does ensureEmptyDirectory
471
739
  // and in case coverage json file gets written in the same directory
@@ -477,7 +745,7 @@ To fix this warning:
477
745
  `-> ${urlToFileSystemPath(htmlCoverageDirectoryIndexFileUrl)}`,
478
746
  );
479
747
  promises.push(
480
- generateCoverageHtmlDirectory(planCoverage, {
748
+ generateCoverageHtmlDirectory(coverage, {
481
749
  rootDirectoryUrl,
482
750
  coverageHtmlDirectoryRelativeUrl: urlToRelativeUrl(
483
751
  coverageReportHtmlDirectoryUrl,
@@ -491,7 +759,7 @@ To fix this warning:
491
759
  if (coverageEnabled && coverageReportJson) {
492
760
  promises.push(
493
761
  generateCoverageJsonFile({
494
- coverage: result.planCoverage,
762
+ coverage,
495
763
  coverageJsonFileUrl: coverageReportJsonFileUrl,
496
764
  logger,
497
765
  }),
@@ -499,7 +767,7 @@ To fix this warning:
499
767
  }
500
768
  if (coverageEnabled && coverageReportTextLog) {
501
769
  promises.push(
502
- generateCoverageTextLog(result.planCoverage, {
770
+ generateCoverageTextLog(coverage, {
503
771
  coverageReportSkipEmpty,
504
772
  coverageReportSkipFull,
505
773
  }),
@@ -508,12 +776,77 @@ To fix this warning:
508
776
  await Promise.all(promises);
509
777
  }
510
778
 
511
- const returnValue = {
512
- testPlanAborted: result.aborted,
513
- testPlanSummary: result.planSummary,
514
- testPlanReport: result.planReport,
515
- testPlanCoverage: planCoverage,
516
- };
517
779
  await afterAllExecutionCallback(returnValue);
518
780
  return returnValue;
519
781
  };
782
+
783
+ const canOverwriteLogGetter = ({
784
+ logMergeForCompletedExecutions,
785
+ executionResult,
786
+ }) => {
787
+ if (!logMergeForCompletedExecutions) {
788
+ return false;
789
+ }
790
+ if (executionResult.status === "aborted") {
791
+ return true;
792
+ }
793
+ if (executionResult.status !== "completed") {
794
+ return false;
795
+ }
796
+ const { consoleCalls = [] } = executionResult;
797
+ if (consoleCalls.length > 0) {
798
+ return false;
799
+ }
800
+ return true;
801
+ };
802
+
803
+ const executeInParallel = async ({
804
+ multipleExecutionsOperation,
805
+ maxExecutionsInParallel,
806
+ cooldownBetweenExecutions,
807
+ executionSteps,
808
+ start,
809
+ }) => {
810
+ const executionResults = [];
811
+ let progressionIndex = 0;
812
+ let remainingExecutionCount = executionSteps.length;
813
+
814
+ const nextChunk = async () => {
815
+ if (multipleExecutionsOperation.signal.aborted) {
816
+ return;
817
+ }
818
+ const outputPromiseArray = [];
819
+ while (
820
+ remainingExecutionCount > 0 &&
821
+ outputPromiseArray.length < maxExecutionsInParallel
822
+ ) {
823
+ remainingExecutionCount--;
824
+ const outputPromise = executeOne(progressionIndex);
825
+ progressionIndex++;
826
+ outputPromiseArray.push(outputPromise);
827
+ }
828
+ if (outputPromiseArray.length) {
829
+ await Promise.all(outputPromiseArray);
830
+ if (remainingExecutionCount > 0) {
831
+ await nextChunk();
832
+ }
833
+ }
834
+ };
835
+
836
+ const executeOne = async (index) => {
837
+ const input = executionSteps[index];
838
+ const output = await start(input);
839
+ if (!multipleExecutionsOperation.signal.aborted) {
840
+ executionResults[index] = output;
841
+ }
842
+ if (cooldownBetweenExecutions) {
843
+ await new Promise((resolve) =>
844
+ setTimeout(resolve, cooldownBetweenExecutions),
845
+ );
846
+ }
847
+ };
848
+
849
+ await nextChunk();
850
+
851
+ return executionResults;
852
+ };
@@ -459,8 +459,8 @@ const prefixFirstAndIndentRemainingLines = ({
459
459
  result += line.length
460
460
  ? `\n${indentation}${line}`
461
461
  : trimLastLine && i === lines.length
462
- ? ""
463
- : `\n`;
462
+ ? ""
463
+ : `\n`;
464
464
  }
465
465
  return result;
466
466
  };