@itmetsam/samurai-framework 0.19.0 → 0.21.0

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 (134) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/assert/assertion-error.d.ts +3 -1
  3. package/dist/assert/assertion-error.js +2 -2
  4. package/dist/assert/locator-assertions.d.ts +35 -4
  5. package/dist/assert/locator-assertions.js +56 -28
  6. package/dist/assert/page-assertions.js +1 -1
  7. package/dist/assert/pending-assertions.d.ts +3 -2
  8. package/dist/assert/pending-assertions.js +5 -4
  9. package/dist/assert/text-match.d.ts +6 -0
  10. package/dist/assert/text-match.js +12 -0
  11. package/dist/capabilities.d.ts +16 -3
  12. package/dist/capabilities.js +16 -3
  13. package/dist/cli/flow-reporter.d.ts +1 -1
  14. package/dist/cli/flow-reporter.js +2 -2
  15. package/dist/cli/flow.js +4 -4
  16. package/dist/flows/core/check-io.d.ts +23 -0
  17. package/dist/flows/core/check-io.js +84 -0
  18. package/dist/flows/core/check.d.ts +16 -2
  19. package/dist/flows/core/check.js +20 -4
  20. package/dist/flows/core/expression/scope.d.ts +9 -2
  21. package/dist/flows/core/expression/scope.js +12 -4
  22. package/dist/flows/core/index.d.ts +2 -1
  23. package/dist/flows/core/index.js +2 -1
  24. package/dist/flows/core/nodes/condition.js +0 -1
  25. package/dist/flows/core/nodes/define.d.ts +0 -2
  26. package/dist/flows/core/nodes/group.js +0 -1
  27. package/dist/flows/core/nodes/test.js +0 -1
  28. package/dist/flows/core/schema.d.ts +10 -5
  29. package/dist/flows/core/schema.js +29 -1
  30. package/dist/flows/core/steps.d.ts +7 -0
  31. package/dist/flows/core/steps.js +15 -0
  32. package/dist/flows/core/wiring.d.ts +72 -0
  33. package/dist/flows/core/wiring.js +151 -0
  34. package/dist/flows/run/branch-context.d.ts +10 -4
  35. package/dist/flows/run/branch-context.js +6 -4
  36. package/dist/flows/run/events.d.ts +30 -2
  37. package/dist/flows/run/handlers/group.js +6 -2
  38. package/dist/flows/run/handlers/support.d.ts +7 -2
  39. package/dist/flows/run/handlers/support.js +36 -7
  40. package/dist/flows/run/handlers/test.js +6 -2
  41. package/dist/flows/run/handlers/types.d.ts +11 -3
  42. package/dist/flows/run/index.d.ts +1 -0
  43. package/dist/flows/run/index.js +1 -0
  44. package/dist/flows/run/node-folder.d.ts +10 -0
  45. package/dist/flows/run/node-folder.js +32 -0
  46. package/dist/flows/run/node-inputs.d.ts +5 -0
  47. package/dist/flows/run/node-inputs.js +62 -0
  48. package/dist/flows/run/node-pool.d.ts +2 -2
  49. package/dist/flows/run/node-pool.js +6 -6
  50. package/dist/flows/run/node-runner.d.ts +24 -0
  51. package/dist/flows/run/node-runner.js +48 -0
  52. package/dist/flows/run/per-browser.d.ts +21 -0
  53. package/dist/flows/run/per-browser.js +46 -0
  54. package/dist/flows/run/project-facts.d.ts +13 -0
  55. package/dist/flows/run/project-facts.js +12 -0
  56. package/dist/flows/run/project-options.d.ts +9 -2
  57. package/dist/flows/run/project.d.ts +13 -8
  58. package/dist/flows/run/project.js +48 -51
  59. package/dist/flows/run/report.d.ts +7 -7
  60. package/dist/flows/run/report.js +3 -5
  61. package/dist/flows/run/run.d.ts +21 -7
  62. package/dist/flows/run/run.js +21 -11
  63. package/dist/flows/run/walker.d.ts +23 -7
  64. package/dist/flows/run/walker.js +18 -7
  65. package/dist/locator/locator.d.ts +3 -1
  66. package/dist/locator/locator.js +6 -0
  67. package/dist/recorder/apply.js +17 -7
  68. package/dist/recorder/normalise.d.ts +18 -3
  69. package/dist/recorder/normalise.js +18 -3
  70. package/dist/recorder/session-secrets.js +4 -0
  71. package/dist/recorder/toolbar/action-guard.d.ts +2 -5
  72. package/dist/recorder/toolbar/action-guard.js +9 -5
  73. package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
  74. package/dist/recorder/toolbar/actions/insert.js +43 -7
  75. package/dist/recorder/toolbar/actions/pick.js +2 -0
  76. package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
  77. package/dist/recorder/toolbar/actions/reply.js +9 -0
  78. package/dist/recorder/toolbar/actions/save-output.d.ts +9 -0
  79. package/dist/recorder/toolbar/actions/save-output.js +46 -0
  80. package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
  81. package/dist/recorder/toolbar/controller-fixture.js +2 -0
  82. package/dist/recorder/toolbar/controller.d.ts +2 -1
  83. package/dist/recorder/toolbar/controller.js +14 -7
  84. package/dist/recorder/toolbar/insertables.d.ts +7 -0
  85. package/dist/recorder/toolbar/insertables.js +12 -0
  86. package/dist/recorder/toolbar/inspect.js +3 -1
  87. package/dist/recorder/toolbar/protocol.d.ts +20 -3
  88. package/dist/recorder/toolbar/source/events-page.js +2 -1
  89. package/dist/recorder/toolbar/source/fields.js +8 -0
  90. package/dist/recorder/toolbar/source/handlers.js +19 -12
  91. package/dist/recorder/toolbar/source/picking.js +22 -7
  92. package/dist/recorder/toolbar/source/recorder-side.js +6 -0
  93. package/dist/recorder/toolbar/source/setup.js +3 -3
  94. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  95. package/dist/recorder/toolbar/source/views-panels.js +50 -10
  96. package/dist/recorder/toolbar/source/views-rail.js +5 -2
  97. package/dist/recorder/toolbar/source.d.ts +5 -5
  98. package/dist/recorder/toolbar/source.js +5 -5
  99. package/dist/recorder/toolbar/types.d.ts +10 -3
  100. package/dist/runner/options.d.ts +5 -0
  101. package/dist/runner/pooled-run.d.ts +3 -2
  102. package/dist/runner/pooled-run.js +4 -2
  103. package/dist/runner/registry.d.ts +7 -3
  104. package/dist/runner/registry.js +29 -11
  105. package/dist/runner/reporter.d.ts +4 -0
  106. package/dist/runner/reporter.js +2 -0
  107. package/dist/runner/run-test.js +23 -2
  108. package/dist/runner/run.d.ts +7 -0
  109. package/dist/runner/run.js +8 -1
  110. package/dist/runner/test-io.d.ts +30 -0
  111. package/dist/runner/test-io.js +111 -0
  112. package/dist/runner/test-runner.js +1 -1
  113. package/dist/runner/worker-protocol.d.ts +2 -0
  114. package/dist/runner/worker-session.js +1 -0
  115. package/dist/runner/worker.d.ts +1 -0
  116. package/dist/runner/worker.js +4 -1
  117. package/dist/steps/edit.d.ts +14 -3
  118. package/dist/steps/edit.js +65 -23
  119. package/dist/steps/emit.d.ts +2 -0
  120. package/dist/steps/emit.js +32 -8
  121. package/dist/steps/index.d.ts +1 -1
  122. package/dist/steps/model.d.ts +42 -2
  123. package/dist/steps/parse/expectation.d.ts +8 -1
  124. package/dist/steps/parse/expectation.js +17 -11
  125. package/dist/steps/parse/step.js +64 -8
  126. package/dist/steps/parse.js +25 -16
  127. package/dist/steps/test-options.d.ts +25 -0
  128. package/dist/steps/test-options.js +158 -0
  129. package/dist/steps/text.d.ts +7 -0
  130. package/dist/steps/text.js +12 -0
  131. package/dist/types/test.d.ts +34 -0
  132. package/dist/wait/output-sink.d.ts +24 -0
  133. package/dist/wait/output-sink.js +59 -0
  134. package/package.json +1 -1
