@itmetsam/samurai-framework 0.22.0 → 0.22.2

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 (75) hide show
  1. package/dist/api.d.ts +3 -3
  2. package/dist/browser/page-content.d.ts +4 -0
  3. package/dist/browser/page-content.js +12 -0
  4. package/dist/browser/page-events.d.ts +11 -0
  5. package/dist/browser/page-events.js +23 -0
  6. package/dist/browser/page-logging.d.ts +24 -0
  7. package/dist/browser/page-logging.js +43 -0
  8. package/dist/browser/page-settings.d.ts +1 -1
  9. package/dist/browser/page-viewport.d.ts +21 -0
  10. package/dist/browser/page-viewport.js +33 -0
  11. package/dist/browser/page.d.ts +6 -9
  12. package/dist/browser/page.js +31 -63
  13. package/dist/browser/screenshot.d.ts +6 -0
  14. package/dist/browser/screenshot.js +14 -0
  15. package/dist/capabilities.d.ts +16 -2
  16. package/dist/capabilities.js +16 -2
  17. package/dist/config/config.js +2 -1
  18. package/dist/config/fixture-use.d.ts +19 -0
  19. package/dist/config/fixture-use.js +44 -0
  20. package/dist/config/group-use.d.ts +4 -2
  21. package/dist/config/group-use.js +30 -20
  22. package/dist/config/use-options.js +5 -1
  23. package/dist/recorder/capture.d.ts +3 -0
  24. package/dist/recorder/capture.js +18 -1
  25. package/dist/recorder/normalise.d.ts +12 -1
  26. package/dist/recorder/normalise.js +29 -1
  27. package/dist/recorder/page-messages.d.ts +6 -2
  28. package/dist/recorder/page-messages.js +29 -21
  29. package/dist/recorder/toolbar/inspect.js +10 -3
  30. package/dist/runner/fixture-defs.d.ts +2 -1
  31. package/dist/runner/fixture-defs.js +51 -18
  32. package/dist/runner/fixture-meta.d.ts +20 -0
  33. package/dist/runner/fixture-meta.js +109 -0
  34. package/dist/runner/fixture-options.d.ts +8 -0
  35. package/dist/runner/fixture-options.js +35 -0
  36. package/dist/runner/fixture-outputs.d.ts +11 -0
  37. package/dist/runner/fixture-outputs.js +28 -0
  38. package/dist/runner/fixture-registry.d.ts +35 -0
  39. package/dist/runner/fixture-registry.js +73 -0
  40. package/dist/runner/fixture-scope.d.ts +39 -9
  41. package/dist/runner/fixture-scope.js +139 -70
  42. package/dist/runner/fixture-start.d.ts +27 -0
  43. package/dist/runner/fixture-start.js +76 -0
  44. package/dist/runner/fixture-use.d.ts +23 -0
  45. package/dist/runner/fixture-use.js +64 -0
  46. package/dist/runner/fixture-worker.d.ts +26 -0
  47. package/dist/runner/fixture-worker.js +46 -0
  48. package/dist/runner/prepare-run.js +5 -0
  49. package/dist/runner/registry.js +15 -5
  50. package/dist/runner/reporter.d.ts +3 -1
  51. package/dist/runner/reporter.js +1 -0
  52. package/dist/runner/run-test.d.ts +3 -0
  53. package/dist/runner/run-test.js +16 -1
  54. package/dist/runner/run.d.ts +7 -0
  55. package/dist/runner/run.js +9 -0
  56. package/dist/runner/test-function.d.ts +2 -2
  57. package/dist/runner/test-runner.js +6 -1
  58. package/dist/runner/use-options.d.ts +16 -2
  59. package/dist/runner/use-options.js +28 -8
  60. package/dist/runner/worker-session.js +6 -0
  61. package/dist/steps/build.js +49 -25
  62. package/dist/steps/emit.js +64 -7
  63. package/dist/steps/index.d.ts +1 -1
  64. package/dist/steps/model.d.ts +60 -10
  65. package/dist/steps/parse/call.js +125 -39
  66. package/dist/steps/parse/expectation.d.ts +2 -1
  67. package/dist/steps/parse/expectation.js +30 -8
  68. package/dist/steps/parse/literals.d.ts +9 -0
  69. package/dist/steps/parse/literals.js +42 -0
  70. package/dist/steps/parse/step.js +28 -1
  71. package/dist/types/config.d.ts +35 -22
  72. package/dist/types/extend.d.ts +54 -2
  73. package/dist/types/test.d.ts +24 -0
  74. package/dist/types/use.d.ts +14 -0
  75. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ /** The key of `use` that attaches fixtures by name (`true`) or removes them (`false`) */
