@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/test",
3
- "version": "3.7.35",
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
- uses,
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
- uses,
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.uses === "function") {
779
- const usesResult = params.uses(execution);
780
- params.uses = usesResult;
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
- const executionRemainingSet = new Set(executionPlanifiedArray);
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 usedTagSet = new Set();
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.uses) {
967
- for (const tagThatWillBeUsed of execution.params.uses) {
968
- usedTagSet.add(tagThatWillBeUsed);
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.uses) {
984
- for (const tagNoLongerInUse of execution.params.uses) {
985
- usedTagSet.delete(tagNoLongerInUse);
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.params.uses) {
1046
- const nonAvailableTag = executionCandidate.params.uses.find(
1047
- (tagToUse) => usedTagSet.has(tagToUse),
1048
- );
1049
- if (nonAvailableTag) {
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
- `"${nonAvailableTag}" is not available, ${executionCandidate.name} will wait until it is released by a previous execution`,
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
+ };
@@ -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,4 @@
1
+ // what a test file executed in a browser can import from "@jsenv/test";
2
+ // everything else in this package is node code driving those executions
3
+
4
+ export { requestAllocatedMs } from "./runtime_browsers/client/request_allocated_ms.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
+ };