@itmetsam/samurai-framework 0.22.1 → 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.
@@ -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
  };
@@ -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,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.2",
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": {