@jsenv/test 3.7.35 → 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.
@@ -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
- const executionRemainingSet = new Set(executionPlanifiedArray);
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();
@@ -10046,6 +10302,7 @@ const nodeChildProcess = ({
10046
10302
  onConsole,
10047
10303
  onRuntimeStarted,
10048
10304
  onRuntimeStopped,
10305
+ onAllocatedMsRequested = () => {},
10049
10306
 
10050
10307
  measureMemoryUsage,
10051
10308
  onMeasureMemoryAvailable,
@@ -10153,6 +10410,15 @@ const nodeChildProcess = ({
10153
10410
  onRuntimeStopped();
10154
10411
  }),
10155
10412
  );
10413
+ cleanupCallbackSet.add(
10414
+ onChildProcessMessage(
10415
+ childProcess,
10416
+ "allocated-ms-request",
10417
+ ({ ms }) => {
10418
+ onAllocatedMsRequested(ms);
10419
+ },
10420
+ ),
10421
+ );
10156
10422
 
10157
10423
  const removeOutputListener = installChildProcessOutputListener(
10158
10424
  childProcess,
@@ -10534,6 +10800,7 @@ const nodeWorkerThread = ({
10534
10800
  onConsole,
10535
10801
  onRuntimeStarted,
10536
10802
  onRuntimeStopped,
10803
+ onAllocatedMsRequested = () => {},
10537
10804
 
10538
10805
  measureMemoryUsage,
10539
10806
  onMeasureMemoryAvailable,
@@ -10620,6 +10887,15 @@ const nodeWorkerThread = ({
10620
10887
  onRuntimeStopped();
10621
10888
  }),
10622
10889
  );
10890
+ cleanupCallbackSet.add(
10891
+ onWorkerThreadMessage(
10892
+ workerThread,
10893
+ "allocated-ms-request",
10894
+ ({ ms }) => {
10895
+ onAllocatedMsRequested(ms);
10896
+ },
10897
+ ),
10898
+ );
10623
10899
 
10624
10900
  const stop = memoize(async () => {
10625
10901
  // read all stdout before terminating
@@ -10871,6 +11147,41 @@ const onceWorkerThreadEvent = (worker, type, callback) => {
10871
11147
  };
10872
11148
  };
10873
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
+
10874
11185
  const istanbulCoverageMapFromCoverage = (coverage) => {
10875
11186
  const { createCoverageMap } = importWithRequire("istanbul-lib-coverage");
10876
11187
 
@@ -11553,4 +11864,4 @@ const inlineRuntime = (fn) => {
11553
11864
  };
11554
11865
  };
11555
11866
 
11556
- 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.35",
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"
@@ -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
- const executionRemainingSet = new Set(executionPlanifiedArray);
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
+ };
@@ -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
+ };
@@ -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
+ };