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