@itmetsam/samurai-framework 0.22.1 → 0.22.3

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.
@@ -38,8 +38,12 @@ export declare const CAPABILITIES: {
38
38
  readonly flowNodeFolders: 2;
39
39
  /** `nodeFolder(nodeId, browser)` from `./flows/run`: a flow node's folder under `outputDir` (version 2 takes the browser). */
40
40
  readonly nodeFolder: 2;
41
- /** Assertion matchers version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`, `toHaveClass` and `toHaveCSS`, in the framework and the step codec. */
42
- readonly assertionMatchers: 2;
41
+ /**
42
+ * Assertion matchers in the framework and the step codec. Version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`,
43
+ * `toHaveClass` and `toHaveCSS`. Version 3 adds `toBeFocused`, `toBeDisabled`, `toBeEditable`, `toBeAttached`,
44
+ * `toBeInViewport`, `toBeEmpty`, `toHaveId`, `toHaveAccessibleName` and `toHaveAccessibleDescription` to the codec.
45
+ */
46
+ readonly assertionMatchers: 3;
43
47
  /** `expect(page).toHaveURL` and `toHaveTitle`, and the `expectPage` step in the step codec. */
44
48
  readonly pageAssertions: true;
45
49
  /**
@@ -50,6 +54,16 @@ export declare const CAPABILITIES: {
50
54
  readonly testInputsOutputs: true;
51
55
  /** A flow runs once per browser: `browser` on every flow event, `flow-done`, `runFlowInProject` resolving to `FlowRunResult`. */
52
56
  readonly flowPerBrowser: true;
57
+ /** The codec's `check`, `uncheck`, `selectOption`, `hover`, `dblclick` and `clear` steps. */
58
+ readonly actionSteps: true;
59
+ /**
60
+ * The codec's locator forms: `exact` and RegExp text and name, `getByPlaceholder` / `getByAltText` / `getByTitle`, the
61
+ * `getByRole` options `level`, `checked`, `pressed`, `expanded`, `selected`, `disabled` and `includeHidden`, and the
62
+ * `nth`, `first`, `last` and `filter({ hasText })` calls. `LocatorCall.text` and `.name` can be a RegExp.
63
+ */
64
+ readonly locatorForms: true;
65
+ /** The recorder writes a `check` / `uncheck` for a checkbox or radio click and a `selectOption` for a `<select>` choice. */
66
+ readonly recorderActionSteps: true;
53
67
  };
54
68
  /** The shape of {@link CAPABILITIES}. */
55
69
  export type Capabilities = typeof CAPABILITIES;
@@ -38,8 +38,12 @@ export const CAPABILITIES = {
38
38
  flowNodeFolders: 2,
39
39
  /** `nodeFolder(nodeId, browser)` from `./flows/run`: a flow node's folder under `outputDir` (version 2 takes the browser). */
40
40
  nodeFolder: 2,
41
- /** Assertion matchers version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`, `toHaveClass` and `toHaveCSS`, in the framework and the step codec. */
42
- assertionMatchers: 2,
41
+ /**
42
+ * Assertion matchers in the framework and the step codec. Version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`,
43
+ * `toHaveClass` and `toHaveCSS`. Version 3 adds `toBeFocused`, `toBeDisabled`, `toBeEditable`, `toBeAttached`,
44
+ * `toBeInViewport`, `toBeEmpty`, `toHaveId`, `toHaveAccessibleName` and `toHaveAccessibleDescription` to the codec.
45
+ */
46
+ assertionMatchers: 3,
43
47
  /** `expect(page).toHaveURL` and `toHaveTitle`, and the `expectPage` step in the step codec. */
44
48
  pageAssertions: true,
45
49
  /**
@@ -50,4 +54,14 @@ export const CAPABILITIES = {
50
54
  testInputsOutputs: true,
51
55
  /** A flow runs once per browser: `browser` on every flow event, `flow-done`, `runFlowInProject` resolving to `FlowRunResult`. */
52
56
  flowPerBrowser: true,
57
+ /** The codec's `check`, `uncheck`, `selectOption`, `hover`, `dblclick` and `clear` steps. */
58
+ actionSteps: true,
59
+ /**
60
+ * The codec's locator forms: `exact` and RegExp text and name, `getByPlaceholder` / `getByAltText` / `getByTitle`, the
61
+ * `getByRole` options `level`, `checked`, `pressed`, `expanded`, `selected`, `disabled` and `includeHidden`, and the
62
+ * `nth`, `first`, `last` and `filter({ hasText })` calls. `LocatorCall.text` and `.name` can be a RegExp.
63
+ */
64
+ locatorForms: true,
65
+ /** The recorder writes a `check` / `uncheck` for a checkbox or radio click and a `selectOption` for a `<select>` choice. */
66
+ recorderActionSteps: true,
53
67
  };
