@jsenv/test 3.7.34 → 3.7.36
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 +346 -8
- package/package.json +4 -3
- package/src/execution/execute_test_plan.js +110 -1
- package/src/execution/execution_timings.js +125 -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 +35 -0
package/dist/jsenv_test.js
CHANGED
|
@@ -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 },
|
|
@@ -6980,6 +7104,7 @@ const run = async ({
|
|
|
6980
7104
|
signal = new AbortController().signal,
|
|
6981
7105
|
logger,
|
|
6982
7106
|
allocatedMs,
|
|
7107
|
+
onAllocatedMsRequested = () => {},
|
|
6983
7108
|
keepRunning = false,
|
|
6984
7109
|
mirrorConsole = false,
|
|
6985
7110
|
collectConsole = false,
|
|
@@ -7030,6 +7155,22 @@ const run = async ({
|
|
|
7030
7155
|
if (allocatedMs) {
|
|
7031
7156
|
timeoutAbortSource = runOperation.timeout(allocatedMs);
|
|
7032
7157
|
}
|
|
7158
|
+
// the file being executed can ask for more time, see requestAllocatedMs
|
|
7159
|
+
const handleAllocatedMsRequest = (ms) => {
|
|
7160
|
+
onAllocatedMsRequested(ms);
|
|
7161
|
+
if (!timeoutAbortSource) {
|
|
7162
|
+
// nothing to extend: execution has no time limit
|
|
7163
|
+
return;
|
|
7164
|
+
}
|
|
7165
|
+
if (ms <= allocatedMs) {
|
|
7166
|
+
return;
|
|
7167
|
+
}
|
|
7168
|
+
allocatedMs = ms;
|
|
7169
|
+
// the request arrives once execution has started, what is left of the
|
|
7170
|
+
// requested duration is what the file can still use
|
|
7171
|
+
timeoutAbortSource.remove();
|
|
7172
|
+
timeoutAbortSource = runOperation.timeout(ms - takeTiming());
|
|
7173
|
+
};
|
|
7033
7174
|
const consoleCalls = [];
|
|
7034
7175
|
onConsoleRef.current = ({ type, text }) => {
|
|
7035
7176
|
if (mirrorConsole) {
|
|
@@ -7082,6 +7223,7 @@ const run = async ({
|
|
|
7082
7223
|
signal: runOperation.signal,
|
|
7083
7224
|
logger,
|
|
7084
7225
|
...runtimeParams,
|
|
7226
|
+
onAllocatedMsRequested: handleAllocatedMsRequest,
|
|
7085
7227
|
collectConsole,
|
|
7086
7228
|
measureMemoryUsage,
|
|
7087
7229
|
onMeasureMemoryAvailable,
|
|
@@ -7536,6 +7678,7 @@ const ensureWebServerIsStarted = async (
|
|
|
7536
7678
|
* @param {Object} [testPlanParameters.webServer] Web server info; required when executing test on browsers
|
|
7537
7679
|
* @param {Object} testPlanParameters.testPlan Object associating files with runtimes where they will be executed
|
|
7538
7680
|
* @param {Object|false} [testPlanParameters.parallel] Maximum amount of execution running at the same time
|
|
7681
|
+
* @param {Object|false} [testPlanParameters.executionTimings=false] Remembers how long executions took to start the longest ones first on the next run
|
|
7539
7682
|
* @param {number} [testPlanParameters.defaultMsAllocatedPerExecution=30000] Milliseconds after which execution is aborted and considered as failed by timeout
|
|
7540
7683
|
* @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
|
|
7541
7684
|
* @param {Object|false} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
|
|
@@ -7594,6 +7737,12 @@ const parallelDefault = {
|
|
|
7594
7737
|
max: "80%", // percentage resolved against the available cpus
|
|
7595
7738
|
maxCpu: "80%",
|
|
7596
7739
|
maxMemory: "50%",
|
|
7740
|
+
// percentage resolved against parallel.max; an execution is heavy when it is
|
|
7741
|
+
// allocated more time than the others (see defaultMsAllocatedPerExecution)
|
|
7742
|
+
maxHeavy: "75%",
|
|
7743
|
+
};
|
|
7744
|
+
const executionTimingsDefault = {
|
|
7745
|
+
fileUrl: undefined,
|
|
7597
7746
|
};
|
|
7598
7747
|
|
|
7599
7748
|
const executeTestPlan = async ({
|
|
@@ -7609,6 +7758,9 @@ const executeTestPlan = async ({
|
|
|
7609
7758
|
handleSIGTERM = true,
|
|
7610
7759
|
updateProcessExitCode = true,
|
|
7611
7760
|
parallel = parallelDefault,
|
|
7761
|
+
// opt-in: it makes the start order depend on a file written by a previous run,
|
|
7762
|
+
// which a test plan snapshotting its own execution order cannot afford
|
|
7763
|
+
executionTimings = false,
|
|
7612
7764
|
// https://github.com/avajs/ava/blob/main/docs/recipes/splitting-tests-ci.md
|
|
7613
7765
|
// https://playwright.dev/docs/test-sharding
|
|
7614
7766
|
fragment,
|
|
@@ -7782,6 +7934,7 @@ const executeTestPlan = async ({
|
|
|
7782
7934
|
const afterEachInOrderCallbackSet = new Set();
|
|
7783
7935
|
const afterAllCallbackSet = new Set();
|
|
7784
7936
|
let finalizeCoverage;
|
|
7937
|
+
let timingsMemory;
|
|
7785
7938
|
|
|
7786
7939
|
try {
|
|
7787
7940
|
let logger;
|
|
@@ -7909,6 +8062,51 @@ const executeTestPlan = async ({
|
|
|
7909
8062
|
`parallel.maxCpu must be a number or a percentage, got ${maxCpu}`,
|
|
7910
8063
|
);
|
|
7911
8064
|
}
|
|
8065
|
+
|
|
8066
|
+
const maxHeavy = parallel.maxHeavy;
|
|
8067
|
+
if (typeof maxHeavy === "string") {
|
|
8068
|
+
const maxHeavyAsRatio = assertPercentageAndConvertToRatio(maxHeavy);
|
|
8069
|
+
parallel.maxHeavy = Math.round(maxHeavyAsRatio * parallel.max) || 1;
|
|
8070
|
+
} else if (typeof maxHeavy === "number") {
|
|
8071
|
+
if (maxHeavy < 1) {
|
|
8072
|
+
parallel.maxHeavy = 1;
|
|
8073
|
+
}
|
|
8074
|
+
} else {
|
|
8075
|
+
throw new TypeError(
|
|
8076
|
+
`parallel.maxHeavy must be a number or a percentage, got ${maxHeavy}`,
|
|
8077
|
+
);
|
|
8078
|
+
}
|
|
8079
|
+
}
|
|
8080
|
+
// executionTimings
|
|
8081
|
+
{
|
|
8082
|
+
if (executionTimings === true) {
|
|
8083
|
+
executionTimings = {};
|
|
8084
|
+
}
|
|
8085
|
+
if (executionTimings) {
|
|
8086
|
+
if (typeof executionTimings !== "object") {
|
|
8087
|
+
throw new TypeError(
|
|
8088
|
+
`executionTimings must be an object, got ${executionTimings}`,
|
|
8089
|
+
);
|
|
8090
|
+
}
|
|
8091
|
+
const unexpectedExecutionTimingsKeys = Object.keys(
|
|
8092
|
+
executionTimings,
|
|
8093
|
+
).filter((key) => !Object.hasOwn(executionTimingsDefault, key));
|
|
8094
|
+
if (unexpectedExecutionTimingsKeys.length > 0) {
|
|
8095
|
+
throw new TypeError(
|
|
8096
|
+
`${unexpectedExecutionTimingsKeys.join(",")}: no such key on executionTimings`,
|
|
8097
|
+
);
|
|
8098
|
+
}
|
|
8099
|
+
executionTimings = {
|
|
8100
|
+
...executionTimingsDefault,
|
|
8101
|
+
...executionTimings,
|
|
8102
|
+
};
|
|
8103
|
+
timingsMemory = createExecutionTimings({
|
|
8104
|
+
fileUrl:
|
|
8105
|
+
executionTimings.fileUrl === undefined
|
|
8106
|
+
? new URL("./.jsenv/jsenv_tests_timings.json", rootDirectoryUrl)
|
|
8107
|
+
: executionTimings.fileUrl,
|
|
8108
|
+
});
|
|
8109
|
+
}
|
|
7912
8110
|
}
|
|
7913
8111
|
// fragment/fragmentByRuntime
|
|
7914
8112
|
{
|
|
@@ -8254,6 +8452,8 @@ To fix this warning:
|
|
|
8254
8452
|
params,
|
|
8255
8453
|
skipped: false,
|
|
8256
8454
|
skipReason: "",
|
|
8455
|
+
// set when the file calls requestAllocatedMs
|
|
8456
|
+
allocatedMsRequested: undefined,
|
|
8257
8457
|
|
|
8258
8458
|
// will be set by run()
|
|
8259
8459
|
status: "planified",
|
|
@@ -8438,7 +8638,31 @@ To fix this warning:
|
|
|
8438
8638
|
|
|
8439
8639
|
const callWhenPreviousExecutionAreDone = createCallOrderer();
|
|
8440
8640
|
|
|
8441
|
-
|
|
8641
|
+
// an execution allowed more time than the others is a heavy one: it is
|
|
8642
|
+
// both worth starting early and worth not having too many of at once
|
|
8643
|
+
const heavyExecutionSet = new Set();
|
|
8644
|
+
for (const execution of executionPlanifiedArray) {
|
|
8645
|
+
if (execution.skipped) {
|
|
8646
|
+
continue;
|
|
8647
|
+
}
|
|
8648
|
+
if (execution.params.allocatedMs > defaultMsAllocatedPerExecution) {
|
|
8649
|
+
heavyExecutionSet.add(execution);
|
|
8650
|
+
continue;
|
|
8651
|
+
}
|
|
8652
|
+
if (timingsMemory) {
|
|
8653
|
+
const allocatedMsRequested = timingsMemory.getAllocatedMsRequested(
|
|
8654
|
+
execution.name,
|
|
8655
|
+
);
|
|
8656
|
+
if (allocatedMsRequested > defaultMsAllocatedPerExecution) {
|
|
8657
|
+
heavyExecutionSet.add(execution);
|
|
8658
|
+
}
|
|
8659
|
+
}
|
|
8660
|
+
}
|
|
8661
|
+
const executionStartOrderArray = timingsMemory
|
|
8662
|
+
? timingsMemory.sortByLongestFirst(executionPlanifiedArray)
|
|
8663
|
+
: executionPlanifiedArray;
|
|
8664
|
+
|
|
8665
|
+
const executionRemainingSet = new Set(executionStartOrderArray);
|
|
8442
8666
|
const executionExecutingSet = new Set();
|
|
8443
8667
|
const usedTagSet = new Set();
|
|
8444
8668
|
const start = async (execution) => {
|
|
@@ -8462,6 +8686,9 @@ To fix this warning:
|
|
|
8462
8686
|
execution.status = "executing";
|
|
8463
8687
|
const executionResult = await run({
|
|
8464
8688
|
...execution.params,
|
|
8689
|
+
onAllocatedMsRequested: (ms) => {
|
|
8690
|
+
execution.allocatedMsRequested = ms;
|
|
8691
|
+
},
|
|
8465
8692
|
signal: operation.signal,
|
|
8466
8693
|
logger,
|
|
8467
8694
|
keepRunning,
|
|
@@ -8476,6 +8703,9 @@ To fix this warning:
|
|
|
8476
8703
|
usedTagSet.delete(tagNoLongerInUse);
|
|
8477
8704
|
}
|
|
8478
8705
|
}
|
|
8706
|
+
if (timingsMemory) {
|
|
8707
|
+
timingsMemory.record(execution);
|
|
8708
|
+
}
|
|
8479
8709
|
if (execution.result.status !== "completed") {
|
|
8480
8710
|
testPlanResult.failed = true;
|
|
8481
8711
|
if (updateProcessExitCode) {
|
|
@@ -8506,6 +8736,12 @@ To fix this warning:
|
|
|
8506
8736
|
};
|
|
8507
8737
|
const startAsMuchAsPossible = async () => {
|
|
8508
8738
|
operation.throwIfAborted();
|
|
8739
|
+
let heavyExecutingCount = 0;
|
|
8740
|
+
for (const executionExecuting of executionExecutingSet) {
|
|
8741
|
+
if (heavyExecutionSet.has(executionExecuting)) {
|
|
8742
|
+
heavyExecutingCount++;
|
|
8743
|
+
}
|
|
8744
|
+
}
|
|
8509
8745
|
const promises = [];
|
|
8510
8746
|
for (const executionCandidate of executionRemainingSet) {
|
|
8511
8747
|
if (executionExecutingSet.size >= parallel.max) {
|
|
@@ -8533,6 +8769,13 @@ To fix this warning:
|
|
|
8533
8769
|
promises.push(promise);
|
|
8534
8770
|
break;
|
|
8535
8771
|
}
|
|
8772
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
8773
|
+
if (heavyExecutingCount >= parallel.maxHeavy) {
|
|
8774
|
+
// leave this slot to a lighter execution: filling every slot
|
|
8775
|
+
// with the heavy ones makes them fight for cpu and memory
|
|
8776
|
+
continue;
|
|
8777
|
+
}
|
|
8778
|
+
}
|
|
8536
8779
|
if (executionCandidate.params.uses) {
|
|
8537
8780
|
const nonAvailableTag = executionCandidate.params.uses.find(
|
|
8538
8781
|
(tagToUse) => usedTagSet.has(tagToUse),
|
|
@@ -8545,6 +8788,9 @@ To fix this warning:
|
|
|
8545
8788
|
}
|
|
8546
8789
|
}
|
|
8547
8790
|
}
|
|
8791
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
8792
|
+
heavyExecutingCount++;
|
|
8793
|
+
}
|
|
8548
8794
|
const promise = (async () => {
|
|
8549
8795
|
await start(executionCandidate);
|
|
8550
8796
|
await startAsMuchAsPossible();
|
|
@@ -8627,6 +8873,10 @@ To fix this warning:
|
|
|
8627
8873
|
}
|
|
8628
8874
|
timings.teardownEnd = takeTiming();
|
|
8629
8875
|
|
|
8876
|
+
if (timingsMemory) {
|
|
8877
|
+
timingsMemory.write();
|
|
8878
|
+
}
|
|
8879
|
+
|
|
8630
8880
|
if (finalizeCoverage) {
|
|
8631
8881
|
await finalizeCoverage();
|
|
8632
8882
|
}
|
|
@@ -8904,6 +9154,7 @@ const createRuntimeUsingPlaywright = ({
|
|
|
8904
9154
|
onConsole,
|
|
8905
9155
|
onRuntimeStarted,
|
|
8906
9156
|
onRuntimeStopped,
|
|
9157
|
+
onAllocatedMsRequested = () => {},
|
|
8907
9158
|
teardownCallbackSet,
|
|
8908
9159
|
isTestPlan,
|
|
8909
9160
|
|
|
@@ -9025,6 +9276,11 @@ ${webServer.rootDirectoryUrl}`);
|
|
|
9025
9276
|
}
|
|
9026
9277
|
|
|
9027
9278
|
const page = await browserContext.newPage();
|
|
9279
|
+
// the only thing the page can tell node before it is done executing;
|
|
9280
|
+
// see runtime_browsers/client/request_allocated_ms.js
|
|
9281
|
+
await page.exposeFunction("__jsenv_request_allocated_ms__", (ms) => {
|
|
9282
|
+
onAllocatedMsRequested(ms);
|
|
9283
|
+
});
|
|
9028
9284
|
if (!isBrowserDedicatedToExecution) {
|
|
9029
9285
|
page.on("close", () => {
|
|
9030
9286
|
onRuntimeStopped();
|
|
@@ -9581,6 +9837,19 @@ const createWekbitRuntime = (params) => {
|
|
|
9581
9837
|
});
|
|
9582
9838
|
};
|
|
9583
9839
|
|
|
9840
|
+
/**
|
|
9841
|
+
* Find a port nobody listens to on the given hostname, trying `initialPort`
|
|
9842
|
+
* first then the following ones.
|
|
9843
|
+
*
|
|
9844
|
+
* @param {number} [initialPort=1] - First port to try.
|
|
9845
|
+
* @param {Object} [options]
|
|
9846
|
+
* @param {AbortSignal} [options.signal]
|
|
9847
|
+
* @param {string} [options.hostname="127.0.0.1"] - Interface the port must be free on.
|
|
9848
|
+
* @param {number} [options.min=1]
|
|
9849
|
+
* @param {number} [options.max=65534] - Give up (throw) past this port.
|
|
9850
|
+
* @param {(port: number) => number} [options.next] - How to pick the next port to try.
|
|
9851
|
+
* @returns {Promise<number>}
|
|
9852
|
+
*/
|
|
9584
9853
|
const findFreePort = async (
|
|
9585
9854
|
initialPort = 1,
|
|
9586
9855
|
{
|
|
@@ -9643,20 +9912,34 @@ const portIsFree = async (port, hostname) => {
|
|
|
9643
9912
|
|
|
9644
9913
|
const startListening = ({ server, port, hostname }) => {
|
|
9645
9914
|
return new Promise((resolve, reject) => {
|
|
9646
|
-
|
|
9647
|
-
|
|
9915
|
+
const onError = (error) => {
|
|
9916
|
+
server.removeListener("listening", onListening);
|
|
9917
|
+
reject(error);
|
|
9918
|
+
};
|
|
9919
|
+
const onListening = () => {
|
|
9920
|
+
server.removeListener("error", onError);
|
|
9648
9921
|
// in case port is 0 (randomly assign an available port)
|
|
9649
9922
|
// https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback
|
|
9650
9923
|
resolve(server.address().port);
|
|
9651
|
-
}
|
|
9924
|
+
};
|
|
9925
|
+
server.once("error", onError);
|
|
9926
|
+
server.once("listening", onListening);
|
|
9652
9927
|
server.listen(port, hostname);
|
|
9653
9928
|
});
|
|
9654
9929
|
};
|
|
9655
9930
|
|
|
9656
9931
|
const stopListening = (server) => {
|
|
9657
9932
|
return new Promise((resolve, reject) => {
|
|
9658
|
-
|
|
9659
|
-
|
|
9933
|
+
const onError = (error) => {
|
|
9934
|
+
server.removeListener("close", onClose);
|
|
9935
|
+
reject(error);
|
|
9936
|
+
};
|
|
9937
|
+
const onClose = () => {
|
|
9938
|
+
server.removeListener("error", onError);
|
|
9939
|
+
resolve();
|
|
9940
|
+
};
|
|
9941
|
+
server.once("error", onError);
|
|
9942
|
+
server.once("close", onClose);
|
|
9660
9943
|
server.close();
|
|
9661
9944
|
});
|
|
9662
9945
|
};
|
|
@@ -10019,6 +10302,7 @@ const nodeChildProcess = ({
|
|
|
10019
10302
|
onConsole,
|
|
10020
10303
|
onRuntimeStarted,
|
|
10021
10304
|
onRuntimeStopped,
|
|
10305
|
+
onAllocatedMsRequested = () => {},
|
|
10022
10306
|
|
|
10023
10307
|
measureMemoryUsage,
|
|
10024
10308
|
onMeasureMemoryAvailable,
|
|
@@ -10126,6 +10410,15 @@ const nodeChildProcess = ({
|
|
|
10126
10410
|
onRuntimeStopped();
|
|
10127
10411
|
}),
|
|
10128
10412
|
);
|
|
10413
|
+
cleanupCallbackSet.add(
|
|
10414
|
+
onChildProcessMessage(
|
|
10415
|
+
childProcess,
|
|
10416
|
+
"allocated-ms-request",
|
|
10417
|
+
({ ms }) => {
|
|
10418
|
+
onAllocatedMsRequested(ms);
|
|
10419
|
+
},
|
|
10420
|
+
),
|
|
10421
|
+
);
|
|
10129
10422
|
|
|
10130
10423
|
const removeOutputListener = installChildProcessOutputListener(
|
|
10131
10424
|
childProcess,
|
|
@@ -10507,6 +10800,7 @@ const nodeWorkerThread = ({
|
|
|
10507
10800
|
onConsole,
|
|
10508
10801
|
onRuntimeStarted,
|
|
10509
10802
|
onRuntimeStopped,
|
|
10803
|
+
onAllocatedMsRequested = () => {},
|
|
10510
10804
|
|
|
10511
10805
|
measureMemoryUsage,
|
|
10512
10806
|
onMeasureMemoryAvailable,
|
|
@@ -10593,6 +10887,15 @@ const nodeWorkerThread = ({
|
|
|
10593
10887
|
onRuntimeStopped();
|
|
10594
10888
|
}),
|
|
10595
10889
|
);
|
|
10890
|
+
cleanupCallbackSet.add(
|
|
10891
|
+
onWorkerThreadMessage(
|
|
10892
|
+
workerThread,
|
|
10893
|
+
"allocated-ms-request",
|
|
10894
|
+
({ ms }) => {
|
|
10895
|
+
onAllocatedMsRequested(ms);
|
|
10896
|
+
},
|
|
10897
|
+
),
|
|
10898
|
+
);
|
|
10596
10899
|
|
|
10597
10900
|
const stop = memoize(async () => {
|
|
10598
10901
|
// read all stdout before terminating
|
|
@@ -10844,6 +11147,41 @@ const onceWorkerThreadEvent = (worker, type, callback) => {
|
|
|
10844
11147
|
};
|
|
10845
11148
|
};
|
|
10846
11149
|
|
|
11150
|
+
/*
|
|
11151
|
+
* Called from a test file to tell the test runner how much time this file needs:
|
|
11152
|
+
*
|
|
11153
|
+
* import { requestAllocatedMs } from "@jsenv/test";
|
|
11154
|
+
*
|
|
11155
|
+
* requestAllocatedMs(90_000);
|
|
11156
|
+
*
|
|
11157
|
+
* The request is sent to the process running the test plan, which restarts the
|
|
11158
|
+
* timeout with the requested duration and remembers it: a file asking for more
|
|
11159
|
+
* time than the others is also a file worth starting early when parallelizing.
|
|
11160
|
+
*
|
|
11161
|
+
* Node.js runtimes only; a browser has no channel to reach the test plan while
|
|
11162
|
+
* the file is executing.
|
|
11163
|
+
*/
|
|
11164
|
+
|
|
11165
|
+
|
|
11166
|
+
const requestAllocatedMs = (ms) => {
|
|
11167
|
+
if (typeof ms !== "number") {
|
|
11168
|
+
throw new TypeError(`requestAllocatedMs expects a number, got ${ms}`);
|
|
11169
|
+
}
|
|
11170
|
+
const message = {
|
|
11171
|
+
__jsenv__: "allocated-ms-request",
|
|
11172
|
+
data: JSON.stringify({ ms }),
|
|
11173
|
+
};
|
|
11174
|
+
if (parentPort) {
|
|
11175
|
+
parentPort.postMessage(message);
|
|
11176
|
+
return;
|
|
11177
|
+
}
|
|
11178
|
+
if (process.send && process.connected) {
|
|
11179
|
+
process.send(message);
|
|
11180
|
+
return;
|
|
11181
|
+
}
|
|
11182
|
+
// file executed on its own (node ./file.test.mjs): there is no allocated time
|
|
11183
|
+
};
|
|
11184
|
+
|
|
10847
11185
|
const istanbulCoverageMapFromCoverage = (coverage) => {
|
|
10848
11186
|
const { createCoverageMap } = importWithRequire("istanbul-lib-coverage");
|
|
10849
11187
|
|
|
@@ -11526,4 +11864,4 @@ const inlineRuntime = (fn) => {
|
|
|
11526
11864
|
};
|
|
11527
11865
|
};
|
|
11528
11866
|
|
|
11529
|
-
export { chromium, chromiumIsolatedTab, execute, executeTestPlan, firefox, firefoxIsolatedTab, inlineRuntime, nodeChildProcess, nodeWorkerThread, reportAsJson, reportAsJunitXml, reportCoverageAsHtml, reportCoverageAsJson, reportCoverageInConsole, reporterList, webkit, webkitIsolatedTab };
|
|
11867
|
+
export { chromium, chromiumIsolatedTab, execute, executeTestPlan, firefox, firefoxIsolatedTab, inlineRuntime, nodeChildProcess, nodeWorkerThread, reportAsJson, reportAsJunitXml, reportCoverageAsHtml, reportCoverageAsJson, reportCoverageInConsole, reporterList, requestAllocatedMs, webkit, webkitIsolatedTab };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/test",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": {
|
|
15
15
|
".": {
|
|
16
|
+
"browser": "./src/main_browser.js",
|
|
16
17
|
"import": {
|
|
17
18
|
"dev:jsenv": "./src/main.js",
|
|
18
19
|
"default": "./dist/jsenv_test.js"
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@c88/v8-coverage": "0.1.1",
|
|
33
|
-
"@jsenv/ast": "6.9.
|
|
34
|
-
"@jsenv/plugin-supervisor": "1.8.
|
|
34
|
+
"@jsenv/ast": "6.9.3",
|
|
35
|
+
"@jsenv/plugin-supervisor": "1.8.14",
|
|
35
36
|
"@jsenv/sourcemap": "1.4.2",
|
|
36
37
|
"he": "1.2.0",
|
|
37
38
|
"istanbul-lib-coverage": "3.2.2",
|
|
@@ -32,6 +32,7 @@ import { existsSync } from "node:fs";
|
|
|
32
32
|
import { takeCoverage } from "node:v8";
|
|
33
33
|
import stripAnsi from "strip-ansi";
|
|
34
34
|
import { generateCoverage } from "../coverage/generate_coverage.js";
|
|
35
|
+
import { createExecutionTimings } from "./execution_timings.js";
|
|
35
36
|
import { githubAnnotationFromError } from "./github_annotation_from_error.js";
|
|
36
37
|
import { createIsInsideFragment } from "./is_inside_fragment.js";
|
|
37
38
|
import { renderOutroContent, reporterList } from "./reporters/reporter_list.js";
|
|
@@ -45,6 +46,7 @@ import { assertAndNormalizeWebServer } from "./web_server_param.js";
|
|
|
45
46
|
* @param {Object} [testPlanParameters.webServer] Web server info; required when executing test on browsers
|
|
46
47
|
* @param {Object} testPlanParameters.testPlan Object associating files with runtimes where they will be executed
|
|
47
48
|
* @param {Object|false} [testPlanParameters.parallel] Maximum amount of execution running at the same time
|
|
49
|
+
* @param {Object|false} [testPlanParameters.executionTimings=false] Remembers how long executions took to start the longest ones first on the next run
|
|
48
50
|
* @param {number} [testPlanParameters.defaultMsAllocatedPerExecution=30000] Milliseconds after which execution is aborted and considered as failed by timeout
|
|
49
51
|
* @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
|
|
50
52
|
* @param {Object|false} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
|
|
@@ -103,6 +105,12 @@ const parallelDefault = {
|
|
|
103
105
|
max: "80%", // percentage resolved against the available cpus
|
|
104
106
|
maxCpu: "80%",
|
|
105
107
|
maxMemory: "50%",
|
|
108
|
+
// percentage resolved against parallel.max; an execution is heavy when it is
|
|
109
|
+
// allocated more time than the others (see defaultMsAllocatedPerExecution)
|
|
110
|
+
maxHeavy: "75%",
|
|
111
|
+
};
|
|
112
|
+
const executionTimingsDefault = {
|
|
113
|
+
fileUrl: undefined,
|
|
106
114
|
};
|
|
107
115
|
|
|
108
116
|
export const executeTestPlan = async ({
|
|
@@ -118,6 +126,9 @@ export const executeTestPlan = async ({
|
|
|
118
126
|
handleSIGTERM = true,
|
|
119
127
|
updateProcessExitCode = true,
|
|
120
128
|
parallel = parallelDefault,
|
|
129
|
+
// opt-in: it makes the start order depend on a file written by a previous run,
|
|
130
|
+
// which a test plan snapshotting its own execution order cannot afford
|
|
131
|
+
executionTimings = false,
|
|
121
132
|
// https://github.com/avajs/ava/blob/main/docs/recipes/splitting-tests-ci.md
|
|
122
133
|
// https://playwright.dev/docs/test-sharding
|
|
123
134
|
fragment,
|
|
@@ -291,6 +302,7 @@ export const executeTestPlan = async ({
|
|
|
291
302
|
const afterEachInOrderCallbackSet = new Set();
|
|
292
303
|
const afterAllCallbackSet = new Set();
|
|
293
304
|
let finalizeCoverage;
|
|
305
|
+
let timingsMemory;
|
|
294
306
|
|
|
295
307
|
try {
|
|
296
308
|
let logger;
|
|
@@ -418,6 +430,51 @@ export const executeTestPlan = async ({
|
|
|
418
430
|
`parallel.maxCpu must be a number or a percentage, got ${maxCpu}`,
|
|
419
431
|
);
|
|
420
432
|
}
|
|
433
|
+
|
|
434
|
+
const maxHeavy = parallel.maxHeavy;
|
|
435
|
+
if (typeof maxHeavy === "string") {
|
|
436
|
+
const maxHeavyAsRatio = assertPercentageAndConvertToRatio(maxHeavy);
|
|
437
|
+
parallel.maxHeavy = Math.round(maxHeavyAsRatio * parallel.max) || 1;
|
|
438
|
+
} else if (typeof maxHeavy === "number") {
|
|
439
|
+
if (maxHeavy < 1) {
|
|
440
|
+
parallel.maxHeavy = 1;
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
throw new TypeError(
|
|
444
|
+
`parallel.maxHeavy must be a number or a percentage, got ${maxHeavy}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// executionTimings
|
|
449
|
+
{
|
|
450
|
+
if (executionTimings === true) {
|
|
451
|
+
executionTimings = {};
|
|
452
|
+
}
|
|
453
|
+
if (executionTimings) {
|
|
454
|
+
if (typeof executionTimings !== "object") {
|
|
455
|
+
throw new TypeError(
|
|
456
|
+
`executionTimings must be an object, got ${executionTimings}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const unexpectedExecutionTimingsKeys = Object.keys(
|
|
460
|
+
executionTimings,
|
|
461
|
+
).filter((key) => !Object.hasOwn(executionTimingsDefault, key));
|
|
462
|
+
if (unexpectedExecutionTimingsKeys.length > 0) {
|
|
463
|
+
throw new TypeError(
|
|
464
|
+
`${unexpectedExecutionTimingsKeys.join(",")}: no such key on executionTimings`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
executionTimings = {
|
|
468
|
+
...executionTimingsDefault,
|
|
469
|
+
...executionTimings,
|
|
470
|
+
};
|
|
471
|
+
timingsMemory = createExecutionTimings({
|
|
472
|
+
fileUrl:
|
|
473
|
+
executionTimings.fileUrl === undefined
|
|
474
|
+
? new URL("./.jsenv/jsenv_tests_timings.json", rootDirectoryUrl)
|
|
475
|
+
: executionTimings.fileUrl,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
421
478
|
}
|
|
422
479
|
// fragment/fragmentByRuntime
|
|
423
480
|
{
|
|
@@ -763,6 +820,8 @@ To fix this warning:
|
|
|
763
820
|
params,
|
|
764
821
|
skipped: false,
|
|
765
822
|
skipReason: "",
|
|
823
|
+
// set when the file calls requestAllocatedMs
|
|
824
|
+
allocatedMsRequested: undefined,
|
|
766
825
|
|
|
767
826
|
// will be set by run()
|
|
768
827
|
status: "planified",
|
|
@@ -947,7 +1006,31 @@ To fix this warning:
|
|
|
947
1006
|
|
|
948
1007
|
const callWhenPreviousExecutionAreDone = createCallOrderer();
|
|
949
1008
|
|
|
950
|
-
|
|
1009
|
+
// an execution allowed more time than the others is a heavy one: it is
|
|
1010
|
+
// both worth starting early and worth not having too many of at once
|
|
1011
|
+
const heavyExecutionSet = new Set();
|
|
1012
|
+
for (const execution of executionPlanifiedArray) {
|
|
1013
|
+
if (execution.skipped) {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (execution.params.allocatedMs > defaultMsAllocatedPerExecution) {
|
|
1017
|
+
heavyExecutionSet.add(execution);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (timingsMemory) {
|
|
1021
|
+
const allocatedMsRequested = timingsMemory.getAllocatedMsRequested(
|
|
1022
|
+
execution.name,
|
|
1023
|
+
);
|
|
1024
|
+
if (allocatedMsRequested > defaultMsAllocatedPerExecution) {
|
|
1025
|
+
heavyExecutionSet.add(execution);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const executionStartOrderArray = timingsMemory
|
|
1030
|
+
? timingsMemory.sortByLongestFirst(executionPlanifiedArray)
|
|
1031
|
+
: executionPlanifiedArray;
|
|
1032
|
+
|
|
1033
|
+
const executionRemainingSet = new Set(executionStartOrderArray);
|
|
951
1034
|
const executionExecutingSet = new Set();
|
|
952
1035
|
const usedTagSet = new Set();
|
|
953
1036
|
const start = async (execution) => {
|
|
@@ -971,6 +1054,9 @@ To fix this warning:
|
|
|
971
1054
|
execution.status = "executing";
|
|
972
1055
|
const executionResult = await run({
|
|
973
1056
|
...execution.params,
|
|
1057
|
+
onAllocatedMsRequested: (ms) => {
|
|
1058
|
+
execution.allocatedMsRequested = ms;
|
|
1059
|
+
},
|
|
974
1060
|
signal: operation.signal,
|
|
975
1061
|
logger,
|
|
976
1062
|
keepRunning,
|
|
@@ -985,6 +1071,9 @@ To fix this warning:
|
|
|
985
1071
|
usedTagSet.delete(tagNoLongerInUse);
|
|
986
1072
|
}
|
|
987
1073
|
}
|
|
1074
|
+
if (timingsMemory) {
|
|
1075
|
+
timingsMemory.record(execution);
|
|
1076
|
+
}
|
|
988
1077
|
if (execution.result.status !== "completed") {
|
|
989
1078
|
testPlanResult.failed = true;
|
|
990
1079
|
if (updateProcessExitCode) {
|
|
@@ -1015,6 +1104,12 @@ To fix this warning:
|
|
|
1015
1104
|
};
|
|
1016
1105
|
const startAsMuchAsPossible = async () => {
|
|
1017
1106
|
operation.throwIfAborted();
|
|
1107
|
+
let heavyExecutingCount = 0;
|
|
1108
|
+
for (const executionExecuting of executionExecutingSet) {
|
|
1109
|
+
if (heavyExecutionSet.has(executionExecuting)) {
|
|
1110
|
+
heavyExecutingCount++;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1018
1113
|
const promises = [];
|
|
1019
1114
|
for (const executionCandidate of executionRemainingSet) {
|
|
1020
1115
|
if (executionExecutingSet.size >= parallel.max) {
|
|
@@ -1042,6 +1137,13 @@ To fix this warning:
|
|
|
1042
1137
|
promises.push(promise);
|
|
1043
1138
|
break;
|
|
1044
1139
|
}
|
|
1140
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
1141
|
+
if (heavyExecutingCount >= parallel.maxHeavy) {
|
|
1142
|
+
// leave this slot to a lighter execution: filling every slot
|
|
1143
|
+
// with the heavy ones makes them fight for cpu and memory
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1045
1147
|
if (executionCandidate.params.uses) {
|
|
1046
1148
|
const nonAvailableTag = executionCandidate.params.uses.find(
|
|
1047
1149
|
(tagToUse) => usedTagSet.has(tagToUse),
|
|
@@ -1054,6 +1156,9 @@ To fix this warning:
|
|
|
1054
1156
|
}
|
|
1055
1157
|
}
|
|
1056
1158
|
}
|
|
1159
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
1160
|
+
heavyExecutingCount++;
|
|
1161
|
+
}
|
|
1057
1162
|
const promise = (async () => {
|
|
1058
1163
|
await start(executionCandidate);
|
|
1059
1164
|
await startAsMuchAsPossible();
|
|
@@ -1137,6 +1242,10 @@ To fix this warning:
|
|
|
1137
1242
|
}
|
|
1138
1243
|
timings.teardownEnd = takeTiming();
|
|
1139
1244
|
|
|
1245
|
+
if (timingsMemory) {
|
|
1246
|
+
timingsMemory.write();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1140
1249
|
if (finalizeCoverage) {
|
|
1141
1250
|
await finalizeCoverage();
|
|
1142
1251
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Remembers, from one run to the next, how long each execution took and how much
|
|
3
|
+
* time it asked for (see requestAllocatedMs).
|
|
4
|
+
*
|
|
5
|
+
* What it is for: with a fixed number of parallel slots, the moment a long
|
|
6
|
+
* execution starts decides when the whole run ends. Started last it keeps one
|
|
7
|
+
* slot busy while every other one is idle; started first it runs while the short
|
|
8
|
+
* ones fill the slots around it. Knowing the durations of the previous run is
|
|
9
|
+
* what allows to start the long ones first.
|
|
10
|
+
*
|
|
11
|
+
* Only the start order is affected: executions keep the index they got from the
|
|
12
|
+
* filesystem, so they are still reported in that order.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync } from "@jsenv/filesystem";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
const MS_IN_A_DAY = 24 * 60 * 60 * 1000;
|
|
19
|
+
// an execution not seen for that long is likely a file that no longer exists
|
|
20
|
+
const ENTRY_MAX_AGE_MS = 30 * MS_IN_A_DAY;
|
|
21
|
+
|
|
22
|
+
export const createExecutionTimings = ({ fileUrl }) => {
|
|
23
|
+
const previousEntryMap = readEntries(fileUrl);
|
|
24
|
+
const entryMap = new Map();
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
getAllocatedMsRequested: (executionName) => {
|
|
28
|
+
const previousEntry = previousEntryMap.get(executionName);
|
|
29
|
+
if (!previousEntry) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return previousEntry.allocatedMsRequested;
|
|
33
|
+
},
|
|
34
|
+
sortByLongestFirst: (executionArray) => {
|
|
35
|
+
const durationMsMap = new Map();
|
|
36
|
+
const durationMsArray = [];
|
|
37
|
+
for (const execution of executionArray) {
|
|
38
|
+
const previousEntry = previousEntryMap.get(execution.name);
|
|
39
|
+
if (previousEntry) {
|
|
40
|
+
durationMsMap.set(execution, previousEntry.durationMs);
|
|
41
|
+
durationMsArray.push(previousEntry.durationMs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (durationMsArray.length === 0) {
|
|
45
|
+
return executionArray;
|
|
46
|
+
}
|
|
47
|
+
// an execution never seen before is assumed to last as long as the median:
|
|
48
|
+
// being new is not a reason to be pushed at the end of the run
|
|
49
|
+
durationMsArray.sort((a, b) => a - b);
|
|
50
|
+
const medianDurationMs =
|
|
51
|
+
durationMsArray[Math.floor(durationMsArray.length / 2)];
|
|
52
|
+
// sort is stable: executions with the same estimation stay in the order
|
|
53
|
+
// they were found on the filesystem
|
|
54
|
+
return [...executionArray].sort((leftExecution, rightExecution) => {
|
|
55
|
+
const leftDurationMs =
|
|
56
|
+
durationMsMap.get(leftExecution) ?? medianDurationMs;
|
|
57
|
+
const rightDurationMs =
|
|
58
|
+
durationMsMap.get(rightExecution) ?? medianDurationMs;
|
|
59
|
+
return rightDurationMs - leftDurationMs;
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
record: (execution) => {
|
|
63
|
+
const { status, timings } = execution.result;
|
|
64
|
+
if (
|
|
65
|
+
status !== "completed" &&
|
|
66
|
+
status !== "failed" &&
|
|
67
|
+
status !== "timedout"
|
|
68
|
+
) {
|
|
69
|
+
// an execution that was skipped, aborted or cancelled says nothing
|
|
70
|
+
// about how long the file needs
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const entry = {
|
|
74
|
+
durationMs: timings.end,
|
|
75
|
+
updatedAt: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
if (execution.allocatedMsRequested !== undefined) {
|
|
78
|
+
entry.allocatedMsRequested = execution.allocatedMsRequested;
|
|
79
|
+
}
|
|
80
|
+
entryMap.set(execution.name, entry);
|
|
81
|
+
},
|
|
82
|
+
write: () => {
|
|
83
|
+
const nowMs = Date.now();
|
|
84
|
+
const executions = {};
|
|
85
|
+
// executions not part of this run keep what was known about them
|
|
86
|
+
for (const [executionName, entry] of previousEntryMap) {
|
|
87
|
+
if (nowMs - entry.updatedAt < ENTRY_MAX_AGE_MS) {
|
|
88
|
+
executions[executionName] = entry;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [executionName, entry] of entryMap) {
|
|
92
|
+
executions[executionName] = entry;
|
|
93
|
+
}
|
|
94
|
+
writeFileSync(fileUrl, JSON.stringify({ executions }, null, " "));
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const readEntries = (fileUrl) => {
|
|
100
|
+
const entryMap = new Map();
|
|
101
|
+
let fileContent;
|
|
102
|
+
try {
|
|
103
|
+
fileContent = readFileSync(new URL(fileUrl), "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
// no memory of a previous run: every execution is an unknown
|
|
106
|
+
return entryMap;
|
|
107
|
+
}
|
|
108
|
+
let executions;
|
|
109
|
+
try {
|
|
110
|
+
({ executions } = JSON.parse(fileContent));
|
|
111
|
+
} catch {
|
|
112
|
+
// file was truncated by a run killed while writing it
|
|
113
|
+
return entryMap;
|
|
114
|
+
}
|
|
115
|
+
if (!executions) {
|
|
116
|
+
return entryMap;
|
|
117
|
+
}
|
|
118
|
+
for (const executionName of Object.keys(executions)) {
|
|
119
|
+
const entry = executions[executionName];
|
|
120
|
+
if (typeof entry.durationMs === "number") {
|
|
121
|
+
entryMap.set(executionName, entry);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return entryMap;
|
|
125
|
+
};
|
package/src/execution/run.js
CHANGED
|
@@ -19,6 +19,7 @@ export const run = async ({
|
|
|
19
19
|
signal = new AbortController().signal,
|
|
20
20
|
logger,
|
|
21
21
|
allocatedMs,
|
|
22
|
+
onAllocatedMsRequested = () => {},
|
|
22
23
|
keepRunning = false,
|
|
23
24
|
mirrorConsole = false,
|
|
24
25
|
collectConsole = false,
|
|
@@ -69,6 +70,22 @@ export const run = async ({
|
|
|
69
70
|
if (allocatedMs) {
|
|
70
71
|
timeoutAbortSource = runOperation.timeout(allocatedMs);
|
|
71
72
|
}
|
|
73
|
+
// the file being executed can ask for more time, see requestAllocatedMs
|
|
74
|
+
const handleAllocatedMsRequest = (ms) => {
|
|
75
|
+
onAllocatedMsRequested(ms);
|
|
76
|
+
if (!timeoutAbortSource) {
|
|
77
|
+
// nothing to extend: execution has no time limit
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (ms <= allocatedMs) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
allocatedMs = ms;
|
|
84
|
+
// the request arrives once execution has started, what is left of the
|
|
85
|
+
// requested duration is what the file can still use
|
|
86
|
+
timeoutAbortSource.remove();
|
|
87
|
+
timeoutAbortSource = runOperation.timeout(ms - takeTiming());
|
|
88
|
+
};
|
|
72
89
|
const consoleCalls = [];
|
|
73
90
|
onConsoleRef.current = ({ type, text }) => {
|
|
74
91
|
if (mirrorConsole) {
|
|
@@ -121,6 +138,7 @@ export const run = async ({
|
|
|
121
138
|
signal: runOperation.signal,
|
|
122
139
|
logger,
|
|
123
140
|
...runtimeParams,
|
|
141
|
+
onAllocatedMsRequested: handleAllocatedMsRequest,
|
|
124
142
|
collectConsole,
|
|
125
143
|
measureMemoryUsage,
|
|
126
144
|
onMeasureMemoryAvailable,
|
package/src/main.js
CHANGED
|
@@ -8,6 +8,8 @@ export { firefox, firefoxIsolatedTab } from "./runtime_browsers/firefox.js";
|
|
|
8
8
|
export { webkit, webkitIsolatedTab } from "./runtime_browsers/webkit.js";
|
|
9
9
|
export { nodeChildProcess } from "./runtime_node/node_child_process.js";
|
|
10
10
|
export { nodeWorkerThread } from "./runtime_node/node_worker_thread.js";
|
|
11
|
+
// called from within a test file
|
|
12
|
+
export { requestAllocatedMs } from "./runtime_node/request_allocated_ms.js";
|
|
11
13
|
// coverage
|
|
12
14
|
export { reportCoverageAsHtml } from "./coverage/report_coverage_as_html.js";
|
|
13
15
|
export { reportCoverageAsJson } from "./coverage/report_coverage_as_json.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Browser side of requestAllocatedMs: reaches the process running the test plan
|
|
3
|
+
* through a function playwright exposed on the page before navigating to it
|
|
4
|
+
* (see using_playwright.js).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const requestAllocatedMs = (ms) => {
|
|
8
|
+
if (typeof ms !== "number") {
|
|
9
|
+
throw new TypeError(`requestAllocatedMs expects a number, got ${ms}`);
|
|
10
|
+
}
|
|
11
|
+
const requestAllocatedMsBinding = window.__jsenv_request_allocated_ms__;
|
|
12
|
+
if (!requestAllocatedMsBinding) {
|
|
13
|
+
// page opened on its own (dev server, browser tab): there is no allocated time
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
requestAllocatedMsBinding(ms);
|
|
17
|
+
};
|
|
@@ -45,6 +45,7 @@ export const createRuntimeUsingPlaywright = ({
|
|
|
45
45
|
onConsole,
|
|
46
46
|
onRuntimeStarted,
|
|
47
47
|
onRuntimeStopped,
|
|
48
|
+
onAllocatedMsRequested = () => {},
|
|
48
49
|
teardownCallbackSet,
|
|
49
50
|
isTestPlan,
|
|
50
51
|
|
|
@@ -166,6 +167,11 @@ ${webServer.rootDirectoryUrl}`);
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
const page = await browserContext.newPage();
|
|
170
|
+
// the only thing the page can tell node before it is done executing;
|
|
171
|
+
// see runtime_browsers/client/request_allocated_ms.js
|
|
172
|
+
await page.exposeFunction("__jsenv_request_allocated_ms__", (ms) => {
|
|
173
|
+
onAllocatedMsRequested(ms);
|
|
174
|
+
});
|
|
169
175
|
if (!isBrowserDedicatedToExecution) {
|
|
170
176
|
page.on("close", () => {
|
|
171
177
|
onRuntimeStopped();
|
|
@@ -55,6 +55,7 @@ export const nodeChildProcess = ({
|
|
|
55
55
|
onConsole,
|
|
56
56
|
onRuntimeStarted,
|
|
57
57
|
onRuntimeStopped,
|
|
58
|
+
onAllocatedMsRequested = () => {},
|
|
58
59
|
|
|
59
60
|
measureMemoryUsage,
|
|
60
61
|
onMeasureMemoryAvailable,
|
|
@@ -162,6 +163,15 @@ export const nodeChildProcess = ({
|
|
|
162
163
|
onRuntimeStopped();
|
|
163
164
|
}),
|
|
164
165
|
);
|
|
166
|
+
cleanupCallbackSet.add(
|
|
167
|
+
onChildProcessMessage(
|
|
168
|
+
childProcess,
|
|
169
|
+
"allocated-ms-request",
|
|
170
|
+
({ ms }) => {
|
|
171
|
+
onAllocatedMsRequested(ms);
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
);
|
|
165
175
|
|
|
166
176
|
const removeOutputListener = installChildProcessOutputListener(
|
|
167
177
|
childProcess,
|
|
@@ -57,6 +57,7 @@ export const nodeWorkerThread = ({
|
|
|
57
57
|
onConsole,
|
|
58
58
|
onRuntimeStarted,
|
|
59
59
|
onRuntimeStopped,
|
|
60
|
+
onAllocatedMsRequested = () => {},
|
|
60
61
|
|
|
61
62
|
measureMemoryUsage,
|
|
62
63
|
onMeasureMemoryAvailable,
|
|
@@ -143,6 +144,15 @@ export const nodeWorkerThread = ({
|
|
|
143
144
|
onRuntimeStopped();
|
|
144
145
|
}),
|
|
145
146
|
);
|
|
147
|
+
cleanupCallbackSet.add(
|
|
148
|
+
onWorkerThreadMessage(
|
|
149
|
+
workerThread,
|
|
150
|
+
"allocated-ms-request",
|
|
151
|
+
({ ms }) => {
|
|
152
|
+
onAllocatedMsRequested(ms);
|
|
153
|
+
},
|
|
154
|
+
),
|
|
155
|
+
);
|
|
146
156
|
|
|
147
157
|
const stop = memoize(async () => {
|
|
148
158
|
// read all stdout before terminating
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Called from a test file to tell the test runner how much time this file needs:
|
|
3
|
+
*
|
|
4
|
+
* import { requestAllocatedMs } from "@jsenv/test";
|
|
5
|
+
*
|
|
6
|
+
* requestAllocatedMs(90_000);
|
|
7
|
+
*
|
|
8
|
+
* The request is sent to the process running the test plan, which restarts the
|
|
9
|
+
* timeout with the requested duration and remembers it: a file asking for more
|
|
10
|
+
* time than the others is also a file worth starting early when parallelizing.
|
|
11
|
+
*
|
|
12
|
+
* Node.js runtimes only; a browser has no channel to reach the test plan while
|
|
13
|
+
* the file is executing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parentPort } from "node:worker_threads";
|
|
17
|
+
|
|
18
|
+
export const requestAllocatedMs = (ms) => {
|
|
19
|
+
if (typeof ms !== "number") {
|
|
20
|
+
throw new TypeError(`requestAllocatedMs expects a number, got ${ms}`);
|
|
21
|
+
}
|
|
22
|
+
const message = {
|
|
23
|
+
__jsenv__: "allocated-ms-request",
|
|
24
|
+
data: JSON.stringify({ ms }),
|
|
25
|
+
};
|
|
26
|
+
if (parentPort) {
|
|
27
|
+
parentPort.postMessage(message);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (process.send && process.connected) {
|
|
31
|
+
process.send(message);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// file executed on its own (node ./file.test.mjs): there is no allocated time
|
|
35
|
+
};
|