@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
@@ -56,6 +56,10 @@ export interface TestEndExtras extends TestRetries {
56
56
  name: string;
57
57
  path: string;
58
58
  }[];
59
+ /** The test's inputs, when it declares any */
60
+ inputs?: Record<string, string>;
61
+ /** The test's saved outputs, when it declares any */
62
+ outputs?: Record<string, string>;
59
63
  }
60
64
  export default class TestReporter implements TestSink {
61
65
  private testResults;
@@ -146,6 +146,8 @@ export default class TestReporter {
146
146
  ...(extras?.logs && { logs: extras.logs }),
147
147
  ...(extras?.logsDropped && { logsDropped: extras.logsDropped }),
148
148
  ...(extras?.screenshots?.length && { screenshots: extras.screenshots }),
149
+ ...(extras?.inputs && { inputs: extras.inputs }),
150
+ ...(extras?.outputs && { outputs: extras.outputs }),
149
151
  ...(extras?.retry && { retry: extras.retry }),
150
152
  ...(extras?.flaky && { flaky: true }),
151
153
  ...(extras?.previousAttempts?.length && {
@@ -13,6 +13,8 @@ import { isSkipSignal, staticSkip } from "./skip.js";
13
13
  import { runOutput } from "./output.js";
14
14
  import { testDirOf } from "./test-dir.js";
15
15
  import { toTestError } from "./test-error.js";
16
+ import { createInputsFixture, createOutputsFixture, ioOfAttempt, missingOutputs, } from "./test-io.js";
17
+ import { beginOutputs, endOutputs } from "../wait/output-sink.js";
16
18
  /**
17
19
  * The state of one test run. A test ends once, whichever of finishing, failing, timing out, an abort or a
18
20
  * closed browser comes first: that one is reported and the rest are ignored.
@@ -28,6 +30,8 @@ class TestOutcome {
28
30
  this.closing = false;
29
31
  /** A person closed the browser, or it crashed, while the test ran */
30
32
  this.browserClosed = false;
33
+ /** The test's inputs and outputs, as its report entry lists them when it ends */
34
+ this.io = () => ({});
31
35
  }
32
36
  get isEnded() {
33
37
  return this.ended;
@@ -49,6 +53,7 @@ class TestOutcome {
49
53
  this.reporter.onTestEnd(this.test, this.browser, error, {
50
54
  ...logs,
51
55
  screenshots: this.evidence?.screenshots,
56
+ ...this.io(),
52
57
  });
53
58
  }
54
59
  }
@@ -137,6 +142,8 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
137
142
  workerIndex: resources.workerIndex ?? 0,
138
143
  env: createEnvFixture(settings.environment, settings.variables),
139
144
  secrets: createSecretsFixture(settings.environment, secrets),
145
+ inputs: createInputsFixture(test.inputs, options.inputs),
146
+ outputs: createOutputsFixture(sink),
140
147
  };
141
148
  await runWithEachHooks(test.suite, fixtures, timeoutController.signal, () => test.function(fixtures));
142
149
  // Already reported as timed out
@@ -146,12 +153,18 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
146
153
  if (unawaited.length > 0) {
147
154
  outcome.finish({
148
155
  message: unawaited
149
- .map((matcher) => `expect(locator).${matcher}() was not awaited`)
156
+ .map((label) => `${label} was not awaited`)
150
157
  .join("\n"),
151
158
  type: "assertion",
152
159
  }, await judgeLogs(page, true));
153
160
  return;
154
161
  }
162
+ // Checked only after a pass: a test that failed first reports its own failure
163
+ const missing = missingOutputs(test.outputs, sink);
164
+ if (missing) {
165
+ outcome.finish(missing, await judgeLogs(page, true));
166
+ return;
167
+ }
155
168
  const verdict = await judgeLogs(page, false);
156
169
  outcome.finish(verdict.error, verdict);
157
170
  }
@@ -168,7 +181,15 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
168
181
  : toTestError(err), await judgeLogs(page, true));
169
182
  }
170
183
  };
171
- await Promise.race([limit, gone, runBody()]);
184
+ // Open for this attempt only: whatever a capture still saves after the attempt ended is ignored
185
+ const sink = beginOutputs(test.outputs ?? []);
186
+ outcome.io = () => ioOfAttempt(test, options.inputs, sink);
187
+ try {
188
+ await Promise.race([limit, gone, runBody()]);
189
+ }
190
+ finally {
191
+ endOutputs();
192
+ }
172
193
  clearTimeout(timeoutTimer);
173
194
  if (onRunAborted)
174
195
  options.signal?.removeEventListener("abort", onRunAborted);
@@ -30,6 +30,13 @@ export interface FoundTest {
30
30
  modifier?: Modifier;
31
31
  /** The `{ reason }` given with the `skip` or `fixme` that applies */
32
32
  reason?: string;
33
+ /** The inputs the test declares, in declaration order, with their defaults; absent when it declares none */
34
+ inputs?: {
35
+ name: string;
36
+ default: string;
37
+ }[];
38
+ /** The names of the outputs the test declares; absent when it declares none */
39
+ outputs?: string[];
33
40
  }
34
41
  /**
35
42
  * Imports the project's specs without running anything and lists the tests the options select, each with the id
@@ -43,7 +43,7 @@ export async function listTests(options = {}) {
43
43
  return inProject(options, async (runner) => {
44
44
  const { tests, srcDir } = await runner.list();
45
45
  return tests.map((test) => {
46
- const { name, file, modifier, reason } = test;
46
+ const { name, file, modifier, reason, inputs, outputs } = test;
47
47
  const relativeFile = toPosix(path.relative(srcDir, file));
48
48
  return {
49
49
  id: testId(relativeFile, name),
@@ -52,6 +52,13 @@ export async function listTests(options = {}) {
52
52
  relativeFile,
53
53
  ...(modifier && { modifier }),
54
54
  ...(reason !== undefined && { reason }),
55
+ ...(inputs && {
56
+ inputs: Object.entries(inputs).map(([name, value]) => ({
57
+ name,
58
+ default: value,
59
+ })),
60
+ }),
61
+ ...(outputs && { outputs }),
55
62
  };
56
63
  });
57
64
  });
@@ -0,0 +1,30 @@
1
+ import type { TestError, TestOptions, TestOutputs } from "../types/test.js";
2
+ import type { OutputSink } from "../wait/output-sink.js";
3
+ /**
4
+ * Checks the `inputs` and `outputs` of a `test()` options object and returns them, leaving out an empty or absent
5
+ * list. `call` is the form used (`test`, `test.only`, ...), named in the error.
6
+ */
7
+ export declare function validateTestIO(call: string, options: TestOptions): {
8
+ inputs?: Record<string, string>;
9
+ outputs?: string[];
10
+ };
11
+ /**
12
+ * The `inputs` fixture: each declared input with the value the run supplies, else its default. Reading a
13
+ * name the test did not declare throws.
14
+ */
15
+ export declare function createInputsFixture(declared: Record<string, string> | undefined, supplied: Record<string, string> | undefined): Readonly<Record<string, string>>;
16
+ /**
17
+ * The `outputs` fixture of one attempt: `save` waits for the element's text (non-empty, or matching the pattern), as
18
+ * `toHaveText` reads it, and keeps it in `sink`. It keeps to its own attempt's sink, so a late value never lands in a later test.
19
+ */
20
+ export declare function createOutputsFixture(sink: OutputSink): TestOutputs;
21
+ /** The failure of a passing attempt that did not save every output it declared */
22
+ export declare function missingOutputs(declared: readonly string[] | undefined, sink: OutputSink): TestError | undefined;
23
+ /** What an attempt's result lists: its inputs when it declares any, its outputs so far when it declares any */
24
+ export declare function ioOfAttempt(test: {
25
+ inputs?: Record<string, string>;
26
+ outputs?: string[];
27
+ }, supplied: Record<string, string> | undefined, sink: OutputSink): {
28
+ inputs?: Record<string, string>;
29
+ outputs?: Record<string, string>;
30
+ };
@@ -0,0 +1,111 @@
1
+ import { captured, textCheck } from "../assert/text-match.js";
2
+ import { readEventually } from "../assert/locator-assertions.js";
3
+ import { track } from "../assert/pending-assertions.js";
4
+ import { isPassthroughKey } from "../config/variables.js";
5
+ import { isIoName } from "../flows/core/steps.js";
6
+ function checkName(call, kind, name) {
7
+ if (typeof name !== "string" || !isIoName(name))
8
+ throw new Error(`${call}(): ${kind} ${quoted(name)} is not a valid name`);
9
+ }
10
+ const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
11
+ /** A name in a message: strings quoted, anything else as written */
12
+ function quoted(name) {
13
+ return typeof name === "string" ? JSON.stringify(name) : String(name);
14
+ }
15
+ /**
16
+ * Checks the `inputs` and `outputs` of a `test()` options object and returns them, leaving out an empty or absent
17
+ * list. `call` is the form used (`test`, `test.only`, ...), named in the error.
18
+ */
19
+ export function validateTestIO(call, options) {
20
+ const { inputs, outputs } = options;
21
+ const result = {};
22
+ if (inputs !== undefined) {
23
+ if (typeof inputs !== "object" || inputs === null || Array.isArray(inputs))
24
+ throw new Error(`${call}(): inputs must be an object of names and defaults`);
25
+ for (const [name, value] of Object.entries(inputs)) {
26
+ checkName(call, "input", name);
27
+ if (typeof value !== "string")
28
+ throw new Error(`${call}(): input "${name}" needs a string default`);
29
+ }
30
+ if (Object.keys(inputs).length > 0)
31
+ result.inputs = { ...inputs };
32
+ }
33
+ if (outputs !== undefined) {
34
+ if (!Array.isArray(outputs))
35
+ throw new Error(`${call}(): outputs must be an array of names`);
36
+ const seen = new Set();
37
+ for (const name of outputs) {
38
+ checkName(call, "output", name);
39
+ if (seen.has(name))
40
+ throw new Error(`${call}(): output "${name}" is listed twice`);
41
+ seen.add(name);
42
+ }
43
+ if (outputs.length > 0)
44
+ result.outputs = [...outputs];
45
+ }
46
+ return result;
47
+ }
48
+ /**
49
+ * The `inputs` fixture: each declared input with the value the run supplies, else its default. Reading a
50
+ * name the test did not declare throws.
51
+ */
52
+ export function createInputsFixture(declared, supplied) {
53
+ return new Proxy(Object.freeze(resolveInputs(declared, supplied)), {
54
+ get(target, key, receiver) {
55
+ if (isPassthroughKey(target, key) || has(target, key))
56
+ return Reflect.get(target, key, receiver);
57
+ throw new Error(`inputs.${String(key)}: not declared, add it to the test's inputs`);
58
+ },
59
+ });
60
+ }
61
+ /** Each declared input with the supplied value, else its default; names the test does not declare are left out */
62
+ function resolveInputs(declared, supplied) {
63
+ return Object.fromEntries(Object.entries(declared ?? {}).map(([name, value]) => [
64
+ name,
65
+ supplied && has(supplied, name) ? supplied[name] : value,
66
+ ]));
67
+ }
68
+ /**
69
+ * The `outputs` fixture of one attempt: `save` waits for the element's text (non-empty, or matching the pattern), as
70
+ * `toHaveText` reads it, and keeps it in `sink`. It keeps to its own attempt's sink, so a late value never lands in a later test.
71
+ */
72
+ export function createOutputsFixture(sink) {
73
+ return {
74
+ save: (name, locator, pattern) => track("outputs.save()", saveText(sink, name, locator, pattern)),
75
+ };
76
+ }
77
+ async function saveText(sink, name, locator, pattern) {
78
+ if (!sink.declares(name))
79
+ throw new Error(`outputs.save(): output ${JSON.stringify(name)} is not declared`);
80
+ const matches = pattern ? textCheck(pattern, "exact") : undefined;
81
+ const text = await readEventually({
82
+ name: "toHaveText",
83
+ locator,
84
+ expected: pattern ?? "an element with text",
85
+ read: () => locator.textContent(),
86
+ // Without a pattern, an element that has not rendered its value yet (no text) is waited for
87
+ check: (actual) => actual !== null && (matches ? matches(actual) : actual !== ""),
88
+ call: `outputs.save(${JSON.stringify(name)})`,
89
+ });
90
+ sink.set(name, pattern ? captured(pattern, text) : text);
91
+ }
92
+ /** The failure of a passing attempt that did not save every output it declared */
93
+ export function missingOutputs(declared, sink) {
94
+ const saved = sink.values();
95
+ const missing = (declared ?? []).filter((name) => !has(saved, name));
96
+ if (missing.length === 0)
97
+ return undefined;
98
+ return {
99
+ message: missing
100
+ .map((name) => `output ${JSON.stringify(name)} was never saved`)
101
+ .join("\n"),
102
+ type: "assertion",
103
+ };
104
+ }
105
+ /** What an attempt's result lists: its inputs when it declares any, its outputs so far when it declares any */
106
+ export function ioOfAttempt(test, supplied, sink) {
107
+ return {
108
+ ...(test.inputs && { inputs: resolveInputs(test.inputs, supplied) }),
109
+ ...(test.outputs && { outputs: sink.values() }),
110
+ };
111
+ }
@@ -88,7 +88,7 @@ export default class TestRunner {
88
88
  env: process.env.SAMURAI_RETRIES,
89
89
  config: config.retries,
90
90
  });
91
- const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir));
91
+ const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir), this.options.inputs);
92
92
  // A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
93
93
  const lanes = concurrency(plan.items);
94
94
  const mode = poolMode({
@@ -13,6 +13,8 @@ export interface WorkItem {
13
13
  serialKey?: string;
14
14
  /** Where this test's screenshots go, instead of the run's `outputDir` (a flow gives each node its own) */
15
15
  outputDir?: string;
16
+ /** The values the run supplies for the inputs tests declare (`runTests({ inputs })`) */
17
+ inputs?: Record<string, string>;
16
18
  }
17
19
  /** What a worker needs to start: the project and the settings that are the same for every test */
18
20
  export interface WorkerInit {
@@ -51,6 +51,7 @@ export function openSession(init) {
51
51
  ...((perTest.outputDir ?? init.outputDir) !== undefined && {
52
52
  outputDir: perTest.outputDir ?? init.outputDir,
53
53
  }),
54
+ ...(perTest.inputs !== undefined && { inputs: perTest.inputs }),
54
55
  port,
55
56
  signal,
56
57
  };
@@ -8,6 +8,7 @@ export interface WorkerSession {
8
8
  /** Runs one test; `onEvent` gets the reporter's events for it. Aborting `signal` ends the test as aborted */
9
9
  runTest(test: RegisteredTestCase, browser: SupportedBrowser, signal: AbortSignal, onEvent: (event: RunEvent) => void, options?: {
10
10
  outputDir?: string;
11
+ inputs?: Record<string, string>;
11
12
  }): Promise<void>;
12
13
  /** Ends the project's session */
13
14
  close(): Promise<void>;
@@ -54,7 +54,10 @@ export function handleMessages(channel, { open, exit }) {
54
54
  id,
55
55
  result: { ...event.result, worker: workerIndex },
56
56
  });
57
- }, item.outputDir !== undefined ? { outputDir: item.outputDir } : {});
57
+ }, {
58
+ ...(item.outputDir !== undefined && { outputDir: item.outputDir }),
59
+ ...(item.inputs !== undefined && { inputs: item.inputs }),
60
+ });
58
61
  }
