@itmetsam/samurai-framework 0.20.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 (132) 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 -5
  12. package/dist/capabilities.js +16 -5
  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/node-folder.d.ts +8 -3
  43. package/dist/flows/run/node-folder.js +28 -4
  44. package/dist/flows/run/node-inputs.d.ts +5 -0
  45. package/dist/flows/run/node-inputs.js +62 -0
  46. package/dist/flows/run/node-pool.d.ts +2 -2
  47. package/dist/flows/run/node-pool.js +6 -6
  48. package/dist/flows/run/node-runner.d.ts +24 -0
  49. package/dist/flows/run/node-runner.js +48 -0
  50. package/dist/flows/run/per-browser.d.ts +21 -0
  51. package/dist/flows/run/per-browser.js +46 -0
  52. package/dist/flows/run/project-facts.d.ts +13 -0
  53. package/dist/flows/run/project-facts.js +12 -0
  54. package/dist/flows/run/project-options.d.ts +9 -2
  55. package/dist/flows/run/project.d.ts +13 -8
  56. package/dist/flows/run/project.js +48 -51
  57. package/dist/flows/run/report.d.ts +7 -7
  58. package/dist/flows/run/report.js +3 -5
  59. package/dist/flows/run/run.d.ts +21 -7
  60. package/dist/flows/run/run.js +21 -11
  61. package/dist/flows/run/walker.d.ts +23 -7
  62. package/dist/flows/run/walker.js +18 -7
  63. package/dist/locator/locator.d.ts +3 -1
  64. package/dist/locator/locator.js +6 -0
  65. package/dist/recorder/apply.js +17 -7
  66. package/dist/recorder/normalise.d.ts +18 -3
  67. package/dist/recorder/normalise.js +18 -3
  68. package/dist/recorder/session-secrets.js +4 -0
  69. package/dist/recorder/toolbar/action-guard.d.ts +2 -5
  70. package/dist/recorder/toolbar/action-guard.js +9 -5
  71. package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
  72. package/dist/recorder/toolbar/actions/insert.js +43 -7
  73. package/dist/recorder/toolbar/actions/pick.js +2 -0
  74. package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
  75. package/dist/recorder/toolbar/actions/reply.js +9 -0
  76. package/dist/recorder/toolbar/actions/save-output.d.ts +9 -0
  77. package/dist/recorder/toolbar/actions/save-output.js +46 -0
  78. package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
  79. package/dist/recorder/toolbar/controller-fixture.js +2 -0
  80. package/dist/recorder/toolbar/controller.d.ts +2 -1
  81. package/dist/recorder/toolbar/controller.js +14 -7
  82. package/dist/recorder/toolbar/insertables.d.ts +7 -0
  83. package/dist/recorder/toolbar/insertables.js +12 -0
  84. package/dist/recorder/toolbar/inspect.js +3 -1
  85. package/dist/recorder/toolbar/protocol.d.ts +20 -3
  86. package/dist/recorder/toolbar/source/events-page.js +2 -1
  87. package/dist/recorder/toolbar/source/fields.js +8 -0
  88. package/dist/recorder/toolbar/source/handlers.js +19 -12
  89. package/dist/recorder/toolbar/source/picking.js +22 -7
  90. package/dist/recorder/toolbar/source/recorder-side.js +6 -0
  91. package/dist/recorder/toolbar/source/setup.js +3 -3
  92. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  93. package/dist/recorder/toolbar/source/views-panels.js +50 -10
  94. package/dist/recorder/toolbar/source/views-rail.js +5 -2
  95. package/dist/recorder/toolbar/source.d.ts +5 -5
  96. package/dist/recorder/toolbar/source.js +5 -5
  97. package/dist/recorder/toolbar/types.d.ts +10 -3
  98. package/dist/runner/options.d.ts +5 -0
  99. package/dist/runner/pooled-run.d.ts +3 -2
  100. package/dist/runner/pooled-run.js +4 -2
  101. package/dist/runner/registry.d.ts +7 -3
  102. package/dist/runner/registry.js +29 -11
  103. package/dist/runner/reporter.d.ts +4 -0
  104. package/dist/runner/reporter.js +2 -0
  105. package/dist/runner/run-test.js +23 -2
  106. package/dist/runner/run.d.ts +7 -0
  107. package/dist/runner/run.js +8 -1
  108. package/dist/runner/test-io.d.ts +30 -0
  109. package/dist/runner/test-io.js +111 -0
  110. package/dist/runner/test-runner.js +1 -1
  111. package/dist/runner/worker-protocol.d.ts +2 -0
  112. package/dist/runner/worker-session.js +1 -0
  113. package/dist/runner/worker.d.ts +1 -0
  114. package/dist/runner/worker.js +4 -1
  115. package/dist/steps/edit.d.ts +14 -3
  116. package/dist/steps/edit.js +65 -23
  117. package/dist/steps/emit.d.ts +2 -0
  118. package/dist/steps/emit.js +32 -8
  119. package/dist/steps/index.d.ts +1 -1
  120. package/dist/steps/model.d.ts +42 -2
  121. package/dist/steps/parse/expectation.d.ts +8 -1
  122. package/dist/steps/parse/expectation.js +17 -11
  123. package/dist/steps/parse/step.js +64 -8
  124. package/dist/steps/parse.js +25 -16
  125. package/dist/steps/test-options.d.ts +25 -0
  126. package/dist/steps/test-options.js +158 -0
  127. package/dist/steps/text.d.ts +7 -0
  128. package/dist/steps/text.js +12 -0
  129. package/dist/types/test.d.ts +34 -0
  130. package/dist/wait/output-sink.d.ts +24 -0
  131. package/dist/wait/output-sink.js +59 -0
  132. package/package.json +1 -1
