@itmetsam/samurai-framework 0.20.0 → 0.21.1

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 (139) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +1 -1
  3. package/dist/assert/assertion-error.d.ts +3 -1
  4. package/dist/assert/assertion-error.js +2 -2
  5. package/dist/assert/locator-assertions.d.ts +35 -4
  6. package/dist/assert/locator-assertions.js +56 -28
  7. package/dist/assert/page-assertions.js +1 -1
  8. package/dist/assert/pending-assertions.d.ts +3 -2
  9. package/dist/assert/pending-assertions.js +5 -4
  10. package/dist/assert/text-match.d.ts +6 -0
  11. package/dist/assert/text-match.js +12 -0
  12. package/dist/browser/browser.js +19 -3
  13. package/dist/browser/chrome-launch.d.ts +2 -0
  14. package/dist/browser/chrome-launch.js +5 -0
  15. package/dist/capabilities.d.ts +16 -5
  16. package/dist/capabilities.js +16 -5
  17. package/dist/cli/flow-reporter.d.ts +1 -1
  18. package/dist/cli/flow-reporter.js +2 -2
  19. package/dist/cli/flow.js +4 -4
  20. package/dist/config/mask.d.ts +6 -2
  21. package/dist/config/mask.js +4 -1
  22. package/dist/flows/core/check-io.d.ts +23 -0
  23. package/dist/flows/core/check-io.js +84 -0
  24. package/dist/flows/core/check.d.ts +16 -2
  25. package/dist/flows/core/check.js +20 -4
  26. package/dist/flows/core/expression/scope.d.ts +9 -2
  27. package/dist/flows/core/expression/scope.js +12 -4
  28. package/dist/flows/core/index.d.ts +2 -1
  29. package/dist/flows/core/index.js +2 -1
  30. package/dist/flows/core/nodes/condition.js +0 -1
  31. package/dist/flows/core/nodes/define.d.ts +0 -2
  32. package/dist/flows/core/nodes/group.js +0 -1
  33. package/dist/flows/core/nodes/test.js +0 -1
  34. package/dist/flows/core/schema.d.ts +10 -5
  35. package/dist/flows/core/schema.js +29 -1
  36. package/dist/flows/core/steps.d.ts +7 -0
  37. package/dist/flows/core/steps.js +15 -0
  38. package/dist/flows/core/wiring.d.ts +72 -0
  39. package/dist/flows/core/wiring.js +151 -0
  40. package/dist/flows/run/branch-context.d.ts +10 -4
  41. package/dist/flows/run/branch-context.js +6 -4
  42. package/dist/flows/run/events.d.ts +30 -2
  43. package/dist/flows/run/handlers/group.js +6 -2
  44. package/dist/flows/run/handlers/support.d.ts +7 -2
  45. package/dist/flows/run/handlers/support.js +36 -7
  46. package/dist/flows/run/handlers/test.js +6 -2
  47. package/dist/flows/run/handlers/types.d.ts +11 -3
  48. package/dist/flows/run/node-folder.d.ts +8 -3
  49. package/dist/flows/run/node-folder.js +28 -4
  50. package/dist/flows/run/node-inputs.d.ts +5 -0
  51. package/dist/flows/run/node-inputs.js +62 -0
  52. package/dist/flows/run/node-pool.d.ts +2 -2
  53. package/dist/flows/run/node-pool.js +6 -6
  54. package/dist/flows/run/node-runner.d.ts +24 -0
  55. package/dist/flows/run/node-runner.js +48 -0
  56. package/dist/flows/run/per-browser.d.ts +21 -0
  57. package/dist/flows/run/per-browser.js +46 -0
  58. package/dist/flows/run/project-facts.d.ts +13 -0
  59. package/dist/flows/run/project-facts.js +12 -0
  60. package/dist/flows/run/project-options.d.ts +9 -2
  61. package/dist/flows/run/project.d.ts +13 -8
  62. package/dist/flows/run/project.js +48 -51
  63. package/dist/flows/run/report.d.ts +7 -7
  64. package/dist/flows/run/report.js +3 -5
  65. package/dist/flows/run/run.d.ts +21 -7
  66. package/dist/flows/run/run.js +21 -11
  67. package/dist/flows/run/walker.d.ts +23 -7
  68. package/dist/flows/run/walker.js +18 -7
  69. package/dist/locator/locator.d.ts +3 -1
  70. package/dist/locator/locator.js +6 -0
  71. package/dist/recorder/apply.js +17 -7
  72. package/dist/recorder/normalise.d.ts +18 -3
  73. package/dist/recorder/normalise.js +18 -3
  74. package/dist/recorder/session-secrets.js +4 -0
  75. package/dist/recorder/toolbar/action-guard.d.ts +2 -5
  76. package/dist/recorder/toolbar/action-guard.js +9 -5
  77. package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
  78. package/dist/recorder/toolbar/actions/insert.js +43 -7
  79. package/dist/recorder/toolbar/actions/pick.d.ts +3 -0
  80. package/dist/recorder/toolbar/actions/pick.js +12 -0
  81. package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
  82. package/dist/recorder/toolbar/actions/reply.js +9 -0
  83. package/dist/recorder/toolbar/actions/save-output.d.ts +15 -0
  84. package/dist/recorder/toolbar/actions/save-output.js +73 -0
  85. package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
  86. package/dist/recorder/toolbar/controller-fixture.js +2 -0
  87. package/dist/recorder/toolbar/controller.d.ts +2 -1
  88. package/dist/recorder/toolbar/controller.js +16 -7
  89. package/dist/recorder/toolbar/insertables.d.ts +7 -0
  90. package/dist/recorder/toolbar/insertables.js +12 -0
  91. package/dist/recorder/toolbar/inspect.js +3 -1
  92. package/dist/recorder/toolbar/protocol.d.ts +43 -3
  93. package/dist/recorder/toolbar/source/events-page.js +2 -1
  94. package/dist/recorder/toolbar/source/fields.js +11 -0
  95. package/dist/recorder/toolbar/source/handlers.js +19 -12
  96. package/dist/recorder/toolbar/source/picking.js +22 -7
  97. package/dist/recorder/toolbar/source/recorder-side.js +14 -0
  98. package/dist/recorder/toolbar/source/setup.js +3 -3
  99. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  100. package/dist/recorder/toolbar/source/views-panels.js +62 -10
  101. package/dist/recorder/toolbar/source/views-rail.js +5 -2
  102. package/dist/recorder/toolbar/source.d.ts +6 -5
  103. package/dist/recorder/toolbar/source.js +6 -5
  104. package/dist/recorder/toolbar/types.d.ts +12 -5
  105. package/dist/runner/options.d.ts +5 -0
  106. package/dist/runner/pooled-run.d.ts +3 -2
  107. package/dist/runner/pooled-run.js +4 -2
  108. package/dist/runner/registry.d.ts +7 -3
  109. package/dist/runner/registry.js +29 -11
  110. package/dist/runner/reporter.d.ts +4 -0
  111. package/dist/runner/reporter.js +2 -0
  112. package/dist/runner/run-test.js +23 -2
  113. package/dist/runner/run.d.ts +7 -0
  114. package/dist/runner/run.js +8 -1
  115. package/dist/runner/test-io.d.ts +30 -0
  116. package/dist/runner/test-io.js +111 -0
  117. package/dist/runner/test-runner.js +1 -1
  118. package/dist/runner/worker-protocol.d.ts +2 -0
  119. package/dist/runner/worker-session.js +1 -0
  120. package/dist/runner/worker.d.ts +1 -0
  121. package/dist/runner/worker.js +4 -1
  122. package/dist/steps/edit.d.ts +14 -3
  123. package/dist/steps/edit.js +65 -23
  124. package/dist/steps/emit.d.ts +2 -0
  125. package/dist/steps/emit.js +32 -8
  126. package/dist/steps/index.d.ts +1 -1
  127. package/dist/steps/model.d.ts +42 -2
  128. package/dist/steps/parse/expectation.d.ts +8 -1
  129. package/dist/steps/parse/expectation.js +17 -11
  130. package/dist/steps/parse/step.js +64 -8
  131. package/dist/steps/parse.js +25 -16
  132. package/dist/steps/test-options.d.ts +25 -0
  133. package/dist/steps/test-options.js +158 -0
  134. package/dist/steps/text.d.ts +7 -0
  135. package/dist/steps/text.js +12 -0
  136. package/dist/types/test.d.ts +34 -0
  137. package/dist/wait/output-sink.d.ts +24 -0
  138. package/dist/wait/output-sink.js +59 -0
  139. package/package.json +44 -4
