@jsenv/test 1.7.9 → 1.8.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.
@@ -1,21 +1,21 @@
1
- import { chmod, stat, lstat, readdir, promises, unlink, openSync, closeSync, rmdir, readFile as readFile$1, writeFile as writeFile$1, writeFileSync as writeFileSync$1, mkdirSync, readFileSync, existsSync, readdirSync } from "node:fs";
1
+ import { chmod, stat, lstat, readdir, promises, unlink, openSync, closeSync, rmdir, readFile as readFile$1, writeFile as writeFile$1, writeFileSync as writeFileSync$1, mkdirSync, readFileSync, readdirSync, existsSync } from "node:fs";
2
+ import process$1, { memoryUsage } from "node:process";
3
+ import v8, { takeCoverage } from "node:v8";
2
4
  import stripAnsi from "strip-ansi";
3
5
  import { URL_META, filterV8Coverage } from "./js/v8_coverage.js";
4
6
  import { pathToFileURL, fileURLToPath } from "node:url";
5
7
  import crypto from "node:crypto";
6
8
  import { dirname } from "node:path";
7
- import process$1, { memoryUsage } from "node:process";
8
9
  import os from "node:os";
9
10
  import tty from "node:tty";
10
11
  import stringWidth from "string-width";
11
12
  import { readGitHubWorkflowEnv, startGithubCheckRun } from "@jsenv/github-check-run";
12
13
  import { createRequire } from "node:module";
14
+ import { applyBabelPlugins } from "@jsenv/ast";
13
15
  import { spawn, spawnSync, fork } from "node:child_process";
14
16
  import { createServer } from "node:net";
15
- import v8, { takeCoverage } from "node:v8";
16
- import { applyBabelPlugins } from "@jsenv/ast";
17
- import { runInNewContext } from "node:vm";
18
17
  import wrapAnsi from "wrap-ansi";
18
+ import { runInNewContext } from "node:vm";
19
19
  import { injectSupervisorIntoHTML, supervisorFileUrl } from "@jsenv/plugin-supervisor";
20
20
  import { SOURCEMAP, generateSourcemapDataUrl } from "@jsenv/sourcemap";
21
21
  import { findFreePort } from "@jsenv/server";
@@ -2515,6 +2515,7 @@ const createLog = ({
2515
2515
  const { columns = 80, rows = 24 } = stream;
2516
2516
 
2517
2517
  const log = {
2518
+ destroyed: false,
2518
2519
  onVerticalOverflow: () => {},
2519
2520
  };
2520
2521
 
@@ -2579,6 +2580,9 @@ const createLog = ({
2579
2580
  };
2580
2581
 
2581
2582
  const write = (string, outputFromOutside = streamOutputSpy()) => {
2583
+ if (log.destroyed) {
2584
+ throw new Error("Cannot write log after destroy");
2585
+ }
2582
2586
  if (!lastOutput) {
2583
2587
  doWrite(string);
2584
2588
  return;
@@ -2599,6 +2603,7 @@ const createLog = ({
2599
2603
  };
2600
2604
 
2601
2605
  const destroy = () => {
2606
+ log.destroyed = true;
2602
2607
  if (streamOutputSpy) {
2603
2608
  streamOutputSpy(); // this uninstalls the spy
2604
2609
  streamOutputSpy = null;
@@ -2724,1238 +2729,1113 @@ const createTeardown = () => {
2724
2729
  };
2725
2730
  };
2726
2731
 
2727
- const generateCoverageJsonFile = async ({
2728
- coverage,
2729
- coverageJsonFileUrl,
2732
+ const createCallOrderer = () => {
2733
+ const queue = [];
2734
+ const callWhenPreviousExecutionAreDone = (executionIndex, callback) => {
2735
+ if (queue[executionIndex]) {
2736
+ throw new Error(`${executionIndex} already used`);
2737
+ }
2738
+
2739
+ let allBeforeAreDone = true;
2740
+ if (executionIndex > 0) {
2741
+ let beforeIndex = executionIndex - 1;
2742
+ do {
2743
+ const value = queue[beforeIndex];
2744
+ if (!value) {
2745
+ allBeforeAreDone = false;
2746
+ break;
2747
+ }
2748
+ } while (beforeIndex--);
2749
+ }
2750
+ if (!allBeforeAreDone) {
2751
+ queue[executionIndex] = callback;
2752
+ return;
2753
+ }
2754
+ queue[executionIndex] = true;
2755
+ callback();
2756
+ let afterIndex = executionIndex + 1;
2757
+ while (afterIndex < queue.length) {
2758
+ const value = queue[afterIndex];
2759
+ if (value === undefined) {
2760
+ break;
2761
+ }
2762
+ if (typeof value === "function") {
2763
+ queue[afterIndex] = true;
2764
+ value();
2765
+ }
2766
+ afterIndex++;
2767
+ }
2768
+ };
2769
+ return callWhenPreviousExecutionAreDone;
2770
+ };
2771
+
2772
+ const readNodeV8CoverageDirectory = async ({
2730
2773
  logger,
2774
+ signal,
2775
+ onV8Coverage,
2776
+ maxMsWaitingForNodeToWriteCoverageFile = 2000,
2731
2777
  }) => {
2732
- const coverageAsText = JSON.stringify(coverage, null, " ");
2733
- logger.info(
2734
- `-> ${urlToFileSystemPath(coverageJsonFileUrl)} (${byteAsFileSize(
2735
- Buffer.byteLength(coverageAsText),
2736
- )})`,
2737
- );
2738
- await writeFile(coverageJsonFileUrl, coverageAsText);
2739
- };
2778
+ const NODE_V8_COVERAGE = process.env.NODE_V8_COVERAGE;
2779
+ const operation = Abort.startOperation();
2780
+ operation.addAbortSignal(signal);
2740
2781
 
2741
- const importWithRequire = createRequire(import.meta.url);
2782
+ let timeSpentTrying = 0;
2783
+ const tryReadDirectory = async () => {
2784
+ const dirContent = readdirSync(NODE_V8_COVERAGE);
2785
+ if (dirContent.length > 0) {
2786
+ return dirContent;
2787
+ }
2788
+ if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
2789
+ await new Promise((resolve) => setTimeout(resolve, 200));
2790
+ timeSpentTrying += 200;
2791
+ logger.debug("retry to read coverage directory");
2792
+ return tryReadDirectory();
2793
+ }
2794
+ logger.warn(`v8 coverage directory is empty at ${NODE_V8_COVERAGE}`);
2795
+ return dirContent;
2796
+ };
2742
2797
 
2743
- const istanbulCoverageMapFromCoverage = (coverage) => {
2744
- const { createCoverageMap } = importWithRequire("istanbul-lib-coverage");
2798
+ try {
2799
+ operation.throwIfAborted();
2800
+ const dirContent = await tryReadDirectory();
2745
2801
 
2746
- const coverageAdjusted = {};
2747
- Object.keys(coverage).forEach((key) => {
2748
- coverageAdjusted[key.slice(2)] = {
2749
- ...coverage[key],
2750
- path: key.slice(2),
2751
- };
2752
- });
2802
+ const coverageDirectoryUrl = assertAndNormalizeDirectoryUrl(
2803
+ NODE_V8_COVERAGE,
2804
+ "NODE_V8_COVERAGE",
2805
+ );
2753
2806
 
2754
- const coverageMap = createCoverageMap(coverageAdjusted);
2755
- return coverageMap;
2807
+ await dirContent.reduce(async (previous, dirEntry) => {
2808
+ operation.throwIfAborted();
2809
+ await previous;
2810
+
2811
+ const dirEntryUrl = new URL(dirEntry, coverageDirectoryUrl);
2812
+ const tryReadJsonFile = async () => {
2813
+ const fileContent = String(readFileSync(dirEntryUrl));
2814
+ if (fileContent === "") {
2815
+ if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
2816
+ await new Promise((resolve) => setTimeout(resolve, 200));
2817
+ timeSpentTrying += 200;
2818
+ return tryReadJsonFile();
2819
+ }
2820
+ console.warn(`Coverage JSON file is empty at ${dirEntryUrl}`);
2821
+ return null;
2822
+ }
2823
+
2824
+ try {
2825
+ const fileAsJson = JSON.parse(fileContent);
2826
+ return fileAsJson;
2827
+ } catch (e) {
2828
+ if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
2829
+ await new Promise((resolve) => setTimeout(resolve, 200));
2830
+ timeSpentTrying += 200;
2831
+ return tryReadJsonFile();
2832
+ }
2833
+ console.warn(
2834
+ createDetailedMessage(`Error while reading coverage file`, {
2835
+ "error stack": e.stack,
2836
+ "file": dirEntryUrl,
2837
+ }),
2838
+ );
2839
+ return null;
2840
+ }
2841
+ };
2842
+
2843
+ const fileContent = await tryReadJsonFile();
2844
+ if (fileContent) {
2845
+ await onV8Coverage(fileContent);
2846
+ }
2847
+ }, Promise.resolve());
2848
+ } finally {
2849
+ await operation.end();
2850
+ }
2756
2851
  };
2757
2852
 
2758
- const generateCoverageHtmlDirectory = async (
2759
- coverage,
2760
- {
2761
- rootDirectoryUrl,
2762
- coverageHtmlDirectoryRelativeUrl,
2763
- coverageReportSkipEmpty,
2764
- coverageReportSkipFull,
2765
- },
2766
- ) => {
2767
- const libReport = importWithRequire("istanbul-lib-report");
2768
- const reports = importWithRequire("istanbul-reports");
2853
+ const importWithRequire = createRequire(import.meta.url);
2769
2854
 
2770
- const context = libReport.createContext({
2771
- dir: fileURLToPath(rootDirectoryUrl),
2772
- coverageMap: istanbulCoverageMapFromCoverage(coverage),
2773
- sourceFinder: (path) =>
2774
- readFileSync(new URL(path, rootDirectoryUrl), "utf8"),
2775
- });
2855
+ const composeTwoV8Coverages = (firstV8Coverage, secondV8Coverage) => {
2856
+ if (secondV8Coverage.result.length === 0) {
2857
+ return firstV8Coverage;
2858
+ }
2776
2859
 
2777
- const report = reports.create("html", {
2778
- skipEmpty: coverageReportSkipEmpty,
2779
- skipFull: coverageReportSkipFull,
2780
- subdir: coverageHtmlDirectoryRelativeUrl,
2781
- });
2782
- report.execute(context);
2860
+ // eslint-disable-next-line import/no-unresolved
2861
+ const { mergeProcessCovs } = importWithRequire("@c88/v8-coverage");
2862
+ // "mergeProcessCovs" do not preserves source-map-cache during the merge
2863
+ // so we store sourcemap cache now
2864
+ const sourceMapCache = {};
2865
+ const visit = (coverageReport) => {
2866
+ if (coverageReport["source-map-cache"]) {
2867
+ Object.assign(sourceMapCache, coverageReport["source-map-cache"]);
2868
+ }
2869
+ };
2870
+ visit(firstV8Coverage);
2871
+ visit(secondV8Coverage);
2872
+ const v8Coverage = mergeProcessCovs([firstV8Coverage, secondV8Coverage]);
2873
+ v8Coverage["source-map-cache"] = sourceMapCache;
2874
+
2875
+ return v8Coverage;
2783
2876
  };
2784
2877
 
2785
- const generateCoverageTextLog = (
2786
- coverage,
2787
- { coverageReportSkipEmpty, coverageReportSkipFull },
2878
+ const composeTwoFileByFileIstanbulCoverages = (
2879
+ firstFileByFileIstanbulCoverage,
2880
+ secondFileByFileIstanbulCoverage,
2788
2881
  ) => {
2789
- const libReport = importWithRequire("istanbul-lib-report");
2790
- const reports = importWithRequire("istanbul-reports");
2791
-
2792
- const context = libReport.createContext({
2793
- coverageMap: istanbulCoverageMapFromCoverage(coverage),
2882
+ const fileByFileIstanbulCoverage = {};
2883
+ Object.keys(firstFileByFileIstanbulCoverage).forEach((key) => {
2884
+ fileByFileIstanbulCoverage[key] = firstFileByFileIstanbulCoverage[key];
2794
2885
  });
2795
- const report = reports.create("text", {
2796
- skipEmpty: coverageReportSkipEmpty,
2797
- skipFull: coverageReportSkipFull,
2886
+ Object.keys(secondFileByFileIstanbulCoverage).forEach((key) => {
2887
+ const firstCoverage = firstFileByFileIstanbulCoverage[key];
2888
+ const secondCoverage = secondFileByFileIstanbulCoverage[key];
2889
+ fileByFileIstanbulCoverage[key] = firstCoverage
2890
+ ? merge(firstCoverage, secondCoverage)
2891
+ : secondCoverage;
2798
2892
  });
2799
- report.execute(context);
2800
- };
2801
2893
 
2802
- const pingServer = async (url) => {
2803
- const server = createServer();
2804
- const { hostname, port } = new URL(url);
2894
+ return fileByFileIstanbulCoverage;
2895
+ };
2805
2896
 
2806
- try {
2807
- await new Promise((resolve, reject) => {
2808
- server.on("error", reject);
2809
- server.on("listening", () => {
2810
- resolve();
2811
- });
2812
- server.listen(port, hostname);
2813
- });
2814
- } catch (error) {
2815
- if (error && error.code === "EADDRINUSE") {
2816
- return true;
2817
- }
2818
- if (error && error.code === "EACCES") {
2819
- return true;
2820
- }
2821
- throw error;
2822
- }
2823
- await new Promise((resolve, reject) => {
2824
- server.on("error", reject);
2825
- server.on("close", resolve);
2826
- server.close();
2827
- });
2828
- return false;
2897
+ const merge = (firstIstanbulCoverage, secondIstanbulCoverage) => {
2898
+ const { createFileCoverage } = importWithRequire("istanbul-lib-coverage");
2899
+ const istanbulFileCoverageObject = createFileCoverage(firstIstanbulCoverage);
2900
+ istanbulFileCoverageObject.merge(secondIstanbulCoverage);
2901
+ const istanbulCoverage = istanbulFileCoverageObject.toJSON();
2902
+ return istanbulCoverage;
2829
2903
  };
2830
2904
 
2831
- const startServerUsingCommand = async (
2832
- webServer,
2833
- { signal, allocatedMs, logger, teardown },
2834
- ) => {
2835
- const spawnedProcess = spawn(webServer.command, [], {
2836
- // On non-windows platforms, `detached: true` makes child process a leader of a new
2837
- // process group, making it possible to kill child process tree with `.kill(-pid)` command.
2838
- // @see https://nodejs.org/api/child_process.html#child_process_options_detached
2839
- detached: process.platform !== "win32",
2840
- stdio: ["pipe", "pipe", "pipe"],
2841
- shell: true,
2842
- cwd: webServer.cwd,
2843
- });
2844
- if (!spawnedProcess.pid) {
2845
- await new Promise((resolve, reject) => {
2846
- spawnedProcess.once("error", (error) => {
2847
- reject(new Error(`Failed to launch: ${error}`));
2848
- });
2849
- });
2850
- }
2905
+ const v8CoverageToIstanbul = async (v8Coverage, { signal }) => {
2906
+ const operation = Abort.startOperation();
2907
+ operation.addAbortSignal(signal);
2851
2908
 
2852
- let errorReceived = false;
2853
- const errorPromise = new Promise((resolve, reject) => {
2854
- spawnedProcess.on("error", (e) => {
2855
- errorReceived = true;
2856
- reject(e);
2857
- });
2858
- });
2909
+ try {
2910
+ const v8ToIstanbul = importWithRequire("v8-to-istanbul");
2911
+ const sourcemapCache = v8Coverage["source-map-cache"];
2912
+ let istanbulCoverageComposed = null;
2859
2913
 
2860
- // const stdout = readline.createInterface({ input: spawnedProcess.stdout });
2861
- // stdout.on("line", () => {
2862
- // logger.debug(`[pid=${spawnedProcess.pid}][out] ${data}`);
2863
- // });
2864
- // const stderr = readline.createInterface({ input: spawnedProcess.stderr });
2865
- // stderr.on("line", (data) => {
2866
- // logger.debug(`[pid=${spawnedProcess.pid}][err] ${data}`);
2867
- // });
2868
- let processClosed = false;
2869
- const closedPromise = new Promise((resolve) => {
2870
- spawnedProcess.once("exit", (exitCode, signal) => {
2871
- logger.debug(
2872
- `[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`,
2873
- );
2874
- processClosed = true;
2875
- resolve();
2876
- });
2877
- });
2878
- const killProcess = async () => {
2879
- logger.debug(`[pid=${spawnedProcess.pid}] <kill>`);
2880
- if (!spawnedProcess.pid || spawnedProcess.killed || processClosed) {
2881
- logger.debug(
2882
- `[pid=${spawnedProcess.pid}] <skipped force kill spawnedProcess.killed=${spawnedProcess.killed} processClosed=${processClosed}>`,
2883
- );
2884
- return;
2885
- }
2886
- logger.debug(`[pid=${spawnedProcess.pid}] <will force kill>`);
2887
- // Force kill the browser.
2888
- try {
2889
- if (process.platform === "win32") {
2890
- const taskkillProcess = spawnSync(
2891
- `taskkill /pid ${spawnedProcess.pid} /T /F`,
2892
- { shell: true },
2893
- );
2894
- const [stdout, stderr] = [
2895
- taskkillProcess.stdout.toString(),
2896
- taskkillProcess.stderr.toString(),
2897
- ];
2898
- if (stdout)
2899
- logger.debug(
2900
- `[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout}`,
2901
- );
2902
- if (stderr)
2903
- logger.info(`[pid=${spawnedProcess.pid}] taskkill stderr: ${stderr}`);
2904
- } else {
2905
- process.kill(-spawnedProcess.pid, "SIGKILL");
2914
+ await v8Coverage.result.reduce(async (previous, fileV8Coverage) => {
2915
+ operation.throwIfAborted();
2916
+ await previous;
2917
+
2918
+ const { source } = fileV8Coverage;
2919
+ let sources;
2920
+ // when v8 coverage comes from playwright (chromium) v8Coverage.source is set
2921
+ if (typeof source === "string") {
2922
+ sources = { source };
2906
2923
  }
2907
- } catch (e) {
2908
- logger.info(
2909
- `[pid=${spawnedProcess.pid}] exception while trying to kill process: ${e}`,
2924
+ // when v8 coverage comes from Node.js, the source can be read from sourcemapCache
2925
+ else if (sourcemapCache) {
2926
+ sources = sourcesFromSourceMapCache(fileV8Coverage.url, sourcemapCache);
2927
+ }
2928
+ const path = urlToFileSystemPath(fileV8Coverage.url);
2929
+
2930
+ const converter = v8ToIstanbul(
2931
+ path,
2932
+ // wrapperLength is undefined we don't need it
2933
+ // https://github.com/istanbuljs/v8-to-istanbul/blob/2b54bc97c5edf8a37b39a171ec29134ba9bfd532/lib/v8-to-istanbul.js#L27
2934
+ undefined,
2935
+ sources,
2910
2936
  );
2911
- // the process might have already stopped
2912
- }
2913
- await closedPromise;
2914
- };
2937
+ await converter.load();
2915
2938
 
2916
- const startOperation = Abort.startOperation();
2917
- startOperation.addAbortSignal(signal);
2918
- const timeoutAbortSource = startOperation.timeout(allocatedMs);
2919
- startOperation.addAbortCallback(killProcess);
2920
- teardown.addCallback(killProcess);
2939
+ converter.applyCoverage(fileV8Coverage.functions);
2940
+ const istanbulCoverage = converter.toIstanbul();
2921
2941
 
2922
- const startedPromise = (async () => {
2923
- const logScale = [100, 250, 500];
2924
- // eslint-disable-next-line no-constant-condition
2925
- while (true) {
2926
- if (errorReceived) {
2927
- break;
2928
- }
2929
- const connected = await pingServer(webServer.origin);
2930
- if (connected) {
2931
- break;
2932
- }
2933
- startOperation.throwIfAborted();
2934
- const delay = logScale.shift() || 1000;
2935
- logger.debug(`Waiting ${delay}ms`);
2936
- await new Promise((x) => setTimeout(x, delay));
2937
- }
2938
- })();
2942
+ istanbulCoverageComposed = istanbulCoverageComposed
2943
+ ? composeTwoFileByFileIstanbulCoverages(
2944
+ istanbulCoverageComposed,
2945
+ istanbulCoverage,
2946
+ )
2947
+ : istanbulCoverage;
2948
+ }, Promise.resolve());
2939
2949
 
2940
- try {
2941
- await Promise.race([errorPromise, startedPromise]);
2942
- } catch (e) {
2943
- if (Abort.isAbortError(e)) {
2944
- if (timeoutAbortSource.signal.aborted) {
2945
- // aborted by timeout
2946
- throw new Error(
2947
- `"${webServer.command}" command did not start a server in less than ${allocatedMs}ms`,
2948
- );
2949
- }
2950
- if (signal.aborted) {
2951
- // aborted from outside
2952
- return;
2953
- }
2950
+ if (!istanbulCoverageComposed) {
2951
+ return {};
2954
2952
  }
2955
- throw e;
2953
+ istanbulCoverageComposed = markAsConvertedFromV8(istanbulCoverageComposed);
2954
+ return istanbulCoverageComposed;
2956
2955
  } finally {
2957
- await startOperation.end();
2956
+ await operation.end();
2958
2957
  }
2959
2958
  };
2960
2959
 
2961
- const startServerUsingModuleUrl = async (webServer, params) => {
2962
- if (!existsSync(new URL(webServer.moduleUrl))) {
2963
- throw new Error(`"${webServer.moduleUrl}" does not lead to a file`);
2964
- }
2965
- return startServerUsingCommand(
2966
- {
2967
- ...webServer,
2968
- command: `node ${fileURLToPath(webServer.moduleUrl)}`,
2969
- },
2970
- params,
2971
- );
2960
+ const markAsConvertedFromV8 = (fileByFileCoverage) => {
2961
+ const fileByFileMarked = {};
2962
+ Object.keys(fileByFileCoverage).forEach((key) => {
2963
+ const fileCoverage = fileByFileCoverage[key];
2964
+ fileByFileMarked[key] = {
2965
+ ...fileCoverage,
2966
+ fromV8: true,
2967
+ };
2968
+ });
2969
+ return fileByFileMarked;
2972
2970
  };
2973
2971
 
2974
- const basicFetch = async (
2975
- url,
2976
- { rejectUnauthorized = true, method = "GET", headers = {} } = {},
2977
- ) => {
2978
- let requestModule;
2979
- if (url.startsWith("http:")) {
2980
- requestModule = await import("node:http");
2981
- } else {
2982
- requestModule = await import("node:https");
2972
+ const sourcesFromSourceMapCache = (url, sourceMapCache) => {
2973
+ const sourceMapAndLineLengths = sourceMapCache[url];
2974
+ if (!sourceMapAndLineLengths) {
2975
+ return {};
2983
2976
  }
2984
- const { request } = requestModule;
2985
2977
 
2986
- const urlObject = new URL(url);
2978
+ const { data, lineLengths } = sourceMapAndLineLengths;
2979
+ // See: https://github.com/nodejs/node/pull/34305
2980
+ if (!data) {
2981
+ return undefined;
2982
+ }
2987
2983
 
2988
- return new Promise((resolve, reject) => {
2989
- const req = request({
2990
- rejectUnauthorized,
2991
- hostname: urlObject.hostname,
2992
- port: urlObject.port,
2993
- path: urlObject.pathname,
2994
- method,
2995
- headers,
2996
- });
2997
- req.on("response", (response) => {
2998
- resolve({
2999
- status: response.statusCode,
3000
- headers: response.headers,
3001
- json: () => {
3002
- req.setTimeout(0);
3003
- req.destroy();
3004
- return new Promise((resolve) => {
3005
- if (response.headers["content-type"] !== "application/json") {
3006
- console.warn("not json");
3007
- }
3008
- let responseBody = "";
3009
- response.setEncoding("utf8");
3010
- response.on("data", (chunk) => {
3011
- responseBody += chunk;
3012
- });
3013
- response.on("end", () => {
3014
- resolve(JSON.parse(responseBody));
3015
- });
3016
- response.on("error", (e) => {
3017
- reject(e);
3018
- });
3019
- });
3020
- },
3021
- });
3022
- });
3023
- req.on("error", reject);
3024
- req.end();
2984
+ const sources = {
2985
+ sourcemap: data,
2986
+ ...(lineLengths ? { source: sourcesFromLineLengths(lineLengths) } : {}),
2987
+ };
2988
+ return sources;
2989
+ };
2990
+
2991
+ const sourcesFromLineLengths = (lineLengths) => {
2992
+ let source = "";
2993
+ lineLengths.forEach((length) => {
2994
+ source += `${"".padEnd(length, ".")}\n`;
3025
2995
  });
2996
+ return source;
3026
2997
  };
3027
2998
 
3028
- const assertAndNormalizeWebServer = async (
3029
- webServer,
3030
- { signal, logger, teardown },
2999
+ const composeV8AndIstanbul = (
3000
+ v8FileByFileCoverage,
3001
+ istanbulFileByFileCoverage,
3002
+ { coverageV8ConflictWarning },
3031
3003
  ) => {
3032
- if (!webServer) {
3033
- throw new TypeError(
3034
- `webServer is required when running tests on browser(s)`,
3035
- );
3036
- }
3037
- const unexpectedParamNames = Object.keys(webServer).filter((key) => {
3038
- return ![
3039
- "origin",
3040
- "moduleUrl",
3041
- "command",
3042
- "cwd",
3043
- "rootDirectoryUrl",
3044
- ].includes(key);
3004
+ const fileByFileCoverage = {};
3005
+ const v8Files = Object.keys(v8FileByFileCoverage);
3006
+ const istanbulFiles = Object.keys(istanbulFileByFileCoverage);
3007
+
3008
+ v8Files.forEach((key) => {
3009
+ fileByFileCoverage[key] = v8FileByFileCoverage[key];
3045
3010
  });
3046
- if (unexpectedParamNames.length > 0) {
3047
- throw new TypeError(
3048
- `${unexpectedParamNames.join(",")}: there is no such param to webServer`,
3049
- );
3050
- }
3051
- if (typeof webServer.origin !== "string") {
3052
- throw new TypeError(
3053
- `webServer.origin must be a string, got ${webServer.origin}`,
3054
- );
3055
- }
3056
- await ensureWebServerIsStarted(webServer, {
3057
- signal,
3058
- teardown,
3059
- logger,
3060
- });
3061
- const { headers } = await basicFetch(webServer.origin, {
3062
- method: "GET",
3063
- rejectUnauthorized: false,
3064
- headers: {
3065
- "x-server-inspect": "1",
3066
- },
3067
- });
3068
- if (String(headers["server"]).includes("jsenv_dev_server")) {
3069
- webServer.isJsenvDevServer = true;
3070
- const { json } = await basicFetch(`${webServer.origin}/__params__.json`, {
3071
- rejectUnauthorized: false,
3072
- });
3073
- if (webServer.rootDirectoryUrl === undefined) {
3074
- const jsenvDevServerParams = await json();
3075
- webServer.rootDirectoryUrl = jsenvDevServerParams.sourceDirectoryUrl;
3011
+ istanbulFiles.forEach((key) => {
3012
+ const v8Coverage = v8FileByFileCoverage[key];
3013
+ if (v8Coverage) {
3014
+ if (coverageV8ConflictWarning) {
3015
+ console.warn(
3016
+ createDetailedMessage(
3017
+ `Coverage conflict on "${key}", found two coverage that cannot be merged together: v8 and istanbul. The istanbul coverage will be ignored.`,
3018
+ {
3019
+ "details": `This happens when a file is executed on a runtime using v8 coverage (node or chromium) and on runtime using istanbul coverage (firefox or webkit)`,
3020
+ "suggestion":
3021
+ "disable this warning with coverageV8ConflictWarning: false",
3022
+ "suggestion 2": `force coverage using istanbul with coverageMethodForBrowsers: "istanbul"`,
3023
+ },
3024
+ ),
3025
+ );
3026
+ }
3027
+ fileByFileCoverage[key] = v8Coverage;
3076
3028
  } else {
3077
- webServer.rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
3078
- webServer.rootDirectoryUrl,
3079
- "webServer.rootDirectoryUrl",
3080
- );
3029
+ fileByFileCoverage[key] = istanbulFileByFileCoverage[key];
3081
3030
  }
3082
- } else {
3083
- webServer.rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
3084
- webServer.rootDirectoryUrl,
3085
- "webServer.rootDirectoryUrl",
3086
- );
3087
- }
3031
+ });
3032
+
3033
+ return fileByFileCoverage;
3088
3034
  };
3089
3035
 
3090
- const ensureWebServerIsStarted = async (
3091
- webServer,
3092
- { signal, teardown, logger, allocatedMs = 5_000 },
3036
+ const normalizeFileByFileCoveragePaths = (
3037
+ fileByFileCoverage,
3038
+ rootDirectoryUrl,
3093
3039
  ) => {
3094
- const aServerIsListening = await pingServer(webServer.origin);
3095
- if (aServerIsListening) {
3096
- return;
3097
- }
3098
- if (webServer.moduleUrl) {
3099
- await startServerUsingModuleUrl(webServer, {
3100
- signal,
3101
- allocatedMs,
3102
- teardown,
3103
- logger,
3104
- });
3105
- return;
3106
- }
3107
- if (webServer.command) {
3108
- await startServerUsingCommand(webServer, {
3109
- signal,
3110
- allocatedMs,
3111
- teardown,
3112
- logger,
3113
- });
3114
- return;
3115
- }
3116
- throw new TypeError(
3117
- `webServer.moduleUrl or webServer.command is required as there is no server listening "${webServer.origin}"`,
3118
- );
3040
+ const fileByFileNormalized = {};
3041
+ Object.keys(fileByFileCoverage).forEach((key) => {
3042
+ const fileCoverage = fileByFileCoverage[key];
3043
+ const { path } = fileCoverage;
3044
+ const url = isFileSystemPath(path)
3045
+ ? fileSystemPathToUrl(path)
3046
+ : new URL(path, rootDirectoryUrl).href;
3047
+ const relativeUrl = urlToRelativeUrl(url, rootDirectoryUrl);
3048
+ fileByFileNormalized[`./${relativeUrl}`] = {
3049
+ ...fileCoverage,
3050
+ path: `./${relativeUrl}`,
3051
+ };
3052
+ });
3053
+ return fileByFileNormalized;
3119
3054
  };
3120
3055
 
3121
- const executionStepsFromTestPlan = async ({
3056
+ const listRelativeFileUrlToCover = async ({
3122
3057
  signal,
3123
3058
  rootDirectoryUrl,
3124
- testPlan,
3059
+ coverageConfig,
3125
3060
  }) => {
3061
+ const matchingFileResultArray = await collectFiles({
3062
+ signal,
3063
+ directoryUrl: rootDirectoryUrl,
3064
+ associations: { cover: coverageConfig },
3065
+ predicate: ({ cover }) => cover,
3066
+ });
3067
+ return matchingFileResultArray.map(({ relativeUrl }) => relativeUrl);
3068
+ };
3069
+
3070
+ // https://github.com/istanbuljs/babel-plugin-istanbul/blob/321740f7b25d803f881466ea819d870f7ed6a254/src/index.js
3071
+
3072
+ const babelPluginInstrument = (api, { useInlineSourceMaps = false }) => {
3073
+ const { programVisitor } = importWithRequire("istanbul-lib-instrument");
3074
+ const { types } = api;
3075
+
3076
+ return {
3077
+ name: "transform-instrument",
3078
+ visitor: {
3079
+ Program: {
3080
+ enter(path, state) {
3081
+ const { file } = state;
3082
+ const { opts } = file;
3083
+ let inputSourceMap;
3084
+ if (useInlineSourceMaps) {
3085
+ // https://github.com/istanbuljs/babel-plugin-istanbul/commit/a9e15643d249a2985e4387e4308022053b2cd0ad#diff-1fdf421c05c1140f6d71444ea2b27638R65
3086
+ inputSourceMap =
3087
+ opts.inputSourceMap || file.inputMap
3088
+ ? file.inputMap.sourcemap
3089
+ : null;
3090
+ } else {
3091
+ inputSourceMap = opts.inputSourceMap;
3092
+ }
3093
+ const __dv__ = programVisitor(
3094
+ types,
3095
+ opts.filenameRelative || opts.filename,
3096
+ {
3097
+ coverageVariable: "__coverage__",
3098
+ inputSourceMap,
3099
+ },
3100
+ );
3101
+ __dv__.enter(path);
3102
+ file.metadata.__dv__ = __dv__;
3103
+ },
3104
+
3105
+ exit(path, state) {
3106
+ const { __dv__ } = state.file.metadata;
3107
+ if (!__dv__) {
3108
+ return;
3109
+ }
3110
+ const object = __dv__.exit(path);
3111
+ // object got two properties: fileCoverage and sourceMappingURL
3112
+ state.file.metadata.coverage = object.fileCoverage;
3113
+ },
3114
+ },
3115
+ },
3116
+ };
3117
+ };
3118
+
3119
+ const relativeUrlToEmptyCoverage = async (
3120
+ relativeUrl,
3121
+ { signal, rootDirectoryUrl },
3122
+ ) => {
3123
+ const operation = Abort.startOperation();
3124
+ operation.addAbortSignal(signal);
3125
+
3126
3126
  try {
3127
- const fileResultArray = await collectFiles({
3128
- signal,
3129
- directoryUrl: rootDirectoryUrl,
3130
- associations: { testPlan },
3131
- predicate: ({ testPlan }) => testPlan,
3127
+ const fileUrl = resolveUrl(relativeUrl, rootDirectoryUrl);
3128
+ const content = await readFile(fileUrl, { as: "string" });
3129
+
3130
+ operation.throwIfAborted();
3131
+ const { metadata } = await applyBabelPlugins({
3132
+ babelPlugins: [babelPluginInstrument],
3133
+ input: content,
3134
+ inputIsJsModule: true,
3135
+ inputUrl: fileUrl,
3132
3136
  });
3133
- const executionSteps = [];
3134
- fileResultArray.forEach(({ relativeUrl, meta }) => {
3135
- const fileExecutionSteps = generateFileExecutionSteps({
3136
- fileRelativeUrl: relativeUrl,
3137
- filePlan: meta.testPlan,
3138
- });
3139
- executionSteps.push(...fileExecutionSteps);
3137
+ const { coverage } = metadata;
3138
+ if (!coverage) {
3139
+ throw new Error(`missing coverage for file`);
3140
+ }
3141
+ // https://github.com/gotwarlost/istanbul/blob/bc84c315271a5dd4d39bcefc5925cfb61a3d174a/lib/command/common/run-with-cover.js#L229
3142
+ Object.keys(coverage.s).forEach(function (key) {
3143
+ coverage.s[key] = 0;
3140
3144
  });
3141
- return executionSteps;
3145
+ return coverage;
3142
3146
  } catch (e) {
3143
- if (Abort.isAbortError(e)) {
3144
- return {
3145
- aborted: true,
3146
- planSummary: {},
3147
- planReport: {},
3148
- planCoverage: null,
3149
- };
3147
+ if (e && e.code === "PARSE_ERROR") {
3148
+ // return an empty coverage for that file when
3149
+ // it contains a syntax error
3150
+ return createEmptyCoverage(relativeUrl);
3150
3151
  }
3151
3152
  throw e;
3153
+ } finally {
3154
+ await operation.end();
3152
3155
  }
3153
3156
  };
3154
3157
 
3155
- const generateFileExecutionSteps = ({ fileRelativeUrl, filePlan }) => {
3156
- const fileExecutionSteps = [];
3157
- Object.keys(filePlan).forEach((executionName) => {
3158
- const stepConfig = filePlan[executionName];
3159
- if (stepConfig === null || stepConfig === undefined) {
3160
- return;
3161
- }
3162
- if (typeof stepConfig !== "object") {
3163
- throw new TypeError(
3164
- createDetailedMessage(
3165
- `found unexpected value in plan, they must be object`,
3166
- {
3167
- ["file relative path"]: fileRelativeUrl,
3168
- ["execution name"]: executionName,
3169
- ["value"]: stepConfig,
3170
- },
3171
- ),
3172
- );
3173
- }
3174
- fileExecutionSteps.push({
3175
- executionName,
3176
- fileRelativeUrl,
3177
- ...stepConfig,
3178
- });
3179
- });
3180
- return fileExecutionSteps;
3158
+ const createEmptyCoverage = (relativeUrl) => {
3159
+ const { createFileCoverage } = importWithRequire("istanbul-lib-coverage");
3160
+ return createFileCoverage(relativeUrl).toJSON();
3181
3161
  };
3182
3162
 
3183
- const readNodeV8CoverageDirectory = async ({
3184
- logger,
3163
+ const getMissingFileByFileCoverage = async ({
3185
3164
  signal,
3186
- onV8Coverage,
3187
- maxMsWaitingForNodeToWriteCoverageFile = 2000,
3165
+ rootDirectoryUrl,
3166
+ coverageConfig,
3167
+ fileByFileCoverage,
3188
3168
  }) => {
3189
- const NODE_V8_COVERAGE = process.env.NODE_V8_COVERAGE;
3169
+ const relativeUrlsToCover = await listRelativeFileUrlToCover({
3170
+ signal,
3171
+ rootDirectoryUrl,
3172
+ coverageConfig,
3173
+ });
3174
+ const relativeUrlsMissing = relativeUrlsToCover.filter((relativeUrlToCover) =>
3175
+ Object.keys(fileByFileCoverage).every((key) => {
3176
+ return key !== `./${relativeUrlToCover}`;
3177
+ }),
3178
+ );
3179
+
3190
3180
  const operation = Abort.startOperation();
3191
3181
  operation.addAbortSignal(signal);
3192
-
3193
- let timeSpentTrying = 0;
3194
- const tryReadDirectory = async () => {
3195
- const dirContent = readdirSync(NODE_V8_COVERAGE);
3196
- if (dirContent.length > 0) {
3197
- return dirContent;
3198
- }
3199
- if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
3200
- await new Promise((resolve) => setTimeout(resolve, 200));
3201
- timeSpentTrying += 200;
3202
- logger.debug("retry to read coverage directory");
3203
- return tryReadDirectory();
3204
- }
3205
- logger.warn(`v8 coverage directory is empty at ${NODE_V8_COVERAGE}`);
3206
- return dirContent;
3207
- };
3208
-
3209
- try {
3182
+ const missingFileByFileCoverage = {};
3183
+ await relativeUrlsMissing.reduce(async (previous, relativeUrlMissing) => {
3210
3184
  operation.throwIfAborted();
3211
- const dirContent = await tryReadDirectory();
3212
-
3213
- const coverageDirectoryUrl = assertAndNormalizeDirectoryUrl(
3214
- NODE_V8_COVERAGE,
3215
- "NODE_V8_COVERAGE",
3216
- );
3185
+ await previous;
3186
+ await operation.withSignal(async (signal) => {
3187
+ const emptyCoverage = await relativeUrlToEmptyCoverage(
3188
+ relativeUrlMissing,
3189
+ {
3190
+ signal,
3191
+ rootDirectoryUrl,
3192
+ },
3193
+ );
3194
+ missingFileByFileCoverage[`./${relativeUrlMissing}`] = emptyCoverage;
3195
+ });
3196
+ }, Promise.resolve());
3197
+ return missingFileByFileCoverage;
3198
+ };
3217
3199
 
3218
- await dirContent.reduce(async (previous, dirEntry) => {
3219
- operation.throwIfAborted();
3220
- await previous;
3200
+ const reportToCoverage = async (
3201
+ report,
3202
+ {
3203
+ signal,
3204
+ logger,
3205
+ rootDirectoryUrl,
3206
+ coverageConfig,
3207
+ coverageIncludeMissing,
3208
+ coverageMethodForNodeJs,
3209
+ coverageV8ConflictWarning,
3210
+ },
3211
+ ) => {
3212
+ // collect v8 and istanbul coverage from executions
3213
+ let { v8Coverage, fileByFileIstanbulCoverage } = await getCoverageFromReport({
3214
+ signal,
3215
+ report,
3216
+ onMissing: ({ file, executionResult, executionName }) => {
3217
+ // several reasons not to have coverage here:
3218
+ // 1. the file we executed did not import an instrumented file.
3219
+ // - a test file without import
3220
+ // - a test file importing only file excluded from coverage
3221
+ // - a coverDescription badly configured so that we don't realize
3222
+ // a file should be covered
3221
3223
 
3222
- const dirEntryUrl = new URL(dirEntry, coverageDirectoryUrl);
3223
- const tryReadJsonFile = async () => {
3224
- const fileContent = String(readFileSync(dirEntryUrl));
3225
- if (fileContent === "") {
3226
- if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
3227
- await new Promise((resolve) => setTimeout(resolve, 200));
3228
- timeSpentTrying += 200;
3229
- return tryReadJsonFile();
3230
- }
3231
- console.warn(`Coverage JSON file is empty at ${dirEntryUrl}`);
3232
- return null;
3233
- }
3224
+ // 2. the file we wanted to executed timedout
3225
+ // - infinite loop
3226
+ // - too extensive operation
3227
+ // - a badly configured or too low allocatedMs for that execution.
3234
3228
 
3235
- try {
3236
- const fileAsJson = JSON.parse(fileContent);
3237
- return fileAsJson;
3238
- } catch (e) {
3239
- if (timeSpentTrying < maxMsWaitingForNodeToWriteCoverageFile) {
3240
- await new Promise((resolve) => setTimeout(resolve, 200));
3241
- timeSpentTrying += 200;
3242
- return tryReadJsonFile();
3243
- }
3244
- console.warn(
3245
- createDetailedMessage(`Error while reading coverage file`, {
3246
- "error stack": e.stack,
3247
- "file": dirEntryUrl,
3248
- }),
3249
- );
3250
- return null;
3251
- }
3252
- };
3229
+ // 3. the file we wanted to execute contains syntax-error
3253
3230
 
3254
- const fileContent = await tryReadJsonFile();
3255
- if (fileContent) {
3256
- await onV8Coverage(fileContent);
3231
+ // in any scenario we are fine because
3232
+ // coverDescription will generate empty coverage for files
3233
+ // that were suppose to be coverage but were not.
3234
+ if (
3235
+ executionResult.status === "completed" &&
3236
+ executionResult.type === "node" &&
3237
+ coverageMethodForNodeJs !== "NODE_V8_COVERAGE"
3238
+ ) {
3239
+ logger.warn(
3240
+ `"${executionName}" execution of ${file} did not properly write coverage into ${executionResult.coverageFileUrl}`,
3241
+ );
3257
3242
  }
3258
- }, Promise.resolve());
3259
- } finally {
3260
- await operation.end();
3261
- }
3262
- };
3243
+ },
3244
+ });
3263
3245
 
3264
- const composeTwoV8Coverages = (firstV8Coverage, secondV8Coverage) => {
3265
- if (secondV8Coverage.result.length === 0) {
3266
- return firstV8Coverage;
3246
+ if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
3247
+ await readNodeV8CoverageDirectory({
3248
+ logger,
3249
+ signal,
3250
+ onV8Coverage: async (nodeV8Coverage) => {
3251
+ const nodeV8CoverageLight = await filterV8Coverage(nodeV8Coverage, {
3252
+ rootDirectoryUrl,
3253
+ coverageConfig,
3254
+ });
3255
+ v8Coverage = v8Coverage
3256
+ ? composeTwoV8Coverages(v8Coverage, nodeV8CoverageLight)
3257
+ : nodeV8CoverageLight;
3258
+ },
3259
+ });
3267
3260
  }
3268
3261
 
3269
- // eslint-disable-next-line import/no-unresolved
3270
- const { mergeProcessCovs } = importWithRequire("@c88/v8-coverage");
3271
- // "mergeProcessCovs" do not preserves source-map-cache during the merge
3272
- // so we store sourcemap cache now
3273
- const sourceMapCache = {};
3274
- const visit = (coverageReport) => {
3275
- if (coverageReport["source-map-cache"]) {
3276
- Object.assign(sourceMapCache, coverageReport["source-map-cache"]);
3277
- }
3278
- };
3279
- visit(firstV8Coverage);
3280
- visit(secondV8Coverage);
3281
- const v8Coverage = mergeProcessCovs([firstV8Coverage, secondV8Coverage]);
3282
- v8Coverage["source-map-cache"] = sourceMapCache;
3262
+ // try to merge v8 with istanbul, if any
3263
+ let fileByFileCoverage;
3264
+ if (v8Coverage) {
3265
+ let v8FileByFileCoverage = await v8CoverageToIstanbul(v8Coverage, {
3266
+ signal,
3267
+ });
3283
3268
 
3284
- return v8Coverage;
3285
- };
3269
+ v8FileByFileCoverage = normalizeFileByFileCoveragePaths(
3270
+ v8FileByFileCoverage,
3271
+ rootDirectoryUrl,
3272
+ );
3286
3273
 
3287
- const composeTwoFileByFileIstanbulCoverages = (
3288
- firstFileByFileIstanbulCoverage,
3289
- secondFileByFileIstanbulCoverage,
3290
- ) => {
3291
- const fileByFileIstanbulCoverage = {};
3292
- Object.keys(firstFileByFileIstanbulCoverage).forEach((key) => {
3293
- fileByFileIstanbulCoverage[key] = firstFileByFileIstanbulCoverage[key];
3294
- });
3295
- Object.keys(secondFileByFileIstanbulCoverage).forEach((key) => {
3296
- const firstCoverage = firstFileByFileIstanbulCoverage[key];
3297
- const secondCoverage = secondFileByFileIstanbulCoverage[key];
3298
- fileByFileIstanbulCoverage[key] = firstCoverage
3299
- ? merge(firstCoverage, secondCoverage)
3300
- : secondCoverage;
3301
- });
3274
+ if (fileByFileIstanbulCoverage) {
3275
+ fileByFileIstanbulCoverage = normalizeFileByFileCoveragePaths(
3276
+ fileByFileIstanbulCoverage,
3277
+ rootDirectoryUrl,
3278
+ );
3279
+ fileByFileCoverage = composeV8AndIstanbul(
3280
+ v8FileByFileCoverage,
3281
+ fileByFileIstanbulCoverage,
3282
+ { coverageV8ConflictWarning },
3283
+ );
3284
+ } else {
3285
+ fileByFileCoverage = v8FileByFileCoverage;
3286
+ }
3287
+ }
3288
+ // get istanbul only
3289
+ else if (fileByFileIstanbulCoverage) {
3290
+ fileByFileCoverage = normalizeFileByFileCoveragePaths(
3291
+ fileByFileIstanbulCoverage,
3292
+ rootDirectoryUrl,
3293
+ );
3294
+ }
3295
+ // no coverage found in execution (or zero file where executed)
3296
+ else {
3297
+ fileByFileCoverage = {};
3298
+ }
3302
3299
 
3303
- return fileByFileIstanbulCoverage;
3304
- };
3300
+ // now add coverage for file not covered
3301
+ if (coverageIncludeMissing) {
3302
+ const missingFileByFileCoverage = await getMissingFileByFileCoverage({
3303
+ signal,
3304
+ rootDirectoryUrl,
3305
+ coverageConfig,
3306
+ fileByFileCoverage,
3307
+ });
3308
+ Object.assign(
3309
+ fileByFileCoverage,
3310
+ normalizeFileByFileCoveragePaths(
3311
+ missingFileByFileCoverage,
3312
+ rootDirectoryUrl,
3313
+ ),
3314
+ );
3315
+ }
3305
3316
 
3306
- const merge = (firstIstanbulCoverage, secondIstanbulCoverage) => {
3307
- const { createFileCoverage } = importWithRequire("istanbul-lib-coverage");
3308
- const istanbulFileCoverageObject = createFileCoverage(firstIstanbulCoverage);
3309
- istanbulFileCoverageObject.merge(secondIstanbulCoverage);
3310
- const istanbulCoverage = istanbulFileCoverageObject.toJSON();
3311
- return istanbulCoverage;
3317
+ return fileByFileCoverage;
3312
3318
  };
3313
3319
 
3314
- const v8CoverageToIstanbul = async (v8Coverage, { signal }) => {
3320
+ const getCoverageFromReport = async ({ signal, report, onMissing }) => {
3315
3321
  const operation = Abort.startOperation();
3316
3322
  operation.addAbortSignal(signal);
3317
3323
 
3318
3324
  try {
3319
- const v8ToIstanbul = importWithRequire("v8-to-istanbul");
3320
- const sourcemapCache = v8Coverage["source-map-cache"];
3321
- let istanbulCoverageComposed = null;
3325
+ let v8Coverage;
3326
+ let fileByFileIstanbulCoverage;
3322
3327
 
3323
- await v8Coverage.result.reduce(async (previous, fileV8Coverage) => {
3328
+ // collect v8 and istanbul coverage from executions
3329
+ await Object.keys(report).reduce(async (previous, file) => {
3324
3330
  operation.throwIfAborted();
3325
3331
  await previous;
3326
3332
 
3327
- const { source } = fileV8Coverage;
3328
- let sources;
3329
- // when v8 coverage comes from playwright (chromium) v8Coverage.source is set
3330
- if (typeof source === "string") {
3331
- sources = { source };
3332
- }
3333
- // when v8 coverage comes from Node.js, the source can be read from sourcemapCache
3334
- else if (sourcemapCache) {
3335
- sources = sourcesFromSourceMapCache(fileV8Coverage.url, sourcemapCache);
3336
- }
3337
- const path = urlToFileSystemPath(fileV8Coverage.url);
3333
+ const executionResultForFile = report[file];
3334
+ await Object.keys(executionResultForFile).reduce(
3335
+ async (previous, executionName) => {
3336
+ operation.throwIfAborted();
3337
+ await previous;
3338
3338
 
3339
- const converter = v8ToIstanbul(
3340
- path,
3341
- // wrapperLength is undefined we don't need it
3342
- // https://github.com/istanbuljs/v8-to-istanbul/blob/2b54bc97c5edf8a37b39a171ec29134ba9bfd532/lib/v8-to-istanbul.js#L27
3343
- undefined,
3344
- sources,
3345
- );
3346
- await converter.load();
3347
-
3348
- converter.applyCoverage(fileV8Coverage.functions);
3349
- const istanbulCoverage = converter.toIstanbul();
3339
+ const executionResultForFileOnRuntime =
3340
+ executionResultForFile[executionName];
3341
+ const { coverageFileUrl } = executionResultForFileOnRuntime;
3342
+ let executionCoverage;
3343
+ try {
3344
+ executionCoverage = JSON.parse(
3345
+ String(readFileSync(new URL(coverageFileUrl))),
3346
+ );
3347
+ } catch (e) {
3348
+ if (e.code === "ENOENT" || e.name === "SyntaxError") {
3349
+ onMissing({
3350
+ executionName,
3351
+ file,
3352
+ executionResult: executionResultForFileOnRuntime,
3353
+ });
3354
+ return;
3355
+ }
3356
+ throw e;
3357
+ }
3350
3358
 
3351
- istanbulCoverageComposed = istanbulCoverageComposed
3352
- ? composeTwoFileByFileIstanbulCoverages(
3353
- istanbulCoverageComposed,
3354
- istanbulCoverage,
3355
- )
3356
- : istanbulCoverage;
3359
+ if (isV8Coverage(executionCoverage)) {
3360
+ v8Coverage = v8Coverage
3361
+ ? composeTwoV8Coverages(v8Coverage, executionCoverage)
3362
+ : executionCoverage;
3363
+ } else {
3364
+ fileByFileIstanbulCoverage = fileByFileIstanbulCoverage
3365
+ ? composeTwoFileByFileIstanbulCoverages(
3366
+ fileByFileIstanbulCoverage,
3367
+ executionCoverage,
3368
+ )
3369
+ : executionCoverage;
3370
+ }
3371
+ },
3372
+ Promise.resolve(),
3373
+ );
3357
3374
  }, Promise.resolve());
3358
3375
 
3359
- if (!istanbulCoverageComposed) {
3360
- return {};
3361
- }
3362
- istanbulCoverageComposed = markAsConvertedFromV8(istanbulCoverageComposed);
3363
- return istanbulCoverageComposed;
3376
+ return {
3377
+ v8Coverage,
3378
+ fileByFileIstanbulCoverage,
3379
+ };
3364
3380
  } finally {
3365
3381
  await operation.end();
3366
3382
  }
3367
3383
  };
3368
3384
 
3369
- const markAsConvertedFromV8 = (fileByFileCoverage) => {
3370
- const fileByFileMarked = {};
3371
- Object.keys(fileByFileCoverage).forEach((key) => {
3372
- const fileCoverage = fileByFileCoverage[key];
3373
- fileByFileMarked[key] = {
3374
- ...fileCoverage,
3375
- fromV8: true,
3376
- };
3377
- });
3378
- return fileByFileMarked;
3379
- };
3380
-
3381
- const sourcesFromSourceMapCache = (url, sourceMapCache) => {
3382
- const sourceMapAndLineLengths = sourceMapCache[url];
3383
- if (!sourceMapAndLineLengths) {
3384
- return {};
3385
- }
3386
-
3387
- const { data, lineLengths } = sourceMapAndLineLengths;
3388
- // See: https://github.com/nodejs/node/pull/34305
3389
- if (!data) {
3390
- return undefined;
3391
- }
3385
+ const isV8Coverage = (coverage) => Boolean(coverage.result);
3392
3386
 
3393
- const sources = {
3394
- sourcemap: data,
3395
- ...(lineLengths ? { source: sourcesFromLineLengths(lineLengths) } : {}),
3396
- };
3397
- return sources;
3387
+ const generateCoverageJsonFile = async ({
3388
+ coverage,
3389
+ coverageJsonFileUrl,
3390
+ logger,
3391
+ }) => {
3392
+ const coverageAsText = JSON.stringify(coverage, null, " ");
3393
+ logger.info(
3394
+ `-> ${urlToFileSystemPath(coverageJsonFileUrl)} (${byteAsFileSize(
3395
+ Buffer.byteLength(coverageAsText),
3396
+ )})`,
3397
+ );
3398
+ await writeFile(coverageJsonFileUrl, coverageAsText);
3398
3399
  };
3399
3400
 
3400
- const sourcesFromLineLengths = (lineLengths) => {
3401
- let source = "";
3402
- lineLengths.forEach((length) => {
3403
- source += `${"".padEnd(length, ".")}\n`;
3401
+ const istanbulCoverageMapFromCoverage = (coverage) => {
3402
+ const { createCoverageMap } = importWithRequire("istanbul-lib-coverage");
3403
+
3404
+ const coverageAdjusted = {};
3405
+ Object.keys(coverage).forEach((key) => {
3406
+ coverageAdjusted[key.slice(2)] = {
3407
+ ...coverage[key],
3408
+ path: key.slice(2),
3409
+ };
3404
3410
  });
3405
- return source;
3411
+
3412
+ const coverageMap = createCoverageMap(coverageAdjusted);
3413
+ return coverageMap;
3406
3414
  };
3407
3415
 
3408
- const composeV8AndIstanbul = (
3409
- v8FileByFileCoverage,
3410
- istanbulFileByFileCoverage,
3411
- { coverageV8ConflictWarning },
3416
+ const generateCoverageHtmlDirectory = async (
3417
+ coverage,
3418
+ {
3419
+ rootDirectoryUrl,
3420
+ coverageHtmlDirectoryRelativeUrl,
3421
+ coverageReportSkipEmpty,
3422
+ coverageReportSkipFull,
3423
+ },
3412
3424
  ) => {
3413
- const fileByFileCoverage = {};
3414
- const v8Files = Object.keys(v8FileByFileCoverage);
3415
- const istanbulFiles = Object.keys(istanbulFileByFileCoverage);
3425
+ const libReport = importWithRequire("istanbul-lib-report");
3426
+ const reports = importWithRequire("istanbul-reports");
3416
3427
 
3417
- v8Files.forEach((key) => {
3418
- fileByFileCoverage[key] = v8FileByFileCoverage[key];
3419
- });
3420
- istanbulFiles.forEach((key) => {
3421
- const v8Coverage = v8FileByFileCoverage[key];
3422
- if (v8Coverage) {
3423
- if (coverageV8ConflictWarning) {
3424
- console.warn(
3425
- createDetailedMessage(
3426
- `Coverage conflict on "${key}", found two coverage that cannot be merged together: v8 and istanbul. The istanbul coverage will be ignored.`,
3427
- {
3428
- "details": `This happens when a file is executed on a runtime using v8 coverage (node or chromium) and on runtime using istanbul coverage (firefox or webkit)`,
3429
- "suggestion":
3430
- "disable this warning with coverageV8ConflictWarning: false",
3431
- "suggestion 2": `force coverage using istanbul with coverageMethodForBrowsers: "istanbul"`,
3432
- },
3433
- ),
3434
- );
3435
- }
3436
- fileByFileCoverage[key] = v8Coverage;
3437
- } else {
3438
- fileByFileCoverage[key] = istanbulFileByFileCoverage[key];
3439
- }
3428
+ const context = libReport.createContext({
3429
+ dir: fileURLToPath(rootDirectoryUrl),
3430
+ coverageMap: istanbulCoverageMapFromCoverage(coverage),
3431
+ sourceFinder: (path) =>
3432
+ readFileSync(new URL(path, rootDirectoryUrl), "utf8"),
3440
3433
  });
3441
3434
 
3442
- return fileByFileCoverage;
3435
+ const report = reports.create("html", {
3436
+ skipEmpty: coverageReportSkipEmpty,
3437
+ skipFull: coverageReportSkipFull,
3438
+ subdir: coverageHtmlDirectoryRelativeUrl,
3439
+ });
3440
+ report.execute(context);
3443
3441
  };
3444
3442
 
3445
- const normalizeFileByFileCoveragePaths = (
3446
- fileByFileCoverage,
3447
- rootDirectoryUrl,
3443
+ const generateCoverageTextLog = (
3444
+ coverage,
3445
+ { coverageReportSkipEmpty, coverageReportSkipFull },
3448
3446
  ) => {
3449
- const fileByFileNormalized = {};
3450
- Object.keys(fileByFileCoverage).forEach((key) => {
3451
- const fileCoverage = fileByFileCoverage[key];
3452
- const { path } = fileCoverage;
3453
- const url = isFileSystemPath(path)
3454
- ? fileSystemPathToUrl(path)
3455
- : new URL(path, rootDirectoryUrl).href;
3456
- const relativeUrl = urlToRelativeUrl(url, rootDirectoryUrl);
3457
- fileByFileNormalized[`./${relativeUrl}`] = {
3458
- ...fileCoverage,
3459
- path: `./${relativeUrl}`,
3460
- };
3447
+ const libReport = importWithRequire("istanbul-lib-report");
3448
+ const reports = importWithRequire("istanbul-reports");
3449
+
3450
+ const context = libReport.createContext({
3451
+ coverageMap: istanbulCoverageMapFromCoverage(coverage),
3461
3452
  });
3462
- return fileByFileNormalized;
3453
+ const report = reports.create("text", {
3454
+ skipEmpty: coverageReportSkipEmpty,
3455
+ skipFull: coverageReportSkipFull,
3456
+ });
3457
+ report.execute(context);
3463
3458
  };
3464
3459
 
3465
- const listRelativeFileUrlToCover = async ({
3466
- signal,
3467
- rootDirectoryUrl,
3468
- coverageConfig,
3469
- }) => {
3470
- const matchingFileResultArray = await collectFiles({
3471
- signal,
3472
- directoryUrl: rootDirectoryUrl,
3473
- associations: { cover: coverageConfig },
3474
- predicate: ({ cover }) => cover,
3460
+ const pingServer = async (url) => {
3461
+ const server = createServer();
3462
+ const { hostname, port } = new URL(url);
3463
+
3464
+ try {
3465
+ await new Promise((resolve, reject) => {
3466
+ server.on("error", reject);
3467
+ server.on("listening", () => {
3468
+ resolve();
3469
+ });
3470
+ server.listen(port, hostname);
3471
+ });
3472
+ } catch (error) {
3473
+ if (error && error.code === "EADDRINUSE") {
3474
+ return true;
3475
+ }
3476
+ if (error && error.code === "EACCES") {
3477
+ return true;
3478
+ }
3479
+ throw error;
3480
+ }
3481
+ await new Promise((resolve, reject) => {
3482
+ server.on("error", reject);
3483
+ server.on("close", resolve);
3484
+ server.close();
3475
3485
  });
3476
- return matchingFileResultArray.map(({ relativeUrl }) => relativeUrl);
3486
+ return false;
3477
3487
  };
3478
3488
 
3479
- // https://github.com/istanbuljs/babel-plugin-istanbul/blob/321740f7b25d803f881466ea819d870f7ed6a254/src/index.js
3489
+ const startServerUsingCommand = async (
3490
+ webServer,
3491
+ { signal, allocatedMs, logger, teardown },
3492
+ ) => {
3493
+ const spawnedProcess = spawn(webServer.command, [], {
3494
+ // On non-windows platforms, `detached: true` makes child process a leader of a new
3495
+ // process group, making it possible to kill child process tree with `.kill(-pid)` command.
3496
+ // @see https://nodejs.org/api/child_process.html#child_process_options_detached
3497
+ detached: process.platform !== "win32",
3498
+ stdio: ["pipe", "pipe", "pipe"],
3499
+ shell: true,
3500
+ cwd: webServer.cwd,
3501
+ });
3502
+ if (!spawnedProcess.pid) {
3503
+ await new Promise((resolve, reject) => {
3504
+ spawnedProcess.once("error", (error) => {
3505
+ reject(new Error(`Failed to launch: ${error}`));
3506
+ });
3507
+ });
3508
+ }
3480
3509
 
3481
- const babelPluginInstrument = (api, { useInlineSourceMaps = false }) => {
3482
- const { programVisitor } = importWithRequire("istanbul-lib-instrument");
3483
- const { types } = api;
3510
+ let errorReceived = false;
3511
+ const errorPromise = new Promise((resolve, reject) => {
3512
+ spawnedProcess.on("error", (e) => {
3513
+ errorReceived = true;
3514
+ reject(e);
3515
+ });
3516
+ });
3484
3517
 
3485
- return {
3486
- name: "transform-instrument",
3487
- visitor: {
3488
- Program: {
3489
- enter(path, state) {
3490
- const { file } = state;
3491
- const { opts } = file;
3492
- let inputSourceMap;
3493
- if (useInlineSourceMaps) {
3494
- // https://github.com/istanbuljs/babel-plugin-istanbul/commit/a9e15643d249a2985e4387e4308022053b2cd0ad#diff-1fdf421c05c1140f6d71444ea2b27638R65
3495
- inputSourceMap =
3496
- opts.inputSourceMap || file.inputMap
3497
- ? file.inputMap.sourcemap
3498
- : null;
3499
- } else {
3500
- inputSourceMap = opts.inputSourceMap;
3501
- }
3502
- const __dv__ = programVisitor(
3503
- types,
3504
- opts.filenameRelative || opts.filename,
3505
- {
3506
- coverageVariable: "__coverage__",
3507
- inputSourceMap,
3508
- },
3518
+ // const stdout = readline.createInterface({ input: spawnedProcess.stdout });
3519
+ // stdout.on("line", () => {
3520
+ // logger.debug(`[pid=${spawnedProcess.pid}][out] ${data}`);
3521
+ // });
3522
+ // const stderr = readline.createInterface({ input: spawnedProcess.stderr });
3523
+ // stderr.on("line", (data) => {
3524
+ // logger.debug(`[pid=${spawnedProcess.pid}][err] ${data}`);
3525
+ // });
3526
+ let processClosed = false;
3527
+ const closedPromise = new Promise((resolve) => {
3528
+ spawnedProcess.once("exit", (exitCode, signal) => {
3529
+ logger.debug(
3530
+ `[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`,
3531
+ );
3532
+ processClosed = true;
3533
+ resolve();
3534
+ });
3535
+ });
3536
+ const killProcess = async () => {
3537
+ logger.debug(`[pid=${spawnedProcess.pid}] <kill>`);
3538
+ if (!spawnedProcess.pid || spawnedProcess.killed || processClosed) {
3539
+ logger.debug(
3540
+ `[pid=${spawnedProcess.pid}] <skipped force kill spawnedProcess.killed=${spawnedProcess.killed} processClosed=${processClosed}>`,
3541
+ );
3542
+ return;
3543
+ }
3544
+ logger.debug(`[pid=${spawnedProcess.pid}] <will force kill>`);
3545
+ // Force kill the browser.
3546
+ try {
3547
+ if (process.platform === "win32") {
3548
+ const taskkillProcess = spawnSync(
3549
+ `taskkill /pid ${spawnedProcess.pid} /T /F`,
3550
+ { shell: true },
3551
+ );
3552
+ const [stdout, stderr] = [
3553
+ taskkillProcess.stdout.toString(),
3554
+ taskkillProcess.stderr.toString(),
3555
+ ];
3556
+ if (stdout)
3557
+ logger.debug(
3558
+ `[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout}`,
3509
3559
  );
3510
- __dv__.enter(path);
3511
- file.metadata.__dv__ = __dv__;
3512
- },
3513
-
3514
- exit(path, state) {
3515
- const { __dv__ } = state.file.metadata;
3516
- if (!__dv__) {
3517
- return;
3518
- }
3519
- const object = __dv__.exit(path);
3520
- // object got two properties: fileCoverage and sourceMappingURL
3521
- state.file.metadata.coverage = object.fileCoverage;
3522
- },
3523
- },
3524
- },
3560
+ if (stderr)
3561
+ logger.info(`[pid=${spawnedProcess.pid}] taskkill stderr: ${stderr}`);
3562
+ } else {
3563
+ process.kill(-spawnedProcess.pid, "SIGKILL");
3564
+ }
3565
+ } catch (e) {
3566
+ logger.info(
3567
+ `[pid=${spawnedProcess.pid}] exception while trying to kill process: ${e}`,
3568
+ );
3569
+ // the process might have already stopped
3570
+ }
3571
+ await closedPromise;
3525
3572
  };
3526
- };
3527
-
3528
- const relativeUrlToEmptyCoverage = async (
3529
- relativeUrl,
3530
- { signal, rootDirectoryUrl },
3531
- ) => {
3532
- const operation = Abort.startOperation();
3533
- operation.addAbortSignal(signal);
3534
3573
 
3535
- try {
3536
- const fileUrl = resolveUrl(relativeUrl, rootDirectoryUrl);
3537
- const content = await readFile(fileUrl, { as: "string" });
3574
+ const startOperation = Abort.startOperation();
3575
+ startOperation.addAbortSignal(signal);
3576
+ const timeoutAbortSource = startOperation.timeout(allocatedMs);
3577
+ startOperation.addAbortCallback(killProcess);
3578
+ teardown.addCallback(killProcess);
3538
3579
 
3539
- operation.throwIfAborted();
3540
- const { metadata } = await applyBabelPlugins({
3541
- babelPlugins: [babelPluginInstrument],
3542
- input: content,
3543
- inputIsJsModule: true,
3544
- inputUrl: fileUrl,
3545
- });
3546
- const { coverage } = metadata;
3547
- if (!coverage) {
3548
- throw new Error(`missing coverage for file`);
3580
+ const startedPromise = (async () => {
3581
+ const logScale = [100, 250, 500];
3582
+ // eslint-disable-next-line no-constant-condition
3583
+ while (true) {
3584
+ if (errorReceived) {
3585
+ break;
3586
+ }
3587
+ const connected = await pingServer(webServer.origin);
3588
+ if (connected) {
3589
+ break;
3590
+ }
3591
+ startOperation.throwIfAborted();
3592
+ const delay = logScale.shift() || 1000;
3593
+ logger.debug(`Waiting ${delay}ms`);
3594
+ await new Promise((x) => setTimeout(x, delay));
3549
3595
  }
3550
- // https://github.com/gotwarlost/istanbul/blob/bc84c315271a5dd4d39bcefc5925cfb61a3d174a/lib/command/common/run-with-cover.js#L229
3551
- Object.keys(coverage.s).forEach(function (key) {
3552
- coverage.s[key] = 0;
3553
- });
3554
- return coverage;
3596
+ })();
3597
+
3598
+ try {
3599
+ await Promise.race([errorPromise, startedPromise]);
3555
3600
  } catch (e) {
3556
- if (e && e.code === "PARSE_ERROR") {
3557
- // return an empty coverage for that file when
3558
- // it contains a syntax error
3559
- return createEmptyCoverage(relativeUrl);
3601
+ if (Abort.isAbortError(e)) {
3602
+ if (timeoutAbortSource.signal.aborted) {
3603
+ // aborted by timeout
3604
+ throw new Error(
3605
+ `"${webServer.command}" command did not start a server in less than ${allocatedMs}ms`,
3606
+ );
3607
+ }
3608
+ if (signal.aborted) {
3609
+ // aborted from outside
3610
+ return;
3611
+ }
3560
3612
  }
3561
3613
  throw e;
3562
3614
  } finally {
3563
- await operation.end();
3615
+ await startOperation.end();
3564
3616
  }
3565
3617
  };
3566
3618
 
3567
- const createEmptyCoverage = (relativeUrl) => {
3568
- const { createFileCoverage } = importWithRequire("istanbul-lib-coverage");
3569
- return createFileCoverage(relativeUrl).toJSON();
3570
- };
3571
-
3572
- const getMissingFileByFileCoverage = async ({
3573
- signal,
3574
- rootDirectoryUrl,
3575
- coverageConfig,
3576
- fileByFileCoverage,
3577
- }) => {
3578
- const relativeUrlsToCover = await listRelativeFileUrlToCover({
3579
- signal,
3580
- rootDirectoryUrl,
3581
- coverageConfig,
3582
- });
3583
- const relativeUrlsMissing = relativeUrlsToCover.filter((relativeUrlToCover) =>
3584
- Object.keys(fileByFileCoverage).every((key) => {
3585
- return key !== `./${relativeUrlToCover}`;
3586
- }),
3619
+ const startServerUsingModuleUrl = async (webServer, params) => {
3620
+ if (!existsSync(new URL(webServer.moduleUrl))) {
3621
+ throw new Error(`"${webServer.moduleUrl}" does not lead to a file`);
3622
+ }
3623
+ return startServerUsingCommand(
3624
+ {
3625
+ ...webServer,
3626
+ command: `node ${fileURLToPath(webServer.moduleUrl)}`,
3627
+ },
3628
+ params,
3587
3629
  );
3588
-
3589
- const operation = Abort.startOperation();
3590
- operation.addAbortSignal(signal);
3591
- const missingFileByFileCoverage = {};
3592
- await relativeUrlsMissing.reduce(async (previous, relativeUrlMissing) => {
3593
- operation.throwIfAborted();
3594
- await previous;
3595
- await operation.withSignal(async (signal) => {
3596
- const emptyCoverage = await relativeUrlToEmptyCoverage(
3597
- relativeUrlMissing,
3598
- {
3599
- signal,
3600
- rootDirectoryUrl,
3601
- },
3602
- );
3603
- missingFileByFileCoverage[`./${relativeUrlMissing}`] = emptyCoverage;
3604
- });
3605
- }, Promise.resolve());
3606
- return missingFileByFileCoverage;
3607
3630
  };
3608
3631
 
3609
- const reportToCoverage = async (
3610
- report,
3611
- {
3612
- signal,
3613
- logger,
3614
- rootDirectoryUrl,
3615
- coverageConfig,
3616
- coverageIncludeMissing,
3617
- coverageMethodForNodeJs,
3618
- coverageV8ConflictWarning,
3619
- },
3632
+ const basicFetch = async (
3633
+ url,
3634
+ { rejectUnauthorized = true, method = "GET", headers = {} } = {},
3620
3635
  ) => {
3621
- // collect v8 and istanbul coverage from executions
3622
- let { v8Coverage, fileByFileIstanbulCoverage } = await getCoverageFromReport({
3623
- signal,
3624
- report,
3625
- onMissing: ({ file, executionResult, executionName }) => {
3626
- // several reasons not to have coverage here:
3627
- // 1. the file we executed did not import an instrumented file.
3628
- // - a test file without import
3629
- // - a test file importing only file excluded from coverage
3630
- // - a coverDescription badly configured so that we don't realize
3631
- // a file should be covered
3632
-
3633
- // 2. the file we wanted to executed timedout
3634
- // - infinite loop
3635
- // - too extensive operation
3636
- // - a badly configured or too low allocatedMs for that execution.
3636
+ let requestModule;
3637
+ if (url.startsWith("http:")) {
3638
+ requestModule = await import("node:http");
3639
+ } else {
3640
+ requestModule = await import("node:https");
3641
+ }
3642
+ const { request } = requestModule;
3637
3643
 
3638
- // 3. the file we wanted to execute contains syntax-error
3644
+ const urlObject = new URL(url);
3639
3645
 
3640
- // in any scenario we are fine because
3641
- // coverDescription will generate empty coverage for files
3642
- // that were suppose to be coverage but were not.
3643
- if (
3644
- executionResult.status === "completed" &&
3645
- executionResult.type === "node" &&
3646
- coverageMethodForNodeJs !== "NODE_V8_COVERAGE"
3647
- ) {
3648
- logger.warn(
3649
- `"${executionName}" execution of ${file} did not properly write coverage into ${executionResult.coverageFileUrl}`,
3650
- );
3651
- }
3652
- },
3653
- });
3654
-
3655
- if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
3656
- await readNodeV8CoverageDirectory({
3657
- logger,
3658
- signal,
3659
- onV8Coverage: async (nodeV8Coverage) => {
3660
- const nodeV8CoverageLight = await filterV8Coverage(nodeV8Coverage, {
3661
- rootDirectoryUrl,
3662
- coverageConfig,
3663
- });
3664
- v8Coverage = v8Coverage
3665
- ? composeTwoV8Coverages(v8Coverage, nodeV8CoverageLight)
3666
- : nodeV8CoverageLight;
3667
- },
3646
+ return new Promise((resolve, reject) => {
3647
+ const req = request({
3648
+ rejectUnauthorized,
3649
+ hostname: urlObject.hostname,
3650
+ port: urlObject.port,
3651
+ path: urlObject.pathname,
3652
+ method,
3653
+ headers,
3668
3654
  });
3669
- }
3670
-
3671
- // try to merge v8 with istanbul, if any
3672
- let fileByFileCoverage;
3673
- if (v8Coverage) {
3674
- let v8FileByFileCoverage = await v8CoverageToIstanbul(v8Coverage, {
3675
- signal,
3655
+ req.on("response", (response) => {
3656
+ resolve({
3657
+ status: response.statusCode,
3658
+ headers: response.headers,
3659
+ json: () => {
3660
+ req.setTimeout(0);
3661
+ req.destroy();
3662
+ return new Promise((resolve) => {
3663
+ if (response.headers["content-type"] !== "application/json") {
3664
+ console.warn("not json");
3665
+ }
3666
+ let responseBody = "";
3667
+ response.setEncoding("utf8");
3668
+ response.on("data", (chunk) => {
3669
+ responseBody += chunk;
3670
+ });
3671
+ response.on("end", () => {
3672
+ resolve(JSON.parse(responseBody));
3673
+ });
3674
+ response.on("error", (e) => {
3675
+ reject(e);
3676
+ });
3677
+ });
3678
+ },
3679
+ });
3676
3680
  });
3681
+ req.on("error", reject);
3682
+ req.end();
3683
+ });
3684
+ };
3677
3685
 
3678
- v8FileByFileCoverage = normalizeFileByFileCoveragePaths(
3679
- v8FileByFileCoverage,
3680
- rootDirectoryUrl,
3686
+ const assertAndNormalizeWebServer = async (
3687
+ webServer,
3688
+ { signal, logger, teardown },
3689
+ ) => {
3690
+ if (!webServer) {
3691
+ throw new TypeError(
3692
+ `webServer is required when running tests on browser(s)`,
3681
3693
  );
3682
-
3683
- if (fileByFileIstanbulCoverage) {
3684
- fileByFileIstanbulCoverage = normalizeFileByFileCoveragePaths(
3685
- fileByFileIstanbulCoverage,
3686
- rootDirectoryUrl,
3687
- );
3688
- fileByFileCoverage = composeV8AndIstanbul(
3689
- v8FileByFileCoverage,
3690
- fileByFileIstanbulCoverage,
3691
- { coverageV8ConflictWarning },
3692
- );
3693
- } else {
3694
- fileByFileCoverage = v8FileByFileCoverage;
3695
- }
3696
3694
  }
3697
- // get istanbul only
3698
- else if (fileByFileIstanbulCoverage) {
3699
- fileByFileCoverage = normalizeFileByFileCoveragePaths(
3700
- fileByFileIstanbulCoverage,
3701
- rootDirectoryUrl,
3695
+ const unexpectedParamNames = Object.keys(webServer).filter((key) => {
3696
+ return ![
3697
+ "origin",
3698
+ "moduleUrl",
3699
+ "command",
3700
+ "cwd",
3701
+ "rootDirectoryUrl",
3702
+ ].includes(key);
3703
+ });
3704
+ if (unexpectedParamNames.length > 0) {
3705
+ throw new TypeError(
3706
+ `${unexpectedParamNames.join(",")}: there is no such param to webServer`,
3702
3707
  );
3703
3708
  }
3704
- // no coverage found in execution (or zero file where executed)
3705
- else {
3706
- fileByFileCoverage = {};
3709
+ if (typeof webServer.origin !== "string") {
3710
+ throw new TypeError(
3711
+ `webServer.origin must be a string, got ${webServer.origin}`,
3712
+ );
3707
3713
  }
3708
-
3709
- // now add coverage for file not covered
3710
- if (coverageIncludeMissing) {
3711
- const missingFileByFileCoverage = await getMissingFileByFileCoverage({
3712
- signal,
3713
- rootDirectoryUrl,
3714
- coverageConfig,
3715
- fileByFileCoverage,
3714
+ await ensureWebServerIsStarted(webServer, {
3715
+ signal,
3716
+ teardown,
3717
+ logger,
3718
+ });
3719
+ const { headers } = await basicFetch(webServer.origin, {
3720
+ method: "GET",
3721
+ rejectUnauthorized: false,
3722
+ headers: {
3723
+ "x-server-inspect": "1",
3724
+ },
3725
+ });
3726
+ if (String(headers["server"]).includes("jsenv_dev_server")) {
3727
+ webServer.isJsenvDevServer = true;
3728
+ const { json } = await basicFetch(`${webServer.origin}/__params__.json`, {
3729
+ rejectUnauthorized: false,
3716
3730
  });
3717
- Object.assign(
3718
- fileByFileCoverage,
3719
- normalizeFileByFileCoveragePaths(
3720
- missingFileByFileCoverage,
3721
- rootDirectoryUrl,
3722
- ),
3731
+ if (webServer.rootDirectoryUrl === undefined) {
3732
+ const jsenvDevServerParams = await json();
3733
+ webServer.rootDirectoryUrl = jsenvDevServerParams.sourceDirectoryUrl;
3734
+ } else {
3735
+ webServer.rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
3736
+ webServer.rootDirectoryUrl,
3737
+ "webServer.rootDirectoryUrl",
3738
+ );
3739
+ }
3740
+ } else {
3741
+ webServer.rootDirectoryUrl = assertAndNormalizeDirectoryUrl(
3742
+ webServer.rootDirectoryUrl,
3743
+ "webServer.rootDirectoryUrl",
3723
3744
  );
3724
3745
  }
3725
-
3726
- return fileByFileCoverage;
3727
3746
  };
3728
3747
 
3729
- const getCoverageFromReport = async ({ signal, report, onMissing }) => {
3730
- const operation = Abort.startOperation();
3731
- operation.addAbortSignal(signal);
3732
-
3733
- try {
3734
- let v8Coverage;
3735
- let fileByFileIstanbulCoverage;
3736
-
3737
- // collect v8 and istanbul coverage from executions
3738
- await Object.keys(report).reduce(async (previous, file) => {
3739
- operation.throwIfAborted();
3740
- await previous;
3741
-
3742
- const executionResultForFile = report[file];
3743
- await Object.keys(executionResultForFile).reduce(
3744
- async (previous, executionName) => {
3745
- operation.throwIfAborted();
3746
- await previous;
3747
-
3748
- const executionResultForFileOnRuntime =
3749
- executionResultForFile[executionName];
3750
- const { coverageFileUrl } = executionResultForFileOnRuntime;
3751
- let executionCoverage;
3752
- try {
3753
- executionCoverage = JSON.parse(
3754
- String(readFileSync(new URL(coverageFileUrl))),
3755
- );
3756
- } catch (e) {
3757
- if (e.code === "ENOENT" || e.name === "SyntaxError") {
3758
- onMissing({
3759
- executionName,
3760
- file,
3761
- executionResult: executionResultForFileOnRuntime,
3762
- });
3763
- return;
3764
- }
3765
- throw e;
3766
- }
3767
-
3768
- if (isV8Coverage(executionCoverage)) {
3769
- v8Coverage = v8Coverage
3770
- ? composeTwoV8Coverages(v8Coverage, executionCoverage)
3771
- : executionCoverage;
3772
- } else {
3773
- fileByFileIstanbulCoverage = fileByFileIstanbulCoverage
3774
- ? composeTwoFileByFileIstanbulCoverages(
3775
- fileByFileIstanbulCoverage,
3776
- executionCoverage,
3777
- )
3778
- : executionCoverage;
3779
- }
3780
- },
3781
- Promise.resolve(),
3782
- );
3783
- }, Promise.resolve());
3784
-
3785
- return {
3786
- v8Coverage,
3787
- fileByFileIstanbulCoverage,
3788
- };
3789
- } finally {
3790
- await operation.end();
3748
+ const ensureWebServerIsStarted = async (
3749
+ webServer,
3750
+ { signal, teardown, logger, allocatedMs = 5_000 },
3751
+ ) => {
3752
+ const aServerIsListening = await pingServer(webServer.origin);
3753
+ if (aServerIsListening) {
3754
+ return;
3755
+ }
3756
+ if (webServer.moduleUrl) {
3757
+ await startServerUsingModuleUrl(webServer, {
3758
+ signal,
3759
+ allocatedMs,
3760
+ teardown,
3761
+ logger,
3762
+ });
3763
+ return;
3764
+ }
3765
+ if (webServer.command) {
3766
+ await startServerUsingCommand(webServer, {
3767
+ signal,
3768
+ allocatedMs,
3769
+ teardown,
3770
+ logger,
3771
+ });
3772
+ return;
3791
3773
  }
3774
+ throw new TypeError(
3775
+ `webServer.moduleUrl or webServer.command is required as there is no server listening "${webServer.origin}"`,
3776
+ );
3792
3777
  };
3793
3778
 
3794
- const isV8Coverage = (coverage) => Boolean(coverage.result);
3795
-
3796
- /*
3797
- * Export a function capable to run a file on a runtime.
3798
- *
3799
- * - Used internally by "executeTestPlan" part of the documented API
3800
- * - Used internally by "execute" an advanced API not documented
3801
- * - logs generated during file execution can be collected
3802
- * - logs generated during file execution can be mirrored (re-logged to the console)
3803
- * - File is given allocatedMs to complete
3804
- * - Errors are collected
3805
- * - File execution result is returned, it contains status/errors/namespace/consoleCalls
3806
- */
3807
-
3808
-
3809
- const run = async ({
3810
- signal = new AbortController().signal,
3811
- logger,
3812
- allocatedMs,
3813
- keepRunning = false,
3814
- mirrorConsole = false,
3815
- collectConsole = false,
3816
- coverageEnabled = false,
3817
- coverageTempDirectoryUrl,
3818
- collectPerformance = false,
3819
- runtime,
3820
- runtimeParams,
3779
+ const executionStepsFromTestPlan = async ({
3780
+ signal,
3781
+ rootDirectoryUrl,
3782
+ testPlan,
3821
3783
  }) => {
3822
- const result = {
3823
- status: "pending",
3824
- errors: [],
3825
- namespace: null,
3826
- };
3827
- const callbacks = [];
3828
-
3829
- const onConsoleRef = { current: () => {} };
3830
- const stopSignal = { notify: () => {} };
3831
- const runtimeLabel = `${runtime.name}/${runtime.version}`;
3832
-
3833
- const runOperation = Abort.startOperation();
3834
- runOperation.addAbortSignal(signal);
3835
- let timeoutAbortSource;
3836
- if (
3837
- // ideally we would rather log than the timeout is ignored
3838
- // when keepRunning is true
3839
- !keepRunning &&
3840
- typeof allocatedMs === "number" &&
3841
- allocatedMs !== Infinity
3842
- ) {
3843
- timeoutAbortSource = runOperation.timeout(allocatedMs);
3844
- }
3845
- const consoleCalls = [];
3846
- onConsoleRef.current = ({ type, text }) => {
3847
- if (mirrorConsole) {
3848
- if (type === "error") {
3849
- process.stderr.write(text);
3850
- } else {
3851
- process.stdout.write(text);
3852
- }
3853
- }
3854
- if (collectConsole) {
3855
- consoleCalls.push({ type, text });
3856
- }
3857
- };
3858
- if (collectConsole) {
3859
- result.consoleCalls = consoleCalls;
3860
- }
3861
-
3862
- // we do not keep coverage in memory, it can grow very big
3863
- // instead we store it on the filesystem
3864
- // and they can be read later at "coverageFileUrl"
3865
- let coverageFileUrl;
3866
- if (coverageEnabled) {
3867
- coverageFileUrl = new URL(
3868
- `./${runtime.name}/${crypto.randomUUID()}.json`,
3869
- coverageTempDirectoryUrl,
3870
- ).href;
3871
- await ensureParentDirectories(coverageFileUrl);
3872
- if (coverageEnabled) {
3873
- result.coverageFileUrl = coverageFileUrl;
3874
- // written within the child_process/worker_thread or during runtime.run()
3875
- // for browsers
3876
- // (because it takes time to serialize and transfer the coverage object)
3877
- }
3878
- }
3879
-
3880
- const startMs = Date.now();
3881
- callbacks.push(() => {
3882
- result.duration = Date.now() - startMs;
3883
- });
3884
-
3885
3784
  try {
3886
- logger.debug(`run() ${runtimeLabel}`);
3887
- runOperation.throwIfAborted();
3888
- const winnerPromise = new Promise((resolve) => {
3889
- raceCallbacks(
3890
- {
3891
- aborted: (cb) => {
3892
- runOperation.signal.addEventListener("abort", cb);
3893
- return () => {
3894
- runOperation.signal.removeEventListener("abort", cb);
3895
- };
3896
- },
3897
- runned: async (cb) => {
3898
- try {
3899
- const runResult = await runtime.run({
3900
- signal: runOperation.signal,
3901
- logger,
3902
- ...runtimeParams,
3903
- collectConsole,
3904
- collectPerformance,
3905
- coverageFileUrl,
3906
- keepRunning,
3907
- stopSignal,
3908
- onConsole: (log) => onConsoleRef.current(log),
3909
- });
3910
- cb(runResult);
3911
- } catch (e) {
3912
- cb({
3913
- status: "failed",
3914
- errors: [e],
3915
- });
3916
- }
3917
- },
3918
- },
3919
- resolve,
3920
- );
3785
+ const fileResultArray = await collectFiles({
3786
+ signal,
3787
+ directoryUrl: rootDirectoryUrl,
3788
+ associations: { testPlan },
3789
+ predicate: ({ testPlan }) => testPlan,
3921
3790
  });
3922
- const winner = await winnerPromise;
3923
- if (winner.name === "aborted") {
3924
- runOperation.throwIfAborted();
3925
- }
3926
- const { status, namespace, errors, performance } = winner.data;
3927
- result.status = status;
3928
- result.errors.push(...errors);
3929
- result.namespace = namespace;
3930
- if (collectPerformance) {
3931
- result.performance = performance;
3932
- }
3791
+ const executionSteps = [];
3792
+ fileResultArray.forEach(({ relativeUrl, meta }) => {
3793
+ const fileExecutionSteps = generateFileExecutionSteps({
3794
+ fileRelativeUrl: relativeUrl,
3795
+ filePlan: meta.testPlan,
3796
+ });
3797
+ executionSteps.push(...fileExecutionSteps);
3798
+ });
3799
+ return executionSteps;
3933
3800
  } catch (e) {
3934
3801
  if (Abort.isAbortError(e)) {
3935
- if (timeoutAbortSource && timeoutAbortSource.signal.aborted) {
3936
- result.status = "timedout";
3937
- } else {
3938
- result.status = "aborted";
3939
- }
3940
- } else {
3941
- result.status = "failed";
3942
- result.errors.push(e);
3802
+ return {
3803
+ aborted: true,
3804
+ planSummary: {},
3805
+ planReport: {},
3806
+ planCoverage: null,
3807
+ };
3943
3808
  }
3944
- } finally {
3945
- await runOperation.end();
3809
+ throw e;
3946
3810
  }
3947
-
3948
- callbacks.forEach((callback) => {
3949
- callback();
3950
- });
3951
- return result;
3952
3811
  };
3953
3812
 
3954
- const ensureGlobalGc = () => {
3955
- if (!global.gc) {
3956
- v8.setFlagsFromString("--expose_gc");
3957
- global.gc = runInNewContext("gc");
3958
- }
3813
+ const generateFileExecutionSteps = ({ fileRelativeUrl, filePlan }) => {
3814
+ const fileExecutionSteps = [];
3815
+ Object.keys(filePlan).forEach((executionName) => {
3816
+ const stepConfig = filePlan[executionName];
3817
+ if (stepConfig === null || stepConfig === undefined) {
3818
+ return;
3819
+ }
3820
+ if (typeof stepConfig !== "object") {
3821
+ throw new TypeError(
3822
+ createDetailedMessage(
3823
+ `found unexpected value in plan, they must be object`,
3824
+ {
3825
+ ["file relative path"]: fileRelativeUrl,
3826
+ ["execution name"]: executionName,
3827
+ ["value"]: stepConfig,
3828
+ },
3829
+ ),
3830
+ );
3831
+ }
3832
+ fileExecutionSteps.push({
3833
+ executionName,
3834
+ fileRelativeUrl,
3835
+ ...stepConfig,
3836
+ });
3837
+ });
3838
+ return fileExecutionSteps;
3959
3839
  };
3960
3840
 
3961
3841
  const EXECUTION_COLORS = {
@@ -4452,424 +4332,28 @@ const formatConsoleSummary = (repartition) => {
4452
4332
  return `console (${parts.join(" ")})`;
4453
4333
  };
4454
4334
 
4455
- const executeSteps = async (
4456
- executionSteps,
4457
- {
4458
- signal,
4459
- teardown,
4460
- logger,
4461
- logRefresh,
4462
- logRuntime,
4463
- logEachDuration,
4464
- logSummary,
4465
- logTimeUsage,
4466
- logMemoryHeapUsage,
4467
- logFileRelativeUrl,
4468
- logMergeForCompletedExecutions,
4469
- logShortForCompletedExecutions,
4470
- rootDirectoryUrl,
4471
- webServer,
4472
-
4473
- keepRunning,
4474
- defaultMsAllocatedPerExecution,
4475
- maxExecutionsInParallel,
4476
- failFast,
4477
- gcBetweenExecutions,
4478
- cooldownBetweenExecutions,
4479
-
4480
- coverageEnabled,
4481
- coverageConfig,
4482
- coverageIncludeMissing,
4483
- coverageMethodForBrowsers,
4484
- coverageMethodForNodeJs,
4485
- coverageV8ConflictWarning,
4486
- coverageTempDirectoryUrl,
4487
-
4488
- beforeExecutionCallback = () => {},
4489
- afterExecutionCallback = () => {},
4490
- } = {},
4335
+ const githubAnnotationFromError = (
4336
+ error,
4337
+ { rootDirectoryUrl, executionInfo },
4491
4338
  ) => {
4492
- executionSteps = executionSteps.filter(
4493
- (executionStep) => !executionStep.runtime?.disabled,
4494
- );
4495
-
4496
- const executePlanReturnValue = {};
4497
- const report = {};
4498
- const callbacks = [];
4499
-
4500
- const multipleExecutionsOperation = Abort.startOperation();
4501
- multipleExecutionsOperation.addAbortSignal(signal);
4502
- const failFastAbortController = new AbortController();
4503
- if (failFast) {
4504
- multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal);
4505
- }
4506
-
4507
- try {
4508
- if (gcBetweenExecutions) {
4509
- ensureGlobalGc();
4510
- }
4511
-
4512
- if (coverageEnabled) {
4513
- // when runned multiple times, we don't want to keep previous files in this directory
4514
- await ensureEmptyDirectory(coverageTempDirectoryUrl);
4515
- callbacks.push(async () => {
4516
- if (multipleExecutionsOperation.signal.aborted) {
4517
- // don't try to do the coverage stuff
4518
- return;
4519
- }
4520
- try {
4521
- if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
4522
- takeCoverage();
4523
- // conceptually we don't need coverage anymore so it would be
4524
- // good to call v8.stopCoverage()
4525
- // but it logs a strange message about "result is not an object"
4526
- }
4527
- const planCoverage = await reportToCoverage(report, {
4528
- signal: multipleExecutionsOperation.signal,
4529
- logger,
4530
- rootDirectoryUrl,
4531
- coverageConfig,
4532
- coverageIncludeMissing,
4533
- coverageMethodForNodeJs,
4534
- coverageV8ConflictWarning,
4535
- });
4536
- executePlanReturnValue.planCoverage = planCoverage;
4537
- } catch (e) {
4538
- if (Abort.isAbortError(e)) {
4539
- return;
4540
- }
4541
- throw e;
4542
- }
4543
- });
4544
- }
4545
-
4546
- const runtimeParams = {
4547
- rootDirectoryUrl,
4548
- webServer,
4549
-
4550
- coverageEnabled,
4551
- coverageConfig,
4552
- coverageMethodForBrowsers,
4553
- coverageMethodForNodeJs,
4554
- isTestPlan: true,
4555
- teardown,
4556
- };
4557
-
4558
- if (logMergeForCompletedExecutions && !process.stdout.isTTY) {
4559
- logMergeForCompletedExecutions = false;
4560
- logger.debug(
4561
- `Force logMergeForCompletedExecutions to false because process.stdout.isTTY is false`,
4562
- );
4563
- }
4564
- const debugLogsEnabled = logger.levels.debug;
4565
- const executionLogsEnabled = logger.levels.info;
4566
- const executionSpinner =
4567
- logRefresh &&
4568
- !debugLogsEnabled &&
4569
- executionLogsEnabled &&
4570
- process.stdout.isTTY &&
4571
- // if there is an error during execution npm will mess up the output
4572
- // (happens when npm runs several command in a workspace)
4573
- // so we enable spinner only when !process.exitCode (no error so far)
4574
- process.exitCode !== 1;
4575
-
4576
- const startMs = Date.now();
4577
- let rawOutput = "";
4578
- let executionLog = createLog({ newLine: "" });
4579
- const counters = {
4580
- total: executionSteps.length,
4581
- aborted: 0,
4582
- timedout: 0,
4583
- failed: 0,
4584
- completed: 0,
4585
- done: 0,
4586
- };
4587
- await executeInParallel({
4588
- multipleExecutionsOperation,
4589
- maxExecutionsInParallel,
4590
- cooldownBetweenExecutions,
4591
- executionSteps,
4592
- start: async (paramsFromStep) => {
4593
- const executionIndex = executionSteps.indexOf(paramsFromStep);
4594
- const { executionName, fileRelativeUrl, runtime } = paramsFromStep;
4595
- const runtimeType = runtime.type;
4596
- const runtimeName = runtime.name;
4597
- const runtimeVersion = runtime.version;
4598
- const executionParams = {
4599
- measurePerformance: false,
4600
- collectPerformance: false,
4601
- collectConsole: true,
4602
- allocatedMs: defaultMsAllocatedPerExecution,
4603
- ...paramsFromStep,
4604
- runtimeParams: {
4605
- fileRelativeUrl,
4606
- ...paramsFromStep.runtimeParams,
4607
- },
4608
- };
4609
- const beforeExecutionInfo = {
4610
- fileRelativeUrl,
4611
- runtimeType,
4612
- runtimeName,
4613
- runtimeVersion,
4614
- executionIndex,
4615
- executionParams,
4616
- startMs: Date.now(),
4617
- executionResult: {
4618
- status: "executing",
4619
- },
4620
- counters,
4621
- timeEllapsed: Date.now() - startMs,
4622
- memoryHeap: memoryUsage().heapUsed,
4623
- };
4624
- if (typeof executionParams.allocatedMs === "function") {
4625
- executionParams.allocatedMs =
4626
- executionParams.allocatedMs(beforeExecutionInfo);
4627
- }
4628
- let spinner;
4629
- if (executionSpinner) {
4630
- spinner = startSpinner({
4631
- log: executionLog,
4632
- render: () => {
4633
- return createExecutionLog(beforeExecutionInfo, {
4634
- logRuntime,
4635
- logEachDuration,
4636
- logTimeUsage,
4637
- logMemoryHeapUsage,
4638
- });
4639
- },
4640
- });
4641
- }
4642
- beforeExecutionCallback(beforeExecutionInfo);
4643
-
4644
- const fileUrl = `${rootDirectoryUrl}${fileRelativeUrl}`;
4645
- let executionResult;
4646
- if (existsSync(new URL(fileUrl))) {
4647
- executionResult = await run({
4648
- signal: multipleExecutionsOperation.signal,
4649
- logger,
4650
- allocatedMs: executionParams.allocatedMs,
4651
- keepRunning,
4652
- mirrorConsole: false, // file are executed in parallel, log would be a mess to read
4653
- collectConsole: executionParams.collectConsole,
4654
- coverageEnabled,
4655
- coverageTempDirectoryUrl,
4656
- runtime: executionParams.runtime,
4657
- runtimeParams: {
4658
- ...runtimeParams,
4659
- ...executionParams.runtimeParams,
4660
- },
4661
- });
4662
- } else {
4663
- executionResult = {
4664
- status: "failed",
4665
- errors: [
4666
- new Error(
4667
- `No file at ${fileRelativeUrl} for execution "${executionName}"`,
4668
- ),
4669
- ],
4670
- };
4671
- }
4672
- counters.done++;
4673
- const fileReport = report[fileRelativeUrl];
4674
- if (fileReport) {
4675
- fileReport[executionName] = executionResult;
4676
- } else {
4677
- report[fileRelativeUrl] = {
4678
- [executionName]: executionResult,
4679
- };
4680
- }
4681
-
4682
- const afterExecutionInfo = {
4683
- ...beforeExecutionInfo,
4684
- runtimeVersion: runtime.version,
4685
- endMs: Date.now(),
4686
- executionResult,
4687
- };
4688
-
4689
- if (executionResult.status === "aborted") {
4690
- counters.aborted++;
4691
- } else if (executionResult.status === "timedout") {
4692
- counters.timedout++;
4693
- } else if (executionResult.status === "failed") {
4694
- counters.failed++;
4695
- } else if (executionResult.status === "completed") {
4696
- counters.completed++;
4697
- }
4698
- if (gcBetweenExecutions) {
4699
- global.gc();
4700
- }
4701
- if (executionLogsEnabled) {
4702
- const log = createExecutionLog(afterExecutionInfo, {
4703
- logShortForCompletedExecutions,
4704
- logRuntime,
4705
- logEachDuration,
4706
- ...(logTimeUsage
4707
- ? {
4708
- timeEllapsed: Date.now() - startMs,
4709
- }
4710
- : {}),
4711
- ...(logMemoryHeapUsage
4712
- ? { memoryHeap: memoryUsage().heapUsed }
4713
- : {}),
4714
- });
4715
- // replace spinner with this execution result
4716
- if (spinner) spinner.stop();
4717
- executionLog.write(log);
4718
- rawOutput += stripAnsi(log);
4719
-
4720
- const canOverwriteLog = canOverwriteLogGetter({
4721
- logMergeForCompletedExecutions,
4722
- executionResult,
4723
- });
4724
- if (canOverwriteLog) {
4725
- // nothing to do, we reuse the current executionLog object
4726
- } else {
4727
- executionLog.destroy();
4728
- executionLog = createLog({ newLine: "" });
4729
- }
4730
- }
4731
- afterExecutionCallback(afterExecutionInfo);
4732
- const isLastExecutionLog = executionIndex === executionSteps.length - 1;
4733
- const cancelRemaining =
4734
- failFast &&
4735
- executionResult.status !== "completed" &&
4736
- counters.done < counters.total;
4737
- if (isLastExecutionLog && logger.levels.info) {
4738
- executionLog.write("\n");
4739
- }
4740
-
4741
- if (cancelRemaining) {
4742
- logger.info(`"failFast" enabled -> cancel remaining executions`);
4743
- failFastAbortController.abort();
4744
- }
4745
- },
4746
- });
4747
- if (!keepRunning) {
4748
- logger.debug("trigger test plan teardown");
4749
- await teardown.trigger();
4750
- }
4751
-
4752
- counters.cancelled = counters.total - counters.done;
4753
- const summary = {
4754
- counters,
4755
- // when execution is aborted, the remaining executions are "cancelled"
4756
- duration: Date.now() - startMs,
4757
- };
4758
- if (logSummary) {
4759
- const summaryLog = formatSummaryLog(summary);
4760
- rawOutput += stripAnsi(summaryLog);
4761
- logger.info(summaryLog);
4762
- }
4763
- if (summary.counters.total !== summary.counters.completed) {
4764
- const logFileUrl = new URL(logFileRelativeUrl, rootDirectoryUrl).href;
4765
- writeFileSync(logFileUrl, rawOutput);
4766
- logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`);
4767
- }
4768
- executePlanReturnValue.aborted = multipleExecutionsOperation.signal.aborted;
4769
- executePlanReturnValue.planSummary = summary;
4770
- executePlanReturnValue.planReport = report;
4771
- for (const callback of callbacks) {
4772
- await callback();
4773
- }
4774
- return executePlanReturnValue;
4775
- } finally {
4776
- await multipleExecutionsOperation.end();
4777
- }
4778
- };
4779
-
4780
- const canOverwriteLogGetter = ({
4781
- logMergeForCompletedExecutions,
4782
- executionResult,
4783
- }) => {
4784
- if (!logMergeForCompletedExecutions) {
4785
- return false;
4786
- }
4787
- if (executionResult.status === "aborted") {
4788
- return true;
4789
- }
4790
- if (executionResult.status !== "completed") {
4791
- return false;
4792
- }
4793
- const { consoleCalls = [] } = executionResult;
4794
- if (consoleCalls.length > 0) {
4795
- return false;
4796
- }
4797
- return true;
4798
- };
4799
-
4800
- const executeInParallel = async ({
4801
- multipleExecutionsOperation,
4802
- maxExecutionsInParallel,
4803
- cooldownBetweenExecutions,
4804
- executionSteps,
4805
- start,
4806
- }) => {
4807
- const executionResults = [];
4808
- let progressionIndex = 0;
4809
- let remainingExecutionCount = executionSteps.length;
4810
-
4811
- const nextChunk = async () => {
4812
- if (multipleExecutionsOperation.signal.aborted) {
4813
- return;
4814
- }
4815
- const outputPromiseArray = [];
4816
- while (
4817
- remainingExecutionCount > 0 &&
4818
- outputPromiseArray.length < maxExecutionsInParallel
4819
- ) {
4820
- remainingExecutionCount--;
4821
- const outputPromise = executeOne(progressionIndex);
4822
- progressionIndex++;
4823
- outputPromiseArray.push(outputPromise);
4824
- }
4825
- if (outputPromiseArray.length) {
4826
- await Promise.all(outputPromiseArray);
4827
- if (remainingExecutionCount > 0) {
4828
- await nextChunk();
4829
- }
4830
- }
4831
- };
4832
-
4833
- const executeOne = async (index) => {
4834
- const input = executionSteps[index];
4835
- const output = await start(input);
4836
- if (!multipleExecutionsOperation.signal.aborted) {
4837
- executionResults[index] = output;
4838
- }
4839
- if (cooldownBetweenExecutions) {
4840
- await new Promise((resolve) =>
4841
- setTimeout(resolve, cooldownBetweenExecutions),
4842
- );
4843
- }
4844
- };
4845
-
4846
- await nextChunk();
4847
-
4848
- return executionResults;
4849
- };
4850
-
4851
- const githubAnnotationFromError = (
4852
- error,
4853
- { rootDirectoryUrl, executionInfo },
4854
- ) => {
4855
- const annotation = {
4856
- annotation_level: "failure",
4857
- path: executionInfo.fileRelativeUrl,
4858
- start_line: 1,
4859
- end_line: 1,
4860
- title: `Error while executing ${executionInfo.fileRelativeUrl} on ${executionInfo.runtimeName}@${executionInfo.runtimeVersion}`,
4861
- };
4862
- const exception = asException(error, { rootDirectoryUrl });
4863
- if (exception.site && typeof exception.site.line === "number") {
4864
- annotation.path = urlToRelativeUrl(exception.site.url, rootDirectoryUrl);
4865
- annotation.start_line = exception.site.line;
4866
- annotation.end_line = exception.site.line;
4867
- annotation.start_column = exception.site.column;
4868
- annotation.end_column = exception.site.column;
4869
- }
4870
- annotation.message = exception.stack;
4871
- return annotation;
4872
- };
4339
+ const annotation = {
4340
+ annotation_level: "failure",
4341
+ path: executionInfo.fileRelativeUrl,
4342
+ start_line: 1,
4343
+ end_line: 1,
4344
+ title: `Error while executing ${executionInfo.fileRelativeUrl} on ${executionInfo.runtimeName}@${executionInfo.runtimeVersion}`,
4345
+ };
4346
+ const exception = asException(error, { rootDirectoryUrl });
4347
+ if (exception.site && typeof exception.site.line === "number") {
4348
+ annotation.path = urlToRelativeUrl(exception.site.url, rootDirectoryUrl);
4349
+ annotation.start_line = exception.site.line;
4350
+ annotation.end_line = exception.site.line;
4351
+ annotation.start_column = exception.site.column;
4352
+ annotation.end_column = exception.site.column;
4353
+ }
4354
+ annotation.message = exception.stack;
4355
+ return annotation;
4356
+ };
4873
4357
 
4874
4358
  const asException = (error, { rootDirectoryUrl }) => {
4875
4359
  const exception = {
@@ -4979,6 +4463,171 @@ const replaceUrls = (source, replace) => {
4979
4463
  });
4980
4464
  };
4981
4465
 
4466
+ /*
4467
+ * Export a function capable to run a file on a runtime.
4468
+ *
4469
+ * - Used internally by "executeTestPlan" part of the documented API
4470
+ * - Used internally by "execute" an advanced API not documented
4471
+ * - logs generated during file execution can be collected
4472
+ * - logs generated during file execution can be mirrored (re-logged to the console)
4473
+ * - File is given allocatedMs to complete
4474
+ * - Errors are collected
4475
+ * - File execution result is returned, it contains status/errors/namespace/consoleCalls
4476
+ */
4477
+
4478
+
4479
+ const run = async ({
4480
+ signal = new AbortController().signal,
4481
+ logger,
4482
+ allocatedMs,
4483
+ keepRunning = false,
4484
+ mirrorConsole = false,
4485
+ collectConsole = false,
4486
+ coverageEnabled = false,
4487
+ coverageTempDirectoryUrl,
4488
+ collectPerformance = false,
4489
+ runtime,
4490
+ runtimeParams,
4491
+ }) => {
4492
+ const result = {
4493
+ status: "pending",
4494
+ errors: [],
4495
+ namespace: null,
4496
+ };
4497
+ const callbacks = [];
4498
+
4499
+ const onConsoleRef = { current: () => {} };
4500
+ const stopSignal = { notify: () => {} };
4501
+ const runtimeLabel = `${runtime.name}/${runtime.version}`;
4502
+
4503
+ const runOperation = Abort.startOperation();
4504
+ runOperation.addAbortSignal(signal);
4505
+ let timeoutAbortSource;
4506
+ if (
4507
+ // ideally we would rather log than the timeout is ignored
4508
+ // when keepRunning is true
4509
+ !keepRunning &&
4510
+ typeof allocatedMs === "number" &&
4511
+ allocatedMs !== Infinity
4512
+ ) {
4513
+ timeoutAbortSource = runOperation.timeout(allocatedMs);
4514
+ }
4515
+ const consoleCalls = [];
4516
+ onConsoleRef.current = ({ type, text }) => {
4517
+ if (mirrorConsole) {
4518
+ if (type === "error") {
4519
+ process.stderr.write(text);
4520
+ } else {
4521
+ process.stdout.write(text);
4522
+ }
4523
+ }
4524
+ if (collectConsole) {
4525
+ consoleCalls.push({ type, text });
4526
+ }
4527
+ };
4528
+ if (collectConsole) {
4529
+ result.consoleCalls = consoleCalls;
4530
+ }
4531
+
4532
+ // we do not keep coverage in memory, it can grow very big
4533
+ // instead we store it on the filesystem
4534
+ // and they can be read later at "coverageFileUrl"
4535
+ let coverageFileUrl;
4536
+ if (coverageEnabled) {
4537
+ coverageFileUrl = new URL(
4538
+ `./${runtime.name}/${crypto.randomUUID()}.json`,
4539
+ coverageTempDirectoryUrl,
4540
+ ).href;
4541
+ await ensureParentDirectories(coverageFileUrl);
4542
+ if (coverageEnabled) {
4543
+ result.coverageFileUrl = coverageFileUrl;
4544
+ // written within the child_process/worker_thread or during runtime.run()
4545
+ // for browsers
4546
+ // (because it takes time to serialize and transfer the coverage object)
4547
+ }
4548
+ }
4549
+
4550
+ const startMs = Date.now();
4551
+ callbacks.push(() => {
4552
+ result.duration = Date.now() - startMs;
4553
+ });
4554
+
4555
+ try {
4556
+ logger.debug(`run() ${runtimeLabel}`);
4557
+ runOperation.throwIfAborted();
4558
+ const winnerPromise = new Promise((resolve) => {
4559
+ raceCallbacks(
4560
+ {
4561
+ aborted: (cb) => {
4562
+ runOperation.signal.addEventListener("abort", cb);
4563
+ return () => {
4564
+ runOperation.signal.removeEventListener("abort", cb);
4565
+ };
4566
+ },
4567
+ runned: async (cb) => {
4568
+ try {
4569
+ const runResult = await runtime.run({
4570
+ signal: runOperation.signal,
4571
+ logger,
4572
+ ...runtimeParams,
4573
+ collectConsole,
4574
+ collectPerformance,
4575
+ coverageFileUrl,
4576
+ keepRunning,
4577
+ stopSignal,
4578
+ onConsole: (log) => onConsoleRef.current(log),
4579
+ });
4580
+ cb(runResult);
4581
+ } catch (e) {
4582
+ cb({
4583
+ status: "failed",
4584
+ errors: [e],
4585
+ });
4586
+ }
4587
+ },
4588
+ },
4589
+ resolve,
4590
+ );
4591
+ });
4592
+ const winner = await winnerPromise;
4593
+ if (winner.name === "aborted") {
4594
+ runOperation.throwIfAborted();
4595
+ }
4596
+ const { status, namespace, errors, performance } = winner.data;
4597
+ result.status = status;
4598
+ result.errors.push(...errors);
4599
+ result.namespace = namespace;
4600
+ if (collectPerformance) {
4601
+ result.performance = performance;
4602
+ }
4603
+ } catch (e) {
4604
+ if (Abort.isAbortError(e)) {
4605
+ if (timeoutAbortSource && timeoutAbortSource.signal.aborted) {
4606
+ result.status = "timedout";
4607
+ } else {
4608
+ result.status = "aborted";
4609
+ }
4610
+ } else {
4611
+ result.status = "failed";
4612
+ result.errors.push(e);
4613
+ }
4614
+ } finally {
4615
+ await runOperation.end();
4616
+ }
4617
+
4618
+ callbacks.forEach((callback) => {
4619
+ callback();
4620
+ });
4621
+ return result;
4622
+ };
4623
+
4624
+ const ensureGlobalGc = () => {
4625
+ if (!global.gc) {
4626
+ v8.setFlagsFromString("--expose_gc");
4627
+ global.gc = runInNewContext("gc");
4628
+ }
4629
+ };
4630
+
4982
4631
  /**
4983
4632
  * Execute a list of files and log how it goes.
4984
4633
  * @param {Object} testPlanParameters
@@ -5029,7 +4678,7 @@ const executeTestPlan = async ({
5029
4678
  githubCheckEnabled = Boolean(process.env.GITHUB_WORKFLOW),
5030
4679
  githubCheckLogLevel,
5031
4680
  githubCheckName = "Jsenv tests",
5032
- githubCheckTitle,
4681
+ githubCheckTitle = "Tests executions",
5033
4682
  githubCheckToken,
5034
4683
  githubCheckRepositoryOwner,
5035
4684
  githubCheckRepositoryName,
@@ -5074,10 +4723,21 @@ const executeTestPlan = async ({
5074
4723
  coverageReportJsonFileUrl,
5075
4724
  coverageReportHtml = !process.env.CI,
5076
4725
  coverageReportHtmlDirectoryUrl,
4726
+
4727
+ beforeExecutionCallback = () => {},
4728
+ afterExecutionCallback = () => {},
4729
+ afterAllExecutionCallback = () => {},
5077
4730
  ...rest
5078
4731
  }) => {
5079
4732
  const teardown = createTeardown();
5080
4733
 
4734
+ const beforeExecutionCallbackSet = new Set();
4735
+ const afterExecutionCallbackSet = new Set();
4736
+ const afterAllExecutionCallbackSet = new Set();
4737
+ beforeExecutionCallbackSet.add(beforeExecutionCallback);
4738
+ afterExecutionCallbackSet.add(afterExecutionCallback);
4739
+ afterAllExecutionCallbackSet.add(afterAllExecutionCallback);
4740
+
5081
4741
  const operation = Abort.startOperation();
5082
4742
  operation.addAbortSignal(signal);
5083
4743
  if (handleSIGINT) {
@@ -5223,204 +4883,460 @@ To fix this warning:
5223
4883
  // It would be strange, for a given file to be both covered and executed
5224
4884
  throw new Error(
5225
4885
  createDetailedMessage(
5226
- `some file will be both covered and executed`,
4886
+ `some file will be both covered and executed`,
4887
+ {
4888
+ patterns: patternsMatchingCoverAndExecute,
4889
+ },
4890
+ ),
4891
+ );
4892
+ }
4893
+ }
4894
+
4895
+ if (coverageTempDirectoryUrl === undefined) {
4896
+ coverageTempDirectoryUrl = new URL(
4897
+ "./.coverage/tmp/",
4898
+ rootDirectoryUrl,
4899
+ );
4900
+ } else {
4901
+ coverageTempDirectoryUrl = assertAndNormalizeDirectoryUrl(
4902
+ coverageTempDirectoryUrl,
4903
+ "coverageTempDirectoryUrl",
4904
+ );
4905
+ }
4906
+ if (coverageReportJson) {
4907
+ if (coverageReportJsonFileUrl === undefined) {
4908
+ coverageReportJsonFileUrl = new URL(
4909
+ "./.coverage/coverage.json",
4910
+ rootDirectoryUrl,
4911
+ );
4912
+ } else {
4913
+ coverageReportJsonFileUrl = assertAndNormalizeFileUrl(
4914
+ coverageReportJsonFileUrl,
4915
+ "coverageReportJsonFileUrl",
4916
+ );
4917
+ }
4918
+ }
4919
+ if (coverageReportHtml) {
4920
+ if (coverageReportHtmlDirectoryUrl === undefined) {
4921
+ coverageReportHtmlDirectoryUrl = new URL(
4922
+ "./.coverage/",
4923
+ rootDirectoryUrl,
4924
+ );
4925
+ } else {
4926
+ coverageReportHtmlDirectoryUrl = assertAndNormalizeDirectoryUrl(
4927
+ coverageReportHtmlDirectoryUrl,
4928
+ "coverageReportHtmlDirectoryUrl",
4929
+ );
4930
+ }
4931
+ }
4932
+ }
4933
+ }
4934
+
4935
+ logger.debug(
4936
+ createDetailedMessage(`Prepare executing plan`, {
4937
+ runtimes: JSON.stringify(runtimes, null, " "),
4938
+ }),
4939
+ );
4940
+
4941
+ // param normalization
4942
+ {
4943
+ if (coverageEnabled) {
4944
+ if (Object.keys(coverageConfig).length === 0) {
4945
+ logger.warn(
4946
+ `coverageConfig is an empty object. Nothing will be instrumented for coverage so your coverage will be empty`,
4947
+ );
4948
+ }
4949
+ if (
4950
+ someNodeRuntime &&
4951
+ coverageEnabled &&
4952
+ coverageMethodForNodeJs === "NODE_V8_COVERAGE"
4953
+ ) {
4954
+ if (process.env.NODE_V8_COVERAGE) {
4955
+ // when runned multiple times, we don't want to keep previous files in this directory
4956
+ await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE);
4957
+ } else {
4958
+ coverageMethodForNodeJs = "Profiler";
4959
+ logger.warn(
4960
+ createDetailedMessage(
4961
+ `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
5227
4962
  {
5228
- patterns: patternsMatchingCoverAndExecute,
4963
+ "suggestion": `set process.env.NODE_V8_COVERAGE`,
4964
+ "suggestion 2": `use coverageMethodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
5229
4965
  },
5230
4966
  ),
5231
4967
  );
5232
4968
  }
5233
4969
  }
4970
+ }
4971
+ }
5234
4972
 
5235
- if (coverageTempDirectoryUrl === undefined) {
5236
- coverageTempDirectoryUrl = new URL(
5237
- "./.coverage/tmp/",
4973
+ testPlan = {
4974
+ "file:///**/node_modules/": null,
4975
+ "**/*./": null,
4976
+ ...testPlan,
4977
+ "**/.jsenv/": null,
4978
+ };
4979
+ logger.debug(`Generate executions`);
4980
+ let executionSteps = await executionStepsFromTestPlan({
4981
+ signal,
4982
+ testPlan,
4983
+ rootDirectoryUrl,
4984
+ });
4985
+ logger.debug(`${executionSteps.length} executions planned`);
4986
+ if (githubCheckEnabled) {
4987
+ const githubCheckRun = await startGithubCheckRun({
4988
+ logLevel: githubCheckLogLevel,
4989
+ githubToken: githubCheckToken,
4990
+ repositoryOwner: githubCheckRepositoryOwner,
4991
+ repositoryName: githubCheckRepositoryName,
4992
+ commitSha: githubCheckCommitSha,
4993
+ checkName: githubCheckName,
4994
+ checkTitle: githubCheckTitle,
4995
+ checkSummary: `${executionSteps.length} files will be executed`,
4996
+ });
4997
+ const annotations = [];
4998
+ afterExecutionCallbackSet.add((afterExecutionInfo) => {
4999
+ const { executionResult } = afterExecutionInfo;
5000
+ const { errors = [] } = executionResult;
5001
+ for (const error of errors) {
5002
+ const annotation = githubAnnotationFromError(error, {
5238
5003
  rootDirectoryUrl,
5239
- );
5240
- } else {
5241
- coverageTempDirectoryUrl = assertAndNormalizeDirectoryUrl(
5242
- coverageTempDirectoryUrl,
5243
- "coverageTempDirectoryUrl",
5244
- );
5004
+ executionInfo: afterExecutionInfo,
5005
+ });
5006
+ annotations.push(annotation);
5245
5007
  }
5246
- if (coverageReportJson) {
5247
- if (coverageReportJsonFileUrl === undefined) {
5248
- coverageReportJsonFileUrl = new URL(
5249
- "./.coverage/coverage.json",
5008
+ });
5009
+ afterAllExecutionCallbackSet.add(async (returnValue) => {
5010
+ const { summary } = returnValue;
5011
+ const title = "Jsenv test results";
5012
+ const summaryText = stripAnsi(formatSummary(summary));
5013
+ if (summary.counters.total !== summary.counters.completed) {
5014
+ await githubCheckRun.fail({
5015
+ title,
5016
+ summary: summaryText,
5017
+ annotations,
5018
+ });
5019
+ return;
5020
+ }
5021
+ await githubCheckRun.pass({
5022
+ title,
5023
+ summary: summaryText,
5024
+ annotations,
5025
+ });
5026
+ });
5027
+ }
5028
+
5029
+ executionSteps = executionSteps.filter(
5030
+ (executionStep) => !executionStep.runtime?.disabled,
5031
+ );
5032
+
5033
+ const returnValue = {
5034
+ aborted: false,
5035
+ summary: null,
5036
+ report: null,
5037
+ coverage: null,
5038
+ };
5039
+ const report = {};
5040
+ const callbacks = [];
5041
+
5042
+ const multipleExecutionsOperation = Abort.startOperation();
5043
+ multipleExecutionsOperation.addAbortSignal(signal);
5044
+ const failFastAbortController = new AbortController();
5045
+ if (failFast) {
5046
+ multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal);
5047
+ }
5048
+
5049
+ try {
5050
+ if (gcBetweenExecutions) {
5051
+ ensureGlobalGc();
5052
+ }
5053
+
5054
+ if (coverageEnabled) {
5055
+ // when runned multiple times, we don't want to keep previous files in this directory
5056
+ await ensureEmptyDirectory(coverageTempDirectoryUrl);
5057
+ callbacks.push(async () => {
5058
+ if (multipleExecutionsOperation.signal.aborted) {
5059
+ // don't try to do the coverage stuff
5060
+ return;
5061
+ }
5062
+ try {
5063
+ if (coverageMethodForNodeJs === "NODE_V8_COVERAGE") {
5064
+ takeCoverage();
5065
+ // conceptually we don't need coverage anymore so it would be
5066
+ // good to call v8.stopCoverage()
5067
+ // but it logs a strange message about "result is not an object"
5068
+ }
5069
+ const coverage = await reportToCoverage(report, {
5070
+ signal: multipleExecutionsOperation.signal,
5071
+ logger,
5250
5072
  rootDirectoryUrl,
5251
- );
5252
- } else {
5253
- coverageReportJsonFileUrl = assertAndNormalizeFileUrl(
5254
- coverageReportJsonFileUrl,
5255
- "coverageReportJsonFileUrl",
5256
- );
5073
+ coverageConfig,
5074
+ coverageIncludeMissing,
5075
+ coverageMethodForNodeJs,
5076
+ coverageV8ConflictWarning,
5077
+ });
5078
+ returnValue.coverage = coverage;
5079
+ } catch (e) {
5080
+ if (Abort.isAbortError(e)) {
5081
+ return;
5082
+ }
5083
+ throw e;
5084
+ }
5085
+ });
5086
+ }
5087
+
5088
+ const runtimeParams = {
5089
+ rootDirectoryUrl,
5090
+ webServer,
5091
+
5092
+ coverageEnabled,
5093
+ coverageConfig,
5094
+ coverageMethodForBrowsers,
5095
+ coverageMethodForNodeJs,
5096
+ isTestPlan: true,
5097
+ teardown,
5098
+ };
5099
+
5100
+ if (logMergeForCompletedExecutions && !process.stdout.isTTY) {
5101
+ logMergeForCompletedExecutions = false;
5102
+ logger.debug(
5103
+ `Force logMergeForCompletedExecutions to false because process.stdout.isTTY is false`,
5104
+ );
5105
+ }
5106
+ const debugLogsEnabled = logger.levels.debug;
5107
+ const executionLogsEnabled = logger.levels.info;
5108
+ const executionSpinner =
5109
+ logRefresh &&
5110
+ maxExecutionsInParallel === 1 &&
5111
+ !debugLogsEnabled &&
5112
+ executionLogsEnabled &&
5113
+ process.stdout.isTTY &&
5114
+ // if there is an error during execution npm will mess up the output
5115
+ // (happens when npm runs several command in a workspace)
5116
+ // so we enable spinner only when !process.exitCode (no error so far)
5117
+ process.exitCode !== 1;
5118
+
5119
+ const startMs = Date.now();
5120
+ let rawOutput = "";
5121
+ let executionLog = createLog({ newLine: "" });
5122
+ const counters = {
5123
+ total: executionSteps.length,
5124
+ aborted: 0,
5125
+ timedout: 0,
5126
+ failed: 0,
5127
+ completed: 0,
5128
+ done: 0,
5129
+ };
5130
+
5131
+ const callWhenPreviousExecutionAreDone = createCallOrderer();
5132
+
5133
+ await executeInParallel({
5134
+ multipleExecutionsOperation,
5135
+ maxExecutionsInParallel,
5136
+ cooldownBetweenExecutions,
5137
+ executionSteps,
5138
+ start: async (paramsFromStep) => {
5139
+ const executionIndex = executionSteps.indexOf(paramsFromStep);
5140
+ const { executionName, fileRelativeUrl, runtime } = paramsFromStep;
5141
+ const runtimeType = runtime.type;
5142
+ const runtimeName = runtime.name;
5143
+ const runtimeVersion = runtime.version;
5144
+ const executionParams = {
5145
+ measurePerformance: false,
5146
+ collectPerformance: false,
5147
+ collectConsole: true,
5148
+ allocatedMs: defaultMsAllocatedPerExecution,
5149
+ ...paramsFromStep,
5150
+ runtimeParams: {
5151
+ fileRelativeUrl,
5152
+ ...paramsFromStep.runtimeParams,
5153
+ },
5154
+ };
5155
+ const beforeExecutionInfo = {
5156
+ fileRelativeUrl,
5157
+ runtimeType,
5158
+ runtimeName,
5159
+ runtimeVersion,
5160
+ executionIndex,
5161
+ executionParams,
5162
+ startMs: Date.now(),
5163
+ executionResult: {
5164
+ status: "executing",
5165
+ },
5166
+ counters,
5167
+ timeEllapsed: Date.now() - startMs,
5168
+ memoryHeap: memoryUsage().heapUsed,
5169
+ };
5170
+ if (typeof executionParams.allocatedMs === "function") {
5171
+ executionParams.allocatedMs =
5172
+ executionParams.allocatedMs(beforeExecutionInfo);
5173
+ }
5174
+ let spinner;
5175
+ if (executionSpinner) {
5176
+ spinner = startSpinner({
5177
+ log: executionLog,
5178
+ render: () => {
5179
+ return createExecutionLog(beforeExecutionInfo, {
5180
+ logRuntime,
5181
+ logEachDuration,
5182
+ logTimeUsage,
5183
+ logMemoryHeapUsage,
5184
+ });
5185
+ },
5186
+ });
5187
+ }
5188
+ for (const beforeExecutionCallback of beforeExecutionCallbackSet) {
5189
+ beforeExecutionCallback(beforeExecutionInfo);
5190
+ }
5191
+ const fileUrl = `${rootDirectoryUrl}${fileRelativeUrl}`;
5192
+ let executionResult;
5193
+ if (existsSync(new URL(fileUrl))) {
5194
+ executionResult = await run({
5195
+ signal: multipleExecutionsOperation.signal,
5196
+ logger,
5197
+ allocatedMs: executionParams.allocatedMs,
5198
+ keepRunning,
5199
+ mirrorConsole: false, // might be executed in parallel: log would be a mess to read
5200
+ collectConsole: executionParams.collectConsole,
5201
+ coverageEnabled,
5202
+ coverageTempDirectoryUrl,
5203
+ runtime: executionParams.runtime,
5204
+ runtimeParams: {
5205
+ ...runtimeParams,
5206
+ ...executionParams.runtimeParams,
5207
+ },
5208
+ });
5209
+ } else {
5210
+ executionResult = {
5211
+ status: "failed",
5212
+ errors: [
5213
+ new Error(
5214
+ `No file at ${fileRelativeUrl} for execution "${executionName}"`,
5215
+ ),
5216
+ ],
5217
+ };
5257
5218
  }
5258
- }
5259
- if (coverageReportHtml) {
5260
- if (coverageReportHtmlDirectoryUrl === undefined) {
5261
- coverageReportHtmlDirectoryUrl = new URL(
5262
- "./.coverage/",
5263
- rootDirectoryUrl,
5264
- );
5219
+ const fileReport = report[fileRelativeUrl];
5220
+ if (fileReport) {
5221
+ fileReport[executionName] = executionResult;
5265
5222
  } else {
5266
- coverageReportHtmlDirectoryUrl = assertAndNormalizeDirectoryUrl(
5267
- coverageReportHtmlDirectoryUrl,
5268
- "coverageReportHtmlDirectoryUrl",
5269
- );
5223
+ report[fileRelativeUrl] = {
5224
+ [executionName]: executionResult,
5225
+ };
5270
5226
  }
5271
- }
5272
- }
5273
- }
5274
5227
 
5275
- logger.debug(
5276
- createDetailedMessage(`Prepare executing plan`, {
5277
- runtimes: JSON.stringify(runtimes, null, " "),
5278
- }),
5279
- );
5228
+ const afterExecutionInfo = {
5229
+ ...beforeExecutionInfo,
5230
+ runtimeVersion: runtime.version,
5231
+ endMs: Date.now(),
5232
+ executionResult,
5233
+ };
5280
5234
 
5281
- // param normalization
5282
- {
5283
- if (coverageEnabled) {
5284
- if (Object.keys(coverageConfig).length === 0) {
5285
- logger.warn(
5286
- `coverageConfig is an empty object. Nothing will be instrumented for coverage so your coverage will be empty`,
5287
- );
5288
- }
5289
- if (
5290
- someNodeRuntime &&
5291
- coverageEnabled &&
5292
- coverageMethodForNodeJs === "NODE_V8_COVERAGE"
5293
- ) {
5294
- if (process.env.NODE_V8_COVERAGE) {
5295
- // when runned multiple times, we don't want to keep previous files in this directory
5296
- await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE);
5297
- } else {
5298
- coverageMethodForNodeJs = "Profiler";
5299
- logger.warn(
5300
- createDetailedMessage(
5301
- `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
5302
- {
5303
- "suggestion": `set process.env.NODE_V8_COVERAGE`,
5304
- "suggestion 2": `use coverageMethodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
5305
- },
5306
- ),
5307
- );
5235
+ if (gcBetweenExecutions) {
5236
+ global.gc();
5308
5237
  }
5309
- }
5310
- }
5311
- }
5312
5238
 
5313
- testPlan = {
5314
- "file:///**/node_modules/": null,
5315
- "**/*./": null,
5316
- ...testPlan,
5317
- "**/.jsenv/": null,
5318
- };
5319
- logger.debug(`Generate executions`);
5320
- const executionSteps = await executionStepsFromTestPlan({
5321
- signal,
5322
- testPlan,
5323
- rootDirectoryUrl,
5324
- });
5325
- logger.debug(`${executionSteps.length} executions planned`);
5326
- let beforeExecutionCallback;
5327
- let afterExecutionCallback;
5328
- let afterAllExecutionCallback = () => {};
5329
- if (githubCheckEnabled) {
5330
- const githubCheckRun = await startGithubCheckRun({
5331
- logLevel: githubCheckLogLevel,
5332
- githubToken: githubCheckToken,
5333
- repositoryOwner: githubCheckRepositoryOwner,
5334
- repositoryName: githubCheckRepositoryName,
5335
- commitSha: githubCheckCommitSha,
5336
- checkName: githubCheckName,
5337
- checkTitle: `Tests executions`,
5338
- checkSummary: `${executionSteps.length} files will be executed`,
5339
- });
5340
- const annotations = [];
5341
- afterExecutionCallback = (afterExecutionInfo) => {
5342
- const { executionResult } = afterExecutionInfo;
5343
- const { errors = [] } = executionResult;
5344
- for (const error of errors) {
5345
- const annotation = githubAnnotationFromError(error, {
5346
- rootDirectoryUrl,
5347
- executionInfo: afterExecutionInfo,
5348
- });
5349
- annotations.push(annotation);
5350
- }
5351
- };
5352
- afterAllExecutionCallback = async ({ testPlanSummary }) => {
5353
- const title = "Jsenv test results";
5354
- const summary = stripAnsi(formatSummary(testPlanSummary));
5355
- if (
5356
- testPlanSummary.counters.total !== testPlanSummary.counters.completed
5357
- ) {
5358
- await githubCheckRun.fail({
5359
- title,
5360
- summary,
5361
- annotations,
5362
- });
5363
- return;
5364
- }
5365
- await githubCheckRun.pass({
5366
- title,
5367
- summary,
5368
- annotations,
5369
- });
5370
- };
5371
- }
5239
+ const timeEllapsed = Date.now() - startMs;
5240
+ const memoryHeap = memoryUsage().heapUsed;
5241
+ callWhenPreviousExecutionAreDone(executionIndex, () => {
5242
+ counters.done++;
5243
+ if (executionResult.status === "aborted") {
5244
+ counters.aborted++;
5245
+ } else if (executionResult.status === "timedout") {
5246
+ counters.timedout++;
5247
+ } else if (executionResult.status === "failed") {
5248
+ counters.failed++;
5249
+ } else if (executionResult.status === "completed") {
5250
+ counters.completed++;
5251
+ }
5372
5252
 
5373
- const result = await executeSteps(executionSteps, {
5374
- signal,
5375
- teardown,
5376
- logger,
5377
- logRefresh,
5378
- logSummary,
5379
- logRuntime,
5380
- logEachDuration,
5381
- logTimeUsage,
5382
- logMemoryHeapUsage,
5383
- logFileRelativeUrl,
5384
- logShortForCompletedExecutions,
5385
- logMergeForCompletedExecutions,
5386
- rootDirectoryUrl,
5387
- webServer,
5253
+ if (executionLogsEnabled) {
5254
+ // replace spinner with this execution result
5255
+ if (spinner) {
5256
+ spinner.stop();
5257
+ spinner = null;
5258
+ }
5388
5259
 
5389
- maxExecutionsInParallel,
5390
- defaultMsAllocatedPerExecution,
5391
- failFast,
5392
- keepRunning,
5393
- cooldownBetweenExecutions,
5394
- gcBetweenExecutions,
5395
-
5396
- githubCheckEnabled,
5397
- githubCheckName,
5398
- githubCheckTitle,
5399
- githubCheckToken,
5400
- githubCheckRepositoryOwner,
5401
- githubCheckRepositoryName,
5402
- githubCheckCommitSha,
5403
-
5404
- coverageEnabled,
5405
- coverageConfig,
5406
- coverageIncludeMissing,
5407
- coverageMethodForBrowsers,
5408
- coverageMethodForNodeJs,
5409
- coverageV8ConflictWarning,
5410
- coverageTempDirectoryUrl,
5260
+ const log = createExecutionLog(afterExecutionInfo, {
5261
+ logShortForCompletedExecutions,
5262
+ logRuntime,
5263
+ logEachDuration,
5264
+ ...(logTimeUsage ? { timeEllapsed } : {}),
5265
+ ...(logMemoryHeapUsage ? { memoryHeap } : {}),
5266
+ });
5411
5267
 
5412
- beforeExecutionCallback,
5413
- afterExecutionCallback,
5414
- });
5268
+ executionLog.write(log);
5269
+ rawOutput += stripAnsi(log);
5270
+ const canOverwriteLog = canOverwriteLogGetter({
5271
+ logMergeForCompletedExecutions,
5272
+ executionResult,
5273
+ });
5274
+ if (canOverwriteLog) {
5275
+ // nothing to do, we reuse the current executionLog object
5276
+ } else {
5277
+ executionLog.destroy();
5278
+ executionLog = createLog({ newLine: "" });
5279
+ }
5280
+ const isLastExecutionLog =
5281
+ executionIndex === executionSteps.length - 1;
5282
+ if (isLastExecutionLog && logger.levels.info) {
5283
+ executionLog.write("\n");
5284
+ }
5285
+ }
5286
+ });
5287
+ for (const afterExecutionCallback of afterExecutionCallbackSet) {
5288
+ afterExecutionCallback(afterExecutionInfo);
5289
+ }
5290
+ const cancelRemaining =
5291
+ failFast &&
5292
+ executionResult.status !== "completed" &&
5293
+ counters.done < counters.total;
5294
+ if (cancelRemaining) {
5295
+ logger.info(`"failFast" enabled -> cancel remaining executions`);
5296
+ failFastAbortController.abort();
5297
+ }
5298
+ },
5299
+ });
5300
+ if (!keepRunning) {
5301
+ logger.debug("trigger test plan teardown");
5302
+ await teardown.trigger();
5303
+ }
5304
+
5305
+ counters.cancelled = counters.total - counters.done;
5306
+ const summary = {
5307
+ counters,
5308
+ // when execution is aborted, the remaining executions are "cancelled"
5309
+ duration: Date.now() - startMs,
5310
+ };
5311
+ if (logSummary) {
5312
+ const summaryLog = formatSummaryLog(summary);
5313
+ rawOutput += stripAnsi(summaryLog);
5314
+ logger.info(summaryLog);
5315
+ }
5316
+ if (summary.counters.total !== summary.counters.completed) {
5317
+ const logFileUrl = new URL(logFileRelativeUrl, rootDirectoryUrl).href;
5318
+ writeFileSync(logFileUrl, rawOutput);
5319
+ logger.info(`-> ${urlToFileSystemPath(logFileUrl)}`);
5320
+ }
5321
+ returnValue.aborted = multipleExecutionsOperation.signal.aborted;
5322
+ returnValue.summary = summary;
5323
+ returnValue.report = report;
5324
+ for (const callback of callbacks) {
5325
+ await callback();
5326
+ }
5327
+ } finally {
5328
+ await multipleExecutionsOperation.end();
5329
+ }
5415
5330
 
5416
5331
  const hasFailed =
5417
- result.planSummary.counters.total !== result.planSummary.counters.completed;
5332
+ returnValue.summary.counters.total !==
5333
+ returnValue.summary.counters.completed;
5418
5334
  if (updateProcessExitCode && hasFailed) {
5419
5335
  process.exitCode = 1;
5420
5336
  }
5421
- const planCoverage = result.planCoverage;
5337
+ const coverage = returnValue.coverage;
5422
5338
  // planCoverage can be null when execution is aborted
5423
- if (planCoverage) {
5339
+ if (coverage) {
5424
5340
  const promises = [];
5425
5341
  // keep this one first because it does ensureEmptyDirectory
5426
5342
  // and in case coverage json file gets written in the same directory
@@ -5432,7 +5348,7 @@ To fix this warning:
5432
5348
  `-> ${urlToFileSystemPath(htmlCoverageDirectoryIndexFileUrl)}`,
5433
5349
  );
5434
5350
  promises.push(
5435
- generateCoverageHtmlDirectory(planCoverage, {
5351
+ generateCoverageHtmlDirectory(coverage, {
5436
5352
  rootDirectoryUrl,
5437
5353
  coverageHtmlDirectoryRelativeUrl: urlToRelativeUrl(
5438
5354
  coverageReportHtmlDirectoryUrl,
@@ -5446,7 +5362,7 @@ To fix this warning:
5446
5362
  if (coverageEnabled && coverageReportJson) {
5447
5363
  promises.push(
5448
5364
  generateCoverageJsonFile({
5449
- coverage: result.planCoverage,
5365
+ coverage,
5450
5366
  coverageJsonFileUrl: coverageReportJsonFileUrl,
5451
5367
  logger,
5452
5368
  }),
@@ -5454,7 +5370,7 @@ To fix this warning:
5454
5370
  }
5455
5371
  if (coverageEnabled && coverageReportTextLog) {
5456
5372
  promises.push(
5457
- generateCoverageTextLog(result.planCoverage, {
5373
+ generateCoverageTextLog(coverage, {
5458
5374
  coverageReportSkipEmpty,
5459
5375
  coverageReportSkipFull,
5460
5376
  }),
@@ -5463,16 +5379,85 @@ To fix this warning:
5463
5379
  await Promise.all(promises);
5464
5380
  }
5465
5381
 
5466
- const returnValue = {
5467
- testPlanAborted: result.aborted,
5468
- testPlanSummary: result.planSummary,
5469
- testPlanReport: result.planReport,
5470
- testPlanCoverage: planCoverage,
5471
- };
5472
- await afterAllExecutionCallback(returnValue);
5382
+ for (const afterAllExecutionCallback of afterAllExecutionCallbackSet) {
5383
+ await afterAllExecutionCallback(returnValue);
5384
+ }
5473
5385
  return returnValue;
5474
5386
  };
5475
5387
 
5388
+ const canOverwriteLogGetter = ({
5389
+ logMergeForCompletedExecutions,
5390
+ executionResult,
5391
+ }) => {
5392
+ if (!logMergeForCompletedExecutions) {
5393
+ return false;
5394
+ }
5395
+ if (executionResult.status === "aborted") {
5396
+ return true;
5397
+ }
5398
+ if (executionResult.status !== "completed") {
5399
+ return false;
5400
+ }
5401
+ const { consoleCalls = [] } = executionResult;
5402
+ if (consoleCalls.length > 0) {
5403
+ return false;
5404
+ }
5405
+ return true;
5406
+ };
5407
+
5408
+ const executeInParallel = async ({
5409
+ multipleExecutionsOperation,
5410
+ maxExecutionsInParallel,
5411
+ cooldownBetweenExecutions,
5412
+ executionSteps,
5413
+ start,
5414
+ }) => {
5415
+ const executionResults = [];
5416
+ let progressionIndex = 0;
5417
+ let remainingExecutionCount = executionSteps.length;
5418
+
5419
+ const nextChunk = async () => {
5420
+ if (multipleExecutionsOperation.signal.aborted) {
5421
+ return;
5422
+ }
5423
+ const outputPromiseArray = [];
5424
+ let previousExecPromise = Promise.resolve();
5425
+ while (
5426
+ remainingExecutionCount > 0 &&
5427
+ outputPromiseArray.length < maxExecutionsInParallel
5428
+ ) {
5429
+ remainingExecutionCount--;
5430
+ const outputPromise = executeOne(progressionIndex, previousExecPromise);
5431
+ previousExecPromise = outputPromise;
5432
+ progressionIndex++;
5433
+ outputPromiseArray.push(outputPromise);
5434
+ }
5435
+ if (outputPromiseArray.length) {
5436
+ await Promise.all(outputPromiseArray);
5437
+ if (remainingExecutionCount > 0) {
5438
+ await nextChunk();
5439
+ }
5440
+ }
5441
+ };
5442
+
5443
+ const executeOne = async (index, previousExecPromise) => {
5444
+ const input = executionSteps[index];
5445
+ const output = await start(input, previousExecPromise);
5446
+ if (!multipleExecutionsOperation.signal.aborted) {
5447
+ executionResults[index] = output;
5448
+ }
5449
+ if (cooldownBetweenExecutions) {
5450
+ await new Promise((resolve) =>
5451
+ setTimeout(resolve, cooldownBetweenExecutions),
5452
+ );
5453
+ }
5454
+ };
5455
+
5456
+ await nextChunk();
5457
+
5458
+ return executionResults;
5459
+ };
5460
+
5476
5461
  const memoize = (compute) => {
5477
5462
  let memoized = false;
5478
5463
  let memoizedValue;