@itmetsam/samurai-framework 0.21.4 → 0.22.0

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 (178) hide show
  1. package/dist/api.d.ts +8 -1
  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 +11 -2
  6. package/dist/browser/browser.js +31 -8
  7. package/dist/browser/cdp.d.ts +11 -0
  8. package/dist/browser/cdp.js +28 -0
  9. package/dist/browser/chrome-launch.d.ts +4 -0
  10. package/dist/browser/chrome-launch.js +11 -3
  11. package/dist/browser/context-cookie-store.d.ts +17 -0
  12. package/dist/browser/context-cookie-store.js +90 -0
  13. package/dist/browser/index.d.ts +8 -1
  14. package/dist/browser/index.js +5 -1
  15. package/dist/browser/keyboard.d.ts +35 -0
  16. package/dist/browser/keyboard.js +83 -0
  17. package/dist/browser/launch-firefox.js +1 -1
  18. package/dist/browser/launch-flags.d.ts +4 -1
  19. package/dist/browser/launch-flags.js +18 -5
  20. package/dist/browser/mouse.d.ts +42 -0
  21. package/dist/browser/mouse.js +66 -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.d.ts +20 -6
  26. package/dist/browser/page.js +24 -8
  27. package/dist/browser/session-capabilities.d.ts +10 -0
  28. package/dist/browser/session-capabilities.js +11 -0
  29. package/dist/browser/session.d.ts +2 -1
  30. package/dist/browser/session.js +3 -3
  31. package/dist/browser/storage-state.d.ts +4 -0
  32. package/dist/browser/storage-state.js +17 -0
  33. package/dist/browser/use-applier.d.ts +38 -0
  34. package/dist/browser/use-applier.js +121 -0
  35. package/dist/browser/use-commands.d.ts +36 -0
  36. package/dist/browser/use-commands.js +120 -0
  37. package/dist/browser/viewport.d.ts +3 -2
  38. package/dist/browser/viewport.js +15 -2
  39. package/dist/cli/args.d.ts +8 -4
  40. package/dist/cli/args.js +8 -5
  41. package/dist/cli/console-reporter.js +5 -1
  42. package/dist/cli/main.js +1 -0
  43. package/dist/cli/usage.d.ts +1 -1
  44. package/dist/cli/usage.js +4 -1
  45. package/dist/config/base-url.d.ts +4 -0
  46. package/dist/config/base-url.js +6 -0
  47. package/dist/config/config.js +8 -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 +8 -0
  51. package/dist/config/group-use.js +41 -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 +18 -0
  57. package/dist/config/use-options.js +184 -0
  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/logger/warn-once.d.ts +4 -0
  84. package/dist/logger/warn-once.js +13 -0
  85. package/dist/request/api-request-context.d.ts +65 -0
  86. package/dist/request/api-request-context.js +198 -0
  87. package/dist/request/api-response.d.ts +34 -0
  88. package/dist/request/api-response.js +57 -0
  89. package/dist/request/cookie-jar.d.ts +48 -0
  90. package/dist/request/cookie-jar.js +164 -0
  91. package/dist/request/http-transport.d.ts +31 -0
  92. package/dist/request/http-transport.js +113 -0
  93. package/dist/request/redirect.d.ts +10 -0
  94. package/dist/request/redirect.js +21 -0
  95. package/dist/request/request-body.d.ts +27 -0
  96. package/dist/request/request-body.js +71 -0
  97. package/dist/request/request.d.ts +16 -0
  98. package/dist/request/request.js +20 -0
  99. package/dist/runner/all-hooks.d.ts +7 -1
  100. package/dist/runner/all-hooks.js +17 -1
  101. package/dist/runner/attachments.d.ts +16 -0
  102. package/dist/runner/attachments.js +60 -0
  103. package/dist/runner/fixture-args.d.ts +15 -0
  104. package/dist/runner/fixture-args.js +95 -0
  105. package/dist/runner/fixture-defs.d.ts +22 -0
  106. package/dist/runner/fixture-defs.js +104 -0
  107. package/dist/runner/fixture-scope.d.ts +33 -0
  108. package/dist/runner/fixture-scope.js +156 -0
  109. package/dist/runner/fixtures.d.ts +37 -0
  110. package/dist/runner/fixtures.js +40 -0
  111. package/dist/runner/global-hooks.d.ts +13 -0
  112. package/dist/runner/global-hooks.js +66 -0
  113. package/dist/runner/hooks.d.ts +6 -1
  114. package/dist/runner/hooks.js +15 -3
  115. package/dist/runner/options.d.ts +13 -2
  116. package/dist/runner/output-tail.d.ts +14 -0
  117. package/dist/runner/output-tail.js +30 -0
  118. package/dist/runner/pool-schedule.d.ts +22 -0
  119. package/dist/runner/pool-schedule.js +55 -0
  120. package/dist/runner/pool-units.d.ts +34 -0
  121. package/dist/runner/pool-units.js +85 -0
  122. package/dist/runner/pooled-run.d.ts +7 -5
  123. package/dist/runner/pooled-run.js +19 -9
  124. package/dist/runner/registry.d.ts +3 -39
  125. package/dist/runner/registry.js +99 -21
  126. package/dist/runner/reporter.d.ts +17 -1
  127. package/dist/runner/reporter.js +15 -2
  128. package/dist/runner/request-fixture.d.ts +14 -0
  129. package/dist/runner/request-fixture.js +25 -0
  130. package/dist/runner/run-hooks.d.ts +16 -0
  131. package/dist/runner/run-hooks.js +52 -0
  132. package/dist/runner/run-test.d.ts +1 -3
  133. package/dist/runner/run-test.js +98 -35
  134. package/dist/runner/run.d.ts +1 -0
  135. package/dist/runner/run.js +1 -0
  136. package/dist/runner/select.d.ts +15 -4
  137. package/dist/runner/select.js +38 -12
  138. package/dist/runner/serial-run.d.ts +23 -0
  139. package/dist/runner/serial-run.js +88 -0
  140. package/dist/runner/serial.d.ts +34 -0
  141. package/dist/runner/serial.js +63 -0
  142. package/dist/runner/shard.d.ts +22 -0
  143. package/dist/runner/shard.js +73 -0
  144. package/dist/runner/steps.d.ts +13 -0
  145. package/dist/runner/steps.js +67 -0
  146. package/dist/runner/suite.d.ts +8 -3
  147. package/dist/runner/suite.js +12 -3
  148. package/dist/runner/test-annotations.d.ts +16 -0
  149. package/dist/runner/test-annotations.js +47 -0
  150. package/dist/runner/test-function.d.ts +115 -0
  151. package/dist/runner/test-function.js +1 -0
  152. package/dist/runner/test-info.d.ts +104 -0
  153. package/dist/runner/test-info.js +170 -0
  154. package/dist/runner/test-runner.d.ts +1 -0
  155. package/dist/runner/test-runner.js +64 -19
  156. package/dist/runner/test-timeout.d.ts +22 -0
  157. package/dist/runner/test-timeout.js +45 -0
  158. package/dist/runner/use-options.d.ts +7 -0
  159. package/dist/runner/use-options.js +25 -0
  160. package/dist/runner/wait-for-url.d.ts +20 -0
  161. package/dist/runner/wait-for-url.js +65 -0
  162. package/dist/runner/web-server.d.ts +16 -0
  163. package/dist/runner/web-server.js +165 -0
  164. package/dist/runner/worker-pool.d.ts +13 -5
  165. package/dist/runner/worker-pool.js +85 -51
  166. package/dist/runner/worker-protocol.d.ts +8 -0
  167. package/dist/runner/worker-session.js +5 -2
  168. package/dist/runner/worker.d.ts +2 -0
  169. package/dist/runner/worker.js +7 -1
  170. package/dist/script/element-handle.d.ts +1 -1
  171. package/dist/script/element-handle.js +1 -1
  172. package/dist/types/config.d.ts +86 -2
  173. package/dist/types/extend.d.ts +30 -0
  174. package/dist/types/test.d.ts +138 -0
  175. package/dist/types/use.d.ts +85 -0
  176. package/package.json +1 -1
  177. package/dist/browser/emulation.d.ts +0 -61
  178. package/dist/browser/emulation.js +0 -180
