@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.
- package/dist/api.d.ts +8 -3
- package/dist/api.js +1 -0
- package/dist/browser/browser-context.d.ts +18 -6
- package/dist/browser/browser-context.js +25 -7
- package/dist/browser/browser.d.ts +4 -4
- package/dist/browser/browser.js +11 -8
- package/dist/browser/chrome-launch.js +9 -1
- package/dist/browser/context-cookie-store.d.ts +17 -0
- package/dist/browser/context-cookie-store.js +90 -0
- package/dist/browser/index.d.ts +3 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/keyboard.d.ts +35 -0
- package/dist/browser/keyboard.js +83 -0
- package/dist/browser/mouse.d.ts +42 -0
- package/dist/browser/mouse.js +66 -0
- package/dist/browser/page-content.d.ts +4 -0
- package/dist/browser/page-content.js +12 -0
- package/dist/browser/page-events.d.ts +11 -0
- package/dist/browser/page-events.js +23 -0
- package/dist/browser/page-logging.d.ts +24 -0
- package/dist/browser/page-logging.js +43 -0
- package/dist/browser/page-navigation.d.ts +0 -2
- package/dist/browser/page-navigation.js +1 -6
- package/dist/browser/page-settings.d.ts +1 -1
- package/dist/browser/page-viewport.d.ts +21 -0
- package/dist/browser/page-viewport.js +33 -0
- package/dist/browser/page.d.ts +23 -16
- package/dist/browser/page.js +48 -71
- package/dist/browser/screenshot.d.ts +6 -0
- package/dist/browser/screenshot.js +14 -0
- package/dist/browser/storage-state.d.ts +0 -2
- package/dist/browser/storage-state.js +0 -20
- package/dist/browser/use-applier.d.ts +16 -5
- package/dist/browser/use-applier.js +44 -17
- package/dist/browser/use-commands.d.ts +36 -0
- package/dist/browser/use-commands.js +120 -0
- package/dist/cli/args.d.ts +8 -4
- package/dist/cli/args.js +8 -5
- package/dist/cli/console-reporter.js +5 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/usage.d.ts +1 -1
- package/dist/cli/usage.js +4 -1
- package/dist/config/base-url.d.ts +4 -0
- package/dist/config/base-url.js +6 -0
- package/dist/config/config.js +7 -0
- package/dist/config/fixture-use.d.ts +19 -0
- package/dist/config/fixture-use.js +44 -0
- package/dist/config/fully-parallel.d.ts +3 -0
- package/dist/config/fully-parallel.js +6 -0
- package/dist/config/group-use.d.ts +10 -0
- package/dist/config/group-use.js +51 -0
- package/dist/config/resolved-config.d.ts +2 -0
- package/dist/config/resolved-config.js +3 -0
- package/dist/config/storage-state-file.d.ts +3 -0
- package/dist/config/storage-state-file.js +20 -0
- package/dist/config/use-options.d.ts +3 -1
- package/dist/config/use-options.js +74 -14
- package/dist/config/web-server-options.d.ts +7 -0
- package/dist/config/web-server-options.js +111 -0
- package/dist/locator/check-actions.js +1 -1
- package/dist/locator/drag-actions.d.ts +17 -0
- package/dist/locator/drag-actions.js +80 -0
- package/dist/locator/element-actions.d.ts +10 -4
- package/dist/locator/element-actions.js +23 -16
- package/dist/locator/element-state.d.ts +5 -0
- package/dist/locator/element-state.js +2 -1
- package/dist/locator/event-actions.d.ts +10 -0
- package/dist/locator/event-actions.js +81 -0
- package/dist/locator/input-actions.d.ts +2 -11
- package/dist/locator/input-actions.js +3 -56
- package/dist/locator/key-actions.d.ts +11 -0
- package/dist/locator/key-actions.js +52 -0
- package/dist/locator/key-map.d.ts +16 -0
- package/dist/locator/key-map.js +76 -0
- package/dist/locator/locator.d.ts +35 -5
- package/dist/locator/locator.js +70 -16
- package/dist/locator/page-scripts.d.ts +2 -0
- package/dist/locator/page-scripts.js +7 -0
- package/dist/locator/pointer-actions.d.ts +44 -0
- package/dist/locator/pointer-actions.js +117 -0
- package/dist/locator/pointer-element-actions.d.ts +21 -0
- package/dist/locator/pointer-element-actions.js +42 -0
- package/dist/request/api-request-context.d.ts +65 -0
- package/dist/request/api-request-context.js +198 -0
- package/dist/request/api-response.d.ts +34 -0
- package/dist/request/api-response.js +57 -0
- package/dist/request/cookie-jar.d.ts +48 -0
- package/dist/request/cookie-jar.js +164 -0
- package/dist/request/http-transport.d.ts +31 -0
- package/dist/request/http-transport.js +113 -0
- package/dist/request/redirect.d.ts +10 -0
- package/dist/request/redirect.js +21 -0
- package/dist/request/request-body.d.ts +27 -0
- package/dist/request/request-body.js +71 -0
- package/dist/request/request.d.ts +16 -0
- package/dist/request/request.js +20 -0
- package/dist/runner/all-hooks.d.ts +7 -1
- package/dist/runner/all-hooks.js +17 -1
- package/dist/runner/fixture-defs.d.ts +2 -1
- package/dist/runner/fixture-defs.js +52 -18
- package/dist/runner/fixture-meta.d.ts +20 -0
- package/dist/runner/fixture-meta.js +109 -0
- package/dist/runner/fixture-options.d.ts +8 -0
- package/dist/runner/fixture-options.js +35 -0
- package/dist/runner/fixture-outputs.d.ts +11 -0
- package/dist/runner/fixture-outputs.js +28 -0
- package/dist/runner/fixture-registry.d.ts +35 -0
- package/dist/runner/fixture-registry.js +73 -0
- package/dist/runner/fixture-scope.d.ts +39 -9
- package/dist/runner/fixture-scope.js +139 -70
- package/dist/runner/fixture-start.d.ts +27 -0
- package/dist/runner/fixture-start.js +76 -0
- package/dist/runner/fixture-use.d.ts +23 -0
- package/dist/runner/fixture-use.js +64 -0
- package/dist/runner/fixture-worker.d.ts +26 -0
- package/dist/runner/fixture-worker.js +46 -0
- package/dist/runner/fixtures.d.ts +4 -1
- package/dist/runner/fixtures.js +3 -2
- package/dist/runner/global-hooks.d.ts +13 -0
- package/dist/runner/global-hooks.js +66 -0
- package/dist/runner/options.d.ts +13 -2
- package/dist/runner/output-tail.d.ts +14 -0
- package/dist/runner/output-tail.js +30 -0
- package/dist/runner/pool-schedule.d.ts +22 -0
- package/dist/runner/pool-schedule.js +55 -0
- package/dist/runner/pool-units.d.ts +34 -0
- package/dist/runner/pool-units.js +85 -0
- package/dist/runner/pooled-run.d.ts +7 -5
- package/dist/runner/pooled-run.js +19 -9
- package/dist/runner/prepare-run.js +5 -0
- package/dist/runner/registry.js +21 -7
- package/dist/runner/reporter.d.ts +13 -1
- package/dist/runner/reporter.js +13 -2
- package/dist/runner/request-fixture.d.ts +14 -0
- package/dist/runner/request-fixture.js +25 -0
- package/dist/runner/run-hooks.d.ts +16 -0
- package/dist/runner/run-hooks.js +52 -0
- package/dist/runner/run-test.d.ts +3 -0
- package/dist/runner/run-test.js +23 -3
- package/dist/runner/run.d.ts +8 -0
- package/dist/runner/run.js +10 -0
- package/dist/runner/select.d.ts +15 -4
- package/dist/runner/select.js +38 -12
- package/dist/runner/serial-run.d.ts +23 -0
- package/dist/runner/serial-run.js +88 -0
- package/dist/runner/serial.d.ts +34 -0
- package/dist/runner/serial.js +63 -0
- package/dist/runner/shard.d.ts +22 -0
- package/dist/runner/shard.js +73 -0
- package/dist/runner/suite.d.ts +3 -3
- package/dist/runner/suite.js +4 -3
- package/dist/runner/test-function.d.ts +15 -3
- package/dist/runner/test-info.js +2 -1
- package/dist/runner/test-runner.d.ts +1 -0
- package/dist/runner/test-runner.js +67 -18
- package/dist/runner/use-options.d.ts +18 -4
- package/dist/runner/use-options.js +33 -6
- package/dist/runner/wait-for-url.d.ts +20 -0
- package/dist/runner/wait-for-url.js +65 -0
- package/dist/runner/web-server.d.ts +16 -0
- package/dist/runner/web-server.js +165 -0
- package/dist/runner/worker-pool.d.ts +13 -5
- package/dist/runner/worker-pool.js +85 -51
- package/dist/runner/worker-protocol.d.ts +8 -0
- package/dist/runner/worker-session.js +9 -1
- package/dist/runner/worker.d.ts +2 -0
- package/dist/runner/worker.js +7 -1
- package/dist/types/config.d.ts +117 -21
- package/dist/types/extend.d.ts +54 -2
- package/dist/types/test.d.ts +42 -0
- package/dist/types/use.d.ts +59 -7
- package/package.json +1 -1
- package/dist/browser/emulation.d.ts +0 -61
- 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.
|
|
103
|
+
const at = nextEligible(this.queue, [...this.slots.values()], slot);
|
|
101
104
|
if (at < 0)
|
|
102
|
-
|
|
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.
|
|
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
|
-
...(
|
|
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,
|
|
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
|
|
225
|
-
result
|
|
218
|
+
worker,
|
|
219
|
+
result,
|
|
226
220
|
});
|
|
227
|
-
this.settle(entry,
|
|
228
|
-
|
|
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,
|
|
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
|
},
|
package/dist/runner/worker.d.ts
CHANGED
|
@@ -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>;
|
package/dist/runner/worker.js
CHANGED
|
@@ -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
|
|
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 {
|