59
62
  finally {
60
63
  current = undefined;
@@ -1,4 +1,4 @@
1
- import type { Step } from "./model.js";
1
+ import type { Step, TestInput } from "./model.js";
2
2
  export type StepEdit = {
3
3
  op: "replace";
4
4
  test: number;
@@ -32,10 +32,21 @@ export type StepEdit = {
32
32
  | {
33
33
  op: "remove-test";
34
34
  test: number;
35
+ }
36
+ /**
37
+ * Sets the test's `inputs` and `outputs` in its options object: a given list replaces that list, one left out
38
+ * stays. When both end up empty the options object goes, unless it holds a `reason`
39
+ */
40
+ | {
41
+ op: "declare";
42
+ test: number;
43
+ inputs?: TestInput[];
44
+ outputs?: string[];
35
45
  };
36
46
  /**
37
47
  * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
38
- * when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
39
- * comments included, stays byte for byte.
48
+ * when the step reads a fixture (`env`, `secrets`, `inputs`, `outputs`) they don't have yet, and its `outputs`
49
+ * when the step saves one not declared yet: everything else, formatting and comments included, stays byte for
50
+ * byte. A step that reads an undeclared input is refused: `declare` it, with its default, first.
40
51
  */
41
52
  export declare function applyEdit(source: string, edit: StepEdit): string;
@@ -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";