@@ -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 {};
@@ -0,0 +1,158 @@
1
+ import ts from "typescript";
2
+ import { isIoName } from "../flows/core/steps.js";
3
+ import { IDENTIFIER } from "./emit.js";
4
+ import { stringOf } from "./parse/literals.js";
5
+ import { indentOf, splice } from "./text.js";
6
+ function keyOf(name) {
7
+ return ts.isIdentifier(name) || ts.isStringLiteral(name)
8
+ ? name.text
9
+ : undefined;
10
+ }
11
+ /** `{ name: "default", … }`; undefined for anything but string literals under plain keys */
12
+ function readInputs(node) {
13
+ if (!ts.isObjectLiteralExpression(node))
14
+ return undefined;
15
+ const inputs = [];
16
+ for (const property of node.properties) {
17
+ if (!ts.isPropertyAssignment(property))
18
+ return undefined;
19
+ const name = keyOf(property.name);
20
+ const value = stringOf(property.initializer);
21
+ if (name === undefined || value === undefined)
22
+ return undefined;
23
+ inputs.push({ name, default: value });
24
+ }
25
+ return inputs;
26
+ }
27
+ /** `["name", …]`; undefined for anything but string literals */
28
+ function readOutputs(node) {
29
+ if (!ts.isArrayLiteralExpression(node))
30
+ return undefined;
31
+ const names = node.elements.map(stringOf);
32
+ return names.every((name) => name !== undefined)
33
+ ? names
34
+ : undefined;
35
+ }
36
+ /**
37
+ * The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
38
+ * `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
39
+ * empty list is left out, as the runtime leaves it out. A `reason` counts only for skip and fixme, and one that
40
+ * isn't a string literal is passed over, as before
41
+ */
42
+ export function readTestOptions(node, kind, modifier) {
43
+ if (!ts.isObjectLiteralExpression(node))
44
+ return undefined;
45
+ const read = {};
46
+ for (const property of node.properties) {
47
+ if (!ts.isPropertyAssignment(property))
48
+ return undefined;
49
+ const key = keyOf(property.name);
50
+ const value = property.initializer;
51
+ if (key === "reason") {
52
+ const reason = stringOf(value);
53
+ if (reason !== undefined && (modifier === "skip" || modifier === "fixme"))
54
+ read.reason = reason;
55
+ }
56
+ else if (kind === "test" && key === "inputs") {
57
+ const inputs = readInputs(value);
58
+ if (!inputs)
59
+ return undefined;
60
+ if (inputs.length > 0)
61
+ read.inputs = inputs;
62
+ }
63
+ else if (kind === "test" && key === "outputs") {
64
+ const outputs = readOutputs(value);
65
+ if (!outputs)
66
+ return undefined;
67
+ if (outputs.length > 0)
68
+ read.outputs = outputs;
69
+ }
70
+ else
71
+ return undefined;
72
+ }
73
+ return read;
74
+ }
75
+ const str = (value) => JSON.stringify(value);
76
+ function listToSource(key, list) {
77
+ if (key === "outputs")
78
+ return `[${list.map(str).join(", ")}]`;
79
+ const entries = list.map(({ name, default: value }) => `${IDENTIFIER.test(name) ? name : str(name)}: ${str(value)}`);
80
+ return `{ ${entries.join(", ")} }`;
81
+ }
82
+ const sameList = (a, b) => JSON.stringify(a) === JSON.stringify(b);
83
+ /** Throws for a list the runtime's `test()` would reject: a name that isn't valid, a name twice, a default that isn't text */
84
+ function checkList(key, list) {
85
+ const kind = key === "inputs" ? "input" : "output";
86
+ const seen = new Set();
87
+ for (const entry of list) {
88
+ const name = typeof entry === "string" ? entry : entry.name;
89
+ if (!isIoName(name))
90
+ throw new Error(`applyEdit(): ${kind} ${JSON.stringify(name)} is not a valid name`);
91
+ if (seen.has(name))
92
+ throw new Error(`applyEdit(): ${kind} "${name}" is listed twice`);
93
+ seen.add(name);
94
+ if (typeof entry !== "string" && typeof entry.default !== "string")
95
+ throw new Error(`applyEdit(): input "${name}" needs a string default`);
96
+ }
97
+ }
98
+ /** The options object at `range`, parsed on its own, with offsets mapped back into the source */
99
+ function optionsObject(source, range) {
100
+ const file = ts.createSourceFile("options.ts", `(${source.slice(range.start, range.end)})`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
101
+ const statement = file.statements[0];
102
+ const object = statement.expression
103
+ .expression;
104
+ const at = (offset) => offset - 1 + range.start;
105
+ return object.properties.map((property) => {
106
+ const assignment = property;
107
+ return {
108
+ key: keyOf(assignment.name),
109
+ start: at(assignment.getStart(file)),
110
+ end: at(assignment.end),
111
+ valueStart: at(assignment.initializer.getStart(file)),
112
+ emptyList: (ts.isObjectLiteralExpression(assignment.initializer) &&
113
+ assignment.initializer.properties.length === 0) ||
114
+ (ts.isArrayLiteralExpression(assignment.initializer) &&
115
+ assignment.initializer.elements.length === 0),
116
+ };
117
+ });
118
+ }
119
+ /**
120
+ * Sets one list of the test's options object: replaces it, adds it (making the object when there is none) or,
121
+ * for an empty list, removes it, and the whole object when nothing but empty lists would be left. The rest of
122
+ * the object, its layout and a `reason`, stays as it is; an unchanged list leaves the source as it is. Throws for
123
+ * names the runtime rejects, before writing
124
+ */
125
+ export function declareList(source, parsed, key, list) {
126
+ checkList(key, list);
127
+ if (list.length > 0 && sameList(parsed[key], list))
128
+ return source;
129
+ const text = `${key}: ${listToSource(key, list)}`;
130
+ if (!parsed.options)
131
+ return list.length === 0
132
+ ? source
133
+ : splice(source, parsed.titleEnd, parsed.titleEnd, `, { ${text} }`);
134
+ const properties = optionsObject(source, parsed.options);
135
+ const index = properties.findIndex((property) => property.key === key);
136
+ const property = properties[index];
137
+ if (list.length === 0) {
138
+ // An empty list that is there still goes (the parse leaves it out), so an object of only empty lists is removed
139
+ if (!property)
140
+ return source;
141
+ const others = properties.filter((_, i) => i !== index);
142
+ if (others.every((other) => other.emptyList))
143
+ return splice(source, parsed.titleEnd, parsed.options.end, "");
144
+ return index > 0
145
+ ? splice(source, properties[index - 1].end, property.end, "")
146
+ : splice(source, property.start, properties[1].start, "");
147
+ }
148
+ if (property)
149
+ return splice(source, property.valueStart, property.end, listToSource(key, list));
150
+ const last = properties[properties.length - 1];
151
+ if (!last)
152
+ return splice(source, parsed.options.start, parsed.options.end, `{ ${text} }`);
153
+ const multiline = source
154
+ .slice(parsed.options.start, properties[0].start)
155
+ .includes("\n");
156
+ const separator = multiline ? `\n${indentOf(source, last.start)}` : " ";
157
+ return splice(source, last.end, last.end, `,${separator}${text}`);
158
+ }
@@ -0,0 +1,7 @@
1
+ /** Offset of the first character of the line `offset` is on */
2
+ export declare const lineStart: (source: string, offset: number) => number;
3
+ /** Offset just after the newline that ends the line `offset` is on, or the end of the source */
4
+ export declare function lineEnd(source: string, offset: number): number;
5
+ /** The spaces and tabs that start the line `offset` is on */
6
+ export declare const indentOf: (source: string, offset: number) => string;
7
+ export declare function splice(source: string, from: number, to: number, text: string): string;
@@ -0,0 +1,12 @@
1
+ /** Offset of the first character of the line `offset` is on */
2
+ export const lineStart = (source, offset) => source.lastIndexOf("\n", offset - 1) + 1;
3
+ /** Offset just after the newline that ends the line `offset` is on, or the end of the source */
4
+ export function lineEnd(source, offset) {
5
+ const newline = source.indexOf("\n", offset);
6
+ return newline === -1 ? source.length : newline + 1;
7
+ }
8
+ /** The spaces and tabs that start the line `offset` is on */
9
+ export const indentOf = (source, offset) => /^[ \t]*/.exec(source.slice(lineStart(source, offset)))[0];
10
+ export function splice(source, from, to, text) {
11
+ return source.slice(0, from) + text + source.slice(to);
12
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Browser } from "../browser/browser.js";
2
2
  import type Page from "../browser/page.js";
3
+ import type Locator from "../locator/locator.js";
3
4
  import type { SupportedBrowser } from "./browser.js";
4
5
 
5
6
  /** What a test function receives */
@@ -14,6 +15,19 @@ export interface TestFixtures {
14
15
  env: Readonly<Record<string, string | number | boolean>>;
15
16
  /** Secrets by name (SAMURAI_SECRET_<NAME>); reading an unset name throws */
16
17
  secrets: Readonly<Record<string, string>>;
18
+ /** The inputs the test declared: the value the run supplies, else the default; reading another name throws */
19
+ inputs: Readonly<Record<string, string>>;
20
+ /** Saves the text values the test declared as outputs */
21
+ outputs: TestOutputs;
22
+ }
23
+
24
+ /** The `outputs` fixture */
25
+ export interface TestOutputs {
26
+ /**
27
+ * Waits up to the expect timeout for the element to have text and saves its trimmed text as output `name`.
28
+ * With `pattern`, waits until the text matches and saves the first capture group, else the whole match.
29
+ */
30
+ save(name: string, locator: Locator, pattern?: RegExp): Promise<void>;
17
31
  }
18
32
 
19
33
  export type TestCase = TestCaseBase;
@@ -27,6 +41,14 @@ export interface TestDetails {
27
41
  reason?: string;
28
42
  }
29
43
 
44
+ /** The optional second argument of `test`, `test.only`, `test.skip` and `test.fixme` */
45
+ export interface TestOptions extends TestDetails {
46
+ /** Named text values the test reads, each with the default it gets when nothing supplies one */
47
+ inputs?: Record<string, string>;
48
+ /** Names of the text values the test saves for later tests to use */
49
+ outputs?: string[];
50
+ }
51
+
30
52
  /** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
31
53
  export type EachHook = (fixtures: TestFixtures) => Promise<void> | void;
32
54
 
@@ -64,6 +86,10 @@ export interface RegisteredTestCase extends TestCaseBase {
64
86
  modifier?: Modifier;
65
87
  /** The reason given with the `skip` or `fixme` that applies */
66
88
  reason?: string;
89
+ /** The inputs the test declared, with their defaults */
90
+ inputs?: Record<string, string>;
91
+ /** The names of the outputs the test declared */
92
+ outputs?: string[];
67
93
  }