@@ -66,6 +66,8 @@ export async function recordCommand({ values, positionals }, io) {
66
66
  if (at !== undefined && (!Number.isInteger(at) || at < 0))
67
67
  throw new Error(`--at must be a step number, got "${values.at}"`);
68
68
  const flags = parseRunnerFlags(values);
69
+ if (flags.headless === true)
70
+ throw new Error("--headless: a recording needs a window; the recorder never runs without one");
69
71
  if (flags.browsers && flags.browsers.length > 1)
70
72
  throw new Error("--browser: a recording uses one browser");
71
73
  const test = values.test === undefined
@@ -81,7 +83,7 @@ export async function recordCommand({ values, positionals }, io) {
81
83
  ...(values.new !== undefined && { create: values.new }),
82
84
  ...(at !== undefined && { at }),
83
85
  ...(values.url !== undefined && { url: values.url }),
84
- ...(flags.headless !== undefined && { headless: flags.headless }),
86
+ headless: false,
85
87
  ...(flags.port !== undefined && { port: flags.port }),
86
88
  ...(flags.browsers && { browser: flags.browsers[0] }),
87
89
  ...(io.signal && { signal: io.signal }),
@@ -1 +1 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json; always with a window):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
package/dist/cli/usage.js CHANGED
@@ -33,7 +33,7 @@ Options for run:
33
33
  --shard=2/4 runs the second of four. Whole files (and serial
34
34
  blocks) go to one shard; an empty shard succeeds
35
35
 
36
- Options for record (also --env, --port, --json, --headless, --no-headless):
36
+ Options for record (also --env, --port, --json; always with a window):
37
37
  --browser <name> Record in this browser (default: the first of the config's browsers)
38
38
  --new <title> Add a test with this title (creates the spec file if needed)
39
39
  --test <name|index> Record into this test; not needed when the file has one
@@ -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
  }
@@ -24,7 +24,7 @@ export interface RecordSpecOptions extends ProjectOptions {
24
24
  browser?: SupportedBrowser;
25
25
  /** The browser's debugging port. @default DEFAULT_PORT (9223) */
26
26
  port?: number;
27
- /** Run the browser without a window. A person can't use it then; for tests and machines without a display. @default the config's `use.headless`, else headless on CI and a window otherwise */
27
+ /** Run the browser without a window. A person can't use it then, so this is only for tests of the recorder itself. Never read from the config, the environment or `CI`: a recording always opens a window. @default false */
28
28
  headless?: boolean;
29
29
  /** Aborting ends the recording, like closing the browser window */
30
30
  signal?: AbortSignal;
@@ -28,7 +28,7 @@ export async function recordSpec(options) {
28
28
  const writer = new SpecWriter(spec.source, file, testIndex);
29
29
  const { browser, page } = await launchRecordingBrowser(browserName, {
30
30
  ...options,
31
- headless: options.headless ?? settings.headless,
31
+ headless: options.headless ?? false,
32
32
  });
33
33
  const loaded = await loadRawConfig();
34
34
  const folder = filesFolder(projectDir(), loaded.filesDir);
@@ -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,32 +1,56 @@
1
+ /** The RegExp or string a matcher stands for */
2
+ function textFromMatcher(matcher) {
3
+ return typeof matcher === "string"
4
+ ? matcher
5
+ : new RegExp(matcher.regex.source, matcher.regex.flags);
6
+ }
7
+ /** `options` without the keys that are undefined, or undefined when nothing is left */
8
+ function defined(options) {
9
+ const entries = Object.entries(options).filter(([, v]) => v !== undefined);
10
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
11
+ }
12
+ function apply(target, call) {
13
+ switch (call.method) {
14
+ case "locator":
15
+ return target.locator(call.xpath);
16
+ case "getByCss":
17
+ return target.getByCss(call.css);
18
+ case "getByText":
19
+ case "getByLabel": {
20
+ const { exact, match, ignoreCase } = call;
21
+ return target[call.method](textFromMatcher(call.text), defined({ exact, match, ignoreCase }));
22
+ }
23
+ case "getByPlaceholder":
24
+ case "getByAltText":
25
+ case "getByTitle":
26
+ return target[call.method](textFromMatcher(call.text), defined({ exact: call.exact }));
27
+ case "getByRole": {
28
+ const { method, role, name, ...options } = call;
29
+ return target.getByRole(role, defined({
30
+ ...options,
31
+ ...(name !== undefined && { name: textFromMatcher(name) }),
32
+ }));
33
+ }
34
+ case "getByTestId":
35
+ return target.getByTestId(call.testId);
36
+ case "nth":
37
+ case "first":
38
+ case "last":
39
+ case "filter": {
40
+ if (!("nth" in target))
41
+ throw new Error(`${call.method} needs a locator to narrow`);
42
+ return call.method === "nth"
43
+ ? target.nth(call.index)
44
+ : call.method === "filter"
45
+ ? target.filter({ hasText: textFromMatcher(call.hasText) })
46
+ : target[call.method]();
47
+ }
48
+ }
49
+ }
1
50
  function fromCalls(page, calls) {
2
51
  const [first, ...rest] = calls;
3
52
  if (!first)
4
53
  throw new Error("A locator needs at least one call");
5
- const apply = (target, call) => {
6
- const text = {
7
- ...(call.method !== "locator" &&
8
- call.method !== "getByCss" &&
9
- call.method !== "getByTestId" &&
10
- call.method !== "getByRole" && {
11
- ...(call.match !== undefined && { match: call.match }),
12
- ...(call.ignoreCase !== undefined && { ignoreCase: call.ignoreCase }),
13
- }),
14
- };
15
- switch (call.method) {
16
- case "locator":
17
- return target.locator(call.xpath);
18
- case "getByCss":
19
- return target.getByCss(call.css);
20
- case "getByText":
21
- return target.getByText(call.text, text);
22
- case "getByLabel":
23
- return target.getByLabel(call.text, text);
24
- case "getByRole":
25
- return target.getByRole(call.role, call.name === undefined ? undefined : { name: call.name });
26
- case "getByTestId":
27
- return target.getByTestId(call.testId);
28
- }
29
- };
30
54
  return rest.reduce((locator, call) => apply(locator, call), apply(page, first));
31
55
  }
32
56
  /** The locator a step's `LocatorSpec` stands for, with its fallbacks */
@@ -17,13 +17,29 @@ export function filesToSource(files) {
17
17
  }
18
18
  /** A name that can follow a `.` or stand as an object key unquoted */
19
19
  export const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
20
- function textOptions(call) {
21
- const parts = [
22
- call.match !== undefined && `match: ${str(call.match)}`,
23
- call.ignoreCase !== undefined && `ignoreCase: ${call.ignoreCase}`,
24
- ].filter(Boolean);
20
+ /** `, { a: 1, b: "x" }` from key and source pairs; a pair without source is left out, and so is an empty object */
21
+ function optionsToSource(pairs) {
22
+ const parts = pairs
23
+ .filter(([, source]) => source !== undefined)
24
+ .map(([key, source]) => `${key}: ${source}`);
25
25
  return parts.length ? `, { ${parts.join(", ")} }` : "";
26
26
  }
27
+ /** A string, number or boolean as source, or undefined when it isn't set */
28
+ const literal = (value) => value === undefined
29
+ ? undefined
30
+ : typeof value === "string"
31
+ ? str(value)
32
+ : String(value);
33
+ const ROLE_OPTIONS = [
34
+ "exact",
35
+ "level",
36
+ "checked",
37
+ "pressed",
38
+ "expanded",
39
+ "selected",
40
+ "disabled",
41
+ "includeHidden",
42
+ ];
27
43
  export function callToSource(call) {
28
44
  switch (call.method) {
29
45
  case "locator":
@@ -32,11 +48,32 @@ export function callToSource(call) {
32
48
  return `getByCss(${str(call.css)})`;
33
49
  case "getByText":
34
50
  case "getByLabel":
35
- return `${call.method}(${str(call.text)}${textOptions(call)})`;
51
+ return `${call.method}(${matcherToSource(call.text)}${optionsToSource([
52
+ ["exact", literal(call.exact)],
53
+ ["match", literal(call.match)],
54
+ ["ignoreCase", literal(call.ignoreCase)],
55
+ ])})`;
56
+ case "getByPlaceholder":
57
+ case "getByAltText":
58
+ case "getByTitle":
59
+ return `${call.method}(${matcherToSource(call.text)}${optionsToSource([["exact", literal(call.exact)]])})`;
36
60
  case "getByRole":
37
- return `getByRole(${str(call.role)}${call.name === undefined ? "" : `, { name: ${str(call.name)} }`})`;
61
+ return `getByRole(${str(call.role)}${optionsToSource([
62
+ [
63
+ "name",
64
+ call.name === undefined ? undefined : matcherToSource(call.name),
65
+ ],
66
+ ...ROLE_OPTIONS.map((key) => [key, literal(call[key])]),
67
+ ])})`;
38
68
  case "getByTestId":
39
69
  return `getByTestId(${str(call.testId)})`;
70
+ case "nth":
71
+ return `nth(${call.index})`;
72
+ case "first":
73
+ case "last":
74
+ return `${call.method}()`;
75
+ case "filter":
76
+ return `filter({ hasText: ${matcherToSource(call.hasText)} })`;
40
77
  }
41
78
  }
42
79
  const chainToSource = (chain) => ["page", ...chain.map(callToSource)].join(".");
@@ -86,6 +123,12 @@ function expectationToSource(expectation, output) {
86
123
  case "toBeHidden":
87
124
  case "toBeEnabled":
88
125
  case "toBeChecked":
126
+ case "toBeFocused":
127
+ case "toBeDisabled":
128
+ case "toBeEditable":
129
+ case "toBeAttached":
130
+ case "toBeInViewport":
131
+ case "toBeEmpty":
89
132
  return `${expectation.matcher}()`;
90
133
  case "toHaveAttribute":
91
134
  return `toHaveAttribute(${str(expectation.name)}, ${matcherToSource(expectation.expected)}${tail})`;
@@ -97,6 +140,12 @@ function expectationToSource(expectation, output) {
97
140
  return `${expectation.matcher}(${matcherToSource(expectation.expected)}${tail})`;
98
141
  }
99
142
  }
143
+ function choiceToSource(option) {
144
+ if (typeof option === "string")
145
+ return str(option);
146
+ const [key, value] = Object.entries(option)[0];
147
+ return `{ ${key}: ${typeof value === "string" ? str(value) : value} }`;
148
+ }
100
149
  /** The statement for a step, on one line */
101
150
  export function stepToSource(step) {
102
151
  switch (step.kind) {
@@ -106,6 +155,14 @@ export function stepToSource(step) {
106
155
  return `await ${locatorToSource(step.locator)}.click();`;
107
156
  case "fill":
108
157
  return `await ${locatorToSource(step.locator)}.fill(${valueToSource(step.value)}${outputOption(step.output)});`;
158
+ case "check":
159
+ case "uncheck":
160
+ case "hover":
161
+ case "dblclick":
162
+ case "clear":
163
+ return `await ${locatorToSource(step.locator)}.${step.kind}();`;
164
+ case "selectOption":
165
+ return `await ${locatorToSource(step.locator)}.selectOption(${choiceToSource(step.option)});`;
109
166
  case "press":
110
167
  return `await ${locatorToSource(step.locator)}.press(${str(step.key)});`;
111
168
  case "expect":
@@ -3,4 +3,4 @@ export { applyEdit, type StepEdit } from "./edit.js";
3
3
  export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
4
4
  export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
5
5
  export { locatorFromSpec } from "./build.js";
6
- export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, PageExpectation, ParsedStep, ParsedTest, Step, TextMatcher, TestInput, TextValue, UnparsedTest, } from "./model.js";
6
+ export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, PageExpectation, SelectChoice, ParsedStep, ParsedTest, Step, TextMatcher, TestInput, TextValue, UnparsedTest, } from "./model.js";
@@ -1,5 +1,9 @@
1
1
  import type { Modifier } from "../types/test.js";
2
- /** One call of a locator expression, as written in a spec: `page.getByRole("button", { name: "Save" })` */
2
+ /**
3
+ * One call of a locator expression, as written in a spec: `page.getByRole("button", { name: "Save" })`.
4
+ * A RegExp text or name is tested as written, so it never comes with `exact`, `match` or `ignoreCase`.
5
+ * `nth`, `first`, `last` and `filter` narrow the calls before them, so they never start a chain
6
+ */
3
7
  export type LocatorCall = {
4
8
  method: "locator";
5
9
  xpath: string;
@@ -7,22 +11,46 @@ export type LocatorCall = {
7
11
  method: "getByCss";
8
12
  css: string;
9
13
  } | {
10
- method: "getByText";
11
- text: string;
14
+ method: "getByText" | "getByLabel";
15
+ text: TextMatcher;
16
+ /** `false`: a case-insensitive substring. Absent: the framework's default, a full case-sensitive match */
17
+ exact?: boolean;
12
18
  match?: "full" | "partial";
13
19
  ignoreCase?: boolean;
14
20
  } | {
15
- method: "getByLabel";
16
- text: string;
17
- match?: "full" | "partial";
18
- ignoreCase?: boolean;
21
+ method: "getByPlaceholder" | "getByAltText" | "getByTitle";
22
+ text: TextMatcher;
23
+ exact?: boolean;
19
24
  } | {
20
25
  method: "getByRole";
21
26
  role: string;
22
- name?: string;
27
+ name?: TextMatcher;
28
+ exact?: boolean;
29
+ /** The heading level */
30
+ level?: number;
31
+ checked?: boolean | "mixed";
32
+ pressed?: boolean | "mixed";
33
+ expanded?: boolean;
34
+ selected?: boolean;
35
+ disabled?: boolean;
36
+ includeHidden?: boolean;
23
37
  } | {
24
38
  method: "getByTestId";
25
39
  testId: string;
40
+ }
41
+ /** The n-th match, 0-based; a negative index counts from the end */
42
+ | {
43
+ method: "nth";
44
+ index: number;
45
+ } | {
46
+ method: "first";
47
+ } | {
48
+ method: "last";
49
+ }
50
+ /** Keeps the matches whose text contains the string (case-insensitive) or matches the RegExp */
51
+ | {
52
+ method: "filter";
53
+ hasText: TextMatcher;
26
54
  };
27
55
  /** A chain of locator calls on `page`, and the locators `withFallbacks` falls back to, most preferred first */
28
56
  export interface LocatorSpec {
@@ -53,7 +81,7 @@ export type TextMatcher = string | {
53
81
  };
54
82
  };
55
83
  export type Expectation = {
56
- matcher: "toBeVisible" | "toBeHidden" | "toBeEnabled" | "toBeChecked";
84
+ matcher: "toBeVisible" | "toBeHidden" | "toBeEnabled" | "toBeChecked" | "toBeFocused" | "toBeDisabled" | "toBeEditable" | "toBeAttached" | "toBeInViewport" | "toBeEmpty";
57
85
  } | {
58
86
  matcher: "toHaveClass";
59
87
  expected: TextMatcher;
@@ -62,7 +90,7 @@ export type Expectation = {
62
90
  name: string;
63
91
  expected: TextMatcher;
64
92
  } | {
65
- matcher: "toHaveText" | "toContainText" | "toHaveValue";
93
+ matcher: "toHaveText" | "toContainText" | "toHaveValue" | "toHaveId" | "toHaveAccessibleName" | "toHaveAccessibleDescription";
66
94
  expected: TextMatcher;
67
95
  } | {
68
96
  matcher: "toHaveAttribute";
@@ -93,6 +121,17 @@ export type FileEntry = {
93
121
  } | {
94
122
  generate: GeneratedFileSpec;
95
123
  };
124
+ /**
125
+ * The option `selectOption` chooses: a string (the option's value or its label), or exactly one of its `value`,
126
+ * `label` or position
127
+ */
128
+ export type SelectChoice = string | {
129
+ value: string;
130
+ } | {
131
+ label: string;
132
+ } | {
133
+ index: number;
134
+ };
96
135
  /** The restricted set of statements the recorder writes. Anything else in a test is a `custom` step */
97
136
  export type Step = {
98
137
  kind: "goto";
@@ -101,6 +140,17 @@ export type Step = {
101
140
  kind: "click";
102
141
  locator: LocatorSpec;
103
142
  }
143
+ /** An action on an element with no argument: ticks or unticks it, moves the pointer over it, double-clicks it, empties a field */
144
+ | {
145
+ kind: "check" | "uncheck" | "hover" | "dblclick" | "clear";
146
+ locator: LocatorSpec;
147
+ }
148
+ /** Chooses one option of a `<select>`; a list of options is `custom` */
149
+ | {
150
+ kind: "selectOption";
151
+ locator: LocatorSpec;
152
+ option: SelectChoice;
153
+ }
104
154
  /** `output` saves the typed value as that output, `{ output: "name" }` */
105
155
  | {
106
156
  kind: "fill";
@@ -1,48 +1,131 @@
1
1
  import ts from "typescript";
2
- import { readObject, stringOf } from "./literals.js";
3
- function readCall(method, args) {
4
- const first = stringOf(args[0]);
5
- if (first === undefined)
2
+ import { readTextMatcher } from "./expectation.js";
3
+ import { booleanOf, integerOf, readProps, stringOf } from "./literals.js";
4
+ /** The calls that narrow the ones before them, so they can't start a chain on `page` */
5
+ const NARROWING = ["nth", "first", "last", "filter"];
6
+ const MATCHES = ["full", "partial"];
7
+ const TRISTATE = ["checked", "pressed"];
8
+ const FLAGS = ["expanded", "selected", "disabled", "includeHidden"];
9
+ const ROLE_KEYS = ["name", "exact", "level", ...TRISTATE, ...FLAGS];
10
+ /** `{ exact, match, ignoreCase }` of a text call, as far as the call allows */
11
+ function readTextOptions(node, allowed) {
12
+ if (!node)
13
+ return {};
14
+ const props = readProps(node, allowed);
15
+ if (!props)
16
+ return undefined;
17
+ const { exact, match, ignoreCase } = props;
18
+ const matchValue = stringOf(match);
19
+ if ((exact && booleanOf(exact) === undefined) ||
20
+ (ignoreCase && booleanOf(ignoreCase) === undefined) ||
21
+ (match && (matchValue === undefined || !MATCHES.includes(matchValue))))
22
+ return undefined;
23
+ return {
24
+ ...(exact && { exact: booleanOf(exact) }),
25
+ ...(match && { match: matchValue }),
26
+ ...(ignoreCase && { ignoreCase: booleanOf(ignoreCase) }),
27
+ };
28
+ }
29
+ /** A call that takes a text (or RegExp) and the options `allowed`; a RegExp takes none */
30
+ function readTextCall(method, args, allowed) {
31
+ const text = readTextMatcher(args[0]);
32
+ if (text === undefined || args.length > 2)
33
+ return undefined;
34
+ const options = readTextOptions(args[1], allowed);
35
+ if (!options || (typeof text !== "string" && args.length === 2))
36
+ return undefined;
37
+ return { method, text, ...options };
38
+ }
39
+ function readRole(args) {
40
+ const role = stringOf(args[0]);
41
+ if (role === undefined || args.length > 2)
42
+ return undefined;
43
+ const props = args.length === 2 ? readProps(args[1], ROLE_KEYS) : {};
44
+ if (!props)
6
45
  return undefined;
46
+ const call = { method: "getByRole", role };
47
+ if (props.name) {
48
+ const name = readTextMatcher(props.name);
49
+ if (name === undefined)
50
+ return undefined;
51
+ call.name = name;
52
+ }
53
+ if (props.exact) {
54
+ const exact = booleanOf(props.exact);
55
+ if (exact === undefined || (call.name && typeof call.name !== "string"))
56
+ return undefined;
57
+ call.exact = exact;
58
+ }
59
+ if (props.level) {
60
+ const level = integerOf(props.level);
61
+ if (level === undefined || level < 1)
62
+ return undefined;
63
+ call.level = level;
64
+ }
65
+ for (const key of TRISTATE) {
66
+ const node = props[key];
67
+ if (!node)
68
+ continue;
69
+ const value = stringOf(node) === "mixed" ? "mixed" : booleanOf(node);
70
+ if (value === undefined)
71
+ return undefined;
72
+ call[key] = value;
73
+ }
74
+ for (const key of FLAGS) {
75
+ const node = props[key];
76
+ if (!node)
77
+ continue;
78
+ const value = booleanOf(node);
79
+ if (value === undefined)
80
+ return undefined;
81
+ call[key] = value;
82
+ }
83
+ return call;
84
+ }
85
+ function readNarrowing(method, args) {
86
+ if (method === "first" || method === "last")
87
+ return args.length === 0 ? { method } : undefined;
88
+ if (method === "nth") {
89
+ const index = integerOf(args[0]);
90
+ return args.length === 1 && index !== undefined
91
+ ? { method, index }
92
+ : undefined;
93
+ }
94
+ const hasText = readProps(args[0], ["hasText"])?.hasText;
95
+ const text = readTextMatcher(hasText);
96
+ return method === "filter" && args.length === 1 && text !== undefined
97
+ ? { method, hasText: text }
98
+ : undefined;
99
+ }
100
+ function readCall(method, args) {
101
+ if (NARROWING.includes(method))
102
+ return readNarrowing(method, args);
7
103
  switch (method) {
8
104
  case "locator":
9
- return args.length === 1 ? { method, xpath: first } : undefined;
10
105
  case "getByCss":
11
- return args.length === 1 ? { method, css: first } : undefined;
12
- case "getByTestId":
13
- return args.length === 1 ? { method, testId: first } : undefined;
14
- case "getByText":
15
- case "getByLabel": {
16
- if (args.length > 2)
17
- return undefined;
18
- const options = args.length === 2 ? readObject(args[1], ["match", "ignoreCase"]) : {};
19
- if (!options)
106
+ case "getByTestId": {
107
+ const first = stringOf(args[0]);
108
+ if (first === undefined || args.length !== 1)
20
109
  return undefined;
21
- const { match, ignoreCase } = options;
22
- if ((match !== undefined && match !== "full" && match !== "partial") ||
23
- (ignoreCase !== undefined && typeof ignoreCase !== "boolean")) {
24
- return undefined;
25
- }
26
- return {
27
- method,
28
- text: first,
29
- ...(match !== undefined && { match: match }),
30
- ...(ignoreCase !== undefined && { ignoreCase: ignoreCase }),
31
- };
32
- }
33
- case "getByRole": {
34
- if (args.length > 2)
35
- return undefined;
36
- const options = args.length === 2 ? readObject(args[1], ["name"]) : {};
37
- if (!options ||
38
- (options.name !== undefined && typeof options.name !== "string"))
39
- return undefined;
40
- return {
41
- method,
42
- role: first,
43
- ...(options.name !== undefined && { name: options.name }),
44
- };
110
+ return method === "locator"
111
+ ? { method, xpath: first }
112
+ : method === "getByCss"
113
+ ? { method, css: first }
114
+ : { method, testId: first };
45
115
  }
116
+ case "getByText":
117
+ case "getByLabel":
118
+ return readTextCall(method, args, [
119
+ "exact",
120
+ "match",
121
+ "ignoreCase",
122
+ ]);
123
+ case "getByPlaceholder":
124
+ case "getByAltText":
125
+ case "getByTitle":
126
+ return readTextCall(method, args, ["exact"]);
127
+ case "getByRole":
128
+ return readRole(args);
46
129
  default:
47
130
  return undefined;
48
131
  }
@@ -60,7 +143,10 @@ function readChain(node) {
60
143
  calls.unshift(call);
61
144
  current = current.expression.expression;
62
145
  }
63
- return ts.isIdentifier(current) && current.text === "page" && calls.length > 0
146
+ return ts.isIdentifier(current) &&
147
+ current.text === "page" &&
148
+ calls.length > 0 &&
149
+ !NARROWING.includes(calls[0].method)
64
150
  ? calls
65
151
  : undefined;
66
152
  }
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import type { Expectation, PageExpectation, TextValue } from "../model.js";
2
+ import type { Expectation, PageExpectation, TextMatcher, TextValue } from "../model.js";
3
3
  /** `env.NAME`, `secrets.NAME`, `inputs.NAME` (or `env["NAME"]` and so on) or a string literal */
4
4
  export declare function readValue(node: ts.Expression | undefined): TextValue | undefined;
5
5
  /** `/source/flags` */
@@ -7,6 +7,7 @@ export declare function readRegex(node: ts.Expression | undefined): {
7
7
  source: string;
8
8
  flags: string;
9
9
  } | undefined;
10
+ export declare function readTextMatcher(node: ts.Expression | undefined): TextMatcher | undefined;
10
11
  /** The matchers that take `{ output }` as their last argument */
11
12
  export declare const OUTPUT_MATCHERS: readonly string[];
12
13
  export declare function readExpectation(matcher: string, args: readonly ts.Expression[]): Expectation | undefined;
@@ -1,6 +1,26 @@
1
1
  import ts from "typescript";
2
2
  import { stringOf } from "./literals.js";
3
3
  const TEXT_MATCHERS = ["toHaveText", "toContainText", "toHaveValue"];
4
+ /** The matchers that take no argument: a call with options (`{ visible: false }`, `{ timeout }`) is custom */
5
+ const NO_ARGUMENT_MATCHERS = [
6
+ "toBeVisible",
7
+ "toBeHidden",
8
+ "toBeEnabled",
9
+ "toBeChecked",
10
+ "toBeFocused",
11
+ "toBeDisabled",
12
+ "toBeEditable",
13
+ "toBeAttached",
14
+ "toBeInViewport",
15
+ "toBeEmpty",
16
+ ];
17
+ /** The matchers that take one text or RegExp; only `TEXT_MATCHERS` can also save an output */
18
+ const TEXT_ARGUMENT_MATCHERS = [
19
+ ...TEXT_MATCHERS,
20
+ "toHaveId",
21
+ "toHaveAccessibleName",
22
+ "toHaveAccessibleDescription",
23
+ ];
4
24
  /** `env.NAME`, `secrets.NAME`, `inputs.NAME` (or `env["NAME"]` and so on) or a string literal */
5
25
  export function readValue(node) {
6
26
  if (!node)
@@ -37,7 +57,7 @@ export function readRegex(node) {
37
57
  const end = node.text.lastIndexOf("/");
38
58
  return { source: node.text.slice(1, end), flags: node.text.slice(end + 1) };
39
59
  }
40
- function readTextMatcher(node) {
60
+ export function readTextMatcher(node) {
41
61
  if (!node)
42
62
  return undefined;
43
63
  const literal = stringOf(node);
@@ -52,11 +72,10 @@ export const OUTPUT_MATCHERS = [
52
72
  "toHaveAttribute",
53
73
  ];
54
74
  export function readExpectation(matcher, args) {
55
- if (matcher === "toBeVisible" ||
56
- matcher === "toBeHidden" ||
57
- matcher === "toBeEnabled" ||
58
- matcher === "toBeChecked")
59
- return args.length === 0 ? { matcher } : undefined;
75
+ if (NO_ARGUMENT_MATCHERS.includes(matcher))
76
+ return args.length === 0
77
+ ? { matcher: matcher }
78
+ : undefined;
60
79
  if (matcher === "toHaveClass") {
61
80
  const expected = readTextMatcher(args[0]);
62
81
  return args.length === 1 && expected !== undefined
@@ -83,10 +102,13 @@ export function readExpectation(matcher, args) {
83
102
  ? { matcher, name, expected }
84
103
  : undefined;
85
104
  }
86
- if (TEXT_MATCHERS.includes(matcher)) {
105
+ if (TEXT_ARGUMENT_MATCHERS.includes(matcher)) {
87
106
  const expected = readTextMatcher(args[0]);
88
107
  return args.length === 1 && expected !== undefined
89
- ? { matcher: matcher, expected }
108
+ ? {
109
+ matcher: matcher,
110
+ expected,
111
+ }
90
112
  : undefined;
91
113
  }
92
114
  return undefined;
@@ -1,4 +1,13 @@
1
1
  import ts from "typescript";
2
2
  export declare function stringOf(node: ts.Node | undefined): string | undefined;
3
+ /** `true` or `false` */
4
+ export declare function booleanOf(node: ts.Node | undefined): boolean | undefined;
5
+ /** An integer literal, with a `-` in front when negative */
6
+ export declare function integerOf(node: ts.Node | undefined): number | undefined;
3
7
  /** `{ key: literal, … }` with only the allowed keys, or undefined when it has anything else */
4
8
  export declare function readObject(node: ts.Node | undefined, allowed: readonly string[]): Record<string, string | boolean> | undefined;
9
+ /**
10
+ * The values of `{ key: value, … }` as expressions, by key, or undefined when the object has a key that isn't
11
+ * allowed or isn't a plain name, a spread, a shorthand or a key twice
12
+ */
13
+ export declare function readProps(node: ts.Node | undefined, allowed: readonly string[]): Record<string, ts.Expression> | undefined;
@@ -5,6 +5,30 @@ export function stringOf(node) {
5
5
  ? node.text
6
6
  : undefined;
7
7
  }
8
+ /** `true` or `false` */
9
+ export function booleanOf(node) {
10
+ if (node?.kind === ts.SyntaxKind.TrueKeyword)
11
+ return true;
12
+ if (node?.kind === ts.SyntaxKind.FalseKeyword)
13
+ return false;
14
+ return undefined;
15
+ }
16
+ /** An integer literal, with a `-` in front when negative */
17
+ export function integerOf(node) {
18
+ if (!node)
19
+ return undefined;
20
+ if (ts.isNumericLiteral(node)) {
21
+ const value = Number(node.text);
22
+ return Number.isInteger(value) ? value : undefined;
23
+ }
24
+ if (ts.isPrefixUnaryExpression(node) &&
25
+ node.operator === ts.SyntaxKind.MinusToken &&
26
+ ts.isNumericLiteral(node.operand)) {
27
+ const value = integerOf(node.operand);
28
+ return value === undefined || value === 0 ? undefined : -value;
29
+ }
30
+ return undefined;
31
+ }
8
32
  /** `{ key: literal, … }` with only the allowed keys, or undefined when it has anything else */
9
33
  export function readObject(node, allowed) {
10
34
  if (!node || !ts.isObjectLiteralExpression(node))
@@ -26,3 +50,21 @@ export function readObject(node, allowed) {
26
50
  }
27
51
  return result;
28
52
  }
53
+ /**
54
+ * The values of `{ key: value, … }` as expressions, by key, or undefined when the object has a key that isn't
55
+ * allowed or isn't a plain name, a spread, a shorthand or a key twice
56
+ */
57
+ export function readProps(node, allowed) {
58
+ if (!node || !ts.isObjectLiteralExpression(node))
59
+ return undefined;
60
+ const props = {};
61
+ for (const property of node.properties) {
62
+ if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name))
63
+ return undefined;
64
+ const name = property.name.text;
65
+ if (!allowed.includes(name) || name in props)
66
+ return undefined;
67
+ props[name] = property.initializer;
68
+ }
69
+ return props;
70
+ }
@@ -2,7 +2,7 @@ import ts from "typescript";
2
2
  import { readLocator } from "./call.js";
3
3
  import { OUTPUT_MATCHERS, readExpectation, readPageExpectation, readRegex, readValue, } from "./expectation.js";
4
4
  import { readFiles } from "./file-entry.js";
5
- import { readObject, stringOf } from "./literals.js";
5
+ import { integerOf, readObject, readProps, stringOf } from "./literals.js";
6
6
  /**
7
7
  * The name in a trailing `{ output: "name" }`: undefined without one, null when the argument is there but
8
8
  * isn't exactly that
@@ -53,6 +53,25 @@ function readLocatorExpect(subject, negated, matcher, args) {
53
53
  }
54
54
  : undefined;
55
55
  }
56
+ /** The actions that take a locator and nothing else */
57
+ const ACTIONS = ["check", "uncheck", "hover", "dblclick", "clear"];
58
+ /** `"value or label"`, `{ value }`, `{ label }` or `{ index }`: exactly one key; anything else is undefined */
59
+ function readSelectChoice(node) {
60
+ const text = stringOf(node);
61
+ if (text !== undefined)
62
+ return text;
63
+ const props = readProps(node, ["value", "label", "index"]);
64
+ const keys = Object.keys(props ?? {});
65
+ if (!props || keys.length !== 1)
66
+ return undefined;
67
+ const [key] = keys;
68
+ if (key === "index") {
69
+ const index = integerOf(props.index);
70
+ return index !== undefined && index >= 0 ? { index } : undefined;
71
+ }
72
+ const value = stringOf(props[key]);
73
+ return value === undefined ? undefined : { [key]: value };
74
+ }
56
75
  /** The step a statement stands for, or undefined when it is outside the recorder's set */
57
76
  export function readStep(statement) {
58
77
  if (!ts.isExpressionStatement(statement) ||
@@ -117,6 +136,14 @@ export function readStep(statement) {
117
136
  return undefined;
118
137
  if (name.text === "click" && args.length === 0)
119
138
  return { kind: "click", locator };
139
+ if (ACTIONS.includes(name.text) && args.length === 0)
140
+ return { kind: name.text, locator };
141
+ if (name.text === "selectOption" && args.length === 1) {
142
+ const option = readSelectChoice(args[0]);
143
+ return option === undefined
144
+ ? undefined
145
+ : { kind: "selectOption", locator, option };
146
+ }
120
147
  if (name.text === "press" && args.length === 1) {
121
148
  const key = stringOf(args[0]);
122
149
  return key === undefined ? undefined : { kind: "press", locator, key };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.22.1",
3
+ "version": "0.22.3",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {