@jsenv/test 1.7.8 → 1.8.0

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