@@ -39,6 +39,7 @@ export default class TestRunner {
39
39
  * was also written to the report. With more than one worker the tests run at the same time in worker processes.
40
40
  */
41
41
  start(): Promise<import("../types/test.js").TestSummary>;
42
+ private execute;
42
43
  /** Whether anything but the default says how many workers to use */
43
44
  private workersAsked;
44
45
  /** The pool of a run: up to `workers` worker processes that read the same project and settings as this run */
@@ -11,13 +11,18 @@ import { DEFAULT_SRC_DIR, DEFAULT_TEST_MATCH } from "../config/defaults.js";
11
11
  import { DEFAULT_PORT } from "./ports.js";
12
12
  import TestReporter from "./reporter.js";
13
13
  import { concurrency, planItems, poolMode, runPooled, serialKeyOf, } from "./pooled-run.js";
14
- import { allHookFixtures, runOneTest } from "./run-test.js";
14
+ import { allHookFixtures } from "./fixtures.js";
15
+ import { runOneTest } from "./run-test.js";
15
16
  import { staticSkip } from "./skip.js";
16
- import { browsersOfRun, loadSpecs, selectTests } from "./select.js";
17
+ import { browserPlan, expandByGroup, loadSpecs, selectTests, } from "./select.js";
17
18
  import { resolveRetries } from "./retries-option.js";
18
19
  import { runWithRetries } from "./retry.js";
20
+ import { chunkBySerialBlock, unitOf } from "./serial.js";
21
+ import { parseShard, shardGroupOf, shardTests } from "./shard.js";
22
+ import { serialBlockRun } from "./serial-run.js";
19
23
  import { resolveWorkers } from "../config/workers-option.js";
20
24
  import { runOutput } from "./output.js";
25
+ import { withRunHooks } from "./run-hooks.js";
21
26
  import { WorkerPool } from "./worker-pool.js";
22
27
  import { processWorkerFactory } from "./worker-process.js";
23
28
  /** Finds the spec files, picks the tests and browsers of a run and runs each test in each browser */
@@ -62,18 +67,37 @@ export default class TestRunner {
62
67
  * was also written to the report. With more than one worker the tests run at the same time in worker processes.
63
68
  */
64
69
  async start() {
70
+ const config = await loadRawConfig();
71
+ // Web servers and `globalSetup` come first and `globalTeardown` last, around everything below
72
+ return withRunHooks({
73
+ config,
74
+ settings: this.run.settings,
75
+ projectDir: projectDir(),
76
+ signal: this.options.signal,
77
+ }, () => this.execute(config));
78
+ }
79
+ async execute(config) {
65
80
  const { onEvent, signal } = this.options;
66
81
  // The specs are imported once: their top-level code runs once per run
67
82
  const loaded = await loadSpecs(this.testFiles);
68
- const config = await loadRawConfig();
69
83
  // A focused test the config refuses stops the run before anything starts; else only the focused ones run
70
84
  assertNoOnly(loaded.all, forbidsOnly(config), loaded.srcDir);
71
- const selected = narrowToOnly(selectTests(loaded, this.options));
72
- const browsers = await browsersOfRun(loaded.groups, this.options);
85
+ const shard = this.options.shard
86
+ ? parseShard(this.options.shard, "shard")
87
+ : undefined;
88
+ const focused = narrowToOnly(selectTests(loaded, this.options));
89
+ const sharded = shard
90
+ ? shardTests(focused, shard, (test) => shardGroupOf(test, config.fullyParallel))
91
+ : focused;
92
+ // A test of two selected groups is two runs, each with its group's `use` and `browsers`
93
+ const selected = expandByGroup(sharded, loaded, this.options);
94
+ const { browsers, browsersOf } = await browserPlan(loaded.groups, this.options);
95
+ const runsIn = (test, browser) => browsersOf(test).includes(browser);
73
96
  const reporter = new TestReporter({
74
97
  environment: this.run.settings.environment,
75
98
  browsers,
76
99
  output: runOutput(this.options, projectDir()).report,
100
+ ...(shard && { shard }),
77
101
  ...(onEvent && { onEvent }),
78
102
  });
79
103
  // How the run goes is settled first: a bad `workers` is an error before any browser starts
@@ -88,7 +112,7 @@ export default class TestRunner {
88
112
  env: process.env.SAMURAI_RETRIES,
89
113
  config: config.retries,
90
114
  });
91
- const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir), this.options.inputs);
115
+ const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir), this.options.inputs, (test) => unitOf(test, config.fullyParallel), runsIn);
92
116
  // A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
