@itmetsam/samurai-framework 0.21.5 → 0.22.1

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.
Files changed (174) hide show
  1. package/dist/api.d.ts +8 -3
  2. package/dist/api.js +1 -0
  3. package/dist/browser/browser-context.d.ts +18 -6
  4. package/dist/browser/browser-context.js +25 -7
  5. package/dist/browser/browser.d.ts +4 -4
  6. package/dist/browser/browser.js +11 -8
  7. package/dist/browser/chrome-launch.js +9 -1
  8. package/dist/browser/context-cookie-store.d.ts +17 -0
  9. package/dist/browser/context-cookie-store.js +90 -0
  10. package/dist/browser/index.d.ts +3 -1
  11. package/dist/browser/index.js +1 -1
  12. package/dist/browser/keyboard.d.ts +35 -0
  13. package/dist/browser/keyboard.js +83 -0
  14. package/dist/browser/mouse.d.ts +42 -0
  15. package/dist/browser/mouse.js +66 -0
  16. package/dist/browser/page-content.d.ts +4 -0
  17. package/dist/browser/page-content.js +12 -0
  18. package/dist/browser/page-events.d.ts +11 -0
  19. package/dist/browser/page-events.js +23 -0
  20. package/dist/browser/page-logging.d.ts +24 -0
  21. package/dist/browser/page-logging.js +43 -0
  22. package/dist/browser/page-navigation.d.ts +0 -2
  23. package/dist/browser/page-navigation.js +1 -6
  24. package/dist/browser/page-settings.d.ts +1 -1
  25. package/dist/browser/page-viewport.d.ts +21 -0
  26. package/dist/browser/page-viewport.js +33 -0
  27. package/dist/browser/page.d.ts +23 -16
  28. package/dist/browser/page.js +48 -71
  29. package/dist/browser/screenshot.d.ts +6 -0
  30. package/dist/browser/screenshot.js +14 -0
  31. package/dist/browser/storage-state.d.ts +0 -2
  32. package/dist/browser/storage-state.js +0 -20
  33. package/dist/browser/use-applier.d.ts +16 -5
  34. package/dist/browser/use-applier.js +44 -17
  35. package/dist/browser/use-commands.d.ts +36 -0
  36. package/dist/browser/use-commands.js +120 -0
  37. package/dist/cli/args.d.ts +8 -4
  38. package/dist/cli/args.js +8 -5
  39. package/dist/cli/console-reporter.js +5 -1
  40. package/dist/cli/main.js +1 -0
  41. package/dist/cli/usage.d.ts +1 -1
  42. package/dist/cli/usage.js +4 -1
  43. package/dist/config/base-url.d.ts +4 -0
  44. package/dist/config/base-url.js +6 -0
  45. package/dist/config/config.js +7 -0
  46. package/dist/config/fixture-use.d.ts +19 -0
  47. package/dist/config/fixture-use.js +44 -0
  48. package/dist/config/fully-parallel.d.ts +3 -0
  49. package/dist/config/fully-parallel.js +6 -0
  50. package/dist/config/group-use.d.ts +10 -0
  51. package/dist/config/group-use.js +51 -0
  52. package/dist/config/resolved-config.d.ts +2 -0
  53. package/dist/config/resolved-config.js +3 -0
  54. package/dist/config/storage-state-file.d.ts +3 -0
  55. package/dist/config/storage-state-file.js +20 -0
  56. package/dist/config/use-options.d.ts +3 -1
  57. package/dist/config/use-options.js +74 -14
  58. package/dist/config/web-server-options.d.ts +7 -0
  59. package/dist/config/web-server-options.js +111 -0
  60. package/dist/locator/check-actions.js +1 -1
  61. package/dist/locator/drag-actions.d.ts +17 -0
  62. package/dist/locator/drag-actions.js +80 -0
  63. package/dist/locator/element-actions.d.ts +10 -4
  64. package/dist/locator/element-actions.js +23 -16
  65. package/dist/locator/element-state.d.ts +5 -0
  66. package/dist/locator/element-state.js +2 -1
  67. package/dist/locator/event-actions.d.ts +10 -0
  68. package/dist/locator/event-actions.js +81 -0
  69. package/dist/locator/input-actions.d.ts +2 -11
  70. package/dist/locator/input-actions.js +3 -56
  71. package/dist/locator/key-actions.d.ts +11 -0
  72. package/dist/locator/key-actions.js +52 -0
  73. package/dist/locator/key-map.d.ts +16 -0
  74. package/dist/locator/key-map.js +76 -0
  75. package/dist/locator/locator.d.ts +35 -5
  76. package/dist/locator/locator.js +70 -16
  77. package/dist/locator/page-scripts.d.ts +2 -0
  78. package/dist/locator/page-scripts.js +7 -0
  79. package/dist/locator/pointer-actions.d.ts +44 -0
  80. package/dist/locator/pointer-actions.js +117 -0
  81. package/dist/locator/pointer-element-actions.d.ts +21 -0
  82. package/dist/locator/pointer-element-actions.js +42 -0
  83. package/dist/request/api-request-context.d.ts +65 -0
  84. package/dist/request/api-request-context.js +198 -0
  85. package/dist/request/api-response.d.ts +34 -0
  86. package/dist/request/api-response.js +57 -0
  87. package/dist/request/cookie-jar.d.ts +48 -0
  88. package/dist/request/cookie-jar.js +164 -0
  89. package/dist/request/http-transport.d.ts +31 -0
  90. package/dist/request/http-transport.js +113 -0
  91. package/dist/request/redirect.d.ts +10 -0
  92. package/dist/request/redirect.js +21 -0
  93. package/dist/request/request-body.d.ts +27 -0
  94. package/dist/request/request-body.js +71 -0
  95. package/dist/request/request.d.ts +16 -0
  96. package/dist/request/request.js +20 -0
  97. package/dist/runner/all-hooks.d.ts +7 -1
  98. package/dist/runner/all-hooks.js +17 -1
  99. package/dist/runner/fixture-defs.d.ts +2 -1
  100. package/dist/runner/fixture-defs.js +52 -18
  101. package/dist/runner/fixture-meta.d.ts +20 -0
  102. package/dist/runner/fixture-meta.js +109 -0
  103. package/dist/runner/fixture-options.d.ts +8 -0
  104. package/dist/runner/fixture-options.js +35 -0
  105. package/dist/runner/fixture-outputs.d.ts +11 -0
  106. package/dist/runner/fixture-outputs.js +28 -0
  107. package/dist/runner/fixture-registry.d.ts +35 -0
  108. package/dist/runner/fixture-registry.js +73 -0
  109. package/dist/runner/fixture-scope.d.ts +39 -9
  110. package/dist/runner/fixture-scope.js +139 -70
  111. package/dist/runner/fixture-start.d.ts +27 -0
  112. package/dist/runner/fixture-start.js +76 -0
  113. package/dist/runner/fixture-use.d.ts +23 -0
  114. package/dist/runner/fixture-use.js +64 -0
  115. package/dist/runner/fixture-worker.d.ts +26 -0
  116. package/dist/runner/fixture-worker.js +46 -0
  117. package/dist/runner/fixtures.d.ts +4 -1
  118. package/dist/runner/fixtures.js +3 -2
  119. package/dist/runner/global-hooks.d.ts +13 -0
  120. package/dist/runner/global-hooks.js +66 -0
  121. package/dist/runner/options.d.ts +13 -2
  122. package/dist/runner/output-tail.d.ts +14 -0
  123. package/dist/runner/output-tail.js +30 -0
  124. package/dist/runner/pool-schedule.d.ts +22 -0
  125. package/dist/runner/pool-schedule.js +55 -0
  126. package/dist/runner/pool-units.d.ts +34 -0
  127. package/dist/runner/pool-units.js +85 -0
  128. package/dist/runner/pooled-run.d.ts +7 -5
  129. package/dist/runner/pooled-run.js +19 -9
  130. package/dist/runner/prepare-run.js +5 -0
  131. package/dist/runner/registry.js +21 -7
  132. package/dist/runner/reporter.d.ts +13 -1
  133. package/dist/runner/reporter.js +13 -2
  134. package/dist/runner/request-fixture.d.ts +14 -0
  135. package/dist/runner/request-fixture.js +25 -0
  136. package/dist/runner/run-hooks.d.ts +16 -0
  137. package/dist/runner/run-hooks.js +52 -0
  138. package/dist/runner/run-test.d.ts +3 -0
  139. package/dist/runner/run-test.js +23 -3
  140. package/dist/runner/run.d.ts +8 -0
  141. package/dist/runner/run.js +10 -0
  142. package/dist/runner/select.d.ts +15 -4
  143. package/dist/runner/select.js +38 -12
  144. package/dist/runner/serial-run.d.ts +23 -0
  145. package/dist/runner/serial-run.js +88 -0
  146. package/dist/runner/serial.d.ts +34 -0
  147. package/dist/runner/serial.js +63 -0
  148. package/dist/runner/shard.d.ts +22 -0
  149. package/dist/runner/shard.js +73 -0
  150. package/dist/runner/suite.d.ts +3 -3
  151. package/dist/runner/suite.js +4 -3
  152. package/dist/runner/test-function.d.ts +15 -3
  153. package/dist/runner/test-info.js +2 -1
  154. package/dist/runner/test-runner.d.ts +1 -0
  155. package/dist/runner/test-runner.js +67 -18
  156. package/dist/runner/use-options.d.ts +18 -4
  157. package/dist/runner/use-options.js +33 -6
  158. package/dist/runner/wait-for-url.d.ts +20 -0
  159. package/dist/runner/wait-for-url.js +65 -0
  160. package/dist/runner/web-server.d.ts +16 -0
  161. package/dist/runner/web-server.js +165 -0
  162. package/dist/runner/worker-pool.d.ts +13 -5
  163. package/dist/runner/worker-pool.js +85 -51
  164. package/dist/runner/worker-protocol.d.ts +8 -0
  165. package/dist/runner/worker-session.js +9 -1
  166. package/dist/runner/worker.d.ts +2 -0
  167. package/dist/runner/worker.js +7 -1
  168. package/dist/types/config.d.ts +117 -21
  169. package/dist/types/extend.d.ts +54 -2
  170. package/dist/types/test.d.ts +42 -0
  171. package/dist/types/use.d.ts +59 -7
  172. package/package.json +1 -1
  173. package/dist/browser/emulation.d.ts +0 -61
  174. package/dist/browser/emulation.js +0 -180