package/dist/api.d.ts CHANGED
@@ -2,5 +2,5 @@ export { test, describe, beforeEach, afterEach, beforeAll, afterAll, } from "./r
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
4
  export { defineStep, type StepDefinition, type StepResult, type StepRunInput, } from "./flows/steps/index.js";
5
- export type { AllHookFixtures, TestDetails, TestFixtures, } from "./types/test.js";
5
+ export type { AllHookFixtures, TestDetails, TestFixtures, TestOptions, TestOutputs, } from "./types/test.js";
6
6
  export { generatedFile, type GeneratedFile, type FileInput, } from "./browser/generated-files.js";
@@ -13,11 +13,13 @@ export interface AssertionErrorDetails {
13
13
  actual: unknown;
14
14
  /** Description of the asserted locator, only for locator assertions */
15
15
  locator?: string | undefined;
16
+ /** The first line of the message, instead of `expect(<subject>).<matcher>` (for `outputs.save`) */
17
+ call?: string | undefined;
16
18
  }
17
19
  export declare class AssertionError extends Error {
18
20
  readonly matcher: string;
19
21
  readonly expected: unknown;
20
22
  readonly actual: unknown;
21
23
  readonly locator: string | undefined;
22
- constructor({ matcher, expected, actual, locator }: AssertionErrorDetails);
24
+ constructor({ matcher, expected, actual, locator, call, }: AssertionErrorDetails);
23
25
  }
@@ -28,9 +28,9 @@ export function toReportValue(value) {
28
28
  }
29
29
  }