68
94
 
69
95
  export interface TestCaseBase {
@@ -88,6 +114,10 @@ interface TestResultSuccess extends TestResultIdentity, TestRetries {
88
114
  logsDropped?: number;
89
115
  /** Named screenshots the test saved, relative to the result folder */
90
116
  screenshots?: { name: string; path: string }[];
117
+ /** The inputs the test declared, with the values this attempt read; absent when it declares none */
118
+ inputs?: Record<string, string>;
119
+ /** The outputs the test saved; absent when it declares none */
120
+ outputs?: Record<string, string>;
91
121
  }
92
122
 
93
123
  /** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
@@ -98,6 +128,10 @@ interface TestResultFailed extends TestResultIdentity, TestError, TestRetries {
98
128
  logsDropped?: number;
99
129
  /** Named screenshots the test saved, relative to the result folder */
100
130
  screenshots?: { name: string; path: string }[];
131
+ /** The inputs the test declared, with the values this attempt read; absent when it declares none */
132
+ inputs?: Record<string, string>;
133
+ /** The outputs the test saved; absent when it declares none */
134
+ outputs?: Record<string, string>;
101
135
  }
102
136
 
103
137
  /** What a result says about the retries of its test; absent when it passed or failed on the first attempt */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Where one attempt of a test keeps the outputs it saves. Once closed it ignores what still arrives, so a
3
+ * capture that outlives its test (it timed out, or nobody awaited it) never lands in the next one.
4
+ */
5
+ export interface OutputSink {
6
+ /** Whether the test declared `name` */
7
+ declares(name: string): boolean;
8
+ /** Keeps `value` under `name`; throws for a name the test did not declare, ignores it once closed */
9
+ set(name: string, value: string): void;
10
+ /** What was saved so far */
11
+ values(): Record<string, string>;
12
+ }
13
+ /** Opens the sink of an attempt that declared `declared`, closing any one still open */
14
+ export declare function beginOutputs(declared: readonly string[]): OutputSink;
15
+ /** The open sink, or none outside a test. A capture takes it when it starts and saves into that one */
16
+ export declare function currentOutputSink(): OutputSink | undefined;
17
+ /** Closes the open sink and returns what it holds */
18
+ export declare function endOutputs(): Record<string, string>;
19
+ /**
20
+ * Takes the open sink for a capture that starts now (`method` names the call in the errors) and returns the
21
+ * function that saves `value` under `name` into it. The sink is bound here: a value that arrives after the test
22
+ * ended goes to its own closed sink and is ignored.
23
+ */
24
+ export declare function bindOutput(method: string, name: string): (value: string) => void;