@@ -0,0 +1,65 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import net from "node:net";
4
+ /** Pause between two probes */
5
+ const POLL_MS = 100;
6
+ /** Resolves true once `url` answers with a status from 200 to 403 (a server that is up but wants a login counts) */
7
+ function urlAnswers(url, options = {}) {
8
+ return new Promise((resolve) => {
9
+ const secure = new URL(url).protocol === "https:";
10
+ const request = (secure ? https : http).get(url, {
11
+ timeout: 2000,
12
+ headers: { Accept: "text/html,application/json,*/*;q=0.8" },
13
+ ...(secure && { rejectUnauthorized: !options.ignoreHTTPSErrors }),
14
+ }, (response) => {
15
+ response.resume();
16
+ const status = response.statusCode ?? 0;
17
+ resolve(status >= 200 && status <= 403);
18
+ });
19
+ request.on("timeout", () => request.destroy());
20
+ request.on("error", () => resolve(false));
21
+ });
22
+ }
23
+ /** Resolves true when something on this machine accepts a TCP connection on `port` (IPv4 or IPv6 `localhost`) */
24
+ function portAnswers(port) {
25
+ return new Promise((resolve) => {
26
+ const socket = net.connect({ port, host: "localhost", timeout: 2000 });
27
+ socket.once("connect", () => {
28
+ socket.destroy();
29
+ resolve(true);
30
+ });
31
+ socket.once("timeout", () => {
32
+ socket.destroy();
33
+ resolve(false);
34
+ });
35
+ socket.once("error", () => resolve(false));
36
+ });
37
+ }
38
+ /** One probe of `target` */
39
+ export function answers(target) {
40
+ return "url" in target
41
+ ? urlAnswers(target.url, {
42
+ ...(target.ignoreHTTPSErrors && { ignoreHTTPSErrors: true }),
43
+ })
44
+ : portAnswers(target.port);
45
+ }
46
+ /** The target as text for messages: the URL, or `localhost:<port>` */
47
+ export function describeTarget(target) {
48
+ return "url" in target ? target.url : `localhost:${target.port}`;
49
+ }
50
+ /**
51
+ * Probes `target` until it answers. Resolves "ready", or "timeout" after `timeout` ms, or "aborted" when `signal`
52
+ * fires or `stopped()` turns true (the caller saw the server die)
53
+ */
54
+ export async function waitUntilReady(target, options) {
55
+ const deadline = Date.now() + options.timeout;
56
+ for (;;) {
57
+ if (options.signal?.aborted || options.stopped?.())
58
+ return "aborted";
59
+ if (await answers(target))
60
+ return "ready";
61
+ if (Date.now() >= deadline)
62
+ return "timeout";
63
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
64
+ }
65
+ }
@@ -0,0 +1,16 @@
1
+ import type { WebServerConfig } from "../types/config.js";
2
+ /** The servers of a run, started */
3
+ export interface RunningServers {
4
+ /** Where the first server with a `url` or `port` answers: the base URL when none is configured */
5
+ baseURL?: string;
6
+ /** Stops every server this run started (not the ones it reused). Safe to call twice */
7
+ stop(): Promise<void>;
8
+ }
9
+ /**
10
+ * Starts the servers one after the other and waits for each. A server that fails to start stops the ones already
11
+ * started and throws `webServer(): what is wrong`, with the last lines of its output.
12
+ */
13
+ export declare function startWebServers(servers: readonly WebServerConfig[], context: {
14
+ projectDir: string;
15
+ signal?: AbortSignal | undefined;
16
+ }): Promise<RunningServers>;
@@ -0,0 +1,165 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { DEFAULT_GRACEFUL_TIMEOUT, DEFAULT_WEB_SERVER_TIMEOUT, } from "../config/web-server-options.js";
4
+ import logger from "../logger/index.js";
5
+ import { OutputTail } from "./output-tail.js";
6
+ import { answers, describeTarget, waitUntilReady, } from "./wait-for-url.js";
7
+ const fail = (problem) => {
8
+ throw new Error(`webServer(): ${problem}`);
9
+ };
10
+ const windows = process.platform === "win32";
11
+ function targetOf(server) {
12
+ return server.url !== undefined
13
+ ? {
14
+ url: server.url,
15
+ ...(server.ignoreHTTPSErrors && { ignoreHTTPSErrors: true }),
16
+ }
17
+ : { port: server.port };
18
+ }
19
+ /** The base URL a server stands for: its URL's origin, or `http://localhost:<port>` */
20
+ function baseURLOf(server) {
21
+ return server.url !== undefined
22
+ ? new URL(server.url).origin
23
+ : `http://localhost:${server.port}`;
24
+ }
25
+ function signalGroup(pid, signal, child) {
26
+ try {
27
+ if (windows) {
28
+ if (signal !== 0)
29
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
30
+ return child?.exitCode === null;
31
+ }
32
+ process.kill(-pid, signal);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ class StartedServer {
40
+ constructor(child, server) {
41
+ this.child = child;
42
+ this.server = server;
43
+ this.exited = false;
44
+ this.exitText = "";
45
+ this.onProcessExit = () => {
46
+ if (this.child.pid !== undefined)
47
+ signalGroup(this.child.pid, "SIGKILL", this.child);
48
+ };
49
+ this.tail = new OutputTail(20, Object.values(server.env ?? {}));
50
+ for (const [stream, mode, out] of [
51
+ [child.stdout, server.stdout ?? "ignore", process.stdout],
52
+ [child.stderr, server.stderr ?? "pipe", process.stderr],
53
+ ])
54
+ stream?.on("data", (chunk) => {
55
+ this.tail.add(chunk);
56
+ if (mode === "pipe")
57
+ out.write(chunk);
58
+ });
59
+ this.exitPromise = new Promise((resolve) => {
60
+ child.once("exit", (code, signal) => {
61
+ this.exited = true;
62
+ this.exitText =
63
+ code !== null ? `code ${code}` : `signal ${String(signal)}`;
64
+ resolve();
65
+ });
66
+ child.once("error", (error) => {
67
+ this.exited = true;
68
+ this.exitText = `error: ${error.message}`;
69
+ resolve();
70
+ });
71
+ });
72
+ process.once("exit", this.onProcessExit);
73
+ }
74
+ hasExited() {
75
+ return this.exited;
76
+ }
77
+ describeExit() {
78
+ return this.exitText;
79
+ }
80
+ /** SIGTERM-style signal to the group, then SIGKILL for what is left after the grace period */
81
+ stop() {
82
+ this.stopping ??= this.stopNow();
83
+ return this.stopping;
84
+ }
85
+ async stopNow() {
86
+ const pid = this.child.pid;
87
+ const { signal = "SIGTERM", timeout = DEFAULT_GRACEFUL_TIMEOUT } = this.server.gracefulShutdown ?? {};
88
+ if (pid !== undefined) {
89
+ if (timeout > 0 && !windows) {
90
+ signalGroup(pid, signal);
91
+ const deadline = Date.now() + timeout;
92
+ while (Date.now() < deadline &&
93
+ (!this.exited || signalGroup(pid, 0, this.child)))
94
+ await new Promise((resolve) => setTimeout(resolve, 50));
95
+ }
96
+ signalGroup(pid, "SIGKILL", this.child);
97
+ await Promise.race([
98
+ this.exitPromise,
99
+ new Promise((resolve) => setTimeout(resolve, 2000)),
100
+ ]);
101
+ }
102
+ process.off("exit", this.onProcessExit);
103
+ }
104
+ }
105
+ async function startOne(server, context) {
106
+ const target = targetOf(server);
107
+ const name = describeTarget(target);
108
+ if (await answers(target)) {
109
+ if (server.reuseExistingServer)
110
+ return undefined;
111
+ fail(`${name} is already in use: stop what runs there, or set reuseExistingServer: true`);
112
+ }
113
+ logger.verbose("Starting web server for %s", name);
114
+ const child = spawn(server.command, {
115
+ shell: true,
116
+ cwd: path.resolve(context.projectDir, server.cwd ?? "."),
117
+ env: { ...process.env, ...server.env },
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ detached: !windows,
120
+ });
121
+ const started = new StartedServer(child, server);
122
+ const timeout = server.timeout ?? DEFAULT_WEB_SERVER_TIMEOUT;
123
+ const verdict = await waitUntilReady(target, {
124
+ timeout,
125
+ ...(context.signal && { signal: context.signal }),
126
+ stopped: () => started.hasExited(),
127
+ });
128
+ if (verdict === "ready")
129
+ return started;
130
+ // Capture what the server printed before it is stopped
131
+ const exited = started.hasExited();
132
+ const how = exited ? started.describeExit() : "";
133
+ await started.stop();
134
+ const output = started.tail.text();
135
+ const log = output === "" ? "" : `\nLast output:\n${output}`;
136
+ if (verdict === "aborted" && !exited)
137
+ return fail(`aborted while waiting for ${name}`);
138
+ if (exited)
139
+ fail(`the process exited (${how}) before ${name} was ready${log}`);
140
+ return fail(`timed out after ${timeout}ms waiting for ${name}${log}`);
141
+ }
142
+ /**
143
+ * Starts the servers one after the other and waits for each. A server that fails to start stops the ones already
144
+ * started and throws `webServer(): what is wrong`, with the last lines of its output.
145
+ */
146
+ export async function startWebServers(servers, context) {
147
+ const started = [];
148
+ const stop = async () => {
149
+ await Promise.all([...started].reverse().map((s) => s.stop()));
150
+ };
151
+ let baseURL;
152
+ try {
153
+ for (const server of servers) {
154
+ const running = await startOne(server, context);
155
+ if (running)
156
+ started.push(running);
157
+ baseURL ??= baseURLOf(server);
158
+ }
159
+ }
160
+ catch (error) {
161
+ await stop();
162
+ throw error;
163
+ }
164
+ return { ...(baseURL !== undefined && { baseURL }), stop };
165
+ }
@@ -36,6 +36,7 @@ export declare class WorkerPool {
36
36
  private readonly slots;
37
37
  private readonly calls;
38
38
  private readonly timers;
39
+ private readonly units;
39
40
  private nextId;
40
41
  /** Workers in a row that exited before they were ready */
41
42
  private startupFailures;
@@ -52,18 +53,25 @@ export declare class WorkerPool {
52
53
  private sendHome;
53
54
  /** Hands queued items to ready workers, then starts workers for what is left */
54
55
  private pump;
55
- private busyKeys;
56
- /** The queue position of the first item whose serial key is not running */
57
- private nextEligible;
58
- /** How many queued items could run at the same time right now */
59
- private eligibleCount;
60
56
  private freeIndex;
61
57
  private start;
62
58
  private dispatch;
63
59
  private onMessage;
64
60
  private onExit;
61
+ /** The attempt a started item is on: the worker's own retry, or the attempt at its serial block */
62
+ private retryOf;
65
63
  /** Records an item's result, tells the listener, and gives the worker its next item */
66
64
  private finish;
65
+ private report;
66
+ /**
67
+ * An item of a serial block ended. After a failure the rest of the block is skipped; with retries the block
68
+ * starts over on a new worker first, and what its tests ended with is held until the last attempt is known.
69
+ */
70
+ private finishInBlock;
71
+ /** Reports what a block's tests ended with in its last attempt */
72
+ private release;
73
+ /** Puts the block back in the queue, from its first test, and tells the worker that ran it to leave */
74
+ private restartBlock;
67
75
  private settle;
68
76
  private emit;
69
77
  /** Fails the queued items of a call, asks its busy workers to stop, and kills the ones that do not */
@@ -1,3 +1,5 @@
1
+ import { failedResult, serialSkipped, UnitState } from "./pool-units.js";
2
+ import { eligibleCount, nextEligible } from "./pool-schedule.js";
1
3
  /** How long a busy worker may take to wind down after an abort before it is killed */
2
4
  const GRACE_MS = 2000;
3
5
  const SHUTDOWN_MS = 60000;
@@ -15,6 +17,7 @@ export class WorkerPool {
15
17
  this.slots = new Map();
16
18
  this.calls = new Set();
17
19
  this.timers = new Set();
20
+ this.units = new Map();
18
21
  this.nextId = 0;
19
22
  /** Workers in a row that exited before they were ready */
20
23
  this.startupFailures = 0;
@@ -95,51 +98,23 @@ export class WorkerPool {
95
98
  if (this.disposed)
96
99
  return;
97
100
  for (const slot of [...this.slots.values()]) {
98
- if (!slot.ready || slot.busy)
101
+ if (!slot.ready || slot.busy || slot.retiring)
99
102
  continue;
100
- const at = this.nextEligible();
103
+ const at = nextEligible(this.queue, [...this.slots.values()], slot);
101
104
  if (at < 0)
102
- break;
105
+ continue;
103
106
  const [entry] = this.queue.splice(at, 1);
104
107
  if (entry)
105
108
  this.dispatch(slot, entry);
106
109
  }
107
110
  let starting = [...this.slots.values()].filter((s) => !s.ready).length;
108
- const wanted = this.eligibleCount();
111
+ const wanted = eligibleCount(this.queue, [...this.slots.values()]);
109
112
  while (this.slots.size < this.options.workers && starting < wanted) {
110
113
  if (!this.start(this.freeIndex()))
111
114
  return;
112
115
  starting++;
113
116
  }
114
117
  }
115
- busyKeys() {
116
- const keys = new Set();
117
- for (const { busy } of this.slots.values()) {
118
- const key = busy?.entry.item.serialKey;
119
- if (key)
120
- keys.add(key);
121
- }
122
- return keys;
123
- }
124
- /** The queue position of the first item whose serial key is not running */
125
- nextEligible() {
126
- const busy = this.busyKeys();
127
- return this.queue.findIndex(({ item }) => !item.serialKey || !busy.has(item.serialKey));
128
- }
129
- /** How many queued items could run at the same time right now */
130
- eligibleCount() {
131
- const taken = this.busyKeys();
132
- let count = 0;
133
- for (const { item } of this.queue) {
134
- if (item.serialKey) {
135
- if (taken.has(item.serialKey))
136
- continue;
137
- taken.add(item.serialKey);
138
- }
139
- count++;
140
- }
141
- return count;
142
- }
143
118
  freeIndex() {
144
119
  let index = 0;
145
120
  while (this.slots.has(index))
@@ -165,6 +140,13 @@ export class WorkerPool {
165
140
  }
166
141
  dispatch(slot, entry) {
167
142
  const id = this.nextId++;
143
+ const { unit, serialKey } = entry.item;
144
+ if (unit) {
145
+ slot.unit = unit;
146
+ slot.unitKey = serialKey;
147
+ if (!this.units.has(unit))
148
+ this.units.set(unit, new UnitState(entry.call));
149
+ }
168
150
  slot.busy = { id, entry, startTime: Date.now() };
169
151
  slot.handle.send({ type: "run", id, item: entry.item });
170
152
  }
@@ -187,7 +169,7 @@ export class WorkerPool {
187
169
  type: "test-start",
188
170
  item: busy.entry.item,
189
171
  worker: slot.index,
190
- ...(message.retry && { retry: message.retry }),
172
+ ...this.retryOf(busy.entry, message.retry),
191
173
  });
192
174
  case "test-end":
193
175
  if (busy?.id !== message.id)
@@ -209,23 +191,84 @@ export class WorkerPool {
209
191
  const message = call.aborted
210
192
  ? "Run aborted"
211
193
  : `The worker process exited (code ${code})`;
212
- this.finish(slot, busy.entry, failure(item, message, busy.startTime));
194
+ this.finish(slot, busy.entry, failedResult(item, message, busy.startTime));
213
195
  }
214
196
  else {
215
197
  this.pump();
216
198
  }
217
199
  }
200
+ /** The attempt a started item is on: the worker's own retry, or the attempt at its serial block */
201
+ retryOf({ item }, workerRetry) {
202
+ const retry = workerRetry || (item.serial ? this.units.get(item.unit)?.attempt : 0);
203
+ return retry ? { retry } : {};
204
+ }
218
205
  /** Records an item's result, tells the listener, and gives the worker its next item */
219
206
  finish(slot, entry, result) {
220
207
  const stamped = { ...result, worker: slot.index };
208
+ if (entry.item.serial)
209
+ this.finishInBlock(slot, entry, stamped);
210
+ else
211
+ this.report(entry, slot.index, stamped);
212
+ this.pump();
213
+ }
214
+ report(entry, worker, result) {
221
215
  this.emit(entry.call, {
222
216
  type: "test-end",
223
217
  item: entry.item,
224
- worker: slot.index,
225
- result: stamped,
218
+ worker,
219
+ result,
226
220
  });
227
- this.settle(entry, stamped);
228
- this.pump();
221
+ this.settle(entry, result);
222
+ }
223
+ /**
224
+ * An item of a serial block ended. After a failure the rest of the block is skipped; with retries the block
225
+ * starts over on a new worker first, and what its tests ended with is held until the last attempt is known.
226
+ */
227
+ finishInBlock(slot, entry, result) {
228
+ const state = this.units.get(entry.item.unit);
229
+ const retries = this.options.init.retries ?? 0;
230
+ if (retries === 0)
231
+ this.report(entry, slot.index, result);
232
+ else
233
+ state.hold(entry, result);
234
+ if (result.status !== "failed") {
235
+ const last = !this.queue.some((e) => e.item.unit === entry.item.unit);
236
+ if (last)
237
+ this.release(state);
238
+ return;
239
+ }
240
+ const aborted = entry.call.aborted || result.message === "Run aborted";
241
+ if (state.attempt < retries && !aborted && !this.disposed) {
242
+ return this.restartBlock(slot, state, entry, result);
243
+ }
244
+ this.release(state);
245
+ for (let i = 0; i < this.queue.length; i++) {
246
+ const next = this.queue[i];
247
+ if (next.item.unit !== entry.item.unit)
248
+ continue;
249
+ this.queue.splice(i--, 1);
250
+ this.report(next, slot.index, serialSkipped(next.item));
251
+ }
252
+ }
253
+ /** Reports what a block's tests ended with in its last attempt */
254
+ release(state) {
255
+ for (const { entry, result } of state.release())
256
+ this.report(entry, result.worker ?? 0, result);
257
+ }
258
+ /** Puts the block back in the queue, from its first test, and tells the worker that ran it to leave */
259
+ restartBlock(slot, state, failed, result) {
260
+ this.queue.unshift(...state.restart(failed, result));
261
+ slot.unit = slot.unitKey = undefined;
262
+ if (this.slots.get(slot.index) !== slot)
263
+ return;
264
+ slot.retiring = true;
265
+ slot.handle.send({ type: "exit" });
266
+ const timer = setTimeout(() => {
267
+ this.timers.delete(timer);
268
+ slot.handle.kill();
269
+ }, this.options.shutdownMs ?? SHUTDOWN_MS);
270
+ timer.unref();
271
+ this.timers.add(timer);
229
272
  }
230
273
  settle({ call, at }, result) {
231
274
  call.results[at] = result;
@@ -246,12 +289,15 @@ export class WorkerPool {
246
289
  /** Fails the queued items of a call, asks its busy workers to stop, and kills the ones that do not */
247
290
  abort(call) {
248
291
  call.aborted = true;
292
+ for (const state of this.units.values())
293
+ if (state.call === call)
294
+ this.release(state);
249
295
  for (let i = this.queue.length - 1; i >= 0; i--) {
250
296
  const entry = this.queue[i];
251
297
  if (entry?.call !== call)
252
298
  continue;
253
299
  this.queue.splice(i, 1);
254
- this.settle(entry, failure(entry.item, "Run aborted"));
300
+ this.settle(entry, failedResult(entry.item, "Run aborted"));
255
301
  }
256
302
  for (const slot of this.slots.values()) {
257
303
  const busy = slot.busy;
@@ -294,15 +340,3 @@ export class WorkerPool {
294
340
  }
295
341
  }
296
342
  }
297
- function failure(item, message, startTime = Date.now()) {
298
- return {
299
- name: item.testName,
300
- file: item.file,
301
- browser: item.browser,
302
- status: "failed",
303
- type: "error",
304
- message,
305
- startTime,
306
- duration: Date.now() - startTime,
307
- };
308
- }
@@ -9,8 +9,14 @@ export interface WorkItem {
9
9
  /** The test's full name */
10
10
  testName: string;
11
11
  browser: SupportedBrowser;
12
+ /** The group this run of the test is for (`--group`): its `use` applies and the result names it */
13
+ group?: string;
12
14
  /** Items with the same key never run at the same time */
13
15
  serialKey?: string;
16
+ /** Items with the same unit run one after the other on the same worker, in the order they are queued */
17
+ unit?: string;
18
+ /** The unit is a serial block: a failure skips the rest, and `retries` run the whole block again (the pool does it, not the worker) */
19
+ serial?: boolean;
14
20
  /** Where this test's screenshots go, instead of the run's `outputDir` (a flow gives each node its own) */
15
21
  outputDir?: string;
16
22
  /** The values the run supplies for the inputs tests declare (`runTests({ inputs })`) */
@@ -21,6 +27,8 @@ export interface WorkerInit {
21
27
  projectDir: string;
22
28
  dataDir?: string;
23
29
  environment?: string;
30
+ /** The run's base URL when no config sets one (it came from a web server): used where the config has none */
31
+ baseURL?: string;
24
32
  timeout?: number;
25
33
  expectTimeout?: number;
26
34
  headless?: boolean;
@@ -3,6 +3,7 @@ import logger from "../logger/index.js";
3
3
  import { withProject } from "./project.js";
4
4
  import TestReporter from "./reporter.js";
5
5
  import { AllHooks } from "./all-hooks.js";
6
+ import { WorkerFixtures } from "./fixture-worker.js";
6
7
  import { allHookFixtures } from "./fixtures.js";
7
8
  import { runOneTest } from "./run-test.js";
8
9
  import { runWithRetries } from "./retry.js";
@@ -29,12 +30,16 @@ export function openSession(init) {
29
30
  }),
30
31
  ...(headless !== undefined && { headless }),
31
32
  }, async (run) => {
33
+ if (run.settings.baseURL === undefined && init.baseURL !== undefined)
34
+ run.settings.baseURL = init.baseURL;
32
35
  const loaded = await loadSpecs(init.files);
33
36
  // The `beforeAll` hooks this worker ran: their `afterAll` hooks run when it closes
34
37
  const allHooks = new AllHooks({
35
38
  timeout: run.settings.timeout,
36
39
  fixtures: (browser) => allHookFixtures(run, browser, workerIndex),
37
40
  });
41
+ // The worker-scoped fixtures this worker set up: torn down when it closes
42
+ const workerFixtures = new WorkerFixtures(run.settings.timeout);
38
43
  resolve({
39
44
  tests: loaded.all,
40
45
  async runTest(test, browser, signal, onEvent, perTest = {}) {
@@ -56,17 +61,20 @@ export function openSession(init) {
56
61
  port,
57
62
  signal,
58
63
  };
59
- await runWithRetries(test, browser, init.retries ?? 0, signal, (sink, attempt) => runOneTest(test, browser, run, options, loaded.srcDir, sink, {
64
+ await runWithRetries(test, browser, perTest.retries ?? init.retries ?? 0, signal, (sink, attempt) => runOneTest(test, browser, run, options, loaded.srcDir, sink, {
60
65
  port,
61
66
  profileDir: workerProfileDir(browser, workerIndex),
62
67
  workerIndex,
63
68
  allHooks,
69
+ workerFixtures,
64
70
  attempt,
65
71
  }), reporter);
66
72
  },
67
73
  async close() {
68
74
  for (const error of await allHooks.closeAll())
69
75
  logger.error("%s", error.message);
76
+ for (const error of await workerFixtures.closeAll())
77
+ logger.error("%s", error.message);
70
78
  release();
71
79
  await finished;
72
80
  },
@@ -9,6 +9,8 @@ export interface WorkerSession {
9
9
  runTest(test: RegisteredTestCase, browser: SupportedBrowser, signal: AbortSignal, onEvent: (event: RunEvent) => void, options?: {
10
10
  outputDir?: string;
11
11
  inputs?: Record<string, string>;
12
+ /** Instead of the run's `retries` */
13
+ retries?: number;
12
14
  }): Promise<void>;
13
15
  /** Ends the project's session */
14
16
  close(): Promise<void>;
@@ -3,6 +3,7 @@ const failedResult = (item, worker, message) => ({
3
3
  name: item.testName,
4
4
  file: item.file,
5
5
  browser: item.browser,
6
+ ...(item.group !== undefined && { group: item.group }),
6
7
  worker,
7
8
  status: "failed",
8
9
  startTime: performance.now(),
@@ -29,7 +30,10 @@ export function handleMessages(channel, { open, exit }) {
29
30
  if (!session)
30
31
  throw new Error("The worker was sent a test before it was initialised");
31
32
  channel.send({ type: "test-start", id });
32
- const test = session.tests.find(({ file, name }) => file === item.file && name === item.testName);
33
+ const found = session.tests.find(({ file, name }) => file === item.file && name === item.testName);
34
+ const test = found && item.group !== undefined
35
+ ? { ...found, group: item.group }
36
+ : found;
33
37
  if (!test) {
34
38
  const message = `Test "${item.testName}" was not found in ${item.file}`;
35
39
  channel.send({
@@ -57,6 +61,8 @@ export function handleMessages(channel, { open, exit }) {
57
61
  }, {
58
62
  ...(item.outputDir !== undefined && { outputDir: item.outputDir }),
59
63
  ...(item.inputs !== undefined && { inputs: item.inputs }),
64
+ // The pool runs a whole serial block again, not the test
65
+ ...(item.serial && { retries: 0 }),
60
66
  });
61
67
  }
62
68
  finally {