2
+ export const FIXTURES_KEY = "fixtures";
3
+ const OPTION_FIXTURE_KEY = /^[a-z][A-Za-z0-9]*Options$/;
4
+ /** Whether `key` of a `use` object is about fixtures: `fixtures`, or an option fixture (`orderOptions`) */
5
+ export function isFixtureUseKey(key) {
6
+ return key === FIXTURES_KEY || OPTION_FIXTURE_KEY.test(key);
7
+ }
8
+ /** The fixture keys of one `use` layer */
9
+ export function pickFixtureUse(layer) {
10
+ return Object.fromEntries(Object.entries(layer ?? {}).filter(([key]) => isFixtureUseKey(key)));
11
+ }
12
+ /** The layer without its fixture keys: what is left is for the browser */
13
+ export function omitFixtureUse(layer) {
14
+ return Object.fromEntries(Object.entries(layer ?? {}).filter(([key]) => !isFixtureUseKey(key)));
15
+ }
16
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
+ /** Checks the fixture keys of one `use` layer; throws `use(): what is wrong` */
18
+ export function checkFixtureUse(options) {
19
+ const attached = options[FIXTURES_KEY];
20
+ if (attached !== undefined) {
21
+ if (!isObject(attached))
22
+ throw new TypeError("use(): fixtures must be an object of fixture names and true or false");
23
+ for (const [name, on] of Object.entries(attached))
24
+ if (typeof on !== "boolean")
25
+ throw new TypeError(`use(): fixtures.${name} must be true or false, got ${JSON.stringify(on)}`);
26
+ }
27
+ for (const [key, value] of Object.entries(options))
28
+ if (key !== FIXTURES_KEY && isFixtureUseKey(key) && !isObject(value))
29
+ throw new TypeError(`use(): ${key} must be an object`);
30
+ }
31
+ /** Merges the fixture keys of the `use` layers in order (config, environment, group, `test.use()`) */
32
+ export function mergeFixtureUse(layers) {
33
+ const merged = { fixtures: {}, options: {} };
34
+ for (const layer of layers)
35
+ for (const [key, value] of Object.entries(pickFixtureUse(layer)))
36
+ if (key === FIXTURES_KEY)
37
+ Object.assign(merged.fixtures, value);
38
+ else
39
+ merged.options[key] = {
40
+ ...merged.options[key],
41
+ ...value,
42
+ };
43
+ return merged;
44
+ }
@@ -1,8 +1,10 @@
1
1
  import type { GroupUse, SamuraiGroup, SamuraiTestConfig } from "../types/config.js";
2
2
  /**
3
- * Checks the `use` of every group of the config: the options of config `use` (`test.use()` ones, and `viewport`).
4
- * Throws `config.groups[1].use.locale: what is wrong`.
3
+ * Checks the `use` of every group of the config: the options of config `use` (`test.use()` ones, and `viewport`),
4
+ * and the fixture keys. Throws `config.groups[1].use.locale: what is wrong`.
5
5
  */
6
6
  export declare function validateGroupUse(config: SamuraiTestConfig): void;
7
+ /** Checks the `use` of every environment the same way: `config.environments.production.use.locale: what is wrong` */
8
+ export declare function validateEnvironmentUse(config: SamuraiTestConfig): void;
7
9
  /** The `use` of the group called `name`; nothing when there is no such group or it has none */
8
10
  export declare function groupUseOf(groups: readonly SamuraiGroup[] | undefined, name: string): GroupUse;
@@ -1,3 +1,4 @@
1
+ import { isFixtureUseKey } from "./fixture-use.js";
1
2
  import { USE_OPTION_NAMES, validateUseOptions } from "./use-options.js";
2
3
  const isSize = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
3
4
  /** Rewrites `use(): locale must ...` as `config.groups[1].use.locale: locale must ...` */
@@ -7,34 +8,43 @@ function atGroup(place, err) {
7
8
  const problem = err.message.replace(/^use\(\): /, "");
8
9
  const name = /^(?:invalid |unknown )?(\w+)/.exec(problem)?.[1];
9
10
  const known = name !== undefined &&
10
- (name === "viewport" || USE_OPTION_NAMES.includes(name));
11
+ (name === "viewport" ||
12
+ USE_OPTION_NAMES.includes(name) ||
13
+ isFixtureUseKey(name));
11
14
  throw new TypeError(`${place}${known ? `.${name}` : ""}: ${problem}`);
12
15
  }
16
+ /** Checks one `use` of a group or an environment; throws `<place>.locale: what is wrong` */
17
+ function validateUseAt(place, use) {
18
+ try {
19
+ const isObject = typeof use === "object" && use !== null && !Array.isArray(use);
20
+ const { viewport, ...options } = (isObject ? use : {});
21
+ // Anything but an object is refused here, with the message `test.use()` gives
22
+ validateUseOptions(isObject ? options : use);
23
+ if (viewport !== undefined &&
24
+ viewport !== null &&
25
+ !(isSize(viewport.width) && isSize(viewport.height)))
26
+ throw new TypeError(`viewport must be { width, height } with whole numbers above 0, or null, got ${JSON.stringify(viewport)}`);
27
+ }
28
+ catch (err) {
29
+ atGroup(place, err);
30
+ }
31
+ }
13
32
  /**
14
- * Checks the `use` of every group of the config: the options of config `use` (`test.use()` ones, and `viewport`).
15
- * Throws `config.groups[1].use.locale: what is wrong`.
33
+ * Checks the `use` of every group of the config: the options of config `use` (`test.use()` ones, and `viewport`),
34
+ * and the fixture keys. Throws `config.groups[1].use.locale: what is wrong`.
16
35
  */
17
36
  export function validateGroupUse(config) {
18
37
  config.groups?.forEach((group, index) => {
19
- if (group.use === undefined)
20
- return;
21
- const place = `config.groups[${index}].use`;
22
- try {
23
- const raw = group.use;
24
- const isObject = typeof raw === "object" && raw !== null && !Array.isArray(raw);
25
- const { viewport, ...options } = (isObject ? raw : {});
26
- // Anything but an object is refused here, with the message `test.use()` gives
27
- validateUseOptions(isObject ? options : raw);
28
- if (viewport !== undefined &&
29
- viewport !== null &&
30
- !(isSize(viewport.width) && isSize(viewport.height)))
31
- throw new TypeError(`viewport must be { width, height } with whole numbers above 0, or null, got ${JSON.stringify(viewport)}`);
32
- }
33
- catch (err) {
34
- atGroup(place, err);
35
- }
38
+ if (group.use !== undefined)
39
+ validateUseAt(`config.groups[${index}].use`, group.use);
36
40
  });
37
41
  }
42
+ /** Checks the `use` of every environment the same way: `config.environments.production.use.locale: what is wrong` */
43
+ export function validateEnvironmentUse(config) {
44
+ for (const [name, environment] of Object.entries(config.environments ?? {}))
45
+ if (environment?.use !== undefined)
46
+ validateUseAt(`config.environments.${name}.use`, environment.use);
47
+ }
38
48
  /** The `use` of the group called `name`; nothing when there is no such group or it has none */
