@itmetsam/samurai-framework 0.21.4 → 0.21.5

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 (71) hide show
  1. package/dist/api.d.ts +3 -1
  2. package/dist/browser/browser.d.ts +9 -0
  3. package/dist/browser/browser.js +25 -5
  4. package/dist/browser/cdp.d.ts +11 -0
  5. package/dist/browser/cdp.js +28 -0
  6. package/dist/browser/chrome-launch.d.ts +4 -0
  7. package/dist/browser/chrome-launch.js +2 -2
  8. package/dist/browser/index.d.ts +5 -0
  9. package/dist/browser/index.js +4 -0
  10. package/dist/browser/launch-firefox.js +1 -1
  11. package/dist/browser/launch-flags.d.ts +4 -1
  12. package/dist/browser/launch-flags.js +18 -5
  13. package/dist/browser/page-settings.d.ts +1 -1
  14. package/dist/browser/page.d.ts +4 -0
  15. package/dist/browser/page.js +8 -1
  16. package/dist/browser/session-capabilities.d.ts +10 -0
  17. package/dist/browser/session-capabilities.js +11 -0
  18. package/dist/browser/session.d.ts +2 -1
  19. package/dist/browser/session.js +3 -3
  20. package/dist/browser/storage-state.d.ts +6 -0
  21. package/dist/browser/storage-state.js +37 -0
  22. package/dist/browser/use-applier.d.ts +27 -0
  23. package/dist/browser/use-applier.js +94 -0
  24. package/dist/browser/viewport.d.ts +3 -2
  25. package/dist/browser/viewport.js +15 -2
  26. package/dist/config/config.js +2 -0
  27. package/dist/config/use-options.d.ts +16 -0
  28. package/dist/config/use-options.js +128 -0
  29. package/dist/logger/warn-once.d.ts +4 -0
  30. package/dist/logger/warn-once.js +13 -0
  31. package/dist/runner/attachments.d.ts +16 -0
  32. package/dist/runner/attachments.js +60 -0
  33. package/dist/runner/fixture-args.d.ts +15 -0
  34. package/dist/runner/fixture-args.js +95 -0
  35. package/dist/runner/fixture-defs.d.ts +22 -0
  36. package/dist/runner/fixture-defs.js +103 -0
  37. package/dist/runner/fixture-scope.d.ts +33 -0
  38. package/dist/runner/fixture-scope.js +156 -0
  39. package/dist/runner/fixtures.d.ts +34 -0
  40. package/dist/runner/fixtures.js +39 -0
  41. package/dist/runner/hooks.d.ts +6 -1
  42. package/dist/runner/hooks.js +15 -3
  43. package/dist/runner/registry.d.ts +3 -39
  44. package/dist/runner/registry.js +93 -19
  45. package/dist/runner/reporter.d.ts +7 -1
  46. package/dist/runner/reporter.js +3 -0
  47. package/dist/runner/run-test.d.ts +1 -3
  48. package/dist/runner/run-test.js +92 -34
  49. package/dist/runner/steps.d.ts +13 -0
  50. package/dist/runner/steps.js +67 -0
  51. package/dist/runner/suite.d.ts +5 -0
  52. package/dist/runner/suite.js +8 -0
  53. package/dist/runner/test-annotations.d.ts +16 -0
  54. package/dist/runner/test-annotations.js +47 -0
  55. package/dist/runner/test-function.d.ts +103 -0
  56. package/dist/runner/test-function.js +1 -0
  57. package/dist/runner/test-info.d.ts +104 -0
  58. package/dist/runner/test-info.js +169 -0
  59. package/dist/runner/test-runner.js +2 -1
  60. package/dist/runner/test-timeout.d.ts +22 -0
  61. package/dist/runner/test-timeout.js +45 -0
  62. package/dist/runner/use-options.d.ts +7 -0
  63. package/dist/runner/use-options.js +18 -0
  64. package/dist/runner/worker-session.js +2 -1
  65. package/dist/script/element-handle.d.ts +1 -1
  66. package/dist/script/element-handle.js +1 -1
  67. package/dist/types/config.d.ts +3 -2
  68. package/dist/types/extend.d.ts +30 -0
  69. package/dist/types/test.d.ts +120 -0
  70. package/dist/types/use.d.ts +47 -0
  71. package/package.json +1 -1
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import logger from "../logger/index.js";
3
3
  import { pathToFileURL } from "url";