93
117
  const lanes = concurrency(plan.items);
94
118
  const mode = poolMode({
@@ -119,7 +143,7 @@ export default class TestRunner {
119
143
  }
120
144
  return reporter.onEnd();
121
145
  }
122
- reporter.onStart(selected.length * browsers.length);
146
+ reporter.onStart(plan.planned.length);
123
147
  // The `afterAll` hooks run when the last test is done, before the run ends
124
148
  const allHooks = new AllHooks({
125
149
  timeout: this.run.settings.timeout,
@@ -127,21 +151,41 @@ export default class TestRunner {
127
151
  });
128
152
  try {
129
153
  for (const browser of browsers) {
130
- for (const test of selected) {
131
- const skip = staticSkip(test);
132
- if (skip) {
133
- reporter.onTestSkipped(test, browser, skip);
134
- continue;
135
- }
136
- if (signal?.aborted) {
137
- reporter.onTestStart(test, browser);
138
- reporter.onTestEnd(test, browser, {
139
- message: "Run aborted",
140
- type: "error",
154
+ const forBrowser = selected.filter((test) => runsIn(test, browser));
155
+ for (const chunk of chunkBySerialBlock(forBrowser)) {
156
+ const runTest = (test, sink, attempt) => runOneTest(test, browser, this.run, this.options, loaded.srcDir, sink, { allHooks, attempt });
157
+ if (chunk.block) {
158
+ const { suite } = chunk.block;
159
+ await serialBlockRun({
160
+ tests: chunk.tests,
161
+ browser,
162
+ retries,
163
+ signal,
164
+ sink: reporter,
165
+ runTest,
166
+ restart: async () => {
167
+ for (const error of await allHooks.closeWithin(suite))
168
+ logger.error("%s", error.message);
169
+ },
141
170
  });
142
171
  continue;
143
172
  }
144
- await runWithRetries(test, browser, retries, signal, (sink, attempt) => runOneTest(test, browser, this.run, this.options, loaded.srcDir, sink, { allHooks, attempt }), reporter);
173
+ for (const test of chunk.tests) {
174
+ const skip = staticSkip(test);
175
+ if (skip) {
176
+ reporter.onTestSkipped(test, browser, skip);
177
+ continue;
178
+ }
179
+ if (signal?.aborted) {
180
+ reporter.onTestStart(test, browser);
181
+ reporter.onTestEnd(test, browser, {
182
+ message: "Run aborted",
183
+ type: "error",
184
+ });
185
+ continue;
186
+ }
187
+ await runWithRetries(test, browser, retries, signal, (sink, attempt) => runTest(test, sink, attempt), reporter);
188
+ }
145
189
  }
146
190
  }
147
191
  }
@@ -170,6 +214,7 @@ export default class TestRunner {
170
214
  dataDir: this.project.dataDir,
171
215
  }),
172
216
  environment: settings.environment,
217
+ ...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
173
218
  timeout: settings.timeout,
174
219
  expectTimeout: settings.expectTimeout,
175
220
  ...(headless !== undefined && { headless }),
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The time limit of one test. It starts once, and can be changed while the test runs: the new limit counts from
3
+ * the start of the test, so the time already spent counts against it (a limit already passed ends the test now).
4
+ */
5
+ export declare class TestTimeout {
6
+ private limit;
7
+ private readonly onExpire;
8
+ private timer;
9
+ private startedAt;
10
+ private expired;
11
+ constructor(limit: number, onExpire: () => void);
12
+ /** The limit in milliseconds; 0 means none */
13
+ get ms(): number;
14
+ /** Starts the clock; nothing can expire before this */
15
+ start(): void;
16
+ /** Changes the limit, re-arming the timer from the start of the test */
17
+ set(ms: number): void;
18
+ stop(): void;
19
+ private arm;
20
+ }
21
+ /** Checks a timeout in milliseconds; `label` starts the error (`setTimeout()`) */
22
+ export declare function checkTimeout(label: string, ms: unknown): number;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The time limit of one test. It starts once, and can be changed while the test runs: the new limit counts from
3
+ * the start of the test, so the time already spent counts against it (a limit already passed ends the test now).
4
+ */
5
+ export class TestTimeout {
6
+ constructor(limit, onExpire) {
7
+ this.limit = limit;
8
+ this.onExpire = onExpire;
9
+ this.expired = false;
10
+ }
11
+ /** The limit in milliseconds; 0 means none */
12
+ get ms() {
13
+ return this.limit;
14
+ }
15
+ /** Starts the clock; nothing can expire before this */
16
+ start() {
17
+ this.startedAt = Date.now();
18
+ this.arm();
19
+ }
20
+ /** Changes the limit, re-arming the timer from the start of the test */
21
+ set(ms) {
22
+ this.limit = ms;
23
+ if (this.startedAt !== undefined && !this.expired)
24
+ this.arm();
25
+ }
26
+ stop() {
27
+ clearTimeout(this.timer);
28
+ }
29
+ arm() {
30
+ clearTimeout(this.timer);
31
+ if (this.limit === 0 || this.startedAt === undefined)
32
+ return;
33
+ const left = Math.max(0, this.limit - (Date.now() - this.startedAt));
34
+ this.timer = setTimeout(() => {
35
+ this.expired = true;
36
+ this.onExpire();
37
+ }, left);
38
+ }
39
+ }
40
+ /** Checks a timeout in milliseconds; `label` starts the error (`setTimeout()`) */
41
+ export function checkTimeout(label, ms) {
42
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0)
43
+ throw new Error(`${label}: the timeout must be a number of milliseconds, 0 or more`);
44
+ return ms;
45
+ }
@@ -0,0 +1,7 @@
1
+ import type { GroupUse } from "../types/config.js";
2
+ import type { RegisteredTestCase } from "../types/test.js";
3
+ /**
4
+ * The `use` options one test runs with: config `use`, then the `use` of the group the test runs for (if any), then
5
+ * the `test.use()` calls around the test, outermost first, a later one winning key by key
6
+ */
7
+ export declare function effectiveUse(test: RegisteredTestCase): Promise<GroupUse>;
@@ -0,0 +1,25 @@
1
+ import { readConfig } from "../config/config.js";
2
+ import { groupUseOf } from "../config/group-use.js";
3
+ import { mergeUse, useOptionsOf } from "../config/use-options.js";
4
+ import logger from "../logger/index.js";
5
+ import { useOf } from "./suite.js";
6
+ /**
7
+ * The `use` options one test runs with: config `use`, then the `use` of the group the test runs for (if any), then
8
+ * the `test.use()` calls around the test, outermost first, a later one winning key by key
9
+ */
10
+ export async function effectiveUse(test) {
11
+ let config;
12
+ let groups;
13
+ try {
14
+ config = await readConfig("use");
15
+ groups = await readConfig("groups");
16
+ }
17
+ catch (err) {
18
+ logger.debug("Could not read use config, using defaults", { err });
19
+ }
20
+ return mergeUse([
21
+ useOptionsOf(config),
22
+ test.group === undefined ? {} : groupUseOf(groups, test.group),
23
+ ...useOf(test.suite),
24
+ ]);
25
+ }
@@ -0,0 +1,20 @@
1
+ /** What to probe: an `http`/`https` URL, or a local port */
2
+ export type ReadyTarget = {
3
+ url: string;
4
+ ignoreHTTPSErrors?: boolean;
5
+ } | {
6
+ port: number;
7
+ };
8
+ /** One probe of `target` */
9
+ export declare function answers(target: ReadyTarget): Promise<boolean>;
10
+ /** The target as text for messages: the URL, or `localhost:<port>` */
11
+ export declare function describeTarget(target: ReadyTarget): string;
12
+ /**
13
+ * Probes `target` until it answers. Resolves "ready", or "timeout" after `timeout` ms, or "aborted" when `signal`
14
+ * fires or `stopped()` turns true (the caller saw the server die)
15
+ */
16
+ export declare function waitUntilReady(target: ReadyTarget, options: {
17
+ timeout: number;
18
+ signal?: AbortSignal;
19
+ stopped?: () => boolean;
20
+ }): Promise<"ready" | "timeout" | "aborted">;
@@ -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 */