@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,113 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import zlib from "node:zlib";
4
+ /** A request that failed before a response was complete */
5
+ export class TransportError extends Error {
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "TransportError";
10
+ }
11
+ }
12
+ function decode(body, encoding) {
13
+ if (body.length === 0)
14
+ return body;
15
+ switch (encoding?.trim().toLowerCase()) {
16
+ case "gzip":
17
+ case "x-gzip":
18
+ return zlib.gunzipSync(body);
19
+ case "br":
20
+ return zlib.brotliDecompressSync(body);
21
+ case "deflate":
22
+ try {
23
+ return zlib.inflateSync(body);
24
+ }
25
+ catch {
26
+ // Some servers send deflate without the zlib header
27
+ return zlib.inflateRawSync(body);
28
+ }
29
+ default:
30
+ return body;
31
+ }
32
+ }
33
+ /** The url without credentials or query: what an error may say about where the request went */
34
+ export function describeURL(url) {
35
+ return `${url.origin}${url.pathname}`;
36
+ }
37
+ /** Sends one request over `http` or `https` and reads the whole response. Errors never include header values. */
38
+ export function sendHop(request) {
39
+ const { url, method, headers, body, signal } = request;
40
+ const where = `${method} ${describeURL(url)}`;
41
+ return new Promise((resolve, reject) => {
42
+ if (signal.aborted)
43
+ return reject(new TransportError(`request aborted (${where})`));
44
+ const transport = url.protocol === "https:" ? https : http;
45
+ let settled = false;
46
+ let timer;
47
+ const finish = (error, value) => {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ signal.removeEventListener("abort", onAbort);
52
+ clearTimeout(timer);
53
+ if (error)
54
+ reject(error);
55
+ else
56
+ resolve(value);
57
+ };
58
+ const req = transport.request(url, {
59
+ method,
60
+ headers: {
61
+ ...headers,
62
+ ...(body && { "content-length": String(body.length) }),
63
+ },
64
+ // No shared pool: nothing stays open after the last request
65
+ agent: false,
66
+ ...(url.protocol === "https:" && {
67
+ rejectUnauthorized: !request.ignoreHTTPSErrors,
68
+ }),
69
+ }, (res) => {
70
+ const chunks = [];
71
+ res.on("data", (chunk) => chunks.push(chunk));
72
+ res.on("error", (err) => finish(new TransportError(`${err.message} (${where})`)));
73
+ res.on("end", () => {
74
+ try {
75
+ const raw = res.rawHeaders;
76
+ const entries = [];
77
+ for (let i = 0; i + 1 < raw.length; i += 2)
78
+ entries.push({
79
+ name: raw[i],
80
+ value: raw[i + 1],
81
+ });
82
+ const decoded = method === "HEAD"
83
+ ? Buffer.alloc(0)
84
+ : decode(Buffer.concat(chunks), res.headers["content-encoding"]);
85
+ finish(undefined, {
86
+ status: res.statusCode ?? 0,
87
+ statusText: res.statusMessage ?? "",
88
+ headers: entries,
89
+ setCookies: res.headers["set-cookie"] ?? [],
90
+ body: decoded,
91
+ });
92
+ }
93
+ catch (err) {
94
+ finish(new TransportError(`the response body could not be decoded: ${err.message} (${where})`));
95
+ }
96
+ });
97
+ });
98
+ const onAbort = () => {
99
+ req.destroy();
100
+ finish(new TransportError(`request aborted (${where})`));
101
+ };
102
+ signal.addEventListener("abort", onAbort, { once: true });
103
+ timer =
104
+ request.timeout > 0
105
+ ? setTimeout(() => {
106
+ req.destroy();
107
+ finish(new TransportError(`timed out after ${request.timeout}ms (${where})`, "ETIMEDOUT"));
108
+ }, request.timeout)
109
+ : undefined;
110
+ req.on("error", (err) => finish(new TransportError(`${err.code ?? err.message} (${where})`, err.code)));
111
+ req.end(body);
112
+ });
113
+ }
@@ -0,0 +1,10 @@
1
+ /** Where a response sends the client next: its `Location` against the URL it came from; `undefined` when it does not redirect */
2
+ export declare function redirectTarget(status: number, location: string | undefined, from: URL): URL | undefined;
3
+ /**
4
+ * The method and body of the request that follows a redirect. 303 (any method but GET and HEAD) and 301/302 after
5
+ * a POST turn into a GET without a body; 307 and 308 keep both.
6
+ */
7
+ export declare function followUp<B>(status: number, method: string, body: B | undefined): {
8
+ method: string;
9
+ body: B | undefined;
10
+ };
@@ -0,0 +1,21 @@
1
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
2
+ /** Where a response sends the client next: its `Location` against the URL it came from; `undefined` when it does not redirect */
3
+ export function redirectTarget(status, location, from) {
4
+ if (!REDIRECTS.has(status) || !location)
5
+ return undefined;
6
+ try {
7
+ return new URL(location, from);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ /**
14
+ * The method and body of the request that follows a redirect. 303 (any method but GET and HEAD) and 301/302 after
15
+ * a POST turn into a GET without a body; 307 and 308 keep both.
16
+ */
17
+ export function followUp(status, method, body) {
18
+ const upgrades = (status === 303 && method !== "GET" && method !== "HEAD") ||
19
+ ((status === 301 || status === 302) && method === "POST");
20
+ return upgrades ? { method: "GET", body: undefined } : { method, body };
21
+ }
@@ -0,0 +1,27 @@
1
+ /** A file in a `multipart` body */
2
+ export interface FilePayload {
3
+ name: string;
4
+ /** @default "application/octet-stream" */
5
+ mimeType?: string;
6
+ buffer: Buffer;
7
+ }
8
+ type Scalar = string | number | boolean;
9
+ /** The body options of a request: at most one is set */
10
+ export interface BodyOptions {
11
+ /** Sent as it is (a string, a `Buffer`), or as JSON (anything else) */
12
+ data?: string | Buffer | object | number | boolean | null;
13
+ /** Sent as `application/x-www-form-urlencoded` */
14
+ form?: Record<string, Scalar> | URLSearchParams;
15
+ /** Sent as `multipart/form-data`: fields and files */
16
+ multipart?: Record<string, Scalar | FilePayload>;
17
+ }
18
+ export interface EncodedBody {
19
+ body: Buffer;
20
+ contentType: string;
21
+ }
22
+ /**
23
+ * The bytes of a request body and the `Content-Type` that goes with them (a header the caller sets wins over it).
24
+ * `undefined` when there is no body. Throws `fetch(): ...` when more than one of the options is set.
25
+ */
26
+ export declare function encodeBody(options: BodyOptions): EncodedBody | undefined;
27
+ export {};
@@ -0,0 +1,71 @@
1
+ import { randomBytes } from "node:crypto";
2
+ function isFile(value) {
3
+ return (typeof value === "object" &&
4
+ value !== null &&
5
+ Buffer.isBuffer(value.buffer));
6
+ }
7
+ function isJson(text) {
8
+ try {
9
+ JSON.parse(text);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ const quoted = (text) => text.replace(/["\r\n]/g, "_");
17
+ function multipartOf(parts) {
18
+ const boundary = `----samurai${randomBytes(12).toString("hex")}`;
19
+ const chunks = [];
20
+ for (const [name, value] of Object.entries(parts)) {
21
+ const file = isFile(value);
22
+ const head = file
23
+ ? `Content-Disposition: form-data; name="${quoted(name)}"; filename="${quoted(value.name)}"\r\nContent-Type: ${value.mimeType ?? "application/octet-stream"}`
24
+ : `Content-Disposition: form-data; name="${quoted(name)}"`;
25
+ chunks.push(Buffer.from(`--${boundary}\r\n${head}\r\n\r\n`), file ? value.buffer : Buffer.from(String(value)), Buffer.from("\r\n"));
26
+ }
27
+ chunks.push(Buffer.from(`--${boundary}--\r\n`));
28
+ return {
29
+ body: Buffer.concat(chunks),
30
+ contentType: `multipart/form-data; boundary=${boundary}`,
31
+ };
32
+ }
33
+ /**
34
+ * The bytes of a request body and the `Content-Type` that goes with them (a header the caller sets wins over it).
35
+ * `undefined` when there is no body. Throws `fetch(): ...` when more than one of the options is set.
36
+ */
37
+ export function encodeBody(options) {
38
+ const given = ["data", "form", "multipart"].filter((key) => options[key] !== undefined);
39
+ if (given.length > 1)
40
+ throw new Error(`fetch(): ${given.join(" and ")} cannot be used together, pick one`);
41
+ const { data, form, multipart } = options;
42
+ if (form !== undefined) {
43
+ const params = form instanceof URLSearchParams
44
+ ? form
45
+ : new URLSearchParams(Object.entries(form).map(([key, value]) => [key, String(value)]));
46
+ return {
47
+ body: Buffer.from(params.toString()),
48
+ contentType: "application/x-www-form-urlencoded",
49
+ };
50
+ }
51
+ if (multipart !== undefined)
52
+ return multipartOf(multipart);
53
+ if (data === undefined)
54
+ return undefined;
55
+ if (typeof data === "string")
56
+ return {
57
+ body: Buffer.from(data),
58
+ contentType: isJson(data)
59
+ ? "application/json"
60
+ : "application/octet-stream",
61
+ };
62
+ if (Buffer.isBuffer(data) || data instanceof Uint8Array)
63
+ return {
64
+ body: Buffer.from(data),
65
+ contentType: "application/octet-stream",
66
+ };
67
+ return {
68
+ body: Buffer.from(JSON.stringify(data)),
69
+ contentType: "application/json",
70
+ };
71
+ }
@@ -0,0 +1,16 @@
1
+ import type { StorageState } from "../types/use.js";
2
+ import { APIRequestContext, type RequestContextOptions } from "./api-request-context.js";
3
+ /** Options of `request.newContext()` */
4
+ export interface NewRequestContextOptions extends RequestContextOptions {
5
+ /** Cookies to start with: a JSON file (relative to the project folder) or the object itself. Local storage is not used. */
6
+ storageState?: string | StorageState;
7
+ }
8
+ /** A request context with its own cookies: the same API as `page.request`, without a browser */
9
+ export declare function createRequestContext(options?: NewRequestContextOptions): APIRequestContext;
10
+ /**
11
+ * Creates `APIRequestContext`s that belong to no page: `request.newContext({ baseURL })`. Inside a test, the
12
+ * `request` fixture is one already.
13
+ */
14
+ export declare const request: {
15
+ newContext: (options?: NewRequestContextOptions) => Promise<APIRequestContext>;
16
+ };
@@ -0,0 +1,20 @@
1
+ import path from "node:path";
2
+ import { projectDir } from "../config/config.js";
3
+ import { loadStorageState } from "../config/storage-state-file.js";
4
+ import { APIRequestContext, } from "./api-request-context.js";
5
+ import { CookieJar } from "./cookie-jar.js";
6
+ /** A request context with its own cookies: the same API as `page.request`, without a browser */
7
+ export function createRequestContext(options = {}) {
8
+ const { storageState, ...rest } = options;
9
+ const state = storageState === undefined
10
+ ? undefined
11
+ : loadStorageState(storageState, path.resolve(projectDir()), "newContext()");
12
+ return new APIRequestContext(rest, new CookieJar(state?.cookies));
13
+ }
14
+ /**
15
+ * Creates `APIRequestContext`s that belong to no page: `request.newContext({ baseURL })`. Inside a test, the
16
+ * `request` fixture is one already.
17
+ */
18
+ export const request = {
19
+ newContext: (options) => Promise.resolve(createRequestContext(options)),
20
+ };
@@ -1,5 +1,5 @@
1
1
  import type { SupportedBrowser } from "../types/browser.js";
2
- import type { AllHookFixtures, RegisteredTestCase } from "../types/test.js";
2
+ import type { AllHookFixtures, RegisteredTestCase, Suite } from "../types/test.js";
3
3
  import { type HookError } from "./hooks.js";
4
4
  export interface AllHooksOptions {
5
5
  /** The limit of each beforeAll / afterAll hook, in ms */
@@ -21,6 +21,12 @@ export declare class AllHooks {
21
21
  ensure(test: RegisteredTestCase, browser: SupportedBrowser): Promise<void>;
22
22
  /** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
23
23
  closeAll(): Promise<HookError[]>;
24
+ /**
25
+ * Runs the `afterAll` hooks of `root` and of the suites inside it that were entered, so the next test that arrives
26
+ * runs their `beforeAll` hooks again: a serial block that starts over. Returns the errors.
27
+ */
28
+ closeWithin(root: Suite): Promise<HookError[]>;
29
+ private runAfter;
24
30
  private enter;
25
31
  private runBefore;
26
32
  }
@@ -21,9 +21,25 @@ export class AllHooks {
21
21
  }
22
22
  /** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
23
23
  async closeAll() {
24
- const errors = [];
25
24
  const entered = this.entered.splice(0).reverse();
26
25
  this.scopes.clear();
26
+ return this.runAfter(entered);
27
+ }
28
+ /**
29
+ * Runs the `afterAll` hooks of `root` and of the suites inside it that were entered, so the next test that arrives
30
+ * runs their `beforeAll` hooks again: a serial block that starts over. Returns the errors.
31
+ */
32
+ async closeWithin(root) {
33
+ const inside = (scope) => suiteChain(scope.suite).includes(root);
34
+ const closing = this.entered.filter(inside).reverse();
35
+ for (const scope of closing) {
36
+ this.entered.splice(this.entered.indexOf(scope), 1);
37
+ this.scopes.get(scope.suite)?.delete(scope.browser);
38
+ }
39
+ return this.runAfter(closing);
40
+ }
41
+ async runAfter(entered) {
42
+ const errors = [];
27
43
  for (const { suite, browser, ready } of entered) {
28
44
  await ready.catch(() => undefined);
29
45
  for (const hook of suite.afterAll ?? []) {
@@ -1,7 +1,8 @@
1
+ import { type FixtureMeta } from "./fixture-meta.js";
1
2
  /** The function of a fixture: sets the value up, calls `use(value)`, cleans up after `use` returns */
2
3
  type FixtureSetup = (args: Record<string, unknown>, use: (value: unknown) => Promise<void>) => Promise<void>;
3
4
  /** One definition of a fixture: the value or the setup function, what it depends on, whether every test gets it */
4
- export interface FixtureDef {
5
+ export interface FixtureDef extends FixtureMeta {
5
6
  name: string;
6
7
  auto: boolean;
7
8
  deps: string[];
@@ -1,4 +1,5 @@
1
1
  import { requestedBy } from "./fixture-args.js";
2
+ import { checkedOptions, OPTIONS_SUFFIX, } from "./fixture-meta.js";
2
3
  /** The fixtures the runner builds for every test: a custom fixture of the same name overrides it */
3
4
  export const BUILT_IN_FIXTURES = new Set([
4
5
  "page",
@@ -11,46 +12,55 @@ export const BUILT_IN_FIXTURES = new Set([
11
12
  "outputs",
12
13
  "context",
13
14
  "baseURL",
15
+ "request",
14
16
  "testInfo",
15
17
  "isMobile",
16
18
  "hasTouch",
17
19
  ]);
18
- const OPTION_KEYS = new Set(["auto", "scope"]);
19
20
  function isPlainObject(value) {
20
21
  if (typeof value !== "object" || value === null)
21
22
  return false;
22
23
  const proto = Object.getPrototypeOf(value);
23
24
  return proto === Object.prototype || proto === null;
24
25
  }
25
- /** `[fixture, options]` is always a pair with options; an array value goes in a function */
26
- function checkedOptions(name, options) {
27
- for (const key of Object.keys(options))
28
- if (!OPTION_KEYS.has(key))
29
- throw new Error(`extend(): fixture "${name}" has an unknown option "${key}"`);
30
- if (options.auto !== undefined && typeof options.auto !== "boolean")
31
- throw new Error(`extend(): fixture "${name}": auto must be true or false`);
32
- if (options.scope === "worker")
33
- throw new Error(`extend(): fixture "${name}" has scope "worker", which is not supported; fixtures live as long as one test ({ scope: "test" })`);
34
- if (options.scope !== undefined && options.scope !== "test")
35
- throw new Error(`extend(): fixture "${name}": scope must be "test"`);
36
- return { auto: options.auto === true };
37
- }
38
26
  function definitionOf(name, raw) {
39
27
  const [fixture, options] = Array.isArray(raw) && raw.length === 2 && isPlainObject(raw[1])
40
28
  ? [raw[0], checkedOptions(name, raw[1])]
41
- : [raw, { auto: false }];
29
+ : [raw, checkedOptions(name, {})];
30
+ const { auto, meta } = options;
31
+ checkOptionFixture(name, fixture, meta);
42
32
  if (typeof fixture !== "function")
43
- return { name, auto: options.auto, deps: [], value: fixture };
33
+ return { name, auto, deps: [], value: fixture, ...meta };
44
34
  const requested = requestedBy(fixture);
45
35
  if (requested.kind === "opaque" || requested.rest)
46
36
  throw new Error(`extend(): fixture "${name}" must destructure the fixtures it uses in its first argument, as ({ page }, use) or ({}, use)`);
47
37
  return {
48
38
  name,
49
- auto: options.auto,
39
+ auto,
50
40
  deps: requested.names,
51
41
  setup: fixture,
42
+ ...meta,
52
43
  };
53
44
  }
45
+ /** `option: true` and `fields` go together with a name ending in `Options` and a plain object as the value */
46
+ function checkOptionFixture(name, fixture, meta) {
47
+ const suffixed = name.endsWith(OPTIONS_SUFFIX) && name !== OPTIONS_SUFFIX;
48
+ if (!meta.option) {
49
+ if (meta.fields)
50
+ throw new Error(suffixed
51
+ ? `extend(): fixture "${name}" has fields but option is not true`
52
+ : `extend(): fixture "${name}" has fields but is not an option fixture; put them on "${name}${OPTIONS_SUFFIX}" (option: true)`);
53
+ return;
54
+ }
55
+ if (!suffixed)
56
+ throw new Error(`extend(): fixture "${name}" is an option fixture, so its name must end in "${OPTIONS_SUFFIX}"`);
57
+ if (typeof fixture === "function")
58
+ throw new Error(`extend(): option fixture "${name}" must be a plain object, not a function`);
59
+ if (!isPlainObject(fixture))
60
+ throw new Error(`extend(): option fixture "${name}" must be a plain object`);
61
+ }
62
+ /** The name of the fixture an option fixture belongs to */
63
+ const fixtureOf = (optionName) => optionName.slice(0, -OPTIONS_SUFFIX.length);
54
64
  /** Where a dependency of `name` (at definition `level`) comes from: another definition, or the built-in fixture */
55
65
  export function dependencyOf(layers, name, level, dep) {
56
66
  if (dep === name)
@@ -64,6 +74,25 @@ export function dependencyOf(layers, name, level, dep) {
64
74
  return { name: dep, level: defs.length - 1 };
65
75
  return BUILT_IN_FIXTURES.has(dep) ? "built-in" : undefined;
66
76
  }
77
+ /** The built-in fixtures a worker fixture may use: those that do not belong to one test or one page */
78
+ const WORKER_BUILT_INS = new Set([
79
+ "browserName",
80
+ "workerIndex",
81
+ "env",
82
+ "secrets",
83
+ "baseURL",
84
+ ]);
85
+ /** A worker fixture outlives every test: it may use the run's values, other worker fixtures and plain values */
86
+ function checkWorkerDependency(layers, def, dep, target) {
87
+ if (target === "built-in") {
88
+ if (!WORKER_BUILT_INS.has(dep))
89
+ throw new Error(`extend(): worker fixture "${def.name}" cannot use "${dep}"; it lives longer than one test (it may use ${[...WORKER_BUILT_INS].join(", ")}, other worker fixtures and values)`);
90
+ return;
91
+ }
92
+ const used = target && layers.get(target.name)?.[target.level];
93
+ if (used && used.scope !== "worker" && used.setup)
94
+ throw new Error(`extend(): worker fixture "${def.name}" cannot use the test-scoped fixture "${dep}"`);
95
+ }
67
96
  /** Throws for a dependency nobody defines and for a cycle (`a` needs `b` needs `a`) */
68
97
  function validate(layers) {
69
98
  const done = new Set();
@@ -82,14 +111,19 @@ function validate(layers) {
82
111
  const target = dependencyOf(layers, name, level, dep);
83
112
  if (target === undefined)
84
113
  throw new Error(`extend(): fixture "${name}" depends on unknown fixture "${dep}"`);
114
+ if (def.scope === "worker")
115
+ checkWorkerDependency(layers, def, dep, target);
85
116
  if (target !== "built-in")
86
117
  walk(target.name, target.level, [...path, key]);
87
118
  }
88
119
  done.add(key);
89
120
  };
90
- for (const [name, defs] of layers)
121
+ for (const [name, defs] of layers) {
122
+ if (defs[defs.length - 1]?.option && !layers.has(fixtureOf(name)))
123
+ throw new Error(`extend(): option fixture "${name}" has no fixture "${fixtureOf(name)}"`);
91
124
  for (let level = 0; level < defs.length; level++)
92
125
  walk(name, level, []);
126
+ }
93
127
  }
94
128
  /** The fixtures after `test.extend(definitions)` on a test that has `base`; throws `extend(): ...` for a definition that cannot work */
95
129
  export function extendLayers(base, definitions) {
@@ -0,0 +1,20 @@
1
+ import type { FixtureField, FixtureOutput } from "../types/extend.js";
2
+ /** What a fixture's options say besides `auto`: its scope, and the metadata the app's catalog and the report use */
3
+ export interface FixtureMeta {
4
+ scope: "test" | "worker";
5
+ label?: string;
6
+ icon?: string;
7
+ description?: string;
8
+ outputs?: FixtureOutput[];
9
+ timeout?: number;
10
+ /** The fixture is the options of the fixture named without the `Options` suffix */
11
+ option: boolean;
12
+ fields?: FixtureField[];
13
+ }
14
+ /** The suffix that pairs an option fixture with its fixture: `order` reads `orderOptions` */
15
+ export declare const OPTIONS_SUFFIX = "Options";
16
+ /** Checks the options of `[fixture, options]` and returns what the definition keeps; throws `extend(): ...` */
17
+ export declare function checkedOptions(name: string, options: Record<string, unknown>): {
18
+ auto: boolean;
19
+ meta: FixtureMeta;
20
+ };
@@ -0,0 +1,109 @@
1
+ /** The suffix that pairs an option fixture with its fixture: `order` reads `orderOptions` */
2
+ export const OPTIONS_SUFFIX = "Options";
3
+ const OPTION_KEYS = new Set([
4
+ "auto",
5
+ "scope",
6
+ "label",
7
+ "icon",
8
+ "description",
9
+ "outputs",
10
+ "timeout",
11
+ "option",
12
+ "fields",
13
+ ]);
14
+ const FIELD_TYPES = [
15
+ "text",
16
+ "code",
17
+ "expression",
18
+ "number",
19
+ "select",
20
+ "segmented",
21
+ "switch",
22
+ "secret",
23
+ "variable",
24
+ ];
25
+ const fail = (name, problem) => {
26
+ throw new Error(`extend(): fixture "${name}": ${problem}`);
27
+ };
28
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
29
+ function checkOutputs(name, outputs) {
30
+ if (!Array.isArray(outputs))
31
+ return fail(name, "outputs must be an array");
32
+ const seen = new Set();
33
+ for (const output of outputs) {
34
+ if (!isObject(output) ||
35
+ typeof output.name !== "string" ||
36
+ output.name === "")
37
+ fail(name, "every output needs a name");
38
+ const { name: key, label, secret } = output;
39
+ if (seen.has(key))
40
+ fail(name, `output "${key}" is listed twice`);
41
+ seen.add(key);
42
+ if (label !== undefined && typeof label !== "string")
43
+ fail(name, `output "${key}": label must be a string`);
44
+ if (secret !== undefined && typeof secret !== "boolean")
45
+ fail(name, `output "${key}": secret must be true or false`);
46
+ }
47
+ return outputs;
48
+ }
49
+ function checkFields(name, fields) {
50
+ if (!Array.isArray(fields))
51
+ return fail(name, "fields must be an array");
52
+ const seen = new Set();
53
+ for (const field of fields) {
54
+ if (!isObject(field) || typeof field.path !== "string" || field.path === "")
55
+ fail(name, "every field needs a path");
56
+ const { path, type, options } = field;
57
+ if (!FIELD_TYPES.includes(type))
58
+ fail(name, `field "${path}" has an unknown type ${JSON.stringify(type)}`);
59
+ if (seen.has(path))
60
+ fail(name, `field "${path}" is listed twice`);
61
+ seen.add(path);
62
+ if ((type === "select" || type === "segmented") && !Array.isArray(options))
63
+ fail(name, `field "${path}" needs options`);
64
+ }
65
+ return fields;
66
+ }
67
+ /** Checks the options of `[fixture, options]` and returns what the definition keeps; throws `extend(): ...` */
68
+ export function checkedOptions(name, options) {
69
+ for (const key of Object.keys(options))
70
+ if (!OPTION_KEYS.has(key))
71
+ throw new Error(`extend(): fixture "${name}" has an unknown option "${key}"`);
72
+ if (options.auto !== undefined && typeof options.auto !== "boolean")
73
+ fail(name, "auto must be true or false");
74
+ if (options.scope !== undefined &&
75
+ !["test", "worker"].includes(options.scope))
76
+ fail(name, 'scope must be "test" or "worker"');
77
+ if (options.label !== undefined && typeof options.label !== "string")
78
+ fail(name, "label must be a string");
79
+ if (options.icon !== undefined &&
80
+ (typeof options.icon !== "string" || options.icon === ""))
81
+ fail(name, "icon must be a non-empty string");
82
+ if (options.description !== undefined &&
83
+ typeof options.description !== "string")
84
+ fail(name, "description must be a string");
85
+ if (options.timeout !== undefined &&
86
+ !(typeof options.timeout === "number" && options.timeout > 0))
87
+ fail(name, "timeout must be a number of milliseconds above 0");
88
+ if (options.option !== undefined && typeof options.option !== "boolean")
89
+ fail(name, "option must be true or false");
90
+ const meta = {
91
+ scope: options.scope === "worker" ? "worker" : "test",
92
+ option: options.option === true,
93
+ ...(options.label !== undefined && { label: options.label }),
94
+ ...(options.icon !== undefined && { icon: options.icon }),
95
+ ...(options.description !== undefined && {
96
+ description: options.description,
97
+ }),
98
+ ...(options.timeout !== undefined && {
99
+ timeout: options.timeout,
100
+ }),
101
+ ...(options.outputs !== undefined && {
102
+ outputs: checkOutputs(name, options.outputs),
103
+ }),
104
+ ...(options.fields !== undefined && {
105
+ fields: checkFields(name, options.fields),
106
+ }),
107
+ };
108
+ return { auto: options.auto === true, meta };
109
+ }
@@ -0,0 +1,8 @@
1
+ import type { TestFixtures } from "../types/test.js";
2
+ import type { FixtureDef } from "./fixture-defs.js";
3
+ /**
4
+ * The value an option fixture gives: its default with the values from `use` over it, key by key. A field of type
5
+ * `secret` holds the name of a secret and one of type `variable` the name of a variable; they are replaced by the
6
+ * secret's or the variable's value here, so the fixture never sees the name.
7
+ */
8
+ export declare function resolveOptions(def: FixtureDef, fromUse: Readonly<Record<string, unknown>> | undefined, base: Pick<TestFixtures, "env" | "secrets">): Record<string, unknown>;
@@ -0,0 +1,35 @@
1
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2
+ /** The object at `path` (dots go into objects) and the last key, or nothing when the path leads nowhere */
3
+ function parentOf(value, path) {
4
+ const keys = path.split(".");
5
+ let parent = value;
6
+ for (const key of keys.slice(0, -1)) {
7
+ const next = parent[key];
8
+ if (!isObject(next))
9
+ return undefined;
10
+ parent = next;
11
+ }
12
+ return { parent, key: keys[keys.length - 1] };
13
+ }
14
+ /**
15
+ * The value an option fixture gives: its default with the values from `use` over it, key by key. A field of type
16
+ * `secret` holds the name of a secret and one of type `variable` the name of a variable; they are replaced by the
17
+ * secret's or the variable's value here, so the fixture never sees the name.
18
+ */
19
+ export function resolveOptions(def, fromUse, base) {
20
+ const merged = structuredClone({
21
+ ...def.value,
22
+ ...fromUse,
23
+ });
24
+ for (const field of def.fields ?? []) {
25
+ if (field.type !== "secret" && field.type !== "variable")
26
+ continue;
27
+ const place = parentOf(merged, field.path);
28
+ const name = place?.parent[place.key];
29
+ if (!place || typeof name !== "string" || name === "")
30
+ continue;
31
+ place.parent[place.key] =
32
+ field.type === "secret" ? base.secrets[name] : base.env[name];
33
+ }
34
+ return merged;
35
+ }
@@ -0,0 +1,11 @@
1
+ import type { FixtureDef } from "./fixture-defs.js";
2
+ /** A value as the report lists it */
3
+ type OutputValue = string | number | boolean;
4
+ /**
5
+ * The declared outputs of a fixture that the report and the app may show: the non-secret keys of its value.
6
+ * Throws when a declared output is not a string, a number or a boolean (the value must be a flat record).
7
+ */
8
+ export declare function outputsOf(def: FixtureDef, value: unknown): Record<string, OutputValue> | undefined;
9
+ /** JSON with the keys of every object in order, so equal options give equal text */
10
+ export declare function stableJson(value: unknown): string;
11
+ export {};