@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
package/README.md CHANGED
@@ -149,6 +149,6 @@ The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/H
149
149
  | ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
150
150
  | `bun run dev` | `samurai` from the sources (`tsx src/index.ts`) |
151
151
  | `bun run test` | Unit tests of the framework (no browser needed) |
152
- | `bun run test:browser` | Browser tests of the framework (Firefox; `SAMURAI_TEST_BROWSER=chrome` or `bun run test:browser:chrome` for Chrome) |
153
- | `bun run test:cli` | Tests of the `samurai` command (build first) |
152
+ | `bun run test-browser` | Browser tests of the framework (Firefox; `SAMURAI_TEST_BROWSER=chrome` or `bun run test-browser-chrome` for Chrome) |
153
+ | `bun run test-cli` | Tests of the `samurai` command (build first) |
154
154
  | `bun run build` | Typechecks and compiles to `dist/` |
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);
@@ -67,12 +67,28 @@ export class Browser {
67
67
  commandTimeout,
68
68
  signal,
69
69
  });
70
+ // The launchers stop listening for the abort once the session is open. Until this returns, nobody else
71
+ // holds the browser to close it, so an abort still has to stop it here. Once: a second stop would cut
72
+ // Chrome's short (chromedriver killed while it still quits Chrome)
73
+ let abandoned = false;
74
+ const abandon = () => {
75
+ if (abandoned)
76
+ return;
77
+ abandoned = true;
78
+ abandonLaunch(proc, connector);
79
+ };
80
+ signal?.addEventListener("abort", abandon, { once: true });
70
81
  try {
71
- return await Browser.attach(proc, connector, browserName);
82
+ const launched = await Browser.attach(proc, connector, browserName);
83
+ signal?.throwIfAborted();
84
+ return launched;
72
85
  }
73
86
  catch (err) {
74
- abandonLaunch(proc, connector);
75
- throw err;
87
+ abandon();
88
+ throw signal?.aborted ? signal.reason : err;
89
+ }
90
+ finally {
91
+ signal?.removeEventListener("abort", abandon);
76
92
  }
77
93
  }
78
94
  /** Sets up a browser whose BiDi session is open: downloads folder, the first page and what it tracks */
@@ -60,6 +60,8 @@ export declare class ChromeProcess extends EventEmitter implements BrowserProces
60
60
  private deleteSession;
61
61
  session: string | undefined;
62
62
  private done;
63
+ /** The session is being deleted: chromedriver is killed once that is over, not before */
64
+ private quitting;
63
65
  constructor(driver: Pick<ReturnType<typeof spawn>, "once" | "kill" | "exitCode" | "signalCode">, deleteSession?: (url: string) => Promise<unknown>);
64
66
  get exitCode(): number | null;
65
67
  get signalCode(): NodeJS.Signals | null;
@@ -139,6 +139,8 @@ export class ChromeProcess extends EventEmitter {
139
139
  this.driver = driver;
140
140
  this.deleteSession = deleteSession;
141
141
  this.done = false;
142
+ /** The session is being deleted: chromedriver is killed once that is over, not before */
143
+ this.quitting = false;
142
144
  driver.once("exit", () => this.ended());
143
145
  }
144
146
  get exitCode() {
@@ -159,7 +161,10 @@ export class ChromeProcess extends EventEmitter {
159
161
  // Deleting the session makes chromedriver quit Chrome; killing chromedriver alone could leave it running
160
162
  const session = this.session;
161
163
  this.session = undefined;
164
+ if (this.quitting)
165
+ return true;
162
166
  if (session) {
167
+ this.quitting = true;
163
168
  this.deleteSession(session)
164
169
  .catch(() => undefined)
165
170
  .finally(() => this.driver.kill(signal));
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { type Logform } from "winston";
1
2
  export declare const MASK = "\u2022\u2022\u2022\u2022";
2
3
  /** Registers a secret value; every later maskText/maskDeep/log line hides it */
3
4
  export declare function registerSecret(name: string, value: string): void;
@@ -7,5 +8,8 @@ export declare function isShortSecret(value: string): boolean;
7
8
  export declare function maskText(text: string): string;
8
9
  /** A copy of `value` with every string in it masked, following JSON.stringify semantics for objects */
9
10
  export declare function maskDeep<T>(value: T): T;
10
- /** Winston format that masks every string field of a log entry (message and metadata) */
11
- export declare const maskFormat: import("logform").FormatWrap;
11
+ /**
12
+ * Winston format that masks every string field of a log entry (message and metadata). Typed through winston's
13
+ * re-export: logform is winston's dependency, not ours, so an inferred type can't be named under an isolated install
14
+ */
15
+ export declare const maskFormat: Logform.FormatWrap;
@@ -80,7 +80,10 @@ function walk(value, path) {
80
80
  path.delete(value);
81
81
  }
82
82
  }
83
- /** Winston format that masks every string field of a log entry (message and metadata) */
83
+ /**
84
+ * Winston format that masks every string field of a log entry (message and metadata). Typed through winston's
85
+ * re-export: logform is winston's dependency, not ours, so an inferred type can't be named under an isolated install
86
+ */
84
87
  export const maskFormat = format((info) => {
85
88
  if (patterns.length === 0)
86
89
  return info;
@@ -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 {};