@itmetsam/samurai-framework 0.22.0 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/api.d.ts +3 -3
  2. package/dist/browser/page-content.d.ts +4 -0
  3. package/dist/browser/page-content.js +12 -0
  4. package/dist/browser/page-events.d.ts +11 -0
  5. package/dist/browser/page-events.js +23 -0
  6. package/dist/browser/page-logging.d.ts +24 -0
  7. package/dist/browser/page-logging.js +43 -0
  8. package/dist/browser/page-settings.d.ts +1 -1
  9. package/dist/browser/page-viewport.d.ts +21 -0
  10. package/dist/browser/page-viewport.js +33 -0
  11. package/dist/browser/page.d.ts +6 -9
  12. package/dist/browser/page.js +31 -63
  13. package/dist/browser/screenshot.d.ts +6 -0
  14. package/dist/browser/screenshot.js +14 -0
  15. package/dist/capabilities.d.ts +16 -2
  16. package/dist/capabilities.js +16 -2
  17. package/dist/config/config.js +2 -1
  18. package/dist/config/fixture-use.d.ts +19 -0
  19. package/dist/config/fixture-use.js +44 -0
  20. package/dist/config/group-use.d.ts +4 -2
  21. package/dist/config/group-use.js +30 -20
  22. package/dist/config/use-options.js +5 -1
  23. package/dist/recorder/capture.d.ts +3 -0
  24. package/dist/recorder/capture.js +18 -1
  25. package/dist/recorder/normalise.d.ts +12 -1
  26. package/dist/recorder/normalise.js +29 -1
  27. package/dist/recorder/page-messages.d.ts +6 -2
  28. package/dist/recorder/page-messages.js +29 -21
  29. package/dist/recorder/toolbar/inspect.js +10 -3
  30. package/dist/runner/fixture-defs.d.ts +2 -1
  31. package/dist/runner/fixture-defs.js +51 -18
  32. package/dist/runner/fixture-meta.d.ts +20 -0
  33. package/dist/runner/fixture-meta.js +109 -0
  34. package/dist/runner/fixture-options.d.ts +8 -0
  35. package/dist/runner/fixture-options.js +35 -0
  36. package/dist/runner/fixture-outputs.d.ts +11 -0
  37. package/dist/runner/fixture-outputs.js +28 -0
  38. package/dist/runner/fixture-registry.d.ts +35 -0
  39. package/dist/runner/fixture-registry.js +73 -0
  40. package/dist/runner/fixture-scope.d.ts +39 -9
  41. package/dist/runner/fixture-scope.js +139 -70
  42. package/dist/runner/fixture-start.d.ts +27 -0
  43. package/dist/runner/fixture-start.js +76 -0
  44. package/dist/runner/fixture-use.d.ts +23 -0
  45. package/dist/runner/fixture-use.js +64 -0
  46. package/dist/runner/fixture-worker.d.ts +26 -0
  47. package/dist/runner/fixture-worker.js +46 -0
  48. package/dist/runner/prepare-run.js +5 -0
  49. package/dist/runner/registry.js +15 -5
  50. package/dist/runner/reporter.d.ts +3 -1
  51. package/dist/runner/reporter.js +1 -0
  52. package/dist/runner/run-test.d.ts +3 -0
  53. package/dist/runner/run-test.js +16 -1
  54. package/dist/runner/run.d.ts +7 -0
  55. package/dist/runner/run.js +9 -0
  56. package/dist/runner/test-function.d.ts +2 -2
  57. package/dist/runner/test-runner.js +6 -1
  58. package/dist/runner/use-options.d.ts +16 -2
  59. package/dist/runner/use-options.js +28 -8
  60. package/dist/runner/worker-session.js +6 -0
  61. package/dist/steps/build.js +49 -25
  62. package/dist/steps/emit.js +64 -7
  63. package/dist/steps/index.d.ts +1 -1
  64. package/dist/steps/model.d.ts +60 -10
  65. package/dist/steps/parse/call.js +125 -39
  66. package/dist/steps/parse/expectation.d.ts +2 -1
  67. package/dist/steps/parse/expectation.js +30 -8
  68. package/dist/steps/parse/literals.d.ts +9 -0
  69. package/dist/steps/parse/literals.js +42 -0
  70. package/dist/steps/parse/step.js +28 -1
  71. package/dist/types/config.d.ts +35 -22
  72. package/dist/types/extend.d.ts +54 -2
  73. package/dist/types/test.d.ts +24 -0
  74. package/dist/types/use.d.ts +14 -0
  75. package/package.json +1 -1
@@ -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 };