@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.
- package/dist/capabilities.d.ts +16 -2
- package/dist/capabilities.js +16 -2
- package/dist/recorder/capture.d.ts +3 -0
- package/dist/recorder/capture.js +18 -1
- package/dist/recorder/normalise.d.ts +12 -1
- package/dist/recorder/normalise.js +29 -1
- package/dist/recorder/page-messages.d.ts +6 -2
- package/dist/recorder/page-messages.js +29 -21
- package/dist/recorder/toolbar/inspect.js +10 -3
- package/dist/steps/build.js +49 -25
- package/dist/steps/emit.js +64 -7
- package/dist/steps/index.d.ts +1 -1
- package/dist/steps/model.d.ts +60 -10
- package/dist/steps/parse/call.js +125 -39
- package/dist/steps/parse/expectation.d.ts +2 -1
- package/dist/steps/parse/expectation.js +30 -8
- package/dist/steps/parse/literals.d.ts +9 -0
- package/dist/steps/parse/literals.js +42 -0
- package/dist/steps/parse/step.js +28 -1
- package/package.json +1 -1
package/dist/capabilities.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
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;
|
package/dist/capabilities.js
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
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
|
package/dist/recorder/capture.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
? {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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}
|
|
49
|
+
: `${call.role} ${quoted(call.name)}`;
|
|
49
50
|
case "getByLabel":
|
|
50
|
-
return `label
|
|
51
|
+
return `label ${quoted(call.text)}`;
|
|
51
52
|
case "getByText":
|
|
52
|
-
return `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
|
package/dist/steps/build.js
CHANGED
|
@@ -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 */
|
package/dist/steps/emit.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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}(${
|
|
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)}${
|
|
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":
|
package/dist/steps/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/steps/model.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Modifier } from "../types/test.js";
|
|
2
|
-
/**
|
|
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:
|
|
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: "
|
|
16
|
-
text:
|
|
17
|
-
|
|
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?:
|
|
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";
|
package/dist/steps/parse/call.js
CHANGED
|
@@ -1,48 +1,131 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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) &&
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
105
|
+
if (TEXT_ARGUMENT_MATCHERS.includes(matcher)) {
|
|
87
106
|
const expected = readTextMatcher(args[0]);
|
|
88
107
|
return args.length === 1 && expected !== undefined
|
|
89
|
-
? {
|
|
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
|
+
}
|
package/dist/steps/parse/step.js
CHANGED
|
@@ -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.
|
|
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": {
|