4
4
  import { validateBrowserConfig } from "./browsers.js";
5
+ import { validateUseConfig } from "./use-options.js";
5
6
  import { validateForbidOnlyConfig } from "./forbid-only.js";
6
7
  import { validateRetriesConfig } from "./retries.js";
7
8
  import { validateWorkersConfig } from "./workers.js";
@@ -30,6 +31,7 @@ export function validateConfig(config) {
30
31
  validateWorkersConfig(config);
31
32
  validateForbidOnlyConfig(config);
32
33
  validateRetriesConfig(config);
34
+ validateUseConfig(config);
33
35
  }
34
36
  /** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
35
37
  export async function loadRawConfig() {
@@ -0,0 +1,16 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ import type { UseOptions } from "../types/use.js";
3
+ /**
4
+ * Checks the options of `test.use()` or of the config's `use` and returns them. The config's `use` also holds
5
+ * options that are not these (`viewport`, `headless`, ...), so it passes `allowOthers`; `test.use()` refuses
6
+ * a key it does not know. Throws `use(): what is wrong`.
7
+ */
8
+ export declare function validateUseOptions(options: unknown, allowOthers?: boolean): UseOptions;
9
+ /** Checks a storage state object, whether it came from the options or from a file */
10
+ export declare function checkStorageStateObject(state: Record<string, unknown>): void;
11
+ /** The options after `layers` are applied in order: a later layer wins, key by key */
12
+ export declare function mergeUse(layers: readonly UseOptions[]): UseOptions;
13
+ /** Checks the new options of a config's `use` */
14
+ export declare function validateUseConfig(config: SamuraiTestConfig): void;
15
+ /** The options of `test.use()` that a config's `use` holds, without the rest (`viewport`, `headless`, ...) */
16
+ export declare function useOptionsOf(configUse: object | undefined): UseOptions;
@@ -0,0 +1,128 @@
1
+ const NAMES = [
2
+ "locale",
3
+ "timezoneId",
4
+ "userAgent",
5
+ "javaScriptEnabled",
6
+ "acceptInsecureCerts",
7
+ "hasTouch",
8
+ "isMobile",
9
+ "storageState",
10
+ "extraHTTPHeaders",
11
+ "colorScheme",
12
+ ];
13
+ const BOOLEANS = [
14
+ "javaScriptEnabled",
15
+ "acceptInsecureCerts",
16
+ "hasTouch",
17
+ "isMobile",
18
+ ];
19
+ const COLOR_SCHEMES = ["light", "dark", "no-preference"];
20
+ const HEADER_NAME = /^[\w!#$%&'*+.^`|~-]+$/;
21
+ const fail = (problem) => {
22
+ throw new TypeError(`use(): ${problem}`);
23
+ };
24
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+ /**
26
+ * Checks the options of `test.use()` or of the config's `use` and returns them. The config's `use` also holds
27
+ * options that are not these (`viewport`, `headless`, ...), so it passes `allowOthers`; `test.use()` refuses
28
+ * a key it does not know. Throws `use(): what is wrong`.
29
+ */
30
+ export function validateUseOptions(options, allowOthers = false) {
31
+ if (!isObject(options))
32
+ return fail("the options must be an object");
33
+ for (const key of Object.keys(options))
34
+ if (!allowOthers && !NAMES.includes(key))
35
+ fail(`unknown option "${key}"`);
36
+ const use = options;
37
+ for (const name of BOOLEANS)
38
+ if (use[name] !== undefined && typeof use[name] !== "boolean")
39
+ fail(`${name} must be true or false, got ${JSON.stringify(use[name])}`);
40
+ checkLocale(use.locale);
41
+ checkTimezone(use.timezoneId);
42
+ if (use.userAgent !== undefined && !nonEmpty(use.userAgent))
43
+ fail("userAgent must be a non-empty string");
44
+ if (use.colorScheme !== undefined && !COLOR_SCHEMES.includes(use.colorScheme))
45
+ fail(`colorScheme must be "light", "dark" or "no-preference", got ${JSON.stringify(use.colorScheme)}`);
46
+ checkHeaders(use.extraHTTPHeaders);
47
+ checkStorageState(use.storageState);
48
+ return use;
49
+ }
50
+ const nonEmpty = (value) => typeof value === "string" && value !== "";
51
+ function checkLocale(locale) {
52
+ if (locale === undefined)
53
+ return;
54
+ try {
55
+ if (!nonEmpty(locale) || Intl.getCanonicalLocales(locale).length !== 1)
56
+ throw new RangeError();
57
+ }
58
+ catch {
59
+ fail(`invalid locale ${JSON.stringify(locale)}`);
60
+ }
61
+ }
62
+ function checkTimezone(timezone) {
63
+ if (timezone === undefined)
64
+ return;
65
+ try {
66
+ if (!nonEmpty(timezone))
67
+ throw new RangeError();
68
+ new Intl.DateTimeFormat(undefined, { timeZone: timezone });
69
+ }
70
+ catch {
71
+ fail(`unknown timezoneId ${JSON.stringify(timezone)}`);
72
+ }
73
+ }
74
+ function checkHeaders(headers) {
75
+ if (headers === undefined)
76
+ return;
77
+ if (!isObject(headers))
78
+ fail("extraHTTPHeaders must be an object of header names and values");
79
+ for (const [name, value] of Object.entries(headers))
80
+ if (!HEADER_NAME.test(name))
81
+ fail(`extraHTTPHeaders has an invalid header name "${name}"`);
82
+ else if (typeof value !== "string")
83
+ fail(`extraHTTPHeaders "${name}" must be a string`);
84
+ }
85
+ function checkStorageState(state) {
86
+ if (state === undefined)
87
+ return;
88
+ if (typeof state === "string") {
89
+ if (state === "")
90
+ fail("storageState must not be an empty path");
91
+ return;
92
+ }
93
+ if (!isObject(state))
94
+ return fail("storageState must be a file path or an object");
95
+ checkStorageStateObject(state);
96
+ }
97
+ /** Checks a storage state object, whether it came from the options or from a file */
98
+ export function checkStorageStateObject(state) {
99
+ for (const key of ["cookies", "origins"])
100
+ if (state[key] !== undefined && !Array.isArray(state[key]))
101
+ fail(`storageState.${key} must be an array`);
102
+ for (const cookie of (state.cookies ?? [])) {
103
+ if (!isObject(cookie) ||
104
+ !nonEmpty(cookie.name) ||
105
+ typeof cookie.value !== "string" ||
106
+ !nonEmpty(cookie.domain))
107
+ fail("every storageState cookie needs a name, a value and a domain");
108
+ }
109
+ for (const origin of (state.origins ?? [])) {
110
+ if (!isObject(origin) ||
111
+ !nonEmpty(origin.origin) ||
112
+ !Array.isArray(origin.localStorage ?? []))
113
+ fail("every storageState origin needs an origin and a localStorage list");
114
+ }
115
+ }
116
+ /** The options after `layers` are applied in order: a later layer wins, key by key */
117
+ export function mergeUse(layers) {
118
+ return Object.assign({}, ...layers);
119
+ }
120
+ /** Checks the new options of a config's `use` */
121
+ export function validateUseConfig(config) {
122
+ if (config.use !== undefined)
123
+ validateUseOptions(config.use, true);
124
+ }
125
+ /** The options of `test.use()` that a config's `use` holds, without the rest (`viewport`, `headless`, ...) */
126
+ export function useOptionsOf(configUse) {
127
+ return Object.fromEntries(Object.entries(configUse ?? {}).filter(([key]) => NAMES.includes(key)));
128
+ }
@@ -0,0 +1,4 @@
1
+ /** Logs a warning the first time `key` is seen in this process; later calls with the same key do nothing */
2
+ export declare function warnOnce(key: string, message: string): void;
3
+ /** Forgets what was warned about (tests) */
4
+ export declare function resetWarnOnce(): void;
@@ -0,0 +1,13 @@
1
+ import logger from "./index.js";
2
+ const warned = new Set();
3
+ /** Logs a warning the first time `key` is seen in this process; later calls with the same key do nothing */
4
+ export function warnOnce(key, message) {
5
+ if (warned.has(key))
6
+ return;
7
+ warned.add(key);
8
+ logger.warn(message);
9
+ }
10
+ /** Forgets what was warned about (tests) */
11
+ export function resetWarnOnce() {
12
+ warned.clear();
13
+ }
@@ -0,0 +1,16 @@
1
+ /** What `testInfo.attach` takes besides the name */
2
+ export interface AttachInput {
3
+ body?: string | Buffer;
4
+ path?: string;
5
+ contentType?: string;
6
+ }
7
+ /**
8
+ * Writes one attachment into `dir` and returns what to list: a body is saved under the cleaned name, a path is
9
+ * copied under it keeping its extension. `used` holds the file names taken so far; a repeat gets -2, -3, ...
10
+ * String bodies are masked like the rest of a report.
11
+ */
12
+ export declare function saveAttachment(dir: string, used: Set<string>, name: string, input: AttachInput): Promise<{
13
+ name: string;
14
+ contentType: string;
15
+ path: string;
16
+ }>;
@@ -0,0 +1,60 @@
1
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { cleanName } from "../browser/evidence.js";
4
+ import { maskText } from "../config/mask.js";
5
+ /** The file types an attachment is known by; the rest is `application/octet-stream` */
6
+ const TYPES = {
7
+ ".txt": "text/plain",
8
+ ".log": "text/plain",
9
+ ".html": "text/html",
10
+ ".json": "application/json",
11
+ ".csv": "text/csv",
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".webm": "video/webm",
16
+ ".pdf": "application/pdf",
17
+ ".zip": "application/zip",
18
+ };
19
+ const OCTET = "application/octet-stream";
20
+ /** The extension (with its dot) that stands for `contentType`, else `fallback` */
21
+ function extensionOf(contentType, fallback) {
22
+ const found = Object.entries(TYPES).find(([, type]) => type === contentType);
23
+ return found ? found[0] : fallback;
24
+ }
25
+ /**
26
+ * Writes one attachment into `dir` and returns what to list: a body is saved under the cleaned name, a path is
27
+ * copied under it keeping its extension. `used` holds the file names taken so far; a repeat gets -2, -3, ...
28
+ * String bodies are masked like the rest of a report.
29
+ */
30
+ export async function saveAttachment(dir, used, name, input) {
31
+ if (name === "")
32
+ throw new Error("attach(): name must not be empty");
33
+ const { body, path: from } = input;
34
+ if (body === undefined && from === undefined)
35
+ throw new Error("attach(): give either body or path");
36
+ if (body !== undefined && from !== undefined)
37
+ throw new Error("attach(): give either body or path, not both");
38
+ const fileExt = from !== undefined ? path.extname(from).toLowerCase() : "";
39
+ const contentType = input.contentType ??
40
+ (from !== undefined
41
+ ? (TYPES[fileExt] ?? OCTET)
42
+ : typeof body === "string"
43
+ ? "text/plain"
44
+ : OCTET);
45
+ const ext = fileExt !== ""
46
+ ? fileExt
47
+ : extensionOf(contentType, typeof body === "string" ? ".txt" : ".bin");
48
+ const base = cleanName(name);
49
+ let unique = base;
50
+ for (let n = 2; used.has(unique); n++)
51
+ unique = `${base}-${n}`;
52
+ used.add(unique);
53
+ const file = path.join(dir, `${unique}${ext}`);
54
+ await mkdir(dir, { recursive: true });
55
+ if (from !== undefined)
56
+ await copyFile(from, file);
57
+ else
58
+ await writeFile(file, typeof body === "string" ? maskText(body) : body);
59
+ return { name, contentType, path: file };
60
+ }
@@ -0,0 +1,15 @@
1
+ /** What a function asks for in the destructuring of its first parameter */
2
+ export type Requested = {
3
+ kind: "names";
4
+ names: string[];
5
+ rest: boolean;
6
+ }
7
+ /** No destructuring pattern: the function takes the fixtures object whole, or none */
8
+ | {
9
+ kind: "opaque";
10
+ };
11
+ /**
12
+ * The fixture names a function destructures from its first parameter (`async ({ page, user }) => ...`), read
13
+ * from its source. A `...rest` entry asks for everything.
14
+ */
15
+ export declare function requestedBy(fn: (...args: never[]) => unknown): Requested;
@@ -0,0 +1,95 @@
1
+ const OPAQUE = { kind: "opaque" };
2
+ /** Where the `}` or `)` that closes the bracket at `from` is, skipping strings; -1 when there is none */
3
+ function closing(text, from) {
4
+ let depth = 0;
5
+ for (let i = from; i < text.length; i++) {
6
+ const c = text[i];
7
+ if (c === '"' || c === "'" || c === "`") {
8
+ i = endOfString(text, i);
9
+ if (i < 0)
10
+ return -1;
11
+ }
12
+ else if (c === "{" || c === "(" || c === "[")
13
+ depth++;
14
+ else if (c === "}" || c === ")" || c === "]") {
15
+ depth--;
16
+ if (depth === 0)
17
+ return i;
18
+ }
19
+ }
20
+ return -1;
21
+ }
22
+ function endOfString(text, from) {
23
+ const quote = text[from];
24
+ for (let i = from + 1; i < text.length; i++) {
25
+ if (text[i] === "\\")
26
+ i++;
27
+ else if (text[i] === quote)
28
+ return i;
29
+ }
30
+ return -1;
31
+ }
32
+ /** Splits at the commas that sit outside brackets and strings */
33
+ function topLevelParts(text) {
34
+ const parts = [];
35
+ let depth = 0;
36
+ let start = 0;
37
+ for (let i = 0; i < text.length; i++) {
38
+ const c = text[i];
39
+ if (c === '"' || c === "'" || c === "`") {
40
+ const end = endOfString(text, i);
41
+ if (end < 0)
42
+ break;
43
+ i = end;
44
+ }
45
+ else if (c === "{" || c === "(" || c === "[")
46
+ depth++;
47
+ else if (c === "}" || c === ")" || c === "]")
48
+ depth--;
49
+ else if (c === "," && depth === 0) {
50
+ parts.push(text.slice(start, i));
51
+ start = i + 1;
52
+ }
53
+ }
54
+ parts.push(text.slice(start));
55
+ return parts.map((part) => part.trim()).filter((part) => part !== "");
56
+ }
57
+ /** The property a destructuring entry reads: `a`, `a: b`, `a = 1`, `"a-b": c`; undefined for a computed key */
58
+ function keyOf(entry) {
59
+ const quoted = /^(["'])(.*?)\1/.exec(entry);
60
+ if (quoted)
61
+ return quoted[2];
62
+ return /^[\w$]+/.exec(entry)?.[0];
63
+ }
64
+ /**
65
+ * The fixture names a function destructures from its first parameter (`async ({ page, user }) => ...`), read
66
+ * from its source. A `...rest` entry asks for everything.
67
+ */
68
+ export function requestedBy(fn) {
69
+ const text = Function.prototype.toString.call(fn);
70
+ if (/^(async\s+)?[\w$]+\s*=>/.test(text))
71
+ return OPAQUE;
72
+ const open = text.indexOf("(");
73
+ if (open < 0)
74
+ return OPAQUE;
75
+ let at = open + 1;
76
+ while (/\s/.test(text[at] ?? ""))
77
+ at++;
78
+ if (text[at] === ")")
79
+ return { kind: "names", names: [], rest: false };
80
+ if (text[at] !== "{")
81
+ return OPAQUE;
82
+ const end = closing(text, at);
83
+ if (end < 0)
84
+ return OPAQUE;
85
+ const names = [];
86
+ let rest = false;
87
+ for (const entry of topLevelParts(text.slice(at + 1, end))) {
88
+ const key = entry.startsWith("...") ? undefined : keyOf(entry);
89
+ if (key === undefined)
90
+ rest = true;
91
+ else
92
+ names.push(key);
93
+ }
94
+ return { kind: "names", names, rest };
95
+ }
@@ -0,0 +1,22 @@
1
+ /** The function of a fixture: sets the value up, calls `use(value)`, cleans up after `use` returns */
2
+ type FixtureSetup = (args: Record<string, unknown>, use: (value: unknown) => Promise<void>) => Promise<void>;
3
+ /** 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
+ name: string;
6
+ auto: boolean;
7
+ deps: string[];
8
+ setup?: FixtureSetup;
9
+ value?: unknown;
10
+ }
11
+ /** Every custom fixture by name; the last definition of a name wins, the ones before it are what it may depend on by its own name */
12
+ export type FixtureLayers = ReadonlyMap<string, readonly FixtureDef[]>;
13
+ /** The fixtures the runner builds for every test: a custom fixture of the same name overrides it */
14
+ export declare const BUILT_IN_FIXTURES: ReadonlySet<string>;
15
+ /** Where a dependency of `name` (at definition `level`) comes from: another definition, or the built-in fixture */
16
+ export declare function dependencyOf(layers: FixtureLayers, name: string, level: number, dep: string): {
17
+ name: string;
18
+ level: number;
19
+ } | "built-in" | undefined;
20
+ /** The fixtures after `test.extend(definitions)` on a test that has `base`; throws `extend(): ...` for a definition that cannot work */
21
+ export declare function extendLayers(base: FixtureLayers | undefined, definitions: unknown): FixtureLayers;
22
+ export {};
@@ -0,0 +1,103 @@
1
+ import { requestedBy } from "./fixture-args.js";
2
+ /** The fixtures the runner builds for every test: a custom fixture of the same name overrides it */
3
+ export const BUILT_IN_FIXTURES = new Set([
4
+ "page",
5
+ "browser",
6
+ "browserName",
7
+ "workerIndex",
8
+ "env",
9
+ "secrets",
10
+ "inputs",
11
+ "outputs",
12
+ "context",
13
+ "baseURL",
14
+ "testInfo",
15
+ "isMobile",
16
+ "hasTouch",
17
+ ]);
18
+ const OPTION_KEYS = new Set(["auto", "scope"]);
19
+ function isPlainObject(value) {
20
+ if (typeof value !== "object" || value === null)
21
+ return false;
22
+ const proto = Object.getPrototypeOf(value);
23
+ return proto === Object.prototype || proto === null;
24
+ }
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
+ function definitionOf(name, raw) {
39
+ const [fixture, options] = Array.isArray(raw) && raw.length === 2 && isPlainObject(raw[1])
40
+ ? [raw[0], checkedOptions(name, raw[1])]
41
+ : [raw, { auto: false }];
42
+ if (typeof fixture !== "function")
43
+ return { name, auto: options.auto, deps: [], value: fixture };
44
+ const requested = requestedBy(fixture);
45
+ if (requested.kind === "opaque" || requested.rest)
46
+ throw new Error(`extend(): fixture "${name}" must destructure the fixtures it uses in its first argument, as ({ page }, use) or ({}, use)`);
47
+ return {
48
+ name,
49
+ auto: options.auto,
50
+ deps: requested.names,
51
+ setup: fixture,
52
+ };
53
+ }
54
+ /** Where a dependency of `name` (at definition `level`) comes from: another definition, or the built-in fixture */
55
+ export function dependencyOf(layers, name, level, dep) {
56
+ if (dep === name)
57
+ return level > 0
58
+ ? { name, level: level - 1 }
59
+ : BUILT_IN_FIXTURES.has(dep)
60
+ ? "built-in"
61
+ : undefined;
62
+ const defs = layers.get(dep);
63
+ if (defs)
64
+ return { name: dep, level: defs.length - 1 };
65
+ return BUILT_IN_FIXTURES.has(dep) ? "built-in" : undefined;
66
+ }
67
+ /** Throws for a dependency nobody defines and for a cycle (`a` needs `b` needs `a`) */
68
+ function validate(layers) {
69
+ const done = new Set();
70
+ const walk = (name, level, path) => {
71
+ const key = `${name}#${level}`;
72
+ if (done.has(key))
73
+ return;
74
+ if (path.includes(key)) {
75
+ const loop = [...path.slice(path.indexOf(key)), key];
76
+ throw new Error(`extend(): fixtures depend on each other in a circle: ${loop.map((k) => k.split("#")[0]).join(" -> ")}`);
77
+ }
78
+ const def = layers.get(name)?.[level];
79
+ if (!def)
80
+ return;
81
+ for (const dep of def.deps) {
82
+ const target = dependencyOf(layers, name, level, dep);
83
+ if (target === undefined)
84
+ throw new Error(`extend(): fixture "${name}" depends on unknown fixture "${dep}"`);
85
+ if (target !== "built-in")
86
+ walk(target.name, target.level, [...path, key]);
87
+ }
88
+ done.add(key);
89
+ };
90
+ for (const [name, defs] of layers)
91
+ for (let level = 0; level < defs.length; level++)
92
+ walk(name, level, []);
93
+ }
94
+ /** The fixtures after `test.extend(definitions)` on a test that has `base`; throws `extend(): ...` for a definition that cannot work */
95
+ export function extendLayers(base, definitions) {
96
+ if (!isPlainObject(definitions))
97
+ throw new Error("extend(): needs an object of fixtures");
98
+ const layers = new Map(base);
99
+ for (const [name, raw] of Object.entries(definitions))
100
+ layers.set(name, [...(layers.get(name) ?? []), definitionOf(name, raw)]);
101
+ validate(layers);
102
+ return layers;
103
+ }
@@ -0,0 +1,33 @@
1
+ import type { TestFixtures } from "../types/test.js";
2
+ import { type FixtureLayers } from "./fixture-defs.js";
3
+ /** A custom fixture whose setup threw or never called `use` */
4
+ export declare class FixtureSetupError extends Error {
5
+ constructor(name: string, cause: unknown);
6
+ }
7
+ /**
8
+ * The custom fixtures of one test run. Fixtures are set up when a test or hook asks for them (by destructuring
9
+ * them), their dependencies first, and torn down in the opposite order. The hooks and the body of a test share
10
+ * one scope, so they see the same instances.
11
+ */
12
+ export declare class FixtureScope {
13
+ private readonly layers;
14
+ private readonly base;
15
+ /** The built-in fixtures, with each custom fixture added (replacing a built-in of that name) once it is set up */
16
+ readonly fixtures: TestFixtures;
17
+ private readonly values;
18
+ private readonly pending;
19
+ private readonly started;
20
+ private closed;
21
+ constructor(layers: FixtureLayers, base: TestFixtures);
22
+ /** Sets up the fixtures marked `auto` */
23
+ prepareAuto(): Promise<void>;
24
+ /** Sets up the custom fixtures `fn` destructures from its first parameter */
25
+ prepare(fn: (...args: never[]) => unknown): Promise<void>;
26
+ /**
27
+ * Finishes the fixtures that were set up, last first, each within `limitMs`. Returns the first error (a
28
+ * fixture's cleanup threw or ran out of time); the others still run.
29
+ */
30
+ teardown(limitMs: number): Promise<Error | undefined>;
31
+ private ensure;
32
+ private setUp;
33
+ }