@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/test",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.37",
|
|
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"
|
|
@@ -32,7 +32,9 @@ 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";
|
|
37
|
+
import { readJsenvDirectives } from "./jsenv_directives.js";
|
|
36
38
|
import { createIsInsideFragment } from "./is_inside_fragment.js";
|
|
37
39
|
import { renderOutroContent, reporterList } from "./reporters/reporter_list.js";
|
|
38
40
|
import { run } from "./run.js";
|
|
@@ -45,6 +47,7 @@ import { assertAndNormalizeWebServer } from "./web_server_param.js";
|
|
|
45
47
|
* @param {Object} [testPlanParameters.webServer] Web server info; required when executing test on browsers
|
|
46
48
|
* @param {Object} testPlanParameters.testPlan Object associating files with runtimes where they will be executed
|
|
47
49
|
* @param {Object|false} [testPlanParameters.parallel] Maximum amount of execution running at the same time
|
|
50
|
+
* @param {Object|false} [testPlanParameters.executionTimings=false] Remembers how long executions took to start the longest ones first on the next run
|
|
48
51
|
* @param {number} [testPlanParameters.defaultMsAllocatedPerExecution=30000] Milliseconds after which execution is aborted and considered as failed by timeout
|
|
49
52
|
* @param {boolean} [testPlanParameters.failFast=false] Fails immediatly when a test execution fails
|
|
50
53
|
* @param {Object|false} [testPlanParameters.coverage=false] Controls if coverage is collected during files executions
|
|
@@ -103,6 +106,12 @@ const parallelDefault = {
|
|
|
103
106
|
max: "80%", // percentage resolved against the available cpus
|
|
104
107
|
maxCpu: "80%",
|
|
105
108
|
maxMemory: "50%",
|
|
109
|
+
// percentage resolved against parallel.max; an execution is heavy when it is
|
|
110
|
+
// allocated more time than the others (see defaultMsAllocatedPerExecution)
|
|
111
|
+
maxHeavy: "75%",
|
|
112
|
+
};
|
|
113
|
+
const executionTimingsDefault = {
|
|
114
|
+
fileUrl: undefined,
|
|
106
115
|
};
|
|
107
116
|
|
|
108
117
|
export const executeTestPlan = async ({
|
|
@@ -118,6 +127,9 @@ export const executeTestPlan = async ({
|
|
|
118
127
|
handleSIGTERM = true,
|
|
119
128
|
updateProcessExitCode = true,
|
|
120
129
|
parallel = parallelDefault,
|
|
130
|
+
// opt-in: it makes the start order depend on a file written by a previous run,
|
|
131
|
+
// which a test plan snapshotting its own execution order cannot afford
|
|
132
|
+
executionTimings = false,
|
|
121
133
|
// https://github.com/avajs/ava/blob/main/docs/recipes/splitting-tests-ci.md
|
|
122
134
|
// https://playwright.dev/docs/test-sharding
|
|
123
135
|
fragment,
|
|
@@ -291,6 +303,7 @@ export const executeTestPlan = async ({
|
|
|
291
303
|
const afterEachInOrderCallbackSet = new Set();
|
|
292
304
|
const afterAllCallbackSet = new Set();
|
|
293
305
|
let finalizeCoverage;
|
|
306
|
+
let timingsMemory;
|
|
294
307
|
|
|
295
308
|
try {
|
|
296
309
|
let logger;
|
|
@@ -418,6 +431,51 @@ export const executeTestPlan = async ({
|
|
|
418
431
|
`parallel.maxCpu must be a number or a percentage, got ${maxCpu}`,
|
|
419
432
|
);
|
|
420
433
|
}
|
|
434
|
+
|
|
435
|
+
const maxHeavy = parallel.maxHeavy;
|
|
436
|
+
if (typeof maxHeavy === "string") {
|
|
437
|
+
const maxHeavyAsRatio = assertPercentageAndConvertToRatio(maxHeavy);
|
|
438
|
+
parallel.maxHeavy = Math.round(maxHeavyAsRatio * parallel.max) || 1;
|
|
439
|
+
} else if (typeof maxHeavy === "number") {
|
|
440
|
+
if (maxHeavy < 1) {
|
|
441
|
+
parallel.maxHeavy = 1;
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
throw new TypeError(
|
|
445
|
+
`parallel.maxHeavy must be a number or a percentage, got ${maxHeavy}`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// executionTimings
|
|
450
|
+
{
|
|
451
|
+
if (executionTimings === true) {
|
|
452
|
+
executionTimings = {};
|
|
453
|
+
}
|
|
454
|
+
if (executionTimings) {
|
|
455
|
+
if (typeof executionTimings !== "object") {
|
|
456
|
+
throw new TypeError(
|
|
457
|
+
`executionTimings must be an object, got ${executionTimings}`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const unexpectedExecutionTimingsKeys = Object.keys(
|
|
461
|
+
executionTimings,
|
|
462
|
+
).filter((key) => !Object.hasOwn(executionTimingsDefault, key));
|
|
463
|
+
if (unexpectedExecutionTimingsKeys.length > 0) {
|
|
464
|
+
throw new TypeError(
|
|
465
|
+
`${unexpectedExecutionTimingsKeys.join(",")}: no such key on executionTimings`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
executionTimings = {
|
|
469
|
+
...executionTimingsDefault,
|
|
470
|
+
...executionTimings,
|
|
471
|
+
};
|
|
472
|
+
timingsMemory = createExecutionTimings({
|
|
473
|
+
fileUrl:
|
|
474
|
+
executionTimings.fileUrl === undefined
|
|
475
|
+
? new URL("./.jsenv/jsenv_tests_timings.json", rootDirectoryUrl)
|
|
476
|
+
: executionTimings.fileUrl,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
421
479
|
}
|
|
422
480
|
// fragment/fragmentByRuntime
|
|
423
481
|
{
|
|
@@ -688,6 +746,9 @@ To fix this warning:
|
|
|
688
746
|
}
|
|
689
747
|
}
|
|
690
748
|
const filePlan = meta.testPlan;
|
|
749
|
+
const directives = readJsenvDirectives(
|
|
750
|
+
new URL(relativeUrl, rootDirectoryUrl),
|
|
751
|
+
);
|
|
691
752
|
for (const groupName of Object.keys(filePlan)) {
|
|
692
753
|
const stepConfig = filePlan[groupName];
|
|
693
754
|
if (stepConfig === null || stepConfig === undefined) {
|
|
@@ -709,7 +770,7 @@ To fix this warning:
|
|
|
709
770
|
runtime,
|
|
710
771
|
runtimeParams,
|
|
711
772
|
allocatedMs = defaultMsAllocatedPerExecution,
|
|
712
|
-
|
|
773
|
+
locks,
|
|
713
774
|
} = stepConfig;
|
|
714
775
|
const params = {
|
|
715
776
|
measureMemoryUsage: true,
|
|
@@ -717,7 +778,7 @@ To fix this warning:
|
|
|
717
778
|
collectPerformance: false,
|
|
718
779
|
collectConsole: true,
|
|
719
780
|
allocatedMs,
|
|
720
|
-
|
|
781
|
+
locks,
|
|
721
782
|
runtime,
|
|
722
783
|
runtimeParams: {
|
|
723
784
|
rootDirectoryUrl,
|
|
@@ -763,6 +824,8 @@ To fix this warning:
|
|
|
763
824
|
params,
|
|
764
825
|
skipped: false,
|
|
765
826
|
skipReason: "",
|
|
827
|
+
// set when the file calls requestAllocatedMs
|
|
828
|
+
allocatedMsRequested: undefined,
|
|
766
829
|
|
|
767
830
|
// will be set by run()
|
|
768
831
|
status: "planified",
|
|
@@ -775,9 +838,22 @@ To fix this warning:
|
|
|
775
838
|
? defaultMsAllocatedPerExecution
|
|
776
839
|
: allocatedMsResult;
|
|
777
840
|
}
|
|
778
|
-
if (typeof params.
|
|
779
|
-
const
|
|
780
|
-
params.
|
|
841
|
+
if (typeof params.locks === "function") {
|
|
842
|
+
const locksResult = params.locks(execution);
|
|
843
|
+
params.locks = locksResult;
|
|
844
|
+
}
|
|
845
|
+
// what the file declares about itself wins over the plan: it is
|
|
846
|
+
// closer to the reason
|
|
847
|
+
if (
|
|
848
|
+
directives.allocatedMs !== undefined &&
|
|
849
|
+
directives.allocatedMs > params.allocatedMs
|
|
850
|
+
) {
|
|
851
|
+
params.allocatedMs = directives.allocatedMs;
|
|
852
|
+
}
|
|
853
|
+
if (directives.lockArray.length > 0) {
|
|
854
|
+
params.locks = params.locks
|
|
855
|
+
? [...new Set([...params.locks, ...directives.lockArray])]
|
|
856
|
+
: directives.lockArray;
|
|
781
857
|
}
|
|
782
858
|
|
|
783
859
|
lastExecution = execution;
|
|
@@ -947,9 +1023,33 @@ To fix this warning:
|
|
|
947
1023
|
|
|
948
1024
|
const callWhenPreviousExecutionAreDone = createCallOrderer();
|
|
949
1025
|
|
|
950
|
-
|
|
1026
|
+
// an execution allowed more time than the others is a heavy one: it is
|
|
1027
|
+
// both worth starting early and worth not having too many of at once
|
|
1028
|
+
const heavyExecutionSet = new Set();
|
|
1029
|
+
for (const execution of executionPlanifiedArray) {
|
|
1030
|
+
if (execution.skipped) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (execution.params.allocatedMs > defaultMsAllocatedPerExecution) {
|
|
1034
|
+
heavyExecutionSet.add(execution);
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (timingsMemory) {
|
|
1038
|
+
const allocatedMsRequested = timingsMemory.getAllocatedMsRequested(
|
|
1039
|
+
execution.name,
|
|
1040
|
+
);
|
|
1041
|
+
if (allocatedMsRequested > defaultMsAllocatedPerExecution) {
|
|
1042
|
+
heavyExecutionSet.add(execution);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const executionStartOrderArray = timingsMemory
|
|
1047
|
+
? timingsMemory.sortByLongestFirst(executionPlanifiedArray)
|
|
1048
|
+
: executionPlanifiedArray;
|
|
1049
|
+
|
|
1050
|
+
const executionRemainingSet = new Set(executionStartOrderArray);
|
|
951
1051
|
const executionExecutingSet = new Set();
|
|
952
|
-
const
|
|
1052
|
+
const lockedResourceSet = new Set();
|
|
953
1053
|
const start = async (execution) => {
|
|
954
1054
|
execution.fileExecutionCount = Object.keys(
|
|
955
1055
|
testPlanResult.results[execution.fileRelativeUrl],
|
|
@@ -963,14 +1063,17 @@ To fix this warning:
|
|
|
963
1063
|
execution.result.status = "skipped";
|
|
964
1064
|
execution.result.value = execution.skipReason;
|
|
965
1065
|
} else {
|
|
966
|
-
if (execution.params.
|
|
967
|
-
for (const
|
|
968
|
-
|
|
1066
|
+
if (execution.params.locks) {
|
|
1067
|
+
for (const resourceToLock of execution.params.locks) {
|
|
1068
|
+
lockedResourceSet.add(resourceToLock);
|
|
969
1069
|
}
|
|
970
1070
|
}
|
|
971
1071
|
execution.status = "executing";
|
|
972
1072
|
const executionResult = await run({
|
|
973
1073
|
...execution.params,
|
|
1074
|
+
onAllocatedMsRequested: (ms) => {
|
|
1075
|
+
execution.allocatedMsRequested = ms;
|
|
1076
|
+
},
|
|
974
1077
|
signal: operation.signal,
|
|
975
1078
|
logger,
|
|
976
1079
|
keepRunning,
|
|
@@ -980,11 +1083,14 @@ To fix this warning:
|
|
|
980
1083
|
});
|
|
981
1084
|
Object.assign(execution.result, executionResult);
|
|
982
1085
|
execution.status = "executed";
|
|
983
|
-
if (execution.params.
|
|
984
|
-
for (const
|
|
985
|
-
|
|
1086
|
+
if (execution.params.locks) {
|
|
1087
|
+
for (const resourceToRelease of execution.params.locks) {
|
|
1088
|
+
lockedResourceSet.delete(resourceToRelease);
|
|
986
1089
|
}
|
|
987
1090
|
}
|
|
1091
|
+
if (timingsMemory) {
|
|
1092
|
+
timingsMemory.record(execution);
|
|
1093
|
+
}
|
|
988
1094
|
if (execution.result.status !== "completed") {
|
|
989
1095
|
testPlanResult.failed = true;
|
|
990
1096
|
if (updateProcessExitCode) {
|
|
@@ -1015,6 +1121,12 @@ To fix this warning:
|
|
|
1015
1121
|
};
|
|
1016
1122
|
const startAsMuchAsPossible = async () => {
|
|
1017
1123
|
operation.throwIfAborted();
|
|
1124
|
+
let heavyExecutingCount = 0;
|
|
1125
|
+
for (const executionExecuting of executionExecutingSet) {
|
|
1126
|
+
if (heavyExecutionSet.has(executionExecuting)) {
|
|
1127
|
+
heavyExecutingCount++;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1018
1130
|
const promises = [];
|
|
1019
1131
|
for (const executionCandidate of executionRemainingSet) {
|
|
1020
1132
|
if (executionExecutingSet.size >= parallel.max) {
|
|
@@ -1042,18 +1154,29 @@ To fix this warning:
|
|
|
1042
1154
|
promises.push(promise);
|
|
1043
1155
|
break;
|
|
1044
1156
|
}
|
|
1045
|
-
if (executionCandidate
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1157
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
1158
|
+
if (heavyExecutingCount >= parallel.maxHeavy) {
|
|
1159
|
+
// leave this slot to a lighter execution: filling every slot
|
|
1160
|
+
// with the heavy ones makes them fight for cpu and memory
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (executionCandidate.params.locks) {
|
|
1165
|
+
const resourceLockedByAnother =
|
|
1166
|
+
executionCandidate.params.locks.find((resourceToLock) =>
|
|
1167
|
+
lockedResourceSet.has(resourceToLock),
|
|
1168
|
+
);
|
|
1169
|
+
if (resourceLockedByAnother) {
|
|
1050
1170
|
logger.debug(
|
|
1051
|
-
`"${
|
|
1171
|
+
`"${resourceLockedByAnother}" is locked, ${executionCandidate.name} will wait until it is released by a previous execution`,
|
|
1052
1172
|
);
|
|
1053
1173
|
continue;
|
|
1054
1174
|
}
|
|
1055
1175
|
}
|
|
1056
1176
|
}
|
|
1177
|
+
if (heavyExecutionSet.has(executionCandidate)) {
|
|
1178
|
+
heavyExecutingCount++;
|
|
1179
|
+
}
|
|
1057
1180
|
const promise = (async () => {
|
|
1058
1181
|
await start(executionCandidate);
|
|
1059
1182
|
await startAsMuchAsPossible();
|
|
@@ -1137,6 +1260,10 @@ To fix this warning:
|
|
|
1137
1260
|
}
|
|
1138
1261
|
timings.teardownEnd = takeTiming();
|
|
1139
1262
|
|
|
1263
|
+
if (timingsMemory) {
|
|
1264
|
+
timingsMemory.write();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1140
1267
|
if (finalizeCoverage) {
|
|
1141
1268
|
await finalizeCoverage();
|
|
1142
1269
|
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reads what a test file declares about its own execution, from the directive
|
|
3
|
+
* prologue — the string literals a module may open with, as in "use strict" or
|
|
4
|
+
* "use client":
|
|
5
|
+
*
|
|
6
|
+
* "jsenv:allocate 90s";
|
|
7
|
+
* "jsenv:lock service-worker";
|
|
8
|
+
*
|
|
9
|
+
* They are read without running the file, which is what allows a lock to be
|
|
10
|
+
* honored: an execution must not have started before we know what it takes.
|
|
11
|
+
* Being grammar rather than convention, a directive cannot be built at runtime
|
|
12
|
+
* and cannot move: it is the first statement or it is nothing.
|
|
13
|
+
*
|
|
14
|
+
* A "jsenv:" directive that cannot be read throws. Everything ignores an
|
|
15
|
+
* unknown directive silently, so a typo would silently give the file back the
|
|
16
|
+
* default budget; the only way it stays useful is to be loud.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createDetailedMessage } from "@jsenv/humanize";
|
|
20
|
+
import { closeSync, openSync, readFileSync, readSync } from "node:fs";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const DIRECTIVE_PREFIX = "jsenv:";
|
|
24
|
+
const DIRECTIVE_NAMES = `"jsenv:allocate <duration>", "jsenv:lock <resource>"`;
|
|
25
|
+
const DURATION_REGEX = /^(\d+)(ms|s|m)$/;
|
|
26
|
+
const MS_PER_UNIT = { ms: 1, s: 1_000, m: 60_000 };
|
|
27
|
+
// a directive prologue sits at the top of the file; comments can precede it but
|
|
28
|
+
// rarely for more than a few lines, and the whole file is read when they do
|
|
29
|
+
const HEAD_BYTE_COUNT = 4096;
|
|
30
|
+
|
|
31
|
+
export const readJsenvDirectives = (fileUrl) => {
|
|
32
|
+
const head = readHead(fileUrl);
|
|
33
|
+
let scanResult = scanDirectivePrologue(head.text);
|
|
34
|
+
if (head.partial && !scanResult.complete) {
|
|
35
|
+
scanResult = scanDirectivePrologue(readFileSync(new URL(fileUrl), "utf8"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let allocatedMs;
|
|
39
|
+
const lockArray = [];
|
|
40
|
+
for (const directiveText of scanResult.directiveTextArray) {
|
|
41
|
+
if (!directiveText.startsWith(DIRECTIVE_PREFIX)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const body = directiveText.slice(DIRECTIVE_PREFIX.length);
|
|
45
|
+
const spaceIndex = body.indexOf(" ");
|
|
46
|
+
const name = spaceIndex === -1 ? body : body.slice(0, spaceIndex);
|
|
47
|
+
const argument = spaceIndex === -1 ? "" : body.slice(spaceIndex + 1).trim();
|
|
48
|
+
const fail = (reason, details = {}) => {
|
|
49
|
+
throw new Error(
|
|
50
|
+
createDetailedMessage(reason, {
|
|
51
|
+
directive: `"${directiveText}"`,
|
|
52
|
+
file: fileURLToPath(fileUrl),
|
|
53
|
+
...details,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (name === "allocate") {
|
|
59
|
+
const match = DURATION_REGEX.exec(argument);
|
|
60
|
+
if (!match) {
|
|
61
|
+
fail(`"jsenv:allocate" expects a duration, got "${argument}"`, {
|
|
62
|
+
["durations accepted"]: `"500ms", "90s", "2m"`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
allocatedMs = Number(match[1]) * MS_PER_UNIT[match[2]];
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (name === "lock") {
|
|
69
|
+
if (argument === "") {
|
|
70
|
+
fail(`"jsenv:lock" expects the name of a resource`);
|
|
71
|
+
}
|
|
72
|
+
lockArray.push(argument);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
fail(`unknown jsenv directive "${name}"`, {
|
|
76
|
+
["directives available"]: DIRECTIVE_NAMES,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return { allocatedMs, lockArray };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const readHead = (fileUrl) => {
|
|
83
|
+
const fileDescriptor = openSync(fileURLToPath(fileUrl), "r");
|
|
84
|
+
try {
|
|
85
|
+
const buffer = Buffer.allocUnsafe(HEAD_BYTE_COUNT);
|
|
86
|
+
const byteCount = readSync(fileDescriptor, buffer, 0, HEAD_BYTE_COUNT, 0);
|
|
87
|
+
return {
|
|
88
|
+
text: buffer.toString("utf8", 0, byteCount),
|
|
89
|
+
partial: byteCount === HEAD_BYTE_COUNT,
|
|
90
|
+
};
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(fileDescriptor);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* Collects the string literals opening the module, stopping at the first token
|
|
98
|
+
* that is neither a comment nor one of them. "complete" tells whether that
|
|
99
|
+
* token was reached: when it was not, the source given was cut short and the
|
|
100
|
+
* caller must read further before trusting the result.
|
|
101
|
+
*/
|
|
102
|
+
const scanDirectivePrologue = (source) => {
|
|
103
|
+
const directiveTextArray = [];
|
|
104
|
+
const length = source.length;
|
|
105
|
+
let index = 0;
|
|
106
|
+
if (source.startsWith("#!")) {
|
|
107
|
+
const lineEndIndex = source.indexOf("\n");
|
|
108
|
+
if (lineEndIndex === -1) {
|
|
109
|
+
return { directiveTextArray, complete: false };
|
|
110
|
+
}
|
|
111
|
+
index = lineEndIndex + 1;
|
|
112
|
+
}
|
|
113
|
+
while (index < length) {
|
|
114
|
+
const char = source[index];
|
|
115
|
+
if (
|
|
116
|
+
char === " " ||
|
|
117
|
+
char === "\t" ||
|
|
118
|
+
char === "\n" ||
|
|
119
|
+
char === "\r" ||
|
|
120
|
+
char === ";"
|
|
121
|
+
) {
|
|
122
|
+
index++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (char === "/" && source[index + 1] === "/") {
|
|
126
|
+
const lineEndIndex = source.indexOf("\n", index);
|
|
127
|
+
if (lineEndIndex === -1) {
|
|
128
|
+
return { directiveTextArray, complete: false };
|
|
129
|
+
}
|
|
130
|
+
index = lineEndIndex + 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (char === "/" && source[index + 1] === "*") {
|
|
134
|
+
const commentEndIndex = source.indexOf("*/", index + 2);
|
|
135
|
+
if (commentEndIndex === -1) {
|
|
136
|
+
return { directiveTextArray, complete: false };
|
|
137
|
+
}
|
|
138
|
+
index = commentEndIndex + 2;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (char === '"' || char === "'") {
|
|
142
|
+
const quote = char;
|
|
143
|
+
let stringIndex = index + 1;
|
|
144
|
+
let text = "";
|
|
145
|
+
while (stringIndex < length) {
|
|
146
|
+
const stringChar = source[stringIndex];
|
|
147
|
+
if (stringChar === "\\") {
|
|
148
|
+
text += source[stringIndex + 1];
|
|
149
|
+
stringIndex += 2;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (stringChar === quote) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (stringChar === "\n") {
|
|
156
|
+
// an unterminated string is not a directive, and not our problem
|
|
157
|
+
return { directiveTextArray, complete: true };
|
|
158
|
+
}
|
|
159
|
+
text += stringChar;
|
|
160
|
+
stringIndex++;
|
|
161
|
+
}
|
|
162
|
+
if (stringIndex >= length) {
|
|
163
|
+
return { directiveTextArray, complete: false };
|
|
164
|
+
}
|
|
165
|
+
directiveTextArray.push(text);
|
|
166
|
+
index = stringIndex + 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
return { directiveTextArray, complete: true };
|
|
170
|
+
}
|
|
171
|
+
return { directiveTextArray, complete: false };
|
|
172
|
+
};
|
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
|
+
};
|