@jsenv/test 3.7.35 → 3.7.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jsenv_test.js +521 -22
- package/package.json +2 -1
- package/src/execution/execute_test_plan.js +146 -19
- package/src/execution/execution_timings.js +125 -0
- package/src/execution/jsenv_directives.js +172 -0
- package/src/execution/run.js +18 -0
- package/src/main.js +2 -0
- package/src/main_browser.js +4 -0
- package/src/runtime_browsers/client/request_allocated_ms.js +17 -0
- package/src/runtime_browsers/using_playwright.js +6 -0
- package/src/runtime_node/node_child_process.js +10 -0
- package/src/runtime_node/node_worker_thread.js +10 -0
- package/src/runtime_node/request_allocated_ms.js +36 -0
package/dist/jsenv_test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSupportsColor, isUnicodeSupported, stripAnsi, eastAsianWidth, clearTerminal, eraseLines } from "./jsenv_test_node_modules.js";
|
|
2
2
|
import { URL_META, createException } from "./exception.js";
|
|
3
|
-
import { readdir, chmod, stat, lstat, chmodSync, statSync, lstatSync, promises, readFile as readFile$1, readdirSync, openSync, closeSync, unlinkSync, rmdirSync, mkdirSync, readFileSync, writeFileSync as writeFileSync$1, unlink, rmdir, existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { readdir, chmod, stat, lstat, chmodSync, statSync, lstatSync, promises, readFile as readFile$1, readdirSync, openSync, closeSync, unlinkSync, rmdirSync, mkdirSync, readFileSync, writeFileSync as writeFileSync$1, unlink, rmdir, existsSync, realpathSync, readSync } from "node:fs";
|
|
4
4
|
import { takeCoverage } from "node:v8";
|
|
5
5
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
@@ -15,7 +15,7 @@ import { availableParallelism, cpus, totalmem, release, freemem } from "node:os"
|
|
|
15
15
|
import { SOURCEMAP, generateSourcemapDataUrl } from "@jsenv/sourcemap";
|
|
16
16
|
import { injectSupervisorIntoHTML, supervisorFileUrl } from "@jsenv/plugin-supervisor";
|
|
17
17
|
import { pidtree } from "pidtree";
|
|
18
|
-
import { Worker } from "node:worker_threads";
|
|
18
|
+
import { Worker, parentPort } from "node:worker_threads";
|
|
19
19
|
import he from "he";
|
|
20
20
|
import "node:tty";
|
|
21
21
|
|
|
@@ -5704,6 +5704,130 @@ const getCoverageFromTestPlanResults = async (
|
|
|
5704
5704
|
|
|
5705
5705
|
const isV8Coverage = (coverage) => Boolean(coverage.result);
|
|
5706
5706
|
|
|
5707
|
+
/*
|
|
5708
|
+
* Remembers, from one run to the next, how long each execution took and how much
|
|
5709
|
+
* time it asked for (see requestAllocatedMs).
|
|
5710
|
+
*
|
|
5711
|
+
* What it is for: with a fixed number of parallel slots, the moment a long
|
|
5712
|
+
* execution starts decides when the whole run ends. Started last it keeps one
|
|
5713
|
+
* slot busy while every other one is idle; started first it runs while the short
|
|
5714
|
+
* ones fill the slots around it. Knowing the durations of the previous run is
|
|
5715
|
+
* what allows to start the long ones first.
|
|
5716
|
+
*
|
|
5717
|
+
* Only the start order is affected: executions keep the index they got from the
|
|
5718
|
+
* filesystem, so they are still reported in that order.
|
|
5719
|
+
*/
|
|
5720
|
+
|
|
5721
|
+
|
|
5722
|
+
const MS_IN_A_DAY = 24 * 60 * 60 * 1000;
|
|
5723
|
+
// an execution not seen for that long is likely a file that no longer exists
|
|
5724
|
+
const ENTRY_MAX_AGE_MS = 30 * MS_IN_A_DAY;
|
|
5725
|
+
|
|
5726
|
+
const createExecutionTimings = ({ fileUrl }) => {
|
|
5727
|
+
const previousEntryMap = readEntries(fileUrl);
|
|
5728
|
+
const entryMap = new Map();
|
|
5729
|
+
|
|
5730
|
+
return {
|
|
5731
|
+
getAllocatedMsRequested: (executionName) => {
|
|
5732
|
+
const previousEntry = previousEntryMap.get(executionName);
|
|
5733
|
+
if (!previousEntry) {
|
|
5734
|
+
return undefined;
|
|
5735
|
+
}
|
|
5736
|
+
return previousEntry.allocatedMsRequested;
|
|
5737
|
+
},
|
|
5738
|
+
sortByLongestFirst: (executionArray) => {
|
|
5739
|
+
const durationMsMap = new Map();
|
|
5740
|
+
const durationMsArray = [];
|
|
5741
|
+
for (const execution of executionArray) {
|
|
5742
|
+
const previousEntry = previousEntryMap.get(execution.name);
|
|
5743
|
+
if (previousEntry) {
|
|
5744
|
+
durationMsMap.set(execution, previousEntry.durationMs);
|
|
5745
|
+
durationMsArray.push(previousEntry.durationMs);
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
if (durationMsArray.length === 0) {
|
|
5749
|
+
return executionArray;
|
|
5750
|
+
}
|
|
5751
|
+
// an execution never seen before is assumed to last as long as the median:
|
|
5752
|
+
// being new is not a reason to be pushed at the end of the run
|
|
5753
|
+
durationMsArray.sort((a, b) => a - b);
|
|
5754
|
+
const medianDurationMs =
|
|
5755
|
+
durationMsArray[Math.floor(durationMsArray.length / 2)];
|
|
5756
|
+
// sort is stable: executions with the same estimation stay in the order
|
|
5757
|
+
// they were found on the filesystem
|
|
5758
|
+
return [...executionArray].sort((leftExecution, rightExecution) => {
|
|
5759
|
+
const leftDurationMs =
|
|
5760
|
+
durationMsMap.get(leftExecution) ?? medianDurationMs;
|
|
5761
|
+
const rightDurationMs =
|
|
5762
|
+
durationMsMap.get(rightExecution) ?? medianDurationMs;
|
|
5763
|
+
return rightDurationMs - leftDurationMs;
|
|
5764
|
+
});
|
|
5765
|
+
},
|
|
5766
|
+
record: (execution) => {
|
|
5767
|
+
const { status, timings } = execution.result;
|
|
5768
|
+
if (
|
|
5769
|
+
status !== "completed" &&
|
|
5770
|
+
status !== "failed" &&
|
|
5771
|
+
status !== "timedout"
|
|
5772
|
+
) {
|
|
5773
|
+
// an execution that was skipped, aborted or cancelled says nothing
|
|
5774
|
+
// about how long the file needs
|
|
5775
|
+
return;
|
|
5776
|
+
}
|
|
5777
|
+
const entry = {
|
|
5778
|
+
durationMs: timings.end,
|
|
5779
|
+
updatedAt: Date.now(),
|
|
5780
|
+
};
|
|
5781
|
+
if (execution.allocatedMsRequested !== undefined) {
|
|
5782
|
+
entry.allocatedMsRequested = execution.allocatedMsRequested;
|
|
5783
|
+
}
|
|
5784
|
+
entryMap.set(execution.name, entry);
|
|
5785
|
+
},
|
|
5786
|
+
write: () => {
|
|
5787
|
+
const nowMs = Date.now();
|
|
5788
|
+
const executions = {};
|
|
5789
|
+
// executions not part of this run keep what was known about them
|
|
5790
|
+
for (const [executionName, entry] of previousEntryMap) {
|
|
5791
|
+
if (nowMs - entry.updatedAt < ENTRY_MAX_AGE_MS) {
|
|
5792
|
+
executions[executionName] = entry;
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
for (const [executionName, entry] of entryMap) {
|
|
5796
|
+
executions[executionName] = entry;
|
|
5797
|
+
}
|
|
5798
|
+
writeFileSync(fileUrl, JSON.stringify({ executions }, null, " "));
|
|
5799
|
+
},
|
|
5800
|
+
};
|
|
5801
|
+
};
|
|
5802
|
+
|
|
5803
|
+
const readEntries = (fileUrl) => {
|
|
5804
|
+
const entryMap = new Map();
|
|
5805
|
+
let fileContent;
|
|
5806
|
+
try {
|
|
5807
|
+
fileContent = readFileSync(new URL(fileUrl), "utf8");
|
|
5808
|
+
} catch {
|
|
5809
|
+
// no memory of a previous run: every execution is an unknown
|
|
5810
|
+
return entryMap;
|
|
5811
|
+
}
|
|
5812
|
+
let executions;
|
|
5813
|
+
try {
|
|
5814
|
+
({ executions } = JSON.parse(fileContent));
|
|
5815
|
+
} catch {
|
|
5816
|
+
// file was truncated by a run killed while writing it
|
|
5817
|
+
return entryMap;
|
|
5818
|
+
}
|
|
5819
|
+
if (!executions) {
|
|
5820
|
+
return entryMap;
|
|
5821
|
+
}
|
|
5822
|
+
for (const executionName of Object.keys(executions)) {
|
|
5823
|
+
const entry = executions[executionName];
|
|
5824
|
+
if (typeof entry.durationMs === "number") {
|
|
5825
|
+
entryMap.set(executionName, entry);
|
|
5826
|
+
}
|
|
5827
|
+
}
|
|
5828
|
+
return entryMap;
|
|
5829
|
+
};
|
|
5830
|
+
|
|
5707
5831
|
const githubAnnotationFromError = (
|
|
5708
5832
|
error,
|
|
5709
5833
|
{ rootDirectoryUrl, execution },
|
|
@@ -5835,6 +5959,176 @@ const replaceUrls$1 = (source, replace) => {
|
|
|
5835
5959
|
});
|
|
5836
5960
|
};
|
|
5837
5961
|
|
|
5962
|
+
/*
|
|
5963
|
+
* Reads what a test file declares about its own execution, from the directive
|
|
5964
|
+
* prologue — the string literals a module may open with, as in "use strict" or
|
|
5965
|
+
* "use client":
|
|
5966
|
+
*
|
|
5967
|
+
* "jsenv:allocate 90s";
|
|
5968
|
+
* "jsenv:lock service-worker";
|
|
5969
|
+
*
|
|
5970
|
+
* They are read without running the file, which is what allows a lock to be
|
|
5971
|
+
* honored: an execution must not have started before we know what it takes.
|
|
5972
|
+
* Being grammar rather than convention, a directive cannot be built at runtime
|
|
5973
|
+
* and cannot move: it is the first statement or it is nothing.
|
|
5974
|
+
*
|
|
5975
|
+
* A "jsenv:" directive that cannot be read throws. Everything ignores an
|
|
5976
|
+
* unknown directive silently, so a typo would silently give the file back the
|
|
5977
|
+
* default budget; the only way it stays useful is to be loud.
|
|
5978
|
+
*/
|
|
5979
|
+
|
|
5980
|
+
|
|
5981
|
+
const DIRECTIVE_PREFIX = "jsenv:";
|
|
5982
|
+
const DIRECTIVE_NAMES = `"jsenv:allocate <duration>", "jsenv:lock <resource>"`;
|
|
5983
|
+
const DURATION_REGEX = /^(\d+)(ms|s|m)$/;
|
|
5984
|
+
const MS_PER_UNIT = { ms: 1, s: 1_000, m: 60_000 };
|
|
5985
|
+
// a directive prologue sits at the top of the file; comments can precede it but
|
|
5986
|
+
// rarely for more than a few lines, and the whole file is read when they do
|
|
5987
|
+
const HEAD_BYTE_COUNT = 4096;
|
|
5988
|
+
|
|
5989
|
+
const readJsenvDirectives = (fileUrl) => {
|
|
5990
|
+
const head = readHead(fileUrl);
|
|
5991
|
+
let scanResult = scanDirectivePrologue(head.text);
|
|
5992
|
+
if (head.partial && !scanResult.complete) {
|
|
5993
|
+
scanResult = scanDirectivePrologue(readFileSync(new URL(fileUrl), "utf8"));
|
|
5994
|
+
}
|
|
5995
|
+
|
|
5996
|
+
let allocatedMs;
|
|
5997
|
+
const lockArray = [];
|
|
5998
|
+
for (const directiveText of scanResult.directiveTextArray) {
|
|
5999
|
+
if (!directiveText.startsWith(DIRECTIVE_PREFIX)) {
|
|
6000
|
+
continue;
|
|
6001
|
+
}
|
|
6002
|
+
const body = directiveText.slice(DIRECTIVE_PREFIX.length);
|
|
6003
|
+
const spaceIndex = body.indexOf(" ");
|
|
6004
|
+
const name = spaceIndex === -1 ? body : body.slice(0, spaceIndex);
|
|
6005
|
+
const argument = spaceIndex === -1 ? "" : body.slice(spaceIndex + 1).trim();
|
|
6006
|
+
const fail = (reason, details = {}) => {
|
|
6007
|
+
throw new Error(
|
|
6008
|
+
createDetailedMessage(reason, {
|
|
6009
|
+
directive: `"${directiveText}"`,
|
|
6010
|
+
file: fileURLToPath(fileUrl),
|
|
6011
|
+
...details,
|
|
6012
|
+
}),
|
|
6013
|
+
);
|
|
6014
|
+
};
|
|
6015
|
+
|
|
6016
|
+
if (name === "allocate") {
|
|
6017
|
+
const match = DURATION_REGEX.exec(argument);
|
|
6018
|
+
if (!match) {
|
|
6019
|
+
fail(`"jsenv:allocate" expects a duration, got "${argument}"`, {
|
|
6020
|
+
["durations accepted"]: `"500ms", "90s", "2m"`,
|
|
6021
|
+
});
|
|
6022
|
+
}
|
|
6023
|
+
allocatedMs = Number(match[1]) * MS_PER_UNIT[match[2]];
|
|
6024
|
+
continue;
|
|
6025
|
+
}
|
|
6026
|
+
if (name === "lock") {
|
|
6027
|
+
if (argument === "") {
|
|
6028
|
+
fail(`"jsenv:lock" expects the name of a resource`);
|
|
6029
|
+
}
|
|
6030
|
+
lockArray.push(argument);
|
|
6031
|
+
continue;
|
|
6032
|
+
}
|
|
6033
|
+
fail(`unknown jsenv directive "${name}"`, {
|
|
6034
|
+
["directives available"]: DIRECTIVE_NAMES,
|
|
6035
|
+
});
|
|
6036
|
+
}
|
|
6037
|
+
return { allocatedMs, lockArray };
|
|
6038
|
+
};
|
|
6039
|
+
|
|
6040
|
+
const readHead = (fileUrl) => {
|
|
6041
|
+
const fileDescriptor = openSync(fileURLToPath(fileUrl), "r");
|
|
6042
|
+
try {
|
|
6043
|
+
const buffer = Buffer.allocUnsafe(HEAD_BYTE_COUNT);
|
|
6044
|
+
const byteCount = readSync(fileDescriptor, buffer, 0, HEAD_BYTE_COUNT, 0);
|
|
6045
|
+
return {
|
|
6046
|
+
text: buffer.toString("utf8", 0, byteCount),
|
|
6047
|
+
partial: byteCount === HEAD_BYTE_COUNT,
|
|
6048
|
+
};
|
|
6049
|
+
} finally {
|
|
6050
|
+
closeSync(fileDescriptor);
|
|
6051
|
+
}
|
|
6052
|
+
};
|
|
6053
|
+
|
|
6054
|
+
/*
|
|
6055
|
+
* Collects the string literals opening the module, stopping at the first token
|
|
6056
|
+
* that is neither a comment nor one of them. "complete" tells whether that
|
|
6057
|
+
* token was reached: when it was not, the source given was cut short and the
|
|
6058
|
+
* caller must read further before trusting the result.
|
|
6059
|
+
*/
|
|
6060
|
+
const scanDirectivePrologue = (source) => {
|
|
6061
|
+
const directiveTextArray = [];
|
|
6062
|
+
const length = source.length;
|
|
6063
|
+
let index = 0;
|
|
6064
|
+
if (source.startsWith("#!")) {
|
|
6065
|
+
const lineEndIndex = source.indexOf("\n");
|
|
6066
|
+
if (lineEndIndex === -1) {
|
|
6067
|
+
return { directiveTextArray, complete: false };
|
|
6068
|
+
}
|
|
6069
|
+
index = lineEndIndex + 1;
|
|
6070
|
+
}
|
|
6071
|
+
while (index < length) {
|
|
6072
|
+
const char = source[index];
|
|
6073
|
+
if (
|
|
6074
|
+
char === " " ||
|
|
6075
|
+
char === "\t" ||
|
|
6076
|
+
char === "\n" ||
|
|
6077
|
+
char === "\r" ||
|
|
6078
|
+
char === ";"
|
|
6079
|
+
) {
|
|
6080
|
+
index++;
|
|
6081
|
+
continue;
|
|
6082
|
+
}
|
|
6083
|
+
if (char === "/" && source[index + 1] === "/") {
|
|
6084
|
+
const lineEndIndex = source.indexOf("\n", index);
|
|
6085
|
+
if (lineEndIndex === -1) {
|
|
6086
|
+
return { directiveTextArray, complete: false };
|
|
6087
|
+
}
|
|
6088
|
+
index = lineEndIndex + 1;
|
|
6089
|
+
continue;
|
|
6090
|
+
}
|
|
6091
|
+
if (char === "/" && source[index + 1] === "*") {
|
|
6092
|
+
const commentEndIndex = source.indexOf("*/", index + 2);
|
|
6093
|
+
if (commentEndIndex === -1) {
|
|
6094
|
+
return { directiveTextArray, complete: false };
|
|
6095
|
+
}
|
|
6096
|
+
index = commentEndIndex + 2;
|
|
6097
|
+
continue;
|
|
6098
|
+
}
|
|
6099
|
+
if (char === '"' || char === "'") {
|
|
6100
|
+
const quote = char;
|
|
6101
|
+
let stringIndex = index + 1;
|
|
6102
|
+
let text = "";
|
|
6103
|
+
while (stringIndex < length) {
|
|
6104
|
+
const stringChar = source[stringIndex];
|
|
6105
|
+
if (stringChar === "\\") {
|
|
6106
|
+
text += source[stringIndex + 1];
|
|
6107
|
+
stringIndex += 2;
|
|
6108
|
+
continue;
|
|
6109
|
+
}
|
|
6110
|
+
if (stringChar === quote) {
|
|
6111
|
+
break;
|
|
6112
|
+
}
|
|
6113
|
+
if (stringChar === "\n") {
|
|
6114
|
+
// an unterminated string is not a directive, and not our problem
|
|
6115
|
+
return { directiveTextArray, complete: true };
|
|
6116
|
+
}
|
|
6117
|
+
text += stringChar;
|
|
6118
|
+
stringIndex++;
|
|
6119
|
+
}
|
|
6120
|
+
if (stringIndex >= length) {
|
|
6121
|
+
return { directiveTextArray, complete: false };
|
|
6122
|
+
}
|
|
6123
|
+
directiveTextArray.push(text);
|
|
6124
|
+
index = stringIndex + 1;
|
|
6125
|
+
continue;
|
|
6126
|
+
}
|
|
6127
|
+
return { directiveTextArray, complete: true };
|
|
6128
|
+
}
|
|
6129
|
+
return { directiveTextArray, complete: false };
|
|
6130
|
+
};
|
|
6131
|
+
|
|
5838
6132
|
const createIsInsideFragment = (fragment, total) => {
|
|
5839
6133
|
let [dividend, divisor] = fragment.split("/");
|
|
5840
6134
|
dividend = parseInt(dividend);
|
|
@@ -6980,6 +7274,7 @@ const run = async ({
|
|
|
6980
7274
|
signal = new AbortController().signal,
|
|
6981
7275
|
logger,
|
|
6982
7276
|
allocatedMs,
|
|
7277
|
+
onAllocatedMsRequested = () => {},
|
|
6983
7278
|
keepRunning = false,
|
|
6984
7279
|
mirrorConsole = false,
|
|
6985
7280
|
collectConsole = false,
|
|
@@ -7030,6 +7325,22 @@ const run = async ({
|
|
|
7030
7325
|
if (allocatedMs) {
|
|
7031
7326
|
timeoutAbortSource = runOperation.timeout(allocatedMs);
|
|
7032
7327
|
}
|
|
7328
|
+
// the file being executed can ask for more time, see requestAllocatedMs
|
|
7329
|
+
const handleAllocatedMsRequest = (ms) => {
|
|
7330
|
+
onAllocatedMsRequested(ms);
|
|
7331
|
+
if (!timeoutAbortSource) {
|
|
7332
|
+
// nothing to extend: execution has no time limit
|
|
7333
|
+
return;
|
|
7334
|
+
}
|
|
7335
|
+
if (ms <= allocatedMs) {
|
|
7336
|
+
return;
|
|
7337
|
+
}
|
|
7338
|
+
allocatedMs = ms;
|
|
7339
|
+
// the request arrives once execution has started, what is left of the
|
|
7340
|
+
// requested duration is what the file can still use
|
|
7341
|
+
timeoutAbortSource.remove();
|
|
7342
|
+
timeoutAbortSource = runOperation.timeout(ms - takeTiming());
|
|
7343
|
+
};
|
|
7033
7344
|
const consoleCalls = [];
|
|
7034
7345
|
onConsoleRef.current = ({ type, text }) => {
|
|
7035
7346
|
if (mirrorConsole) {
|
|
@@ -7082,6 +7393,7 @@ const run = async ({
|
|
|
7082
7393
|
signal: runOperation.signal,
|
|
7083
7394
|
logger,
|
|
7084
7395
|
...runtimeParams,
|
|
7396
|
+
onAllocatedMsRequested: handleAllocatedMsRequest,
|
|
7085
7397
|
collectConsole,
|
|
7086
7398
|
measureMemoryUsage,
|
|
7087
7399
|
onMeasureMemoryAvailable,
|
|
@@ -7536,6 +7848,7 @@ const ensureWebServerIsStarted = async (
|
|
|
7536
7848
|
* @param {Object} [testPlanParameters.webServer] Web server info; required when executing test on browsers
|
|
7537
7849
|
* @param {Object} testPlanParameters.testPlan Object associating files with runtimes where they will be executed
|
|
7538
7850
|
* @param {Object|false} [testPlanParameters.parallel] Maximum amount of execution running at the same time
|
|
7851
|
+
* @param {Object|false} [testPlanParameters.executionTimings=false] Remembers how long executions took to start the longest ones first on the next run
|
|
7539
7852
|
* @param {number} [testPlanParameters.defaultMsAllocatedPerExecution=30000] Milliseconds after which execution is aborted and considered as failed by timeout
|
|
7540
7853
|
* @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
|
|
7541
7854
|
* @param {Object|false} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
|
|
@@ -7594,6 +7907,12 @@ const parallelDefault = {
|
|
|
7594
7907
|
max: "80%", // percentage resolved against the available cpus
|
|
7595
7908
|
maxCpu: "80%",
|
|
7596
7909
|
maxMemory: "50%",
|
|
7910
|
+
// percentage resolved against parallel.max; an execution is heavy when it is
|
|
7911
|
+
// allocated more time than the others (see defaultMsAllocatedPerExecution)
|
|
7912
|
+
maxHeavy: "75%",
|
|
7913
|
+
};
|
|
7914
|
+
const executionTimingsDefault = {
|
|
7915
|
+
fileUrl: undefined,
|
|
7597
7916
|
};
|
|
7598
7917
|
|
|
7599
7918
|
const executeTestPlan = async ({
|
|
@@ -7609,6 +7928,9 @@ const executeTestPlan = async ({
|
|
|
7609
7928
|
handleSIGTERM = true,
|
|
7610
7929
|
updateProcessExitCode = true,
|
|
7611
7930
|
parallel = parallelDefault,
|
|
7931
|
+
// opt-in: it makes the start order depend on a file written by a previous run,
|
|
7932
|
+
// which a test plan snapshotting its own execution order cannot afford
|
|
7933
|
+
executionTimings = false,
|
|
7612
7934
|
// https://github.com/avajs/ava/blob/main/docs/recipes/splitting-tests-ci.md
|
|
7613
7935
|
// https://playwright.dev/docs/test-sharding
|
|
7614
7936
|
fragment,
|
|
@@ -7782,6 +8104,7 @@ const executeTestPlan = async ({
|
|
|
7782
8104
|
const afterEachInOrderCallbackSet = new Set();
|
|
7783
8105
|
const afterAllCallbackSet = new Set();
|
|
7784
8106
|
let finalizeCoverage;
|
|
8107
|
+
let timingsMemory;
|
|
7785
8108
|
|
|
7786
8109
|
try {
|
|
7787
8110
|
let logger;
|
|
@@ -7909,6 +8232,51 @@ const executeTestPlan = async ({
|
|
|
7909
8232
|
`parallel.maxCpu must be a number or a percentage, got ${maxCpu}`,
|
|
7910
8233
|
);
|
|
7911
8234
|
}
|
|
8235
|
+
|
|
8236
|
+
const maxHeavy = parallel.maxHeavy;
|
|
8237
|
+
if (typeof maxHeavy === "string") {
|
|
8238
|
+
const maxHeavyAsRatio = assertPercentageAndConvertToRatio(maxHeavy);
|
|
8239
|
+
parallel.maxHeavy = Math.round(maxHeavyAsRatio * parallel.max) || 1;
|
|
8240
|
+
} else if (typeof maxHeavy === "number") {
|
|
8241
|
+
if (maxHeavy < 1) {
|
|
8242
|
+
parallel.maxHeavy = 1;
|
|
8243
|
+
}
|
|
8244
|
+
} else {
|
|
8245
|
+
throw new TypeError(
|
|
8246
|
+
`parallel.maxHeavy must be a number or a percentage, got ${maxHeavy}`,
|
|
8247
|
+
);
|
|
8248
|
+
}
|
|
8249
|
+
}
|
|
8250
|
+
// executionTimings
|
|
8251
|
+
{
|
|
8252
|
+
if (executionTimings === true) {
|
|
8253
|
+
executionTimings = {};
|
|
8254
|
+
}
|
|
8255
|
+
if (executionTimings) {
|
|
8256
|
+
if (typeof executionTimings !== "object") {
|
|
8257
|
+
throw new TypeError(
|
|
8258
|
+
`executionTimings must be an object, got ${executionTimings}`,
|
|
8259
|
+
);
|
|
8260
|
+
}
|
|
8261
|
+
const unexpectedExecutionTimingsKeys = Object.keys(
|
|
8262
|
+
executionTimings,
|
|
8263
|
+
).filter((key) => !Object.hasOwn(executionTimingsDefault, key));
|
|
8264
|
+
if (unexpectedExecutionTimingsKeys.length > 0) {
|
|
8265
|
+
throw new TypeError(
|
|
8266
|
+
`${unexpectedExecutionTimingsKeys.join(",")}: no such key on executionTimings`,
|
|
8267
|
+
);
|
|
8268
|
+
}
|
|
8269
|
+
executionTimings = {
|
|
8270
|
+
...executionTimingsDefault,
|
|
8271
|
+
...executionTimings,
|
|
8272
|
+
};
|
|
8273
|
+
timingsMemory = createExecutionTimings({
|
|
8274
|
+
fileUrl:
|
|
8275
|
+
executionTimings.fileUrl === undefined
|
|
8276
|
+
? new URL("./.jsenv/jsenv_tests_timings.json", rootDirectoryUrl)
|
|
8277
|
+
: executionTimings.fileUrl,
|
|
8278
|
+
});
|
|
8279
|
+
}
|
|
7912
8280
|
}
|
|
7913
8281
|
// fragment/fragmentByRuntime
|
|
7914
8282
|
{
|
|
@@ -8179,6 +8547,9 @@ To fix this warning:
|
|
|
8179
8547
|
}
|
|
8180
8548
|
}
|
|
8181
8549
|
const filePlan = meta.testPlan;
|
|
8550
|
+
const directives = readJsenvDirectives(
|
|
8551
|
+
new URL(relativeUrl, rootDirectoryUrl),
|
|
8552
|
+
);
|
|
8182
8553
|
for (const groupName of Object.keys(filePlan)) {
|
|
8183
8554
|
const stepConfig = filePlan[groupName];
|
|
8184
8555
|
if (stepConfig === null || stepConfig === undefined) {
|
|
@@ -8200,7 +8571,7 @@ To fix this warning:
|
|
|
8200
8571
|
runtime,
|
|
8201
8572
|
runtimeParams,
|
|
8202
8573
|
allocatedMs = defaultMsAllocatedPerExecution,
|
|
8203
|
-
|
|
8574
|
+
locks,
|
|
8204
8575
|
} = stepConfig;
|
|
8205
8576
|
const params = {
|
|
8206
8577
|
measureMemoryUsage: true,
|
|
@@ -8208,7 +8579,7 @@ To fix this warning:
|
|
|
8208
8579
|
collectPerformance: false,
|
|
8209
8580
|
collectConsole: true,
|
|
8210
8581
|
allocatedMs,
|
|
8211
|
-
|
|
8582
|
+
locks,
|
|
8212
8583
|
runtime,
|
|
8213
8584
|
runtimeParams: {
|
|
8214
8585
|
rootDirectoryUrl,
|
|
@@ -8254,6 +8625,8 @@ To fix this warning:
|
|
|
8254
8625
|
params,
|
|
8255
8626
|
skipped: false,
|
|
8256
8627
|
skipReason: "",
|
|
8628
|
+
// set when the file calls requestAllocatedMs
|
|
8629
|
+
allocatedMsRequested: undefined,
|
|
8257
8630
|
|
|
8258
8631
|
// will be set by run()
|
|
8259
8632
|
status: "planified",
|
|
@@ -8266,9 +8639,22 @@ To fix this warning:
|
|
|
8266
8639
|
? defaultMsAllocatedPerExecution
|
|
8267
8640
|
: allocatedMsResult;
|
|
8268
8641
|
}
|
|
8269
|
-
if (typeof params.
|
|
8270
|
-
const
|
|
8271
|
-
params.
|
|
8642
|
+
if (typeof params.locks === "function") {
|
|
8643
|
+
const locksResult = params.locks(execution);
|
|
8644
|
+
params.locks = locksResult;
|
|
8645
|
+
}
|
|
8646
|
+
// what the file declares about itself wins over the plan: it is
|
|
8647
|
+
// closer to the reason
|
|
8648
|
+
if (
|
|
8649
|
+
directives.allocatedMs !== undefined &&
|
|
8650
|
+
directives.allocatedMs > params.allocatedMs
|
|
8651
|
+
) {
|
|
8652
|
+
params.allocatedMs = directives.allocatedMs;
|
|
8653
|
+
}
|
|
8654
|
+
if (directives.lockArray.length > 0) {
|
|
8655
|
+
params.locks = params.locks
|
|
8656
|
+
? [...new Set([...params.locks, ...directives.lockArray])]
|
|
8657
|
+
: directives.lockArray;
|
|
8272
8658
|
}
|
|
8273
8659
|
|
|
8274
8660
|
lastExecution = execution;
|
|
@@ -8438,9 +8824,33 @@ To fix this warning:
|
|
|
8438
8824
|
|
|
8439
8825
|
const callWhenPreviousExecutionAreDone = createCallOrderer();
|
|
8440
8826
|
|
|
8441
|
-
|
|
8827
|
+
// an execution allowed more time than the others is a heavy one: it is
|
|
8828
|
+
// both worth starting early and worth not having too many of at once
|
|
8829
|
+
const heavyExecutionSet = new Set();
|
|
8830
|
+
for (const execution of executionPlanifiedArray) {
|
|
8831
|
+
if (execution.skipped) {
|
|
8832
|
+
continue;
|
|
8833
|
+
}
|
|
8834
|
+
if (execution.params.allocatedMs > defaultMsAllocatedPerExecution) {
|
|
8835
|
+
heavyExecutionSet.add(execution);
|
|
8836
|
+
continue;
|
|
8837
|
+
}
|
|
8838
|
+
if (timingsMemory) {
|
|
8839
|
+
const allocatedMsRequested = timingsMemory.getAllocatedMsRequested(
|
|
8840
|
+
execution.name,
|
|
8841
|
+
);
|
|
8842
|
+
if (allocatedMsRequested > defaultMsAllocatedPerExecution) {
|
|
8843
|
+
heavyExecutionSet.add(execution);
|
|
8844
|
+
}
|
|
8845
|
+
}
|
|
8846
|
+
}
|
|
8847
|
+
const executionStartOrderArray = timingsMemory
|
|
8848
|
+
? timingsMemory.sortByLongestFirst(executionPlanifiedArray)
|
|
8849
|
+
: executionPlanifiedArray;
|
|
8850
|
+
|
|
8851
|
+
const executionRemainingSet = new Set(executionStartOrderArray);
|
|
8442
8852
|
const executionExecutingSet = new Set();
|
|
8443
|
-
const
|
|
8853
|
+
const lockedResourceSet = new Set();
|
|
8444
8854
|
const start = async (execution) => {
|
|
8445
8855
|
execution.fileExecutionCount = Object.keys(
|
|
8446
8856
|
testPlanResult.results[execution.fileRelativeUrl],
|
|
@@ -8454,14 +8864,17 @@ To fix this warning:
|
|
|
8454
8864
|
execution.result.status = "skipped";
|
|
8455
8865
|
execution.result.value = execution.skipReason;
|
|
8456
8866
|
} else {
|
|
8457
|
-
if (execution.params.
|
|
8458
|
-
for (const
|
|
8459
|
-
|
|
8867
|
+
if (execution.params.locks) {
|
|
8868
|
+
for (const resourceToLock of execution.params.locks) {
|
|
8869
|
+
lockedResourceSet.add(resourceToLock);
|
|
8460
8870
|
}
|
|
8461
8871
|
}
|
|
8462
8872
|
execution.status = "executing";
|
|
8463
8873
|
const executionResult = await run({
|
|
8464
8874
|
...execution.params,
|
|
8875
|
+
onAllocatedMsRequested: (ms) => {
|
|
8876
|
+
execution.allocatedMsRequested = ms;
|
|
8877
|
+
},
|
|
8465
8878
|
signal: operation.signal,
|
|
8466
8879
|
logger,
|
|
8467
8880
|
keepRunning,
|
|
@@ -8471,11 +8884,14 @@ To fix this warning:
|
|
|
8471
8884
|
});
|
|
8472
8885
|
Object.assign(execution.result, executionResult);
|
|
8473
8886
|
execution.status = "executed";
|
|
8474
|
-
if (execution.params.
|
|
8475
|
-
for (const
|
|
8476
|
-
|
|
8887
|
+
if (execution.params.locks) {
|
|
8888
|
+
for (const resourceToRelease of execution.params.locks) {
|
|
8889
|
+
lockedResourceSet.delete(resourceToRelease);
|
|
8477
8890
|
}
|
|
8478
8891
|
}
|
|
8892
|
+
if (timingsMemory) {
|
|
8893
|
+
timingsMemory.record(execution);
|
|
8894
|
+
}
|
|
8479
8895
|
if (execution.result.status !== "completed") {
|
|
8480
8896
|
testPlanResult.failed = true;
|
|
8481
8897
|
if (updateProcessExitCode) {
|
|
@@ -8506,6 +8922,12 @@ To fix this warning:
|
|
|
8506
8922
|
};
|
|
8507
8923
|
const startAsMuchAsPossible = async () => {
|
|
8508
8924
|
operation.throwIfAborted();
|
|
8925
|
+
let heavyExecutingCount = 0;
|
|
8926
|
+
for (const executionExecuting of executionExecutingSet) {
|
|
8927
|
+
if (heavyExecutionSet.has(executionExecuting)) {
|
|
8928
|
+
heavyExecutingCount++;
|
|
8929
|
+
}
|
|
8930
|
+
}
|
|
8509
8931
|
const promises = [];
|
|
8510
8932
|
for (const executionCandidate of executionRemainingSet) {
|
|
8511
8933
|
if (executionExecutingSet.size >= parallel.max) {
|
|
@@ -8533,18 +8955,29 @@ To fix this warning:
|
|
|
8533
8955
|
promises.push(promise);
|
|
8534
8956
|
break;
|
|
8535
8957
|
}
|
|
8536
|
-
if (executionCandidate
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8958
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
8959
|
+
if (heavyExecutingCount >= parallel.maxHeavy) {
|
|
8960
|
+
// leave this slot to a lighter execution: filling every slot
|
|
8961
|
+
// with the heavy ones makes them fight for cpu and memory
|
|
8962
|
+
continue;
|
|
8963
|
+
}
|
|
8964
|
+
}
|
|
8965
|
+
if (executionCandidate.params.locks) {
|
|
8966
|
+
const resourceLockedByAnother =
|
|
8967
|
+
executionCandidate.params.locks.find((resourceToLock) =>
|
|
8968
|
+
lockedResourceSet.has(resourceToLock),
|
|
8969
|
+
);
|
|
8970
|
+
if (resourceLockedByAnother) {
|
|
8541
8971
|
logger.debug(
|
|
8542
|
-
`"${
|
|
8972
|
+
`"${resourceLockedByAnother}" is locked, ${executionCandidate.name} will wait until it is released by a previous execution`,
|
|
8543
8973
|
);
|
|
8544
8974
|
continue;
|
|
8545
8975
|
}
|
|
8546
8976
|
}
|
|
8547
8977
|
}
|
|
8978
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
8979
|
+
heavyExecutingCount++;
|
|
8980
|
+
}
|
|
8548
8981
|
const promise = (async () => {
|
|
8549
8982
|
await start(executionCandidate);
|
|
8550
8983
|
await startAsMuchAsPossible();
|
|
@@ -8627,6 +9060,10 @@ To fix this warning:
|
|
|
8627
9060
|
}
|
|
8628
9061
|
timings.teardownEnd = takeTiming();
|
|
8629
9062
|
|
|
9063
|
+
if (timingsMemory) {
|
|
9064
|
+
timingsMemory.write();
|
|
9065
|
+
}
|
|
9066
|
+
|
|
8630
9067
|
if (finalizeCoverage) {
|
|
8631
9068
|
await finalizeCoverage();
|
|
8632
9069
|
}
|
|
@@ -8904,6 +9341,7 @@ const createRuntimeUsingPlaywright = ({
|
|
|
8904
9341
|
onConsole,
|
|
8905
9342
|
onRuntimeStarted,
|
|
8906
9343
|
onRuntimeStopped,
|
|
9344
|
+
onAllocatedMsRequested = () => {},
|
|
8907
9345
|
teardownCallbackSet,
|
|
8908
9346
|
isTestPlan,
|
|
8909
9347
|
|
|
@@ -9025,6 +9463,11 @@ ${webServer.rootDirectoryUrl}`);
|
|
|
9025
9463
|
}
|
|
9026
9464
|
|
|
9027
9465
|
const page = await browserContext.newPage();
|
|
9466
|
+
// the only thing the page can tell node before it is done executing;
|
|
9467
|
+
// see runtime_browsers/client/request_allocated_ms.js
|
|
9468
|
+
await page.exposeFunction("__jsenv_request_allocated_ms__", (ms) => {
|
|
9469
|
+
onAllocatedMsRequested(ms);
|
|
9470
|
+
});
|
|
9028
9471
|
if (!isBrowserDedicatedToExecution) {
|
|
9029
9472
|
page.on("close", () => {
|
|
9030
9473
|
onRuntimeStopped();
|
|
@@ -10046,6 +10489,7 @@ const nodeChildProcess = ({
|
|
|
10046
10489
|
onConsole,
|
|
10047
10490
|
onRuntimeStarted,
|
|
10048
10491
|
onRuntimeStopped,
|
|
10492
|
+
onAllocatedMsRequested = () => {},
|
|
10049
10493
|
|
|
10050
10494
|
measureMemoryUsage,
|
|
10051
10495
|
onMeasureMemoryAvailable,
|
|
@@ -10153,6 +10597,15 @@ const nodeChildProcess = ({
|
|
|
10153
10597
|
onRuntimeStopped();
|
|
10154
10598
|
}),
|
|
10155
10599
|
);
|
|
10600
|
+
cleanupCallbackSet.add(
|
|
10601
|
+
onChildProcessMessage(
|
|
10602
|
+
childProcess,
|
|
10603
|
+
"allocated-ms-request",
|
|
10604
|
+
({ ms }) => {
|
|
10605
|
+
onAllocatedMsRequested(ms);
|
|
10606
|
+
},
|
|
10607
|
+
),
|
|
10608
|
+
);
|
|
10156
10609
|
|
|
10157
10610
|
const removeOutputListener = installChildProcessOutputListener(
|
|
10158
10611
|
childProcess,
|
|
@@ -10534,6 +10987,7 @@ const nodeWorkerThread = ({
|
|
|
10534
10987
|
onConsole,
|
|
10535
10988
|
onRuntimeStarted,
|
|
10536
10989
|
onRuntimeStopped,
|
|
10990
|
+
onAllocatedMsRequested = () => {},
|
|
10537
10991
|
|
|
10538
10992
|
measureMemoryUsage,
|
|
10539
10993
|
onMeasureMemoryAvailable,
|
|
@@ -10620,6 +11074,15 @@ const nodeWorkerThread = ({
|
|
|
10620
11074
|
onRuntimeStopped();
|
|
10621
11075
|
}),
|
|
10622
11076
|
);
|
|
11077
|
+
cleanupCallbackSet.add(
|
|
11078
|
+
onWorkerThreadMessage(
|
|
11079
|
+
workerThread,
|
|
11080
|
+
"allocated-ms-request",
|
|
11081
|
+
({ ms }) => {
|
|
11082
|
+
onAllocatedMsRequested(ms);
|
|
11083
|
+
},
|
|
11084
|
+
),
|
|
11085
|
+
);
|
|
10623
11086
|
|
|
10624
11087
|
const stop = memoize(async () => {
|
|
10625
11088
|
// read all stdout before terminating
|
|
@@ -10871,6 +11334,42 @@ const onceWorkerThreadEvent = (worker, type, callback) => {
|
|
|
10871
11334
|
};
|
|
10872
11335
|
};
|
|
10873
11336
|
|
|
11337
|
+
/*
|
|
11338
|
+
* Asks the test runner for more time from a test file, when the amount is
|
|
11339
|
+
* computed rather than known in advance (it depends on the platform, on how
|
|
11340
|
+
* many fixtures were found...). A fixed amount belongs in a directive instead,
|
|
11341
|
+
* which the runner reads without executing the file:
|
|
11342
|
+
*
|
|
11343
|
+
* "jsenv:allocate 90s";
|
|
11344
|
+
*
|
|
11345
|
+
* The request is sent to the process running the test plan, which restarts the
|
|
11346
|
+
* timeout with the requested duration and remembers it: a file asking for more
|
|
11347
|
+
* time than the others is also a file worth starting early when parallelizing.
|
|
11348
|
+
*
|
|
11349
|
+
* Node.js runtimes only; a browser has no channel to reach the test plan while
|
|
11350
|
+
* the file is executing.
|
|
11351
|
+
*/
|
|
11352
|
+
|
|
11353
|
+
|
|
11354
|
+
const requestAllocatedMs = (ms) => {
|
|
11355
|
+
if (typeof ms !== "number") {
|
|
11356
|
+
throw new TypeError(`requestAllocatedMs expects a number, got ${ms}`);
|
|
11357
|
+
}
|
|
11358
|
+
const message = {
|
|
11359
|
+
__jsenv__: "allocated-ms-request",
|
|
11360
|
+
data: JSON.stringify({ ms }),
|
|
11361
|
+
};
|
|
11362
|
+
if (parentPort) {
|
|
11363
|
+
parentPort.postMessage(message);
|
|
11364
|
+
return;
|
|
11365
|
+
}
|
|
11366
|
+
if (process.send && process.connected) {
|
|
11367
|
+
process.send(message);
|
|
11368
|
+
return;
|
|
11369
|
+
}
|
|
11370
|
+
// file executed on its own (node ./file.test.mjs): there is no allocated time
|
|
11371
|
+
};
|
|
11372
|
+
|
|
10874
11373
|
const istanbulCoverageMapFromCoverage = (coverage) => {
|
|
10875
11374
|
const { createCoverageMap } = importWithRequire("istanbul-lib-coverage");
|
|
10876
11375
|
|
|
@@ -11553,4 +12052,4 @@ const inlineRuntime = (fn) => {
|
|
|
11553
12052
|
};
|
|
11554
12053
|
};
|
|
11555
12054
|
|
|
11556
|
-
export { chromium, chromiumIsolatedTab, execute, executeTestPlan, firefox, firefoxIsolatedTab, inlineRuntime, nodeChildProcess, nodeWorkerThread, reportAsJson, reportAsJunitXml, reportCoverageAsHtml, reportCoverageAsJson, reportCoverageInConsole, reporterList, webkit, webkitIsolatedTab };
|
|
12055
|
+
export { chromium, chromiumIsolatedTab, execute, executeTestPlan, firefox, firefoxIsolatedTab, inlineRuntime, nodeChildProcess, nodeWorkerThread, reportAsJson, reportAsJunitXml, reportCoverageAsHtml, reportCoverageAsJson, reportCoverageInConsole, reporterList, requestAllocatedMs, webkit, webkitIsolatedTab };
|