@@ -1,12 +1,9 @@
1
1
  import ts from "typescript";
2
2
  import { stepToSource } from "./emit.js";
3
3
  import { parseSpec } from "./parse.js";
4
- const lineStart = (source, offset) => source.lastIndexOf("\n", offset - 1) + 1;
5
- function lineEnd(source, offset) {
6
- const newline = source.indexOf("\n", offset);
7
- return newline === -1 ? source.length : newline + 1;
8
- }
9
- const indentOf = (source, offset) => /^[ \t]*/.exec(source.slice(lineStart(source, offset)))[0];
4
+ import { OUTPUT_MATCHERS } from "./parse/expectation.js";
5
+ import { declareList } from "./test-options.js";
6
+ import { indentOf, lineEnd, lineStart, splice } from "./text.js";
10
7
  function pickTest(source, test) {
11
8
  const found = parseSpec(source)[test];
12
9
  if (!found)
@@ -19,9 +16,6 @@ function pickStep(parsed, index) {
19
16
  throw new RangeError(`Test "${parsed.name}" has no step ${index}`);
20
17
  return step;
21
18
  }
22
- function splice(source, from, to, text) {
23
- return source.slice(0, from) + text + source.slice(to);
24
- }
25
19
  /** Inserts statement text so it becomes step `index`, copying the indentation of the steps around it */
26
20
  function insertCode(source, parsed, index, code) {
27
21
  if (index < 0 || index > parsed.steps.length) {
@@ -52,11 +46,58 @@ function removeStep(source, parsed, index) {
52
46
  const { anchor, end } = pickStep(parsed, index);
53
47
  return splice(source, lineStart(source, anchor), lineEnd(source, end), "");
54
48
  }
49
+ const VALUE_FIXTURES = { env: "env", secret: "secrets", input: "inputs" };
55
50
  /** The fixtures a step's statement reads */
56
51
  function fixturesOf(step) {
52
+ if (step.kind === "saveOutput")
53
+ return ["outputs"];
57
54
  if (step.kind !== "fill" || step.value.kind === "literal")
58
55
  return [];
59
- return [step.value.kind === "env" ? "env" : "secrets"];
56
+ return [VALUE_FIXTURES[step.value.kind]];
57
+ }
58
+ /** The output a step saves, if any */
59
+ function outputOf(step) {
60
+ if (step.kind === "saveOutput")
61
+ return step.name;
62
+ return step.kind === "fill" || step.kind === "expect"
63
+ ? step.output
64
+ : undefined;
65
+ }
66
+ /** Refuses a step that reads an input the test doesn't declare: the default has to be set first */
67
+ function checkInputs(parsed, step) {
68
+ if (step.kind !== "fill" || step.value.kind !== "input")
69
+ return;
70
+ const { name } = step.value;
71
+ if (!parsed.inputs?.some((input) => input.name === name))
72
+ throw new Error(`applyEdit(): input "${name}" is not declared`);
73
+ }
74
+ /** Refuses an output the runtime can't save: from a negated check, or from a matcher that doesn't take one */
75
+ function checkOutput(step) {
76
+ if (step.kind !== "expect" || step.output === undefined)
77
+ return;
78
+ if (step.not)
79
+ throw new Error("applyEdit(): output can't be saved from a negated check");
80
+ if (!OUTPUT_MATCHERS.includes(step.expectation.matcher))
81
+ throw new Error(`applyEdit(): output can't be saved from ${step.expectation.matcher}`);
82
+ }
83
+ /** Adds the output a step saves to the test's `outputs`, when it isn't there yet */
84
+ function withOutput(source, parsed, step) {
85
+ const name = outputOf(step);
86
+ const outputs = parsed.outputs ?? [];
87
+ if (name === undefined || outputs.includes(name))
88
+ return source;
89
+ return declareList(source, parsed, "outputs", [...outputs, name]);
90
+ }
91
+ /**
92
+ * Puts a step's statement in at `[from, to)` of the body, then what it needs: its fixtures, its output and
93
+ * the `generatedFile` import. Each goes before the last in the file, so the parsed offsets stay valid
94
+ */
95
+ function withStep(source, parsed, step, place) {
96
+ checkInputs(parsed, step);
97
+ checkOutput(step);
98
+ const next = withOutput(withFixtures(place(source, stepToSource(step)), parsed, step), parsed, step);
99
+ // Last: the import shifts every offset
100
+ return usesGeneratedFile(step) ? withGeneratedFileImport(next) : next;
60
101
  }
61
102
  /** Adds the fixtures the step reads to the test's parameters, when they aren't there yet */
62
103
  function withFixtures(source, parsed, step) {
@@ -102,26 +143,27 @@ function withGeneratedFileImport(source) {
102
143
  }
103
144
  /**
104
145
  * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
105
- * when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
106
- * comments included, stays byte for byte.
146
+ * when the step reads a fixture (`env`, `secrets`, `inputs`, `outputs`) they don't have yet, and its `outputs`
147
+ * when the step saves one not declared yet: everything else, formatting and comments included, stays byte for
148
+ * byte. A step that reads an undeclared input is refused: `declare` it, with its default, first.
107
149
  */
108
150
  export function applyEdit(source, edit) {
109
151
  const parsed = pickTest(source, edit.test);
110
152
  switch (edit.op) {
111
153
  case "replace": {
112
154
  const { start, end } = pickStep(parsed, edit.index);
113
- // The parameters come before the body: adding to them leaves the step's offsets valid
114
- const next = withFixtures(splice(source, start, end, stepToSource(edit.step)), parsed, edit.step);
115
- // Last: the import shifts every offset
116
- return usesGeneratedFile(edit.step)
117
- ? withGeneratedFileImport(next)
118
- : next;
155
+ return withStep(source, parsed, edit.step, (text, code) => splice(text, start, end, code));
119
156
  }
120
- case "insert": {
121
- const next = withFixtures(insertCode(source, parsed, edit.index, stepToSource(edit.step)), parsed, edit.step);
122
- return usesGeneratedFile(edit.step)
123
- ? withGeneratedFileImport(next)
124
- : next;
157
+ case "insert":
158
+ return withStep(source, parsed, edit.step, (text, code) => insertCode(text, parsed, edit.index, code));
159
+ case "declare": {
160
+ let next = source;
161
+ for (const key of ["inputs", "outputs"]) {
162
+ const list = edit[key];
163
+ if (list)
164
+ next = declareList(next, pickTest(next, edit.test), key, list);
165
+ }
166
+ return next;
125
167
  }
126
168
  case "remove":
127
169
  return removeStep(source, parsed, edit.index);
@@ -1,6 +1,8 @@
1
1
  import type { FileEntry, LocatorCall, LocatorSpec, Step, TextValue } from "./model.js";
2
2
  /** The argument of `setInputFiles` / `dropFiles`: one entry bare, else an array */
3
3
  export declare function filesToSource(files: readonly FileEntry[]): string;
4
+ /** A name that can follow a `.` or stand as an object key unquoted */
5
+ export declare const IDENTIFIER: RegExp;
4
6
  export declare function callToSource(call: LocatorCall): string;
5
7
  export declare function locatorToSource({ chain, fallbacks }: LocatorSpec): string;
6
8
  export declare function valueToSource(value: TextValue): string;
@@ -15,7 +15,8 @@ export function filesToSource(files) {
15
15
  ? fileEntryToSource(files[0])
16
16
  : `[${files.map(fileEntryToSource).join(", ")}]`;
17
17
  }
18
- const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
18
+ /** A name that can follow a `.` or stand as an object key unquoted */
19
+ export const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
19
20
  function textOptions(call) {
20
21
  const parts = [
21
22
  call.match !== undefined && `match: ${str(call.match)}`,
@@ -48,17 +49,38 @@ export function locatorToSource({ chain, fallbacks }) {
48
49
  export function valueToSource(value) {
49
50
  if (value.kind === "literal")
50
51
  return str(value.value);
51
- const object = value.kind === "env" ? "env" : "secrets";
52
+ const object = value.kind === "env"
53
+ ? "env"
54
+ : value.kind === "secret"
55
+ ? "secrets"
56
+ : "inputs";
52
57
  return IDENTIFIER.test(value.name)
53
58
  ? `${object}.${value.name}`
54
59
  : `${object}[${str(value.name)}]`;
55
60
  }
61
+ /**
62
+ * A regex literal for a pattern. The source goes through `RegExp`, which escapes a `/` and a line break and leaves
63
+ * text that is already escaped alone, so a pattern typed by hand still makes a literal that compiles.
64
+ */
65
+ function regexToSource({ source, flags }) {
66
+ let escaped = source;
67
+ try {
68
+ escaped = new RegExp(source, flags).source;
69
+ }
70
+ catch {
71
+ // Not a valid pattern: written unchanged
72
+ }
73
+ return `/${escaped}/${flags}`;
74
+ }
56
75
  function matcherToSource(expected) {
57
76
  return typeof expected === "string"
58
77
  ? str(expected)
59
- : `/${expected.regex.source}/${expected.regex.flags}`;
78
+ : regexToSource(expected.regex);
60
79
  }
61
- function expectationToSource(expectation) {
80
+ /** The `{ output }` option a fill or a check ends with, as its last argument */
81
+ const outputOption = (output) => output === undefined ? "" : `, { output: ${str(output)} }`;
82
+ function expectationToSource(expectation, output) {
83
+ const tail = outputOption(output);
62
84
  switch (expectation.matcher) {
63
85
  case "toBeVisible":
64
86
  case "toBeHidden":
@@ -66,13 +88,13 @@ function expectationToSource(expectation) {
66
88
  case "toBeChecked":
67
89
  return `${expectation.matcher}()`;
68
90
  case "toHaveAttribute":
69
- return `toHaveAttribute(${str(expectation.name)}, ${matcherToSource(expectation.expected)})`;
91
+ return `toHaveAttribute(${str(expectation.name)}, ${matcherToSource(expectation.expected)}${tail})`;
70
92
  case "toHaveCSS":
71
93
  return `toHaveCSS(${str(expectation.name)}, ${matcherToSource(expectation.expected)})`;
72
94
  case "toHaveCount":
73
95
  return `toHaveCount(${expectation.expected})`;
74
96
  default:
75
- return `${expectation.matcher}(${matcherToSource(expectation.expected)})`;
97
+ return `${expectation.matcher}(${matcherToSource(expectation.expected)}${tail})`;
76
98
  }
77
99
  }
78
100
  /** The statement for a step, on one line */
@@ -83,11 +105,11 @@ export function stepToSource(step) {
83
105
  case "click":
84
106
  return `await ${locatorToSource(step.locator)}.click();`;
85
107
  case "fill":
86
- return `await ${locatorToSource(step.locator)}.fill(${valueToSource(step.value)});`;
108
+ return `await ${locatorToSource(step.locator)}.fill(${valueToSource(step.value)}${outputOption(step.output)});`;
87
109
  case "press":
88
110
  return `await ${locatorToSource(step.locator)}.press(${str(step.key)});`;
89
111
  case "expect":
90
- return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
112
+ return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation, step.output)};`;
91
113
  case "expectPage":
92
114
  return `await expect(page)${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
93
115
  case "waitForNetworkIdle":
@@ -99,6 +121,8 @@ export function stepToSource(step) {
99
121
  case "setInputFiles":
100
122
  case "dropFiles":
101
123
  return `await ${locatorToSource(step.locator)}.${step.kind}(${filesToSource(step.files)});`;
124
+ case "saveOutput":
125
+ return `await outputs.save(${str(step.name)}, ${locatorToSource(step.locator)}${step.pattern ? `, ${regexToSource(step.pattern)}` : ""});`;
102
126
  case "custom":
103
127
  return step.code;
104
128
  }
@@ -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, TextValue, UnparsedTest, } from "./model.js";
6
+ export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, PageExpectation, ParsedStep, ParsedTest, Step, TextMatcher, TestInput, TextValue, UnparsedTest, } from "./model.js";
@@ -39,6 +39,11 @@ export type TextValue = {
39
39
  } | {
40
40
  kind: "secret";
41
41
  name: string;
42
+ }
43
+ /** One of the test's declared `inputs`, `inputs.name` */
44
+ | {
45
+ kind: "input";
46
+ name: string;
42
47
  };
43
48
  /** What an assertion expects of text: a string or a regular expression */
44
49
  export type TextMatcher = string | {
@@ -95,15 +100,33 @@ export type Step = {
95
100
  } | {
96
101
  kind: "click";
97
102
  locator: LocatorSpec;
98
- } | {
103
+ }
104
+ /** `output` saves the typed value as that output, `{ output: "name" }` */
105
+ | {
99
106
  kind: "fill";
100
107
  locator: LocatorSpec;
101
108
  value: TextValue;
109
+ output?: string;
102
110
  } | {
103
111
  kind: "expect";
104
112
  locator: LocatorSpec;
105
113
  not: boolean;
106
114
  expectation: Expectation;
115
+ /**
116
+ * Saves what passed as that output. Read only with `toHaveText`, `toContainText`, `toHaveValue` and
117
+ * `toHaveAttribute`, never with `not`: any other shape is `custom`
118
+ */
119
+ output?: string;
120
+ }
121
+ /** Saves an element's text as an output, `outputs.save(name, locator, /pattern/)`: the first group, else the whole match */
122
+ | {
123
+ kind: "saveOutput";
124
+ name: string;
125
+ locator: LocatorSpec;
126
+ pattern?: {
127
+ source: string;
128
+ flags: string;
129
+ };
107
130
  }
108
131
  /** An assertion on the page, `expect(page).toHaveURL(…)` */
109
132
  | {
@@ -187,6 +210,22 @@ export interface ParsedTest {
187
210
  modifier?: Modifier;
188
211
  /** The `{ reason }` written with the `skip` or `fixme` that applies, when it is a string literal */
189
212
  reason?: string;
213
+ /** The `inputs` of the test's options object, in source order; undefined without the key */
214
+ inputs?: TestInput[];
215
+ /** The `outputs` of the test's options object; undefined without the key */
216
+ outputs?: string[];
217
+ /** The range of the test's options object (`test(title, { … }, fn)`), when it has one */
218
+ options?: {
219
+ start: number;
220
+ end: number;
221
+ };
222
+ /** Offset just after the title argument, where an options object goes */
223
+ titleEnd: number;
224
+ }
225
+ /** A declared input and the value it gets when nothing supplies one */
226
+ export interface TestInput {
227
+ name: string;
228
+ default: string;
190
229
  }
191
230
  /** A `test` or `describe` call the codec can't read: its title isn't a plain string, or its function isn't written inline */
192
231
  export interface UnparsedTest {
@@ -194,5 +233,6 @@ export interface UnparsedTest {
194
233
  /** The statement's range, as for `ParsedTest` */
195
234
  start: number;
196
235
  end: number;
197
- reason: "dynamic-title" | "not-inline";
236
+ /** `dynamic-options`: the options object isn't a literal the codec can read (`{ inputs: defaults }`, a variable) */
237
+ reason: "dynamic-title" | "not-inline" | "dynamic-options";
198
238
  }
@@ -1,6 +1,13 @@
1
1
  import ts from "typescript";
2
2
  import type { Expectation, PageExpectation, TextValue } from "../model.js";
3
- /** `env.NAME`, `env["NAME"]`, `secrets.NAME`, `secrets["NAME"]` or a string literal */
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
+ /** `/source/flags` */
6
+ export declare function readRegex(node: ts.Expression | undefined): {
7
+ source: string;
8
+ flags: string;
9
+ } | undefined;
10
+ /** The matchers that take `{ output }` as their last argument */
11
+ export declare const OUTPUT_MATCHERS: readonly string[];
5
12
  export declare function readExpectation(matcher: string, args: readonly ts.Expression[]): Expectation | undefined;
6
13
  export declare function readPageExpectation(matcher: string, args: readonly ts.Expression[]): PageExpectation | undefined;
@@ -1,7 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import { stringOf } from "./literals.js";
3
3
  const TEXT_MATCHERS = ["toHaveText", "toContainText", "toHaveValue"];
4
- /** `env.NAME`, `env["NAME"]`, `secrets.NAME`, `secrets["NAME"]` or a string literal */
4
+ /** `env.NAME`, `secrets.NAME`, `inputs.NAME` (or `env["NAME"]` and so on) or a string literal */
5
5
  export function readValue(node) {
6
6
  if (!node)
7
7
  return undefined;
@@ -26,25 +26,31 @@ export function readValue(node) {
26
26
  return { kind: "env", name };
27
27
  if (object.text === "secrets")
28
28
  return { kind: "secret", name };
29
+ if (object.text === "inputs")
30
+ return { kind: "input", name };
29
31
  return undefined;
30
32
  }
33
+ /** `/source/flags` */
34
+ export function readRegex(node) {
35
+ if (!node || !ts.isRegularExpressionLiteral(node))
36
+ return undefined;
37
+ const end = node.text.lastIndexOf("/");
38
+ return { source: node.text.slice(1, end), flags: node.text.slice(end + 1) };
39
+ }
31
40
  function readTextMatcher(node) {
32
41
  if (!node)
33
42
  return undefined;
34
43
  const literal = stringOf(node);
35
44
  if (literal !== undefined)
36
45
  return literal;
37
- if (ts.isRegularExpressionLiteral(node)) {
38
- const end = node.text.lastIndexOf("/");
39
- return {
40
- regex: {
41
- source: node.text.slice(1, end),
42
- flags: node.text.slice(end + 1),
43
- },
44
- };
45
- }
46
- return undefined;
46
+ const regex = readRegex(node);
47
+ return regex && { regex };
47
48
  }
49
+ /** The matchers that take `{ output }` as their last argument */
50
+ export const OUTPUT_MATCHERS = [
51
+ ...TEXT_MATCHERS,
52
+ "toHaveAttribute",
53
+ ];
48
54
  export function readExpectation(matcher, args) {
49
55
  if (matcher === "toBeVisible" ||
50
56
  matcher === "toBeHidden" ||
@@ -1,8 +1,58 @@
1
1
  import ts from "typescript";
2
2
  import { readLocator } from "./call.js";
3
- import { readExpectation, readPageExpectation, readValue, } from "./expectation.js";
3
+ import { OUTPUT_MATCHERS, readExpectation, readPageExpectation, readRegex, readValue, } from "./expectation.js";
4
4
  import { readFiles } from "./file-entry.js";
5
5
  import { readObject, stringOf } from "./literals.js";
6
+ /**
7
+ * The name in a trailing `{ output: "name" }`: undefined without one, null when the argument is there but
8
+ * isn't exactly that
9
+ */
10
+ function outputOf(node) {
11
+ if (!node)
12
+ return undefined;
13
+ const output = readObject(node, ["output"])?.output;
14
+ return typeof output === "string" ? output : null;
15
+ }
16
+ /** `outputs.save("name", locator)` or `outputs.save("name", locator, /pattern/)` */
17
+ function readSaveOutput(args) {
18
+ const name = stringOf(args[0]);
19
+ const locator = args[1] && readLocator(args[1]);
20
+ const pattern = readRegex(args[2]);
21
+ if (name === undefined ||
22
+ !locator ||
23
+ args.length > 3 ||
24
+ (args.length === 3 && !pattern))
25
+ return undefined;
26
+ return {
27
+ kind: "saveOutput",
28
+ name,
29
+ locator,
30
+ ...(pattern && { pattern }),
31
+ };
32
+ }
33
+ /** `expect(locator)…matcher(…)`, with a trailing `{ output }` only on the matchers that take one and never with `not` */
34
+ function readLocatorExpect(subject, negated, matcher, args) {
35
+ const locator = readLocator(subject);
36
+ if (!locator)
37
+ return undefined;
38
+ const last = args[args.length - 1];
39
+ const takesOutput = OUTPUT_MATCHERS.includes(matcher) &&
40
+ !!last &&
41
+ ts.isObjectLiteralExpression(last);
42
+ const output = takesOutput ? outputOf(last) : undefined;
43
+ if (output === null || (output !== undefined && negated))
44
+ return undefined;
45
+ const expectation = readExpectation(matcher, takesOutput ? args.slice(0, -1) : args);
46
+ return expectation
47
+ ? {
48
+ kind: "expect",
49
+ locator,
50
+ not: negated,
51
+ expectation,
52
+ ...(output !== undefined && { output }),
53
+ }
54
+ : undefined;
55
+ }
6
56
  /** The step a statement stands for, or undefined when it is outside the recorder's set */
7
57
  export function readStep(statement) {
8
58
  if (!ts.isExpressionStatement(statement) ||
@@ -58,12 +108,10 @@ export function readStep(statement) {
58
108
  ? { kind: "expectPage", not: negated, expectation }
59
109
  : undefined;
60
110
  }
61
- const locator = readLocator(arg);
62
- const expectation = readExpectation(name.text, args);
63
- return locator && expectation
64
- ? { kind: "expect", locator, not: negated, expectation }
65
- : undefined;
111
+ return readLocatorExpect(arg, negated, name.text, args);
66
112
  }
113
+ if (ts.isIdentifier(target) && target.text === "outputs")
114
+ return name.text === "save" ? readSaveOutput(args) : undefined;
67
115
  const locator = readLocator(target);
68
116
  if (!locator)
69
117
  return undefined;
@@ -73,9 +121,17 @@ export function readStep(statement) {
73
121
  const key = stringOf(args[0]);
74
122
  return key === undefined ? undefined : { kind: "press", locator, key };
75
123
  }
76
- if (name.text === "fill" && args.length === 1) {
124
+ if (name.text === "fill" && (args.length === 1 || args.length === 2)) {
77
125
  const value = readValue(args[0]);
78
- return value ? { kind: "fill", locator, value } : undefined;
126
+ const output = outputOf(args[1]);
127
+ return value && output !== null
128
+ ? {
129
+ kind: "fill",
130
+ locator,
131
+ value,
132
+ ...(output !== undefined && { output }),
133
+ }
134
+ : undefined;
79
135
  }
80
136
  if ((name.text === "setInputFiles" || name.text === "dropFiles") &&
81
137
  args.length === 1) {
@@ -1,6 +1,7 @@
1
1
  import ts from "typescript";
2
- import { readObject, stringOf } from "./parse/literals.js";
2
+ import { stringOf } from "./parse/literals.js";
3
3
  import { readStep } from "./parse/step.js";
4
+ import { readTestOptions } from "./test-options.js";
4
5
  function parseStatement(source, statement, sourceFile) {
5
6
  const start = statement.getStart(sourceFile);
6
7
  const end = statement.end;
@@ -58,21 +59,19 @@ function calleeOf(expression) {
58
59
  return undefined;
59
60
  }
60
61
  /**
61
- * The callback of `test(title, fn)` and `describe(title, fn)`, and for a skip or fixme also of
62
- * `(title, { reason }, fn)` with the reason when it is a literal
62
+ * The callback of `test(title, fn)` and `describe(title, fn)`, and of `(title, options, fn)` with what the
63
+ * options say and where they sit. Undefined when the options object isn't one the codec can read
63
64
  */
64
- function callbackOf(node, modifier) {
65
+ function argumentsOf(node, kind, modifier) {
65
66
  const [, second, third] = node.arguments;
66
- if ((modifier === "skip" || modifier === "fixme") &&
67
- second &&
68
- ts.isObjectLiteralExpression(second)) {
69
- const reason = readObject(second, ["reason"])?.reason;
70
- return {
71
- callback: third,
72
- ...(typeof reason === "string" && { reason }),
73
- };
74
- }
75
- return { callback: second };
67
+ if (node.arguments.length < 3)
68
+ return { callback: second };
69
+ const options = readTestOptions(second, kind, modifier);
70
+ return (options && {
71
+ callback: third,
72
+ options,
73
+ range: { start: second.getStart(), end: second.end },
74
+ });
76
75
  }
77
76
  /** What the runner applies to a test (see `modifierOf` in the runner): the innermost `skip` or `fixme` with its reason, else `only` */
78
77
  function effectiveMark(own, describes) {
@@ -128,12 +127,18 @@ export function parseSpecFile(source) {
128
127
  !(callee.name === "test" && isConditionForm(node, callee))) {
129
128
  const kind = callee.name;
130
129
  const title = stringOf(node.arguments[0]);
131
- const { callback, reason } = callbackOf(node, callee.modifier);
132
- const body = callbackBody(callback);
133
130
  if (title === undefined) {
134
131
  unparsed.push(unreadable(node, kind, "dynamic-title"));
135
132
  return;
136
133
  }
134
+ const args = argumentsOf(node, kind, callee.modifier);
135
+ if (!args) {
136
+ unparsed.push(unreadable(node, kind, "dynamic-options"));
137
+ return;
138
+ }
139
+ const { callback, options = {}, range } = args;
140
+ const { reason, inputs, outputs } = options;
141
+ const body = callbackBody(callback);
137
142
  if (!body) {
138
143
  unparsed.push(unreadable(node, kind, "not-inline"));
139
144
  return;
@@ -154,6 +159,10 @@ export function parseSpecFile(source) {
154
159
  removable: isRemovable(node),
155
160
  fixtures: callbackFixtures(callback, sourceFile),
156
161
  ...effectiveMark(mark, describes),
162
+ ...(inputs && { inputs }),
163
+ ...(outputs && { outputs }),
164
+ ...(range && { options: range }),
165
+ titleEnd: node.arguments[0].end,
157
166
  });
158
167
  return;
159
168
  }
@@ -0,0 +1,25 @@
1
+ import ts from "typescript";
2
+ import type { Modifier } from "../types/test.js";
3
+ import type { ParsedTest, TestInput } from "./model.js";
4
+ /** What a `test()` or `describe()` options object says, as far as the codec reads it */
5
+ export interface TestOptionsRead {
6
+ reason?: string;
7
+ inputs?: TestInput[];
8
+ outputs?: string[];
9
+ }
10
+ type ListKey = "inputs" | "outputs";
11
+ /**
12
+ * The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
13
+ * `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
14
+ * empty list is left out, as the runtime leaves it out. A `reason` counts only for skip and fixme, and one that
15
+ * isn't a string literal is passed over, as before
16
+ */
17
+ export declare function readTestOptions(node: ts.Expression, kind: "test" | "describe", modifier: Modifier | undefined): TestOptionsRead | undefined;
18
+ /**
19
+ * Sets one list of the test's options object: replaces it, adds it (making the object when there is none) or,
20
+ * for an empty list, removes it, and the whole object when nothing but empty lists would be left. The rest of
21
+ * the object, its layout and a `reason`, stays as it is; an unchanged list leaves the source as it is. Throws for
22
+ * names the runtime rejects, before writing
23
+ */
24
+ export declare function declareList(source: string, parsed: ParsedTest, key: ListKey, list: TestInput[] | string[]): string;
25
+ export {};