30
30
  export class AssertionError extends Error {
31
- constructor({ matcher, expected, actual, locator }) {
31
+ constructor({ matcher, expected, actual, locator, call, }) {
32
32
  const subject = locator === undefined ? "value" : "locator";
33
- const lines = [`expect(${subject}).${matcher}`];
33
+ const lines = [call ?? `expect(${subject}).${matcher}`];
34
34
  if (locator !== undefined) {
35
35
  lines.push(` locator: ${locator}`);
36
36
  }
@@ -3,6 +3,32 @@ export interface LocatorAssertionOptions {
3
3
  /** Time (ms) to keep retrying. Defaults to config `expect.timeout`, then 5000 */
4
4
  timeout?: number;
5
5
  }
6
+ /** The options of the checks that can keep the text they found */
7
+ export interface TextAssertionOptions extends LocatorAssertionOptions {
8
+ /** Saves the text that passed (a RegExp's first group, else its match) as this output of the running test */
9
+ output?: string;
10
+ }
11
+ /** What `readEventually` reads, what it waits for and how its failure is named */
12
+ export interface EventualRead<V> {
13
+ /** The matcher, with `not.` in front when negated */
14
+ name: string;
15
+ locator: Locator;
16
+ expected: unknown;
17
+ read: () => Promise<V>;
18
+ /** True for the read that ends the wait */
19
+ check: (actual: V) => boolean;
20
+ /** Time (ms) to keep retrying. Defaults to the expect timeout */
21
+ timeout?: number | undefined;
22
+ /** A read of `null` (no element) never passes, and fails as "could not read" */
23
+ mustExist?: boolean;
24
+ /** The first line of a failure, instead of `expect(locator).<name>` */
25
+ call?: string;
26
+ }
27
+ /**
28
+ * Re-reads the element until `check` passes or the timeout expires, and resolves with the read that passed. A
29
+ * timeout fails as an `AssertionError` with the last read, or as "could not read" when no read completed.
30
+ */
31
+ export declare function readEventually<V>({ name, locator, expected, read, check, timeout: perCall, mustExist, call, }: EventualRead<V>): Promise<V>;
6
32
  export declare class LocatorAssertions {
7
33
  private readonly locator;
8
34
  private readonly negated;
@@ -14,15 +40,20 @@ export declare class LocatorAssertions {
14
40
  * (no element) never passes, with or without `.not`, and fails as "could not read"
15
41
  */
16
42
  private assertEventually;
43
+ /**
44
+ * A text check that can keep what it read: with `options.output` the read that passed goes to that output, as
45
+ * `expected` captures it. The sink is taken now, when the call starts.
46
+ */
47
+ private assertText;
17
48
  /** For matchers that need an element: `read` answers null when there is none, which never passes */
18
49
  private assertExisting;
19
50
  toBeVisible(options?: LocatorAssertionOptions): Promise<void>;
20
51
  /** Trimmed text content equals `expected` (string) or matches it (RegExp) */
21
- toHaveText(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
52
+ toHaveText(expected: string | RegExp, options?: TextAssertionOptions): Promise<void>;
22
53
  /** Text content contains `expected` (string) or matches it (RegExp) */
23
- toContainText(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
24
- toHaveValue(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
25
- toHaveAttribute(name: string, expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
54
+ toContainText(expected: string | RegExp, options?: TextAssertionOptions): Promise<void>;
55
+ toHaveValue(expected: string | RegExp, options?: TextAssertionOptions): Promise<void>;
56
+ toHaveAttribute(name: string, expected: string | RegExp, options?: TextAssertionOptions): Promise<void>;
26
57
  toHaveCount(expected: number, options?: LocatorAssertionOptions): Promise<void>;
27
58
  /** The element is missing, or has no box or is hidden by CSS (the opposite of `toBeVisible`) */
28
59
  toBeHidden(options?: LocatorAssertionOptions): Promise<void>;
@@ -2,7 +2,33 @@ import { resolveTimeout } from "../config/timeouts.js";
2
2
  import { waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
3
3
  import { AssertionError } from "./assertion-error.js";
4
4
  import { track } from "./pending-assertions.js";
5
- import { classCheck, matchesText, textCheck } from "./text-match.js";
5
+ import { bindOutput } from "../wait/output-sink.js";
6
+ import { captured, classCheck, matchesText, textCheck } from "./text-match.js";
7
+ /**
8
+ * Re-reads the element until `check` passes or the timeout expires, and resolves with the read that passed. A
9
+ * timeout fails as an `AssertionError` with the last read, or as "could not read" when no read completed.
10
+ */
11
+ export async function readEventually({ name, locator, expected, read, check, timeout: perCall, mustExist = false, call, }) {
12
+ const timeout = await resolveTimeout(perCall);
13
+ try {
14
+ return await waitUntil(read, (actual) => (mustExist && actual === null ? false : check(actual)), { timeout });
15
+ }
16
+ catch (err) {
17
+ if (!(err instanceof WaitTimeoutError))
18
+ throw err;
19
+ // Reads never resolve to undefined, so an undefined `last` means no read completed
20
+ if (err.last === undefined || (mustExist && err.last === null)) {
21
+ throw new Error(`${call ?? `expect(locator).${name}`}: could not read ${locator.selector} within ${timeout}ms`);
22
+ }
23
+ throw new AssertionError({
24
+ matcher: name,
25
+ expected,
26
+ actual: err.last,
27
+ locator: locator.selector,
28
+ call,
29
+ });
30
+ }
31
+ }
6
32
  export class LocatorAssertions {
7
33
  constructor(locator, negated = false) {
8
34
  this.locator = locator;
@@ -16,30 +42,32 @@ export class LocatorAssertions {
16
42
  * Re-reads the element until `check` passes or the timeout expires. With `mustExist`, a read of `null`
17
43
  * (no element) never passes, with or without `.not`, and fails as "could not read"
18
44
  */
19
- assertEventually(matcher, expected, read, check, options, mustExist = false) {
45
+ assertEventually(matcher, expected, read, check, options, mustExist = false, save) {
20
46
  const name = this.negated ? `not.${matcher}` : matcher;
21
- return track(name, (async () => {
22
- const timeout = await resolveTimeout(options?.timeout);
23
- try {
24
- await waitUntil(read, (actual) => mustExist && actual === null
25
- ? false
26
- : check(actual) !== this.negated, { timeout });
27
- }
28
- catch (err) {
29
- if (!(err instanceof WaitTimeoutError))
30
- throw err;
31
- // Reads never resolve to undefined, so an undefined `last` means no read completed
32
- if (err.last === undefined || (mustExist && err.last === null)) {
33
- throw new Error(`expect(locator).${name}: could not read ${this.locator.selector} within ${timeout}ms`);
34
- }
35
- throw new AssertionError({
36
- matcher: name,
37
- expected,
38
- actual: err.last,
39
- locator: this.locator.selector,
40
- });
41
- }
42
- })());
47
+ return track(`expect(locator).${name}()`, readEventually({
48
+ name,
49
+ locator: this.locator,
50
+ expected,
51
+ read,
52
+ check: (actual) => check(actual) !== this.negated,
53
+ timeout: options?.timeout,
54
+ mustExist,
55
+ }).then((actual) => save?.(actual)));
56
+ }
57
+ /**
58
+ * A text check that can keep what it read: with `options.output` the read that passed goes to that output, as
59
+ * `expected` captures it. The sink is taken now, when the call starts.
60
+ */
61
+ assertText(matcher, expected, read, mode, options) {
62
+ let save;
63
+ if (options?.output !== undefined) {
64
+ const call = `expect(locator).${this.negated ? "not." : ""}${matcher}`;
65
+ if (this.negated)
66
+ throw new Error(`${call}: output can't be saved from a negated check`);
67
+ const keep = bindOutput(call, options.output);
68
+ save = (actual) => keep(captured(expected, actual));
69
+ }
70
+ return this.assertEventually(matcher, expected, read, textCheck(expected, mode), options, false, save);
43
71
  }
44
72
  /** For matchers that need an element: `read` answers null when there is none, which never passes */
45
73
  assertExisting(matcher, expected, read, check, options) {
@@ -50,17 +78,17 @@ export class LocatorAssertions {
50
78
  }
51
79
  /** Trimmed text content equals `expected` (string) or matches it (RegExp) */
52
80
  toHaveText(expected, options) {
53
- return this.assertEventually("toHaveText", expected, () => this.locator.textContent(), textCheck(expected, "exact"), options);
81
+ return this.assertText("toHaveText", expected, () => this.locator.textContent(), "exact", options);
54
82
  }
55
83
  /** Text content contains `expected` (string) or matches it (RegExp) */
56
84
  toContainText(expected, options) {
57
- return this.assertEventually("toContainText", expected, () => this.locator.textContent(), textCheck(expected, "contains"), options);
85
+ return this.assertText("toContainText", expected, () => this.locator.textContent(), "contains", options);
58
86
  }
59
87
  toHaveValue(expected, options) {
60
- return this.assertEventually("toHaveValue", expected, () => this.locator.inputValue(), textCheck(expected, "exact"), options);
88
+ return this.assertText("toHaveValue", expected, () => this.locator.inputValue(), "exact", options);
61
89
  }
62
90
  toHaveAttribute(name, expected, options) {
63
- return this.assertEventually("toHaveAttribute", expected, () => this.locator.getAttribute(name), textCheck(expected, "exact"), options);
91
+ return this.assertText("toHaveAttribute", expected, () => this.locator.getAttribute(name), "exact", options);
64
92
  }
65
93
  toHaveCount(expected, options) {
66
94
  return this.assertEventually("toHaveCount", expected, () => this.locator.count(), (count) => count === expected, options);
@@ -19,7 +19,7 @@ export class PageAssertions {
19
19
  */
20
20
  assertEventually(matcher, what, expectedOf, read, check, options) {
21
21
  const name = this.negated ? `not.${matcher}` : matcher;
22
- return track(name, (async () => {
22
+ return track(`expect(page).${name}()`, (async () => {
23
23
  // Resolved inside the tracked body: a throw (no base URL) rejects the tracked promise
24
24
  const expected = expectedOf();
25
25
  const timeout = await resolveTimeout(options?.timeout);
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Returns the matcher names of locator assertions that were never awaited and are
2
+ * Returns the labels of locator assertions that were never awaited and are
3
3
  * still running or already failed, and stops tracking them. The runner calls this
4
4
  * after a test to detect a missing `await`.
5
5
  */
6
6
  export declare function takePendingAssertions(): string[];
7
- export declare function track(matcher: string, assertion: Promise<void>): Promise<void>;
7
+ /** Tracks `assertion` until someone awaits it; `label` names the call when it never is */
8
+ export declare function track(label: string, assertion: Promise<void>): Promise<void>;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Locator assertions nobody has awaited yet that are still running or have failed,
3
- * mapped to their matcher name
3
+ * mapped to their label (the call as a message names it, e.g. `expect(locator).toBeVisible()`)
4
4
  */
5
5
  const pendingAssertions = new Map();
6
6
  /**
7
- * Returns the matcher names of locator assertions that were never awaited and are
7
+ * Returns the labels of locator assertions that were never awaited and are
8
8
  * still running or already failed, and stops tracking them. The runner calls this
9
9
  * after a test to detect a missing `await`.
10
10
  */
@@ -28,11 +28,12 @@ class TrackedAssertion extends Promise {
28
28
  return super.then(onfulfilled, onrejected);
29
29
  }
30
30
  }
31
- export function track(matcher, assertion) {
31
+ /** Tracks `assertion` until someone awaits it; `label` names the call when it never is */
32
+ export function track(label, assertion) {
32
33
  const tracked = new TrackedAssertion((resolve, reject) => {
33
34
  assertion.then(resolve, reject);
34
35
  });
35
- pendingAssertions.set(tracked, matcher);
36
+ pendingAssertions.set(tracked, label);
36
37
  // The native then does not count as awaiting: a pass stops tracking, a failure stays
37
38
  // tracked. Handling the rejection keeps an un-awaited failure from being unhandled.
38
39
  Promise.prototype.then.call(tracked, () => pendingAssertions.delete(tracked), () => { });
@@ -1,5 +1,11 @@
1
1
  /** Whether `actual` equals (`exact`) or contains (`contains`) `expected`, or matches it when it is a RegExp */
2
2
  export declare function matchesText(actual: string, expected: string | RegExp, mode: "exact" | "contains"): boolean;
3
+ /**
4
+ * What a text check keeps as an output: the first capture group of a RegExp, else its whole match, else (a
5
+ * string, or no match) the whole `actual`. The flags `g` and `y` are dropped, so the result never depends on a
6
+ * `lastIndex` left by an earlier use.
7
+ */
8
+ export declare function captured(matcher: string | RegExp, actual: string): string;
3
9
  /** A check for a read that may find nothing (`null`): true when the text is there and matches */
4
10
  export declare function textCheck(expected: string | RegExp, mode: "exact" | "contains"): (actual: string | null) => boolean;
5
11
  /** A check for a class attribute: a string equals it after collapsing whitespace and trimming both sides, a RegExp tests it as written */
@@ -5,6 +5,18 @@ export function matchesText(actual, expected, mode) {
5
5
  return actual.search(expected) !== -1;
6
6
  return mode === "exact" ? actual === expected : actual.includes(expected);
7
7
  }
8
+ /**
9
+ * What a text check keeps as an output: the first capture group of a RegExp, else its whole match, else (a
10
+ * string, or no match) the whole `actual`. The flags `g` and `y` are dropped, so the result never depends on a
11
+ * `lastIndex` left by an earlier use.
12
+ */
13
+ export function captured(matcher, actual) {
14
+ if (typeof matcher === "string")
15
+ return actual;
16
+ const plain = new RegExp(matcher.source, matcher.flags.replace(/[gy]/g, ""));
17
+ const match = plain.exec(actual);
18
+ return match ? (match[1] ?? match[0]) : actual;
19
+ }
8
20
  /** A check for a read that may find nothing (`null`): true when the text is there and matches */
9
21
  export function textCheck(expected, mode) {
10
22
  return (actual) => actual !== null && matchesText(actual, expected, mode);
@@ -12,7 +12,7 @@ export declare const CAPABILITIES: {
12
12
  /** The recorder writing its recording to files in the project. */
13
13
  readonly recorderFiles: true;
14
14
  /** The flows contract version; a test keeps it equal to `FLOWS_API`. */
15
- readonly flowsApi: 2;
15
+ readonly flowsApi: 3;
16
16
  /** Steps that flows can run, including project steps made with `defineStep`. */
17
17
  readonly flowSteps: true;
18
18
  /** Browsers the framework downloads and manages itself. */
@@ -31,14 +31,25 @@ export declare const CAPABILITIES: {
31
31
  readonly recorderStartStatus: true;
32
32
  /** `outputDir` on `runTests` and `runFlowInProject`: a folder per run for the report and the screenshots. */
33
33
  readonly outputDir: true;
34
- /** `runFlowInProject`'s `outputDir` gives each node its own folder, `nodes/<node id>/`. */
35
- readonly flowNodeFolders: true;
36
- /** `nodeFolder(nodeId)` from `./flows/run`: a flow node's folder under `outputDir`, so an app need not rebuild it. */
37
- readonly nodeFolder: true;
34
+ /**
35
+ * `runFlowInProject`'s `outputDir` gives each node its own folder. Version 2: one per browser run,
36
+ * `<browser>/nodes/<node id>/`, and screenshot paths in a node's results are relative to `outputDir`.
37
+ */
38
+ readonly flowNodeFolders: 2;
39
+ /** `nodeFolder(nodeId, browser)` from `./flows/run`: a flow node's folder under `outputDir` (version 2 takes the browser). */
40
+ readonly nodeFolder: 2;
38
41
  /** Assertion matchers version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`, `toHaveClass` and `toHaveCSS`, in the framework and the step codec. */
39
42
  readonly assertionMatchers: 2;
40
43
  /** `expect(page).toHaveURL` and `toHaveTitle`, and the `expectPage` step in the step codec. */
41
44
  readonly pageAssertions: true;
45
+ /**
46
+ * Test inputs and outputs: `test()` options `{ inputs, outputs }`, the `inputs` / `outputs` fixtures, `{ output }` on
47
+ * `fill` and the text checks, `inputs` / `outputs` on results and `listTests`, the codec's `saveOutput` step and
48
+ * `declare` edit, and the recorder toolbar's Save as output and Insert → input.
49
+ */
50
+ readonly testInputsOutputs: true;
51
+ /** A flow runs once per browser: `browser` on every flow event, `flow-done`, `runFlowInProject` resolving to `FlowRunResult`. */
52
+ readonly flowPerBrowser: true;
42
53
  };
43
54
  /** The shape of {@link CAPABILITIES}. */
44
55
  export type Capabilities = typeof CAPABILITIES;
@@ -12,7 +12,7 @@ export const CAPABILITIES = {
12
12
  /** The recorder writing its recording to files in the project. */
13
13
  recorderFiles: true,
14
14
  /** The flows contract version; a test keeps it equal to `FLOWS_API`. */
15
- flowsApi: 2,
15
+ flowsApi: 3,
16
16
  /** Steps that flows can run, including project steps made with `defineStep`. */
17
17
  flowSteps: true,
18
18
  /** Browsers the framework downloads and manages itself. */
@@ -31,12 +31,23 @@ export const CAPABILITIES = {
31
31
  recorderStartStatus: true,
32
32
  /** `outputDir` on `runTests` and `runFlowInProject`: a folder per run for the report and the screenshots. */
33
33
  outputDir: true,
34
- /** `runFlowInProject`'s `outputDir` gives each node its own folder, `nodes/<node id>/`. */
35
- flowNodeFolders: true,
36
- /** `nodeFolder(nodeId)` from `./flows/run`: a flow node's folder under `outputDir`, so an app need not rebuild it. */
37
- nodeFolder: true,
34
+ /**
35
+ * `runFlowInProject`'s `outputDir` gives each node its own folder. Version 2: one per browser run,
36
+ * `<browser>/nodes/<node id>/`, and screenshot paths in a node's results are relative to `outputDir`.
37
+ */
38
+ flowNodeFolders: 2,
39
+ /** `nodeFolder(nodeId, browser)` from `./flows/run`: a flow node's folder under `outputDir` (version 2 takes the browser). */
40
+ nodeFolder: 2,
38
41
  /** Assertion matchers version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`, `toHaveClass` and `toHaveCSS`, in the framework and the step codec. */
39
42
  assertionMatchers: 2,
40
43
  /** `expect(page).toHaveURL` and `toHaveTitle`, and the `expectPage` step in the step codec. */
41
44
  pageAssertions: true,
45
+ /**
46
+ * Test inputs and outputs: `test()` options `{ inputs, outputs }`, the `inputs` / `outputs` fixtures, `{ output }` on
47
+ * `fill` and the text checks, `inputs` / `outputs` on results and `listTests`, the codec's `saveOutput` step and
48
+ * `declare` edit, and the recorder toolbar's Save as output and Insert → input.
49
+ */
50
+ testInputsOutputs: true,
51
+ /** A flow runs once per browser: `browser` on every flow event, `flow-done`, `runFlowInProject` resolving to `FlowRunResult`. */
52
+ flowPerBrowser: true,
42
53
  };
@@ -8,7 +8,7 @@ export interface FlowReporterOptions {
8
8
  /** Definitions of the project's steps, for their labels. @default the built-in steps */
9
9
  steps?: readonly AnyNodeDefinition[];
10
10
  }
11
- /** Prints a flow run for a person: one line per node as it ends, then a count. Start and End are not listed. */
11
+ /** Prints a flow run for a person, per browser: a header, one line per node as it ends, then a count. Start and End are not listed. */
12
12
  export declare class FlowReporter {
13
13
  private readonly write;
14
14
  private readonly paint;
@@ -4,7 +4,7 @@ import { STEP_NODES } from "../flows/steps/index.js";
4
4
  import { painter } from "./paint.js";
5
5
  const took = (ms) => ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
6
6
  const SKIPPED_BY_SHAPE = new Set(["start", "end"]);
7
- /** Prints a flow run for a person: one line per node as it ends, then a count. Start and End are not listed. */
7
+ /** Prints a flow run for a person, per browser: a header, one line per node as it ends, then a count. Start and End are not listed. */
8
8
  export class FlowReporter {
9
9
  constructor({ flow, write, color, steps }) {
10
10
  this.started = Date.now();
@@ -42,7 +42,7 @@ export class FlowReporter {
42
42
  handle(event) {
43
43
  if (event.type === "flow-start") {
44
44
  this.started = Date.now();
45
- this.write(`Flow "${this.flow.name}" against ${event.environment}\n`);
45
+ this.write(`Flow "${this.flow.name}" against ${event.environment} in ${event.browser}\n`);
46
46
  }
47
47
  else if (event.type === "node-end") {
48
48
  const node = this.nodes.get(event.nodeId);
package/dist/cli/flow.js CHANGED
@@ -164,9 +164,9 @@ async function run(refs, io, options) {
164
164
  : new FlowReporter({ flow, write: io.out, color: io.color, steps });
165
165
  if (index > 0 && !options.json)
166
166
  io.out("\n");
167
- let summary;
167
+ let result;
168
168
  try {
169
- summary = await runFlowInProject({
169
+ result = await runFlowInProject({
170
170
  ...options.run,
171
171
  projectDir,
172
172
  flow,
@@ -186,10 +186,10 @@ async function run(refs, io, options) {
186
186
  return refuse(e.problems, flow);
187
187
  throw e;
188
188
  }
189
- const file = await writeFlowReport(projectDir, builder.finish(summary));
189
+ const file = await writeFlowReport(projectDir, builder.finish(result));
190
190
  if (!options.json)
191
191
  io.out(`Report: ${path.relative(projectDir, file) || file}\n`);
192
- if (summary.status !== "passed")
192
+ if (result.status !== "passed")
193
193
  code = 1;
194
194
  }
195
195
  return code;
@@ -0,0 +1,23 @@
1
+ import type { AnyNodeDefinition } from "./nodes/define.js";
2
+ import type { FlowProblem } from "./nodes/index.js";
3
+ import { type FlowFile, type FlowNodeModel } from "./schema.js";
4
+ import { type FlowFacts } from "./wiring.js";
5
+ /** What checkFlow hands the input and output checks */
6
+ interface IoCheck {
7
+ facts: FlowFacts;
8
+ /** Expressions are checked only when the environment is known */
9
+ environment?: {
10
+ name: string;
11
+ variables: Record<string, unknown>;
12
+ };
13
+ nodes?: readonly AnyNodeDefinition[];
14
+ /** A node's name in messages */
15
+ titleOf: (node: FlowNodeModel) => string;
16
+ }
17
+ /**
18
+ * Problems with inputs and outputs: an input with no source, an override expression
19
+ * that doesn't check out, an entry for an input the test doesn't declare, a Group
20
+ * node with an expression, and two branches of one Parallel saving the same output.
21
+ */
22
+ export declare function ioProblems(flow: FlowFile, check: IoCheck): FlowProblem[];
23
+ export {};
@@ -0,0 +1,84 @@
1
+ import { problemOf, scopeAt } from "./expression/index.js";
2
+ import { meetOf, reachOrder } from "./graph.js";
3
+ import { isInputSource } from "./schema.js";
4
+ import { declaredInputs, declaredOutputs, wiringAt, } from "./wiring.js";
5
+ /**
6
+ * Problems with inputs and outputs: an input with no source, an override expression
7
+ * that doesn't check out, an entry for an input the test doesn't declare, a Group
8
+ * node with an expression, and two branches of one Parallel saving the same output.
9
+ */
10
+ export function ioProblems(flow, check) {
11
+ return [
12
+ ...flow.nodes.flatMap((n) => n.kind === "test" || n.kind === "group"
13
+ ? inputProblems(flow, n, check)
14
+ : []),
15
+ ...flow.nodes
16
+ .filter((n) => n.kind === "parallel")
17
+ .flatMap((p) => branchClashes(flow, p, check)),
18
+ ];
19
+ }
20
+ function inputProblems(flow, node, check) {
21
+ const known = node.kind === "test"
22
+ ? !!node.ref?.testId && !!check.facts.tests[node.ref.testId]
23
+ : !!node.ref?.group && !!check.facts.groups[node.ref.group];
24
+ if (!known)
25
+ return [];
26
+ const problems = [];
27
+ const at = (level, message) => problems.push({ level, nodeId: node.id, message });
28
+ const declared = declaredInputs(node, check.facts);
29
+ const groupSet = new Set();
30
+ for (const [name, source] of Object.entries(node.inputs ?? {})) {
31
+ if (!declared.has(name))
32
+ at("warning", `input "${name}" is not used by this ${node.kind === "group" ? "node" : "test"}`);
33
+ else if (!isInputSource(source) || !("expression" in source))
34
+ continue;
35
+ else if (node.kind === "group")
36
+ groupSet.add(name);
37
+ else if (check.environment) {
38
+ const problem = problemOf(source.expression, scopeAt(flow, node.id, check.environment, {
39
+ nodes: check.nodes,
40
+ facts: check.facts,
41
+ }));
42
+ if (problem)
43
+ at("error", `${check.titleOf(node)}: input "${name}": ${problem.message}`);
44
+ }
45
+ }
46
+ if (groupSet.size)
47
+ at("error", `${check.titleOf(node)}: Group nodes can't set inputs (${[...groupSet].map((x) => `"${x}"`).join(", ")})`);
48
+ for (const [name, wiring] of Object.entries(wiringAt(flow, node.id, { facts: check.facts })))
49
+ if (wiring.from === "none" && !groupSet.has(name))
50
+ at("error", `${check.titleOf(node)}: input "${name}" has no source. Save it as an output in an earlier test, set an expression, or use the default`);
51
+ return problems;
52
+ }
53
+ /**
54
+ * `<parallel title>: branches both save "x"`, once per output name two branches
55
+ * save. A node two branches both reach before the meet counts for the first only.
56
+ */
57
+ function branchClashes(flow, parallel, check) {
58
+ const meet = meetOf(flow, parallel.id);
59
+ const beyond = new Set(meet ? reachOrder(flow, meet) : []);
60
+ const savedBy = new Map();
61
+ const counted = new Set();
62
+ for (const e of flow.edges.filter((x) => x.source === parallel.id)) {
63
+ const names = new Set(reachOrder(flow, e.target)
64
+ .filter((id) => {
65
+ if (beyond.has(id) || counted.has(id))
66
+ return false;
67
+ counted.add(id);
68
+ return true;
69
+ })
70
+ .flatMap((id) => {
71
+ const n = flow.nodes.find((x) => x.id === id);
72
+ return n ? declaredOutputs(n, check.facts) : [];
73
+ }));
74
+ for (const name of names)
75
+ savedBy.set(name, (savedBy.get(name) ?? 0) + 1);
76
+ }
77
+ return [...savedBy]
78
+ .filter(([, count]) => count > 1)
79
+ .map(([name]) => ({
80
+ level: "error",
81
+ nodeId: parallel.id,
82
+ message: `${check.titleOf(parallel)}: branches both save "${name}"`,
83
+ }));
84
+ }
@@ -1,6 +1,7 @@
1
1
  import { type FlowProblem } from "./nodes/index.js";
2
2
  import type { AnyNodeDefinition } from "./nodes/define.js";
3
3
  import type { FlowFile, FlowNodeModel } from "./schema.js";
4
+ import type { FlowFacts } from "./wiring.js";
4
5
  export type { FlowProblem } from "./nodes/index.js";
5
6
  export interface CheckOptions {
6
7
  /** The test ids that exist. When given, a Test node whose test is gone is an error. */
@@ -30,6 +31,12 @@ export interface CheckOptions {
30
31
  titles?: (node: FlowNodeModel) => string | undefined;
31
32
  /** Definitions of kinds core doesn't have (steps). A node of a kind in neither is "Unknown step". */
32
33
  nodes?: readonly AnyNodeDefinition[];
34
+ /**
35
+ * The project's tests' inputs and outputs and its groups' tests. When given, every
36
+ * Test and Group node's inputs are checked (a source for each, override expressions,
37
+ * unused entries) and so are a Parallel's branches (two saving one output name).
38
+ */
39
+ facts?: FlowFacts;
33
40
  }
34
41
  /**
35
42
  * Checks a flow before a run: the shape of the graph, keys, references that still
@@ -38,8 +45,15 @@ export interface CheckOptions {
38
45
  * its key, else its kind's label (the app names it by what the editor shows).
39
46
  */
40
47
  export declare function checkFlow(flow: FlowFile, options?: CheckOptions): FlowProblem[];
41
- /** Every expression field that doesn't check out, as "Label: message" */
48
+ /**
49
+ * Every expression field that doesn't check out, as "Label: message". `options.nodes`
50
+ * adds definitions core doesn't have (steps); `options.facts` lets expressions read
51
+ * a keyed Test node's declared outputs (`nodes.<key>.<output>`).
52
+ */
42
53
  export declare function expressionProblems(flow: FlowFile, environment: {
43
54
  name: string;
44
55
  variables: Record<string, unknown>;
45
- }, nodes?: readonly AnyNodeDefinition[]): FlowProblem[];
56
+ }, options?: {
57
+ nodes?: readonly AnyNodeDefinition[];
58
+ facts?: FlowFacts;
59
+ }): FlowProblem[];
@@ -1,3 +1,4 @@
1
+ import { ioProblems } from "./check-io.js";
1
2
  import { problemOf, scopeAt } from "./expression/index.js";
2
3
  import { KEY_PATTERN } from "./keys.js";
3
4
  import { branchNames, dynamicOf, getPath, nodeDef, withDefaults, } from "./nodes/index.js";
@@ -142,7 +143,17 @@ export function checkFlow(flow, options = {}) {
142
143
  }
143
144
  problems.push(...emptyExpressions(flow, defOf));
144
145
  if (options.environment)
145
- problems.push(...expressionProblems(flow, options.environment, options.nodes));
146
+ problems.push(...expressionProblems(flow, options.environment, {
147
+ nodes: options.nodes,
148
+ facts: options.facts,
149
+ }));
150
+ if (options.facts)
151
+ problems.push(...ioProblems(flow, {
152
+ facts: options.facts,
153
+ environment: options.environment,
154
+ nodes: options.nodes,
155
+ titleOf: (n) => options.titles?.(n) ?? titleOf(n, defOf(n.kind)),
156
+ }));
146
157
  const nodeCount = flow.nodes.filter((n) => {
147
158
  const s = shapeOf(n);
148
159
  return s !== "start" && s !== "end";
@@ -177,8 +188,13 @@ function emptyExpressions(flow, defOf) {
177
188
  }));
178
189
  });
179
190
  }
180
- /** Every expression field that doesn't check out, as "Label: message" */
181
- export function expressionProblems(flow, environment, nodes = []) {
191
+ /**
192
+ * Every expression field that doesn't check out, as "Label: message". `options.nodes`
193
+ * adds definitions core doesn't have (steps); `options.facts` lets expressions read
194
+ * a keyed Test node's declared outputs (`nodes.<key>.<output>`).
195
+ */
196
+ export function expressionProblems(flow, environment, options = {}) {
197
+ const { nodes = [], facts } = options;
182
198
  const defOf = definitions(nodes);
183
199
  return flow.nodes.flatMap((n) => {
184
200
  const def = defOf(n.kind);
@@ -190,7 +206,7 @@ export function expressionProblems(flow, environment, nodes = []) {
190
206
  const text = getPath(n, f.path);
191
207
  if (typeof text !== "string" || text.trim() === "")
192
208
  return [];
193
- const problem = problemOf(text, scopeAt(flow, n.id, environment, nodes));
209
+ const problem = problemOf(text, scopeAt(flow, n.id, environment, { nodes, facts }));
194
210
  return problem
195
211
  ? [
196
212
  {
@@ -1,8 +1,15 @@
1
1
  import type { AnyNodeDefinition } from "../nodes/define.js";
2
2
  import type { FlowFile } from "../schema.js";
3
+ import { type FlowFacts } from "../wiring.js";
3
4
  import type { ExpressionScope } from "./check.js";
4
- /** What an expression at `nodeId` may use; `nodes` adds definitions core doesn't have (steps) */
5
+ /**
6
+ * What an expression at `nodeId` may use. `options.nodes` adds definitions core
7
+ * doesn't have (steps); `options.facts` adds each keyed Test node's declared outputs.
8
+ */
5
9
  export declare function scopeAt(flow: FlowFile, nodeId: string, environment: {
6
10
  name: string;
7
11
  variables: Record<string, unknown>;
8
- }, nodes?: readonly AnyNodeDefinition[]): ExpressionScope;
12
+ }, options?: {
13
+ nodes?: readonly AnyNodeDefinition[];
14
+ facts?: FlowFacts;
15
+ }): ExpressionScope;