39
49
  export function groupUseOf(groups, name) {
40
50
  return groups?.find((group) => group.name === name)?.use ?? {};
@@ -1,3 +1,4 @@
1
+ import { checkFixtureUse, isFixtureUseKey } from "./fixture-use.js";
1
2
  /** The options of `use`: config, group, `test.use()` and (those that can change at run time) `page.use()` */
2
3
  export const USE_OPTION_NAMES = [
3
4
  "locale",
@@ -47,9 +48,12 @@ export function validateUseOptions(options, allowOthers = false) {
47
48
  const renamed = RENAMED[key];
48
49
  if (renamed !== undefined)
49
50
  fail(`unknown option "${key}"; use "${renamed}"`);
50
- if (!allowOthers && !USE_OPTION_NAMES.includes(key))
51
+ if (!allowOthers &&
52
+ !USE_OPTION_NAMES.includes(key) &&
53
+ !isFixtureUseKey(key))
51
54
  fail(`unknown option "${key}"`);
52
55
  }
56
+ checkFixtureUse(options);
53
57
  const use = options;
54
58
  for (const name of BOOLEANS)
55
59
  if (use[name] !== undefined && typeof use[name] !== "boolean")
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * - `{ type: "click", target, candidates, unique, textEntry }`: a click, retargeted to the nearest interactive
9
9
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
10
+ * On a checkbox or radio it also carries `checked`, the state the click leaves.
11
+ * - `{ type: "select", target, candidates, option }`: a trusted `change` of a single-choice `<select>`; `option` is
12
+ * `{ value }`, else `{ label }`, else `{ index }` of the chosen option.
10
13
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
11
14
  * values never leave the page: `secret` is set and `value` is left out.
12
15
  * - `{ type: "press", target, candidates, unique, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
@@ -14,6 +14,9 @@ const RUN_KEY = "samurai.recorder.run";
14
14
  *
15
15
  * - `{ type: "click", target, candidates, unique, textEntry }`: a click, retargeted to the nearest interactive
16
16
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
17
+ * On a checkbox or radio it also carries `checked`, the state the click leaves.
18
+ * - `{ type: "select", target, candidates, option }`: a trusted `change` of a single-choice `<select>`; `option` is
19
+ * `{ value }`, else `{ label }`, else `{ index }` of the chosen option.
17
20
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
18
21
  * values never leave the page: `secret` is set and `value` is left out.
19
22
  * - `{ type: "press", target, candidates, unique, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
@@ -76,7 +79,21 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
76
79
  if (event.detail === 0 && el.form && el.form === lastEnter.form && now - lastEnter.at < 150) return;
77
80
  lastClick = { el, at: now };
78
81
  const candidates = candidatesOf(el);
79
- report({ type: "click", target: el, candidates, unique: uniqueOf(el, candidates), textEntry: isTextEntry(el) });
82
+ // A checkbox or radio click is a check or an uncheck, by the state it leaves: the browser has already flipped a
83
+ // control that is the click's own target, while a label's click flips its control only after this listener
84
+ const checkable = el.tagName === "INPUT" && (el.type === "checkbox" || el.type === "radio");
85
+ const checked = checkable ? (el.type === "radio" ? true : event.target === el ? el.checked : !el.checked) : undefined;
86
+ report({ type: "click", target: el, candidates, unique: uniqueOf(el, candidates), textEntry: isTextEntry(el), ...(checked !== undefined && { checked }) });
87
+ }, true);
88
+
89
+ // A choice in a <select> (one that takes a single option; several are a list, which stays custom code)
90
+ document.addEventListener("change", (event) => {
91
+ if (inToolbar(event)) return;
92
+ const el = event.target;
93
+ if (off() || !event.isTrusted || !(el instanceof HTMLSelectElement) || el.multiple || !el.selectedOptions.length) return;
94
+ const chosen = el.selectedOptions[0];
95
+ const option = chosen.value !== "" ? { value: chosen.value } : chosen.label !== "" ? { label: chosen.label } : { index: chosen.index };
96
+ report({ type: "select", target: el, candidates: candidatesOf(el), option });
80
97
  }, true);
81
98
 
82
99
  document.addEventListener("input", (event) => {
@@ -1,4 +1,4 @@
1
- import type { Expectation, FileEntry, LocatorSpec, PageExpectation, Step, TestInput } from "../steps/model.js";
1
+ import type { Expectation, FileEntry, LocatorSpec, PageExpectation, SelectChoice, Step, TestInput } from "../steps/model.js";
2
2
  /** What the recorder does to the step list: a new step at `index`, a step rewritten in place, or one taken out */
3
3
  export type RecorderEvent =
4
4
  /** `declare`: an input the step reads, declared with its default before the step goes in */
@@ -21,6 +21,15 @@ export type Observation = {
21
21
  key: string;
22
22
  locator: LocatorSpec;
23
23
  textEntry: boolean;
24
+ /** Of a checkbox or radio: whether the click leaves it checked. Recorded as `check` or `uncheck` */
25
+ checked?: boolean;
26
+ }
27
+ /** A `<select>` changed to this option */
28
+ | {
29
+ type: "select";
30
+ key: string;
31
+ locator: LocatorSpec;
32
+ option: SelectChoice;
24
33
  } | {
25
34
  type: "input";
26
35
  key: string;
@@ -143,6 +152,8 @@ export declare class Normaliser {
143
152
  private pickerCancelled;
144
153
  private filesSet;
145
154
  private clicked;
155
+ /** A choice in a `<select>`; the click that opened it, just before, is the same step */
156
+ private selected;
146
157
  private pressed;
147
158
  /** An Alt+click */
148
159
  private asserted;
@@ -105,6 +105,8 @@ export class Normaliser {
105
105
  return this.filesSet(observation, at);
106
106
  case "click":
107
107
  return this.clicked(observation, at);
108
+ case "select":
109
+ return this.selected(observation, at);
108
110
  case "press":
109
111
  return this.pressed(observation, at);
110
112
  case "assert":
@@ -163,12 +165,38 @@ export class Normaliser {
163
165
  return []; // focusing a field: the fill that follows needs no click
164
166
  this.lastFill = undefined;
165
167
  this.lastAction = at;
168
+ if (observation.checked !== undefined)
169
+ return [
170
+ this.insert({
171
+ kind: observation.checked ? "check" : "uncheck",
172
+ locator: observation.locator,
173
+ }),
174
+ ];
166
175
  const events = [
167
176
  this.insert({ kind: "click", locator: observation.locator }),
168
177
  ];
169
- this.lastClick = { index: this.next - 1, at };
178
+ this.lastClick = { index: this.next - 1, at, key: observation.key };
170
179
  return events;
171
180
  }
181
+ /** A choice in a `<select>`; the click that opened it, just before, is the same step */
182
+ selected(observation, at) {
183
+ this.lastFill = undefined;
184
+ this.lastAction = at;
185
+ const step = {
186
+ kind: "selectOption",
187
+ locator: observation.locator,
188
+ option: observation.option,
189
+ };
190
+ const click = this.lastClick;
191
+ if (click &&
192
+ click.key === observation.key &&
193
+ click.index === this.next - 1) {
194
+ this.lastClick = undefined;
195
+ this.lastStep = step;
196
+ return [{ op: "replace", index: click.index, step }];
197
+ }
198
+ return [this.insert(step)];
199
+ }
172
200
  pressed(observation, at) {
173
201
  // The page may react by clearing the field: what is typed next is a new fill, not a rewrite of this one
174
202
  this.lastFill = undefined;
@@ -1,5 +1,5 @@
1
1
  import type { ElementHandle } from "../script/element-handle.js";
2
- import type { LocatorCall, LocatorSpec } from "../steps/model.js";
2
+ import type { LocatorCall, LocatorSpec, SelectChoice } from "../steps/model.js";
3
3
  import type { Observation } from "./normalise.js";
4
4
  /** What the files capture script sends */
5
5
  export type FilesMessage = {
@@ -20,12 +20,16 @@ export type FilesMessage = {
20
20
  };
21
21
  /** What the capture script sends */
22
22
  export interface PageMessage {
23
- type: "click" | "input" | "press" | "assert";
23
+ type: "click" | "input" | "press" | "assert" | "select";
24
24
  target: ElementHandle;
25
25
  candidates: LocatorCall[];
26
26
  /** Of a click or press: the candidates that matched only the target when it happened, checked in the page */
27
27
  unique?: LocatorCall[];
28
28
  textEntry?: boolean;
29
+ /** Of a click on a checkbox or radio: whether it leaves the control checked */
30
+ checked?: boolean;
31
+ /** Of a `select` message: the option the tester chose */
32
+ option?: SelectChoice;
29
33
  /** The key of a `press` message */
30
34
  pressed?: string;
31
35
  value?: string;
@@ -2,25 +2,33 @@
2
2
  export function observationFor(message, locator) {
3
3
  const key = message.target.sharedId;
4
4
  return message.type === "click"
5
- ? { type: "click", key, locator, textEntry: message.textEntry === true }
6
- : message.type === "press"
7
- ? { type: "press", key, locator, pressed: message.pressed ?? "" }
8
- : message.type === "assert"
9
- ? {
10
- type: "assert",
11
- key,
12
- locator,
13
- text: message.text ?? "",
14
- ...(message.value !== undefined && { value: message.value }),
15
- }
16
- : {
17
- type: "input",
18
- key,
19
- locator,
20
- ...(message.value !== undefined && { value: message.value }),
21
- ...(message.secret && { secret: true }),
22
- ...(message.secretName !== undefined && {
23
- secretName: message.secretName,
24
- }),
25
- };
5
+ ? {
6
+ type: "click",
7
+ key,
8
+ locator,
9
+ textEntry: message.textEntry === true,
10
+ ...(message.checked !== undefined && { checked: message.checked }),
11
+ }
12
+ : message.type === "select"
13
+ ? { type: "select", key, locator, option: message.option ?? "" }
14
+ : message.type === "press"
15
+ ? { type: "press", key, locator, pressed: message.pressed ?? "" }
16
+ : message.type === "assert"
17
+ ? {
18
+ type: "assert",
19
+ key,
20
+ locator,
21
+ text: message.text ?? "",
22
+ ...(message.value !== undefined && { value: message.value }),
23
+ }
24
+ : {
25
+ type: "input",
26
+ key,
27
+ locator,
28
+ ...(message.value !== undefined && { value: message.value }),
29
+ ...(message.secret && { secret: true }),
30
+ ...(message.secretName !== undefined && {
31
+ secretName: message.secretName,
32
+ }),
33
+ };
26
34
  }
@@ -1,5 +1,6 @@
1
1
  import { callFunction } from "../../script/call-function.js";
2
2
  import { locatorFromSpec } from "../../steps/build.js";
3
+ import { callToSource } from "../../steps/emit.js";
3
4
  import { CANDIDATES_JS } from "../candidates.js";
4
5
  import { expectationFor } from "../expectation.js";
5
6
  import { TOOLBAR_SANDBOX } from "./protocol.js";
@@ -45,19 +46,25 @@ function describeCall(call) {
45
46
  case "getByRole":
46
47
  return call.name === undefined
47
48
  ? call.role
48
- : `${call.role} "${call.name}"`;
49
+ : `${call.role} ${quoted(call.name)}`;
49
50
  case "getByLabel":
50
- return `label "${call.text}"`;
51
+ return `label ${quoted(call.text)}`;
51
52
  case "getByText":
52
- return `text "${call.text}"`;
53
+ return `text ${quoted(call.text)}`;
53
54
  case "getByTestId":
54
55
  return `test id "${call.testId}"`;
55
56
  case "getByCss":
56
57
  return call.css;
57
58
  case "locator":
58
59
  return call.xpath;
60
+ default:
61
+ return callToSource(call);
59
62
  }
60
63
  }
64
+ /** A text as the panel shows it: in quotes, a RegExp as written */
65
+ const quoted = (text) => typeof text === "string"
66
+ ? `"${text}"`
67
+ : `/${text.regex.source}/${text.regex.flags}`;
61
68
  const sameCall = (a, b) => JSON.stringify(a) === JSON.stringify(b);
62
69
  /**
63
70
  * What the Assert panel needs for a picked element: its short label, text, value, attributes, the first
@@ -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",
@@ -16,42 +17,50 @@ export const BUILT_IN_FIXTURES = new Set([
16
17
  "isMobile",
17
18
  "hasTouch",
18
19
  ]);
19
- const OPTION_KEYS = new Set(["auto", "scope"]);
20
20
  function isPlainObject(value) {
21
21
  if (typeof value !== "object" || value === null)
22
22
  return false;
23
23
  const proto = Object.getPrototypeOf(value);
24
24
  return proto === Object.prototype || proto === null;
25
25
  }
26
- /** `[fixture, options]` is always a pair with options; an array value goes in a function */
27
- function checkedOptions(name, options) {
28
- for (const key of Object.keys(options))
29
- if (!OPTION_KEYS.has(key))
30
- throw new Error(`extend(): fixture "${name}" has an unknown option "${key}"`);
31
- if (options.auto !== undefined && typeof options.auto !== "boolean")
32
- throw new Error(`extend(): fixture "${name}": auto must be true or false`);
33
- if (options.scope === "worker")
34
- throw new Error(`extend(): fixture "${name}" has scope "worker", which is not supported; fixtures live as long as one test ({ scope: "test" })`);
35
- if (options.scope !== undefined && options.scope !== "test")
36
- throw new Error(`extend(): fixture "${name}": scope must be "test"`);
37
- return { auto: options.auto === true };
38
- }
39
26
  function definitionOf(name, raw) {
40
27
  const [fixture, options] = Array.isArray(raw) && raw.length === 2 && isPlainObject(raw[1])
41
28
  ? [raw[0], checkedOptions(name, raw[1])]
42
- : [raw, { auto: false }];
29
+ : [raw, checkedOptions(name, {})];
30
+ const { auto, meta } = options;
31
+ checkOptionFixture(name, fixture, meta);
43
32
  if (typeof fixture !== "function")
44
- return { name, auto: options.auto, deps: [], value: fixture };
33
+ return { name, auto, deps: [], value: fixture, ...meta };
45
34
  const requested = requestedBy(fixture);
46
35
  if (requested.kind === "opaque" || requested.rest)
47
36
  throw new Error(`extend(): fixture "${name}" must destructure the fixtures it uses in its first argument, as ({ page }, use) or ({}, use)`);
48
37
  return {
49
38
  name,
50
- auto: options.auto,
39
+ auto,
51
40
  deps: requested.names,
52
41
  setup: fixture,
42
+ ...meta,
53
43
  };
54
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);
55
64
  /** Where a dependency of `name` (at definition `level`) comes from: another definition, or the built-in fixture */
56
65
  export function dependencyOf(layers, name, level, dep) {
57
66
  if (dep === name)
@@ -65,6 +74,25 @@ export function dependencyOf(layers, name, level, dep) {
65
74
  return { name: dep, level: defs.length - 1 };
66
75
  return BUILT_IN_FIXTURES.has(dep) ? "built-in" : undefined;
67
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
+ }
68
96
  /** Throws for a dependency nobody defines and for a cycle (`a` needs `b` needs `a`) */
69
97
  function validate(layers) {
70
98
  const done = new Set();
@@ -83,14 +111,19 @@ function validate(layers) {
83
111
  const target = dependencyOf(layers, name, level, dep);
84
112
  if (target === undefined)
85
113
  throw new Error(`extend(): fixture "${name}" depends on unknown fixture "${dep}"`);
114
+ if (def.scope === "worker")
115
+ checkWorkerDependency(layers, def, dep, target);
86
116
  if (target !== "built-in")
87
117
  walk(target.name, target.level, [...path, key]);
88
118
  }
89
119
  done.add(key);
90
120
  };
91
- 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)}"`);
92
124
  for (let level = 0; level < defs.length; level++)
93
125
  walk(name, level, []);
126
+ }
94
127
  }
95
128
  /** The fixtures after `test.extend(definitions)` on a test that has `base`; throws `extend(): ...` for a definition that cannot work */
96
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
+ }