@itmetsam/samurai-framework 0.21.0 → 0.21.2

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.
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no Playwright or Puppeteer underneath. Firefox speaks BiDi itself; Chrome is driven through its own `chromedriver`, which the framework starts for you.
4
4
 
5
- [![CI](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml)
5
+ [![npm](https://img.shields.io/npm/v/@itmetsam/samurai-framework)](https://www.npmjs.com/package/@itmetsam/samurai-framework)
6
+
7
+ > This repository is a read-only mirror of `packages/framework` in the SAMURAI monorepo. Issues are welcome; pull requests aren't merged here. See [CONTRIBUTING.md](CONTRIBUTING.md).
6
8
 
7
9
  ```ts
8
10
  import { expect, test } from "../api.js";
@@ -56,6 +58,8 @@ cd samurai-framework
56
58
  bun install
57
59
  ```
58
60
 
61
+ The clone is the mirror described in [CONTRIBUTING.md](CONTRIBUTING.md). It has no lockfile of its own (the monorepo keeps one for all its packages), so `bun install` resolves the ranges in `package.json`.
62
+
59
63
  ### 3. Write a test
60
64
 
61
65
  Tests live in the folder `srcDir` points to (`./src/tests` in `samurai.config.ts`) and end in `.spec.ts`. This repository has one as `src/tests/example.spec.ts`:
@@ -138,7 +142,8 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
138
142
  | [Running tests and reports](https://github.com/degrootsam/samurai-framework/wiki/Running-tests-and-reports) | What a run does, `report.json`, page logs, timeouts |
139
143
  | [Steps and the recorder](https://github.com/degrootsam/samurai-framework/wiki/Steps-and-the-recorder) | The step codec, the recording engine and the recorder toolbar |
140
144
  | [Embedding the framework](https://github.com/degrootsam/samurai-framework/wiki/Embedding-the-framework) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
141
- | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
145
+ | [Contributing](CONTRIBUTING.md) | Issues and pull requests on this mirror |
146
+ | [Developer guide](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
142
147
  | [Roadmap](https://github.com/degrootsam/samurai-framework/wiki/Roadmap) | What is built and what is still open |
143
148
 
144
149
  The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/Home) lists every guide by topic.
@@ -149,6 +154,6 @@ The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/H
149
154
  | ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
150
155
  | `bun run dev` | `samurai` from the sources (`tsx src/index.ts`) |
151
156
  | `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) |
157
+ | `bun run test-browser` | Browser tests of the framework (Firefox; `SAMURAI_TEST_BROWSER=chrome` or `bun run test-browser-chrome` for Chrome) |
158
+ | `bun run test-cli` | Tests of the `samurai` command (build first) |
154
159
  | `bun run build` | Typechecks and compiles to `dist/` |
@@ -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));
@@ -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;
@@ -11,6 +11,12 @@ export interface TrackerOptions {
11
11
  * Follows the network activity of one page (its frames included): which requests are in flight,
12
12
  * and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
13
13
  * so a redirect chain is one live request at a time.
14
+ *
15
+ * Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
16
+ * leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
17
+ * redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
18
+ * So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
19
+ * with its response or error is announced then.
14
20
  */
15
21
  export declare class NetworkTracker {
16
22
  private tree;
@@ -21,13 +27,16 @@ export declare class NetworkTracker {
21
27
  private subscription;
22
28
  /**
23
29
  * Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
24
- * hop's flight (`live` false) but keeps it, for the next hop's `redirectedFrom`.
30
+ * hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
31
+ * hop's `redirectedFrom`.
25
32
  */
26
33
  private hops;
27
34
  /** Rejects a pending `waitForIdle`; each wait adds its own */
28
35
  private waiters;
29
36
  private disposed;
30
37
  private constructor();
38
+ /** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
39
+ private get redirectsStartNextHop();
31
40
  static start(connector: BiDiConnector, tree: ContextTree, contextId: string, options?: TrackerOptions): Promise<NetworkTracker>;
32
41
  /** Requests currently in flight */
33
42
  get inflight(): number;
@@ -52,6 +61,13 @@ export declare class NetworkTracker {
52
61
  private owns;
53
62
  private static key;
54
63
  private onBeforeRequestSent;
64
+ /** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
65
+ private startHop;
66
+ /**
67
+ * The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
68
+ * when the hop it was redirected from is known.
69
+ */
70
+ private hopOf;
55
71
  private onResponseCompleted;
56
72
  private onFetchError;
57
73
  /**
@@ -12,6 +12,12 @@ const URLS_IN_MESSAGE = 5;
12
12
  * Follows the network activity of one page (its frames included): which requests are in flight,
13
13
  * and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
14
14
  * so a redirect chain is one live request at a time.
15
+ *
16
+ * Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
17
+ * leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
18
+ * redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
19
+ * So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
20
+ * with its response or error is announced then.
15
21
  */
16
22
  export class NetworkTracker {
17
23
  constructor(tree, contextId, ignore, connector) {
@@ -22,7 +28,8 @@ export class NetworkTracker {
22
28
  this.events = new EventEmitter();
23
29
  /**
24
30
  * Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
25
- * hop's flight (`live` false) but keeps it, for the next hop's `redirectedFrom`.
31
+ * hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
32
+ * hop's `redirectedFrom`.
26
33
  */
27
34
  this.hops = new Map();
28
35
  /** Rejects a pending `waitForIdle`; each wait adds its own */
@@ -31,30 +38,22 @@ export class NetworkTracker {
31
38
  this.onBeforeRequestSent = (params) => {
32
39
  if (!this.owns(params.context))
33
40
  return;
34
- const key = NetworkTracker.key(params.request, params.redirectCount);
35
- const { redirectCount } = params;
36
- const previousKey = `${params.request.request}:${redirectCount - 1}`;
37
- const redirectedFrom = redirectCount > 0 ? (this.hops.get(previousKey)?.request ?? null) : null;
38
- this.hops.delete(previousKey);
39
- const request = toRequest(params.request, params.navigation, params.isBlocked, redirectedFrom);
40
- if (this.ignore(request))
41
- return;
42
- this.hops.set(key, { request, context: params.context, live: true });
43
- this.emit("request", request);
44
- this.events.emit("change");
41
+ this.startHop(params);
45
42
  };
46
43
  this.onResponseCompleted = (params) => {
47
44
  if (!this.owns(params.context))
48
45
  return;
49
46
  const key = NetworkTracker.key(params.request, params.redirectCount);
50
- const hop = this.hops.get(key);
51
- if (!hop?.live)
47
+ const hop = this.hopOf(params);
48
+ if (!hop?.live || hop.answered)
52
49
  return;
53
50
  const { request } = hop;
54
51
  const { response } = params;
55
52
  // A redirect response keeps its request around: the next hop links back to it
56
53
  if (response.status < 300 || response.status >= 400)
57
54
  this.hops.delete(key);
55
+ else if (this.redirectsStartNextHop && hasLocation(response))
56
+ hop.answered = true;
58
57
  else
59
58
  hop.live = false;
60
59
  this.emit("response", {
@@ -71,11 +70,13 @@ export class NetworkTracker {
71
70
  if (!this.owns(params.context))
72
71
  return;
73
72
  const key = NetworkTracker.key(params.request, params.redirectCount);
74
- const hop = this.hops.get(key);
73
+ const hop = this.hopOf(params);
75
74
  if (!hop?.live)
76
75
  return;
77
76
  this.hops.delete(key);
78
- this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
77
+ // A hop that has its response already did not fail
78
+ if (!hop.answered)
79
+ this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
79
80
  this.events.emit("change");
80
81
  };
81
82
  /**
@@ -86,19 +87,24 @@ export class NetworkTracker {
86
87
  if (!this.owns(params.context))
87
88
  return;
88
89
  let changed = false;
89
- for (const [key, { request, context, live }] of [...this.hops]) {
90
+ for (const [key, { request, context, live, answered }] of [...this.hops]) {
90
91
  if (!live || request.navigation === params.navigation)
91
92
  continue;
92
93
  if (!this.tree.isWithin(context, params.context))
93
94
  continue;
94
95
  this.hops.delete(key);
95
- this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
96
+ if (!answered)
97
+ this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
96
98
  changed = true;
97
99
  }
98
100
  if (changed)
99
101
  this.events.emit("change");
100
102
  };
101
103
  }
104
+ /** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
105
+ get redirectsStartNextHop() {
106
+ return this.connector.browserName === "chrome";
107
+ }
102
108
  static async start(connector, tree, contextId, options = {}) {
103
109
  const tracker = new NetworkTracker(tree, contextId, options.ignore ?? (() => false), connector);
104
110
  // Listen first, then subscribe: nothing can arrive in between
@@ -189,6 +195,41 @@ export class NetworkTracker {
189
195
  static key(data, redirectCount) {
190
196
  return `${data.request}:${redirectCount}`;
191
197
  }
198
+ /** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
199
+ startHop(params) {
200
+ const key = NetworkTracker.key(params.request, params.redirectCount);
201
+ const { redirectCount } = params;
202
+ const previousKey = `${params.request.request}:${redirectCount - 1}`;
203
+ const previous = redirectCount > 0 ? this.hops.get(previousKey) : undefined;
204
+ this.hops.delete(previousKey);
205
+ const request = toRequest(params.request, params.navigation, params.isBlocked, previous?.request ?? null);
206
+ if (this.ignore(request)) {
207
+ if (previous?.live)
208
+ this.events.emit("change");
209
+ return undefined;
210
+ }
211
+ const hop = {
212
+ request,
213
+ context: params.context,
214
+ live: true,
215
+ answered: false,
216
+ };
217
+ this.hops.set(key, hop);
218
+ this.emit("request", request);
219
+ this.events.emit("change");
220
+ return hop;
221
+ }
222
+ /**
223
+ * The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
224
+ * when the hop it was redirected from is known.
225
+ */
226
+ hopOf(params) {
227
+ const hop = this.hops.get(NetworkTracker.key(params.request, params.redirectCount));
228
+ if (hop || params.redirectCount === 0)
229
+ return hop;
230
+ const previousKey = `${params.request.request}:${params.redirectCount - 1}`;
231
+ return this.hops.has(previousKey) ? this.startHop(params) : undefined;
232
+ }
192
233
  /** A listener that throws must not stop the tracker or the other listeners */
193
234
  emit(event, ...args) {
194
235
  for (const listener of this.events.listeners(event)) {
@@ -201,3 +242,7 @@ export class NetworkTracker {
201
242
  }
202
243
  }
203
244
  }
245
+ /** Whether a 3xx response says where to go next: only then does the browser follow it */
246
+ function hasLocation(response) {
247
+ return response.headers.some((header) => header.name.toLowerCase() === "location");
248
+ }
@@ -1,8 +1,11 @@
1
1
  import type { PickInfo } from "../protocol.js";
2
2
  /** Whether a text holds the value of one of the secrets */
3
3
  export declare function holdsSecret(secrets: Map<string, string | undefined>): (text: string | undefined) => boolean;
4
+ /** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
5
+ export declare function maskSecrets(secrets: Map<string, string | undefined>): (text: string) => string;
4
6
  /**
5
7
  * A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
8
+ * the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
6
9
  * and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
7
10
  */
8
11
  export declare function withoutSecrets(info: PickInfo & {
@@ -1,11 +1,20 @@
1
+ import { MASK } from "../../../config/mask.js";
1
2
  import { expectationFor } from "../../expectation.js";
2
3
  /** Whether a text holds the value of one of the secrets */
3
4
  export function holdsSecret(secrets) {
4
5
  const values = [...secrets.values()].filter((value) => value !== undefined && value !== "");
5
6
  return (text) => text !== undefined && values.some((value) => text.includes(value));
6
7
  }
8
+ /** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
9
+ export function maskSecrets(secrets) {
10
+ const values = [...secrets.values()]
11
+ .filter((value) => value !== undefined && value !== "")
12
+ .sort((a, b) => b.length - a.length);
13
+ return (text) => values.reduce((masked, value) => masked.split(value).join(MASK), text);
14
+ }
7
15
  /**
8
16
  * A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
17
+ * the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
9
18
  * and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
10
19
  */
11
20
  export function withoutSecrets(info, secrets) {
@@ -24,7 +33,8 @@ export function withoutSecrets(info, secrets) {
24
33
  return {
25
34
  ...rest,
26
35
  text,
27
- outputText: secret(info.outputText) ? "" : info.outputText,
36
+ outputText: maskSecrets(secrets)(info.outputText),
37
+ ...(secret(info.outputText) && { showsSecret: true }),
28
38
  attributes: info.attributes.filter((a) => !secret(a.value)),
29
39
  className: secret(info.className ?? undefined) ? null : info.className,
30
40
  css: secret(info.css.value) ? { ...info.css, value: "" } : info.css,
@@ -1,9 +1,15 @@
1
- import type { ActionReply } from "../protocol.js";
1
+ import type { ActionReply, OutputPreview } from "../protocol.js";
2
2
  import type { ActionContext, Of } from "../types.js";
3
3
  /**
4
- * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses an
5
- * element whose text holds a secret, a name `test()` would reject, an element with no text when there is no
6
- * pattern, and a pattern that isn't valid or doesn't match the element's text as picked, read the way
7
- * `outputs.save` reads it.
4
+ * What Save as output would save from an element that shows a secret, worked out here because the page only has
5
+ * its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
6
+ * as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
7
+ */
8
+ export declare function outputPreview(ctx: ActionContext, message: Of<"output-preview">): OutputPreview;
9
+ /**
10
+ * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
11
+ * name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
12
+ * or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
13
+ * secret is saved too: the panel warns that the output may hold it.
8
14
  */
9
15
  export declare function saveOutputAction(ctx: ActionContext, message: Of<"save-output">): Promise<ActionReply>;
@@ -1,38 +1,65 @@
1
- import { matchesText } from "../../../assert/text-match.js";
2
- import { holdsSecret } from "./pick.js";
1
+ import { captured, matchesText } from "../../../assert/text-match.js";
2
+ import { holdsSecret, maskSecrets } from "./pick.js";
3
3
  import { fail, nameProblem, PAUSED } from "./reply.js";
4
+ const PICK_AGAIN = "Pick the element again";
5
+ /** A pattern as typed, compiled the way the step writes it; undefined when there is none, an error when it isn't valid */
6
+ function compile(pattern) {
7
+ const source = String(pattern ?? "").trim();
8
+ if (source === "")
9
+ return undefined;
10
+ try {
11
+ return new RegExp(source);
12
+ }
13
+ catch {
14
+ return { error: "pattern is not a valid regular expression" };
15
+ }
16
+ }
4
17
  /**
5
- * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses an
6
- * element whose text holds a secret, a name `test()` would reject, an element with no text when there is no
7
- * pattern, and a pattern that isn't valid or doesn't match the element's text as picked, read the way
8
- * `outputs.save` reads it.
18
+ * What Save as output would save from an element that shows a secret, worked out here because the page only has
19
+ * its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
20
+ * as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
21
+ */
22
+ export function outputPreview(ctx, message) {
23
+ const pattern = String(message.pattern ?? "");
24
+ const answer = { preview: true, pattern };
25
+ const pick = ctx.pick();
26
+ if (!pick || pick.id !== message.id)
27
+ return { ...answer, ok: false, error: PICK_AGAIN };
28
+ const compiled = compile(pattern);
29
+ if (compiled && !(compiled instanceof RegExp))
30
+ return { ...answer, ok: false, error: "Not a valid pattern" };
31
+ if (compiled && !matchesText(pick.text, compiled, "exact"))
32
+ return { ...answer, ok: false, error: "No match" };
33
+ const value = compiled ? captured(compiled, pick.text) : pick.text;
34
+ return {
35
+ ...answer,
36
+ ok: true,
37
+ value: maskSecrets(ctx.secrets)(value),
38
+ secret: holdsSecret(ctx.secrets)(value),
39
+ };
40
+ }
41
+ /**
42
+ * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
43
+ * name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
44
+ * or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
45
+ * secret is saved too: the panel warns that the output may hold it.
9
46
  */
10
47
  export async function saveOutputAction(ctx, message) {
11
48
  if (ctx.isPaused())
12
49
  return fail(PAUSED);
13
50
  const pick = ctx.pick();
14
51
  if (!pick || pick.id !== message.id)
15
- return fail("Pick the element again");
16
- // The step would write the secret into the run's results
17
- if (holdsSecret(ctx.secrets)(pick.text))
18
- return fail("this element shows a secret; it can't be an output");
52
+ return fail(PICK_AGAIN);
19
53
  const name = String(message.name ?? "").trim();
20
54
  const problem = nameProblem(name);
21
55
  if (problem)
22
56
  return fail(problem, "name");
23
- const source = String(message.pattern ?? "").trim();
24
- let pattern;
25
- if (source !== "") {
26
- try {
27
- pattern = new RegExp(source);
28
- }
29
- catch {
30
- return fail("pattern is not a valid regular expression");
31
- }
32
- if (!matchesText(pick.text, pattern, "exact"))
33
- return fail("pattern doesn't match the element's text");
34
- }
35
- else if (pick.text === "")
57
+ const pattern = compile(message.pattern);
58
+ if (pattern && !(pattern instanceof RegExp))
59
+ return fail(pattern.error);
60
+ if (pattern && !matchesText(pick.text, pattern, "exact"))
61
+ return fail("pattern doesn't match the element's text");
62
+ if (!pattern && pick.text === "")
36
63
  return fail("the element has no text");
37
64
  ctx.setPick(undefined);
38
65
  ctx.deps.observe({
@@ -4,7 +4,7 @@ import { assertAction } from "./actions/assert.js";
4
4
  import { assertPageAction, pageInfoOf } from "./actions/assert-page.js";
5
5
  import { addInsertableAction, insertAction } from "./actions/insert.js";
6
6
  import { withoutSecrets } from "./actions/pick.js";
7
- import { saveOutputAction } from "./actions/save-output.js";
7
+ import { outputPreview, saveOutputAction } from "./actions/save-output.js";
8
8
  import { fail, PAUSED, UNIDENTIFIABLE } from "./actions/reply.js";
9
9
  import { insertablesOf } from "./insertables.js";
10
10
  import { LEFT, PageScope } from "./page-scope.js";
@@ -148,6 +148,8 @@ export class ToolbarController {
148
148
  return this.replying(message, () => addInsertableAction(this.actions, message));
149
149
  case "save-output":
150
150
  return this.replying(message, () => saveOutputAction(this.actions, message));
151
+ case "output-preview":
152
+ return this.reply(message.id, outputPreview(this.actions, message));
151
153
  case "wait":
152
154
  if (this.paused)
153
155
  return;
@@ -118,6 +118,12 @@ export type ToolbarMessage = {
118
118
  name: string;
119
119
  pattern?: string;
120
120
  }
121
+ /** The pattern of Save as output for an element that shows a secret, as typed: answered with an `OutputPreview` */
122
+ | {
123
+ action: "output-preview";
124
+ id: number;
125
+ pattern: string;
126
+ }
121
127
  /** The Page panel opened: answered with a `PageInfo` */
122
128
  | {
123
129
  action: "page-info";
@@ -148,6 +154,8 @@ export type PickInfo = {
148
154
  text: string;
149
155
  /** The whole text as `outputs.save` reads it (`textContent`, trimmed): what Save as output captures from */
150
156
  outputText: string;
157
+ /** `outputText` held a secret, which is masked in it: Save as output asks the recorder for its preview */
158
+ showsSecret?: true;
151
159
  value?: string;
152
160
  attributes: {
153
161
  name: string;
@@ -186,6 +194,21 @@ export type PageInfo = {
186
194
  ok: false;
187
195
  error: string;
188
196
  };
197
+ /**
198
+ * The answer to `output-preview`: what `outputs.save` would keep from the element's real text with `pattern`,
199
+ * every secret masked; `secret` when the value held one. `pattern` is the one asked about, so a late answer is dropped.
200
+ */
201
+ export type OutputPreview = {
202
+ preview: true;
203
+ pattern: string;
204
+ } & ({
205
+ ok: true;
206
+ value: string;
207
+ secret: boolean;
208
+ } | {
209
+ ok: false;
210
+ error: string;
211
+ });
189
212
  /** The answer to `insert`, `add-insertable`, `assert`, `assert-page` and `save-output` */
190
213
  export type ActionReply = {
191
214
  ok: true;
@@ -16,7 +16,10 @@ export const FIELDS_JS = String.raw ` const press = (action, detail) => {
16
16
  if (ui.output[key] === value) return;
17
17
  ui.output[key] = value;
18
18
  if (key === "name") ui.output.nameError = null;
19
- else ui.output.error = null;
19
+ else {
20
+ ui.output.error = null;
21
+ if (ui.output.info && ui.output.info.ok && ui.output.info.showsSecret) askPreview(ui.output);
22
+ }
20
23
  render();
21
24
  }
22
25
  else if (field === "attribute" && ui.assert) {
@@ -63,7 +63,7 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
63
63
  const r = target.getBoundingClientRect();
64
64
  const rect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
65
65
  if (ui.pickFor === "output") {
66
- ui.output = { id, target, candidates, info: null, name: "", pattern: "", nameError: null, error: null, rect };
66
+ ui.output = { id, target, candidates, info: null, name: "", pattern: "", preview: null, nameError: null, error: null, rect };
67
67
  } else {
68
68
  ui.assert = {
69
69
  id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
@@ -27,6 +27,13 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
27
27
  render();
28
28
  };
29
29
  const reply = (id, result) => {
30
+ // A Save as output preview, for the pattern still typed (a late one is dropped); it doesn't end a pending action
31
+ if (result && result.preview) {
32
+ const o = ui.panel === "output" && ui.output;
33
+ if (!o || o.id !== id || o.pattern.trim() !== result.pattern) return;
34
+ o.preview = result;
35
+ return render();
36
+ }
30
37
  if (!ui.pending || id !== ui.pending || !result) return;
31
38
  ui.busy = false;
32
39
  const ok = result.ok === true;
@@ -44,6 +51,7 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
44
51
  } else if (ui.panel === "output" && ui.output && !ui.output.info) {
45
52
  ui.output.info = result;
46
53
  if (ok) ui.focus = "outputName";
54
+ if (ok && result.showsSecret) askPreview(ui.output);
47
55
  } else if (ui.panel === "page" && ui.page && !ui.page.info) {
48
56
  ui.page.info = result;
49
57
  if (ok) {
@@ -82,17 +82,28 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
82
82
  if (!match) return { error: "No match" };
83
83
  return { value: match[1] !== undefined ? match[1] : match[0] };
84
84
  };
85
+ // An element that shows a secret reaches the page with it masked: the recorder works out its preview from the real text
86
+ const askPreview = (o) => {
87
+ o.preview = null;
88
+ post({ action: "output-preview", id: o.id, pattern: o.pattern.trim() });
89
+ };
90
+ const outputCapture = (o) => {
91
+ if (!o.info.showsSecret) return captureOf(o.info.outputText, o.pattern);
92
+ if (!o.preview) return { value: "…" };
93
+ return o.preview.ok ? { value: o.preview.value, secret: o.preview.secret } : { error: o.preview.error };
94
+ };
85
95
  const outputPanel = () => {
86
96
  const o = ui.output;
87
97
  const info = o.info;
88
98
  const head = h("div", { class: "h" }, "Save as output", info && info.ok && h("code", { title: info.label }, info.label));
89
99
  if (!info) return [head, h("div", { class: "msg" }, "…")];
90
100
  if (!info.ok) return [head, h("div", { class: "err" }, info.error)];
91
- const capture = captureOf(info.outputText, o.pattern);
101
+ const capture = outputCapture(o);
92
102
  return [
93
103
  head,
104
+ info.showsSecret && h("div", { class: "warn out-secret" }, "This element shows a secret. The saved output may contain it; use a pattern that captures only the non-secret part."),
94
105
  h("div", { class: "lbl" }, "Text"),
95
- h("div", { class: "msg out-text" }, info.text),
106
+ h("div", { class: "msg out-text" }, info.showsSecret ? info.outputText : info.text),
96
107
  h("div", { class: "lbl" }, "Name"),
97
108
  h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputName", value: o.name, "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
98
109
  o.nameError && h("div", { class: "field-error" }, o.nameError),
@@ -100,6 +111,7 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
100
111
  h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputPattern", value: o.pattern, placeholder: "Order #(\\d+)", "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
101
112
  h("div", { class: "lbl" }, "Saves"),
102
113
  capture.error ? h("div", { class: "warn out-capture" }, capture.error) : h("code", { class: "out-capture" }, capture.value),
114
+ capture.secret && h("div", { class: "err out-leak" }, "The output will contain a secret: reports mask it, but whatever reads the output gets the real value."),
103
115
  o.error && h("div", { class: "err" }, o.error),
104
116
  h("div", { class: "foot" }, h("button", { class: "add", "data-action": "output-save", disabled: ui.busy || !o.name.trim() }, "Save")),
105
117
  ];
@@ -9,7 +9,8 @@
9
9
  * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
10
10
  * - `update(state)`: the recorder's state, after every change and every page load.
11
11
  * - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
12
- * `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`).
12
+ * `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
13
+ * `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
13
14
  * - `root`: the shadow root, for tests.
14
15
  * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
15
16
  * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
@@ -24,7 +24,8 @@ import { ATTACH_JS } from "./source/attach.js";
24
24
  * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
25
25
  * - `update(state)`: the recorder's state, after every change and every page load.
26
26
  * - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
27
- * `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`).
27
+ * `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
28
+ * `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
28
29
  * - `root`: the shadow root, for tests.
29
30
  * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
30
31
  * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
@@ -1,7 +1,7 @@
1
1
  import type { ElementHandle } from "../../script/element-handle.js";
2
2
  import type { LocatorCall, LocatorSpec } from "../../steps/model.js";
3
3
  import type { Observation } from "../normalise.js";
4
- import type { ActionReply, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
4
+ import type { ActionReply, OutputPreview, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
5
5
  /** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
6
6
  export interface Inspection {
7
7
  info: PickInfo & {
@@ -14,7 +14,7 @@ export interface Inspection {
14
14
  export interface ControllerDeps {
15
15
  /** Sends the state to every loaded top-level document's toolbar */
16
16
  push(state: ToolbarState): void;
17
- reply(id: number, result: PickInfo | PageInfo | ActionReply): void;
17
+ reply(id: number, result: PickInfo | PageInfo | ActionReply | OutputPreview): void;
18
18
  currentUrl(): Promise<string>;
19
19
  /** The recorded page's URL and title */
20
20
  pageInfo(): Promise<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {
@@ -57,11 +57,15 @@
57
57
  "scripts": {
58
58
  "dev": "tsx src/index.ts",
59
59
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
60
+ "build-watch": "node scripts/copy-types.mjs && tsc -w --preserveWatchOutput -p tsconfig.build.json",
60
61
  "prepack": "npm run build",
62
+ "typecheck": "tsc --noEmit --noUnusedLocals",
61
63
  "test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
62
- "test:browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 ${SAMURAI_TEST_SHARD:+--test-shard=$SAMURAI_TEST_SHARD} \"src/**/*.browser-test.ts\"",
63
- "test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",
64
- "test:cli": "tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
64
+ "test-browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 $(node scripts/browser-shards.mjs)",
65
+ "test-browser-chrome": "SAMURAI_TEST_BROWSER=chrome bun run test-browser",
66
+ "test-cli": "tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 \"src/**/*.cli-test.ts\"",
67
+ "knip": "knip --no-config-hints",
68
+ "madge": "madge --circular src"
65
69
  },
66
70
  "keywords": [
67
71
  "testing",
@@ -77,6 +81,8 @@
77
81
  "devDependencies": {
78
82
  "@types/node": "^25.6.0",
79
83
  "esbuild": "^0.28.2",
84
+ "knip": "^6.42.1",
85
+ "madge": "^8.0.0",
80
86
  "tsx": "^4.23.15"
81
87
  },
82
88
  "dependencies": {
@@ -113,5 +119,39 @@
113
119
  },
114
120
  "engines": {
115
121
  "node": ">=24"
122
+ },
123
+ "nx": {
124
+ "name": "framework",
125
+ "targets": {
126
+ "test": {
127
+ "dependsOn": [
128
+ "build"
129
+ ]
130
+ },
131
+ "test-cli": {
132
+ "dependsOn": [
133
+ "build"
134
+ ],
135
+ "cache": true,
136
+ "parallelism": false
137
+ },
138
+ "test-browser": {
139
+ "cache": true,
140
+ "parallelism": false,
141
+ "inputs": [
142
+ "default",
143
+ {
144
+ "env": "SAMURAI_TEST_BROWSER"
145
+ },
146
+ {
147
+ "env": "SAMURAI_TEST_SHARD"
148
+ }
149
+ ]
150
+ },
151
+ "build-watch": {
152
+ "continuous": true,
153
+ "cache": false
154
+ }
155
+ }
116
156
  }
117
157
  }