@itmetsam/samurai-framework 0.5.0 → 0.7.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 (78) hide show
  1. package/README.md +4 -4
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/api.d.ts +1 -0
  4. package/dist/api.js +1 -0
  5. package/dist/browser/browser-finder.d.ts +1 -0
  6. package/dist/browser/browser-finder.js +1 -0
  7. package/dist/browser/browser.d.ts +8 -0
  8. package/dist/browser/browser.js +33 -12
  9. package/dist/browser/evidence.d.ts +19 -0
  10. package/dist/browser/evidence.js +35 -0
  11. package/dist/browser/page.d.ts +6 -0
  12. package/dist/browser/page.js +20 -3
  13. package/dist/browser/screenshot.d.ts +2 -0
  14. package/dist/browser/screenshot.js +5 -2
  15. package/dist/browser/warm-up.d.ts +14 -0
  16. package/dist/browser/warm-up.js +37 -0
  17. package/dist/browsers/cache-dir.d.ts +2 -0
  18. package/dist/browsers/cache-dir.js +15 -0
  19. package/dist/browsers/downloader.d.ts +23 -0
  20. package/dist/browsers/downloader.js +52 -0
  21. package/dist/browsers/index.d.ts +6 -0
  22. package/dist/browsers/index.js +5 -0
  23. package/dist/browsers/overrides.d.ts +17 -0
  24. package/dist/browsers/overrides.js +15 -0
  25. package/dist/browsers/pins.d.ts +16 -0
  26. package/dist/browsers/pins.js +14 -0
  27. package/dist/browsers/resolve.d.ts +26 -0
  28. package/dist/browsers/resolve.js +56 -0
  29. package/dist/browsers/store.d.ts +52 -0
  30. package/dist/browsers/store.js +170 -0
  31. package/dist/cli/args.d.ts +2 -0
  32. package/dist/cli/args.js +2 -0
  33. package/dist/cli/browsers.d.ts +6 -0
  34. package/dist/cli/browsers.js +117 -0
  35. package/dist/cli/main.d.ts +1 -1
  36. package/dist/cli/main.js +26 -2
  37. package/dist/project/config-edit.d.ts +16 -0
  38. package/dist/project/config-edit.js +218 -0
  39. package/dist/project/index.d.ts +3 -0
  40. package/dist/project/index.js +3 -0
  41. package/dist/project/secrets-file.d.ts +8 -0
  42. package/dist/project/secrets-file.js +69 -0
  43. package/dist/project/variables.d.ts +6 -0
  44. package/dist/project/variables.js +17 -0
  45. package/dist/recorder/candidates.d.ts +7 -0
  46. package/dist/recorder/candidates.js +118 -0
  47. package/dist/recorder/capture.d.ts +8 -1
  48. package/dist/recorder/capture.js +25 -116
  49. package/dist/recorder/index.d.ts +1 -1
  50. package/dist/recorder/normalise.d.ts +28 -1
  51. package/dist/recorder/normalise.js +40 -1
  52. package/dist/recorder/recorder.d.ts +54 -0
  53. package/dist/recorder/recorder.js +195 -10
  54. package/dist/recorder/session.d.ts +15 -0
  55. package/dist/recorder/session.js +61 -7
  56. package/dist/recorder/toolbar/controller.d.ts +136 -0
  57. package/dist/recorder/toolbar/controller.js +471 -0
  58. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  59. package/dist/recorder/toolbar/inspect.js +94 -0
  60. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  61. package/dist/recorder/toolbar/protocol.js +4 -0
  62. package/dist/recorder/toolbar/source.d.ts +32 -0
  63. package/dist/recorder/toolbar/source.js +856 -0
  64. package/dist/recorder/toolbar/styles.d.ts +6 -0
  65. package/dist/recorder/toolbar/styles.js +95 -0
  66. package/dist/runner/reporter.d.ts +5 -1
  67. package/dist/runner/reporter.js +4 -3
  68. package/dist/runner/test-runner.d.ts +9 -0
  69. package/dist/runner/test-runner.js +44 -4
  70. package/dist/steps/emit.js +4 -0
  71. package/dist/steps/model.d.ts +11 -0
  72. package/dist/steps/parse.js +22 -3
  73. package/dist/transport/bidi-connection.d.ts +2 -0
  74. package/dist/transport/bidi-connection.js +33 -2
  75. package/dist/types/bidi-modules/script.d.ts +20 -3
  76. package/dist/types/config.d.ts +5 -0
  77. package/dist/types/test.d.ts +4 -0
  78. package/package.json +10 -1
@@ -1,39 +1,99 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import logger from "../logger/index.js";
3
3
  import { addPreload, runInLoadedContexts } from "../script/preload.js";
4
+ import { callFunction } from "../script/call-function.js";
4
5
  import { ChannelArg, fromRemoteValue } from "../script/serialize.js";
5
6
  import { ElementHandle } from "../script/element-handle.js";
6
- import { CAPTURE_OFF, CAPTURE_SOURCE } from "./capture.js";
7
+ import { locatorFromSpec } from "../steps/build.js";
8
+ import { addVariable, setSecret } from "../project/index.js";
9
+ import { CAPTURE_MUTE, CAPTURE_OFF, CAPTURE_SOURCE, CAPTURE_UNMUTE, } from "./capture.js";
7
10
  import { Normaliser, } from "./normalise.js";
8
11
  import { rankLocator } from "./ranking.js";
12
+ import { ToolbarController, } from "./toolbar/controller.js";
13
+ import { inspectElement } from "./toolbar/inspect.js";
14
+ import { TOOLBAR_KEY, TOOLBAR_SANDBOX, } from "./toolbar/protocol.js";
15
+ import { TOOLBAR_SOURCE } from "./toolbar/source.js";
16
+ /** The toolbar's hook in its sandbox */
17
+ const HOOK = `globalThis[Symbol.for(${JSON.stringify(TOOLBAR_KEY)})]`;
9
18
  /**
10
19
  * Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
11
20
  * typing and Alt+click assertions over a `script.message` channel; each element is given locators that
12
21
  * were checked against the page; the normaliser turns the stream into steps.
13
22
  */
14
23
  export class Recorder {
15
- constructor(page, connector, context, normaliser, options, channel) {
24
+ constructor(page, connector, context, normaliser, options,
25
+ /** The capture script's channel: page messages only */
26
+ channel,
27
+ /** The toolbar's channel, handed only to the toolbar's sandbox: toolbar messages only */
28
+ toolbarChannel) {
16
29
  this.page = page;
17
30
  this.connector = connector;
18
31
  this.context = context;
19
32
  this.normaliser = normaliser;
20
33
  this.options = options;
21
34
  this.channel = channel;
35
+ this.toolbarChannel = toolbarChannel;
22
36
  this.queue = { tail: Promise.resolve() };
23
37
  this.known = new Map();
24
38
  this.stopped = false;
39
+ /** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
40
+ this.toolbarRealms = new Set();
41
+ /** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
42
+ this.toolbarBacklog = Promise.resolve();
43
+ this.toolbarWaiting = 0;
44
+ /**
45
+ * Page messages come only on the capture channel; toolbar messages only on the toolbar channel and
46
+ * only from the toolbar's sandbox realm. Neither channel is reachable from page script (each stays in
47
+ * its script's closure, and the toolbar's in a sandbox the page cannot see); the realm check also
48
+ * holds if a page realm ever got the toolbar's channel.
49
+ */
25
50
  this.onMessage = (params) => {
26
- if (params.channel !== this.channel ||
27
- params.source.context !== this.context)
51
+ if (params.source.context !== this.context)
28
52
  return;
29
- const message = fromRemoteValue(params.data);
30
- this.enqueue(async () => this.handle(message));
53
+ if (params.channel === this.channel) {
54
+ const message = fromRemoteValue(params.data);
55
+ if (message.type !== "toolbar")
56
+ this.enqueue(async () => this.handle(message));
57
+ return;
58
+ }
59
+ if (params.channel !== this.toolbarChannel || !this.controller)
60
+ return;
61
+ const realm = params.source.realm ?? "";
62
+ if (this.toolbarWaiting === 0 && this.toolbarRealms.has(realm)) {
63
+ this.fromToolbar(params.data);
64
+ return;
65
+ }
66
+ // A sandbox not seen yet (a new document's toolbar): look it up, and keep later messages behind it
67
+ this.toolbarWaiting++;
68
+ this.toolbarBacklog = this.toolbarBacklog
69
+ .then(async () => {
70
+ if (!this.toolbarRealms.has(realm))
71
+ await this.listToolbarRealms();
72
+ if (this.toolbarRealms.has(realm))
73
+ this.fromToolbar(params.data);
74
+ else
75
+ logger.warn("Recorder ignored a toolbar message from outside the toolbar's sandbox");
76
+ })
77
+ .catch((err) => {
78
+ logger.debug("Could not check a toolbar message's realm", { err });
79
+ })
80
+ .finally(() => this.toolbarWaiting--);
81
+ };
82
+ this.onLoad = (params) => {
83
+ if (params.context === this.context && params.url !== "about:blank")
84
+ this.controller?.loaded();
31
85
  };
32
86
  this.onNavigation = (params) => {
33
87
  if (params.context !== this.context || params.url === "about:blank")
34
88
  return;
89
+ this.controller?.navigating();
35
90
  this.known.clear();
36
- this.enqueue(async () => this.emit(this.normaliser.navigated(params.url, Date.now())));
91
+ this.enqueue(async () => {
92
+ // Paused: navigations are not recorded either
93
+ if (this.controller?.paused)
94
+ return;
95
+ this.emit(this.normaliser.navigated(params.url, Date.now()));
96
+ });
37
97
  };
38
98
  }
39
99
  static async start(target, options) {
@@ -42,17 +102,49 @@ export class Recorder {
42
102
  at,
43
103
  ...(options.baseURL !== undefined && { baseURL: options.baseURL }),
44
104
  });
45
- const recorder = new Recorder(target.page, target.connector, target.context, normaliser, options, `samurai-recorder-${randomUUID()}`);
105
+ const recorder = new Recorder(target.page, target.connector, target.context, normaliser, options,
106
+ // The connector's debug log leaves out the data of these channels' messages (it knows them by prefix)
107
+ `samurai-recorder-${randomUUID()}`, `samurai-toolbar-${randomUUID()}`);
46
108
  await recorder.begin(options.initialGoto ?? true);
47
109
  return recorder;
48
110
  }
111
+ async listToolbarRealms() {
112
+ const { realms } = await this.connector.send("script.getRealms", {
113
+ context: this.context,
114
+ type: "window",
115
+ });
116
+ this.toolbarRealms = new Set(realms
117
+ .filter((info) => info.type === "window" && info.sandbox === TOOLBAR_SANDBOX)
118
+ .map((info) => info.realm));
119
+ }
120
+ /** A message from the toolbar's own sandbox */
121
+ fromToolbar(data) {
122
+ const message = fromRemoteValue(data);
123
+ const controller = this.controller;
124
+ if (!controller || message.type !== "toolbar" || !("action" in message))
125
+ return;
126
+ if (message.action === "ready") {
127
+ // Not behind a slow insert: the ready watchdog is waiting for it
128
+ void controller.handle(message);
129
+ }
130
+ else if (controller.accept(message)) {
131
+ // Decided as it arrives: a double click's second copy would only run after the first had finished
132
+ this.enqueue(async () => controller.handle(message));
133
+ }
134
+ }
49
135
  async begin(initialGoto) {
136
+ const toolbar = this.options.toolbar;
137
+ if (toolbar)
138
+ this.controller = this.createController(toolbar);
50
139
  this.connector.onEvent("script.message", this.onMessage);
51
140
  this.connector.onEvent("browsingContext.navigationStarted", this.onNavigation);
141
+ if (toolbar)
142
+ this.connector.onEvent("browsingContext.load", this.onLoad);
52
143
  try {
53
144
  this.subscription = await this.connector.subscribe([
54
145
  "script.message",
55
146
  "browsingContext.navigationStarted",
147
+ ...(toolbar ? ["browsingContext.load"] : []),
56
148
  ]);
57
149
  if (initialGoto) {
58
150
  const url = await this.page.url();
@@ -65,12 +157,62 @@ export class Recorder {
65
157
  arguments: [new ChannelArg(this.channel)],
66
158
  onRunError: "log",
67
159
  });
160
+ if (this.controller) {
161
+ this.toolbarPreload = await addPreload(this.connector, {
162
+ source: TOOLBAR_SOURCE,
163
+ sandbox: TOOLBAR_SANDBOX,
164
+ contexts: [this.context],
165
+ arguments: [new ChannelArg(this.toolbarChannel)],
166
+ onRunError: "log",
167
+ });
168
+ // The document that is loaded now gets its state and its ready watchdog as a new one would
169
+ this.controller.loaded();
170
+ }
68
171
  }
69
172
  catch (err) {
173
+ this.controller?.dispose();
70
174
  await this.release();
71
175
  throw err;
72
176
  }
73
177
  }
178
+ createController(toolbar) {
179
+ const { projectDir, environment, onStatus, onStop, onSecretAdded } = toolbar;
180
+ return new ToolbarController({
181
+ push: (state) => this.callToolbar("update", [state]),
182
+ reply: (id, result) => this.callToolbar("reply", [id, result]),
183
+ currentUrl: () => this.page.url(),
184
+ rank: (target, candidates) => rankLocator(this.page, target, candidates),
185
+ inspect: (target, locator, candidates) => inspectElement(this.page, this.connector, this.context, target, locator, candidates),
186
+ type: async (locator, value) => {
187
+ await this.mute();
188
+ try {
189
+ await locatorFromSpec(this.page, locator).fill(value, {
190
+ timeout: 5000,
191
+ });
192
+ }
193
+ finally {
194
+ await this.unmute();
195
+ }
196
+ },
197
+ mute: () => this.mute(),
198
+ unmute: () => this.unmute(),
199
+ addVariable: (name, value) => addVariable(projectDir, environment, name, value),
200
+ setSecret: (name, value) => setSecret(projectDir, environment, name, value),
201
+ secretAdded: (name) => onSecretAdded?.(name),
202
+ observe: (observation) => this.emit(this.normaliser.observe(observation, Date.now())),
203
+ stop: () => {
204
+ if (onStop)
205
+ onStop();
206
+ else
207
+ void this.stop();
208
+ },
209
+ onStatus: (status) => onStatus?.(status),
210
+ }, toolbar);
211
+ }
212
+ /** Calls the toolbar hook's `method` in the loaded document; a document between loads has no toolbar yet */
213
+ callToolbar(method, args) {
214
+ callFunction(this.connector, this.context, `(...args) => { ${HOOK}?.${method}(...args); }`, args, { sandbox: TOOLBAR_SANDBOX, awaitPromise: false }).catch((err) => logger.debug("Could not reach the recorder toolbar", { err }));
215
+ }
74
216
  enqueue(work) {
75
217
  this.queue.tail = this.queue.tail.then(work).catch((err) => {
76
218
  logger.warn("Recorder could not process an event", { err });
@@ -79,8 +221,11 @@ export class Recorder {
79
221
  emit(events) {
80
222
  if (this.stopped)
81
223
  return;
82
- for (const event of events)
224
+ for (const event of events) {
83
225
  this.options.onEvent(event);
226
+ if (event.op === "insert")
227
+ this.controller?.stepAdded(event.step);
228
+ }
84
229
  }
85
230
  async locatorFor(message) {
86
231
  const { sharedId } = message.target;
@@ -91,6 +236,9 @@ export class Recorder {
91
236
  return spec;
92
237
  }
93
238
  async handle(message) {
239
+ // Paused: what the capture script still reports (before a new document is muted) is dropped
240
+ if (this.controller?.paused)
241
+ return;
94
242
  const locator = await this.locatorFor(message);
95
243
  if (!locator) {
96
244
  logger.warn("Recorder found no locator that matches only the element the tester used; the %s was skipped", message.type);
@@ -124,20 +272,57 @@ export class Recorder {
124
272
  async release() {
125
273
  this.connector.offEvent("script.message", this.onMessage);
126
274
  this.connector.offEvent("browsingContext.navigationStarted", this.onNavigation);
275
+ this.connector.offEvent("browsingContext.load", this.onLoad);
127
276
  await Promise.allSettled([
128
277
  this.subscription?.unsubscribe(),
129
278
  this.preload?.dispose(),
279
+ this.toolbarPreload?.dispose(),
130
280
  ]);
131
281
  }
282
+ /**
283
+ * Pauses capture in the loaded document: clicks and typing are not reported until `unmute`.
284
+ * @internal
285
+ */
286
+ async mute() {
287
+ await this.setMuted(CAPTURE_MUTE);
288
+ }
289
+ /** @internal */
290
+ async unmute() {
291
+ await this.setMuted(CAPTURE_UNMUTE);
292
+ }
293
+ async setMuted(source) {
294
+ await runInLoadedContexts(this.connector, {
295
+ source,
296
+ contexts: [this.context],
297
+ onRunError: "log",
298
+ });
299
+ }
132
300
  /** Stops recording. Events already received are still processed and reported first */
133
- async stop() {
301
+ stop() {
302
+ this.stopping ??= this.end();
303
+ return this.stopping;
304
+ }
305
+ async end() {
134
306
  if (this.stopped)
135
307
  return;
308
+ this.controller?.dispose();
136
309
  await runInLoadedContexts(this.connector, {
137
310
  source: CAPTURE_OFF,
138
311
  contexts: [this.context],
139
312
  onRunError: "log",
140
313
  }).catch(() => undefined);
314
+ // A later recording of this document must not start muted
315
+ if (this.controller?.paused)
316
+ await this.unmute().catch(() => undefined);
317
+ if (this.toolbarPreload) {
318
+ // The window may stay open: take the toolbar and its listeners off the page
319
+ await runInLoadedContexts(this.connector, {
320
+ source: `() => { ${HOOK}?.dispose(); }`,
321
+ contexts: [this.context],
322
+ sandbox: TOOLBAR_SANDBOX,
323
+ onRunError: "log",
324
+ }).catch(() => undefined);
325
+ }
141
326
  await this.queue.tail;
142
327
  this.stopped = true;
143
328
  await this.release();
@@ -1,6 +1,9 @@
1
1
  import { Browser } from "../browser/browser.js";
2
2
  import type Page from "../browser/page.js";
3
+ import type { PreparedRun } from "../runner/prepare-run.js";
3
4
  import { type ProjectOptions } from "../runner/project.js";
5
+ import type { SamuraiTestConfig } from "../types/config.js";
6
+ import type { ControllerOptions } from "./toolbar/controller.js";
4
7
  import type { RecorderEvent } from "./normalise.js";
5
8
  export interface RecordSpecOptions extends ProjectOptions {
6
9
  /** The spec file, absolute or relative to the project folder. Created when `create` is given and it doesn't exist */
@@ -24,6 +27,14 @@ export interface RecordSpecOptions extends ProjectOptions {
24
27
  signal?: AbortSignal;
25
28
  /** Called after each step is written to the file, with the file's new content */
26
29
  onEvent?: (event: RecorderEvent, source: string) => void;
30
+ /**
31
+ * Called when the recording is paused or resumed, or the toolbar fails (or comes back) on a page.
32
+ * There is no initial call: until the first one the recording is `{ state: "recording", toolbar: true }`
33
+ */
34
+ onStatus?: (status: {
35
+ state: "recording" | "paused";
36
+ toolbar: boolean;
37
+ }) => void;
27
38
  /** Called once the browser is open and recording */
28
39
  onStarted?: (context: {
29
40
  page: Page;
@@ -38,6 +49,10 @@ export interface RecordResult {
38
49
  /** Names the recorded steps read with `secrets.<NAME>`: they must be set before the test can run */
39
50
  secrets: string[];
40
51
  }
52
+ /** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
53
+ export declare function toolbarOptions(run: Pick<PreparedRun, "settings" | "secrets">, source: string, dir: string, config: Pick<SamuraiTestConfig, "environments">, testIndex: number): Promise<ControllerOptions>;
54
+ /** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
55
+ export declare function resultSecrets(events: readonly Pick<RecorderEvent, "step">[], added: Iterable<string>): string[];
41
56
  /** The index of the test `selector` names (see `RecordSpecOptions.test`) */
42
57
  export declare function pickTest(tests: readonly {
43
58
  name: string;
@@ -1,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Browser } from "../browser/browser.js";
4
- import { projectDir } from "../config/config.js";
4
+ import { ensureWarm } from "../browser/warm-up.js";
5
+ import { loadConfig, projectDir } from "../config/config.js";
6
+ import { isGitIgnored } from "../project/index.js";
5
7
  import { withProject } from "../runner/project.js";
6
8
  import { parseSpec } from "../steps/parse.js";
7
9
  import { applyRecorderEvent } from "./apply.js";
@@ -12,6 +14,47 @@ function addTest(source, title) {
12
14
  : `${source.replace(/\s*$/, "")}\n\n`;
13
15
  return `${header}test(${JSON.stringify(title)}, async ({ page }) => {\n});\n`;
14
16
  }
17
+ /** Every `secrets.<NAME>` a parsed value contains, at any depth */
18
+ function secretNames(node, found) {
19
+ if (Array.isArray(node))
20
+ node.forEach((item) => secretNames(item, found));
21
+ else if (node !== null && typeof node === "object") {
22
+ const record = node;
23
+ if (record.kind === "secret" && typeof record.name === "string")
24
+ found.add(record.name);
25
+ Object.values(record).forEach((item) => secretNames(item, found));
26
+ }
27
+ }
28
+ /** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
29
+ export async function toolbarOptions(run, source, dir, config, testIndex) {
30
+ const tests = parseSpec(source);
31
+ const names = new Set(run.secrets.keys());
32
+ secretNames(tests, names);
33
+ const secrets = {};
34
+ for (const name of names)
35
+ secrets[name] = run.secrets.get(name);
36
+ const { environment } = run.settings;
37
+ return {
38
+ environment,
39
+ canAddVariables: typeof config.environments === "object",
40
+ variables: run.settings.variables,
41
+ secrets,
42
+ secretsIgnored: await isGitIgnored(dir, `.env.${environment}`),
43
+ screenshots: tests[testIndex]?.steps.filter(({ step }) => step.kind === "screenshot")
44
+ .length ?? 0,
45
+ };
46
+ }
47
+ /** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
48
+ export function resultSecrets(events, added) {
49
+ return [
50
+ ...new Set([
51
+ ...events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
52
+ ? [step.value.name]
53
+ : []),
54
+ ...added,
55
+ ]),
56
+ ];
57
+ }
15
58
  /** The index of the test `selector` names (see `RecordSpecOptions.test`) */
16
59
  export function pickTest(tests, selector) {
17
60
  if (tests.length === 0)
@@ -46,12 +89,16 @@ export function pickTest(tests, selector) {
46
89
  * await recordSpec({ file: "tests/login.spec.ts", create: "signs in", environment: "staging" });
47
90
  */
48
91
  export async function recordSpec(options) {
49
- return withProject(options, async ({ settings }) => {
92
+ return withProject(options, async (run) => {
93
+ const { settings } = run;
50
94
  const file = path.resolve(projectDir(), options.file);
51
95
  let source = existsSync(file) ? readFileSync(file, "utf8") : undefined;
52
96
  if (source === undefined && options.create === undefined) {
53
97
  throw new Error(`${options.file} does not exist; give a title with --new to create it`);
54
98
  }
99
+ // Before the spec is touched: a recording without its browser changes nothing
100
+ await ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
101
+ options.signal?.throwIfAborted();
55
102
  source ??= "";
56
103
  if (options.create !== undefined &&
57
104
  !parseSpec(source).some((t) => t.name === options.create)) {
@@ -71,6 +118,9 @@ export async function recordSpec(options) {
71
118
  headless: options.headless ?? settings.headless,
72
119
  }, options.signal);
73
120
  const events = [];
121
+ const addedSecrets = new Set();
122
+ let stopRequested;
123
+ const stopped = new Promise((resolve) => (stopRequested = resolve));
74
124
  try {
75
125
  const start = options.url ?? settings.baseURL;
76
126
  if (options.url !== undefined)
@@ -81,6 +131,13 @@ export async function recordSpec(options) {
81
131
  at,
82
132
  initialGoto: at === 0 && start !== undefined,
83
133
  ...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
134
+ toolbar: {
135
+ ...(await toolbarOptions(run, source, projectDir(), await loadConfig(), testIndex)),
136
+ projectDir: projectDir(),
137
+ ...(options.onStatus && { onStatus: options.onStatus }),
138
+ onSecretAdded: (name) => addedSecrets.add(name),
139
+ onStop: () => stopRequested(),
140
+ },
84
141
  onEvent: (event) => {
85
142
  source = applyRecorderEvent(source, testIndex, event);
86
143
  writeFileSync(file, source);
@@ -98,6 +155,7 @@ export async function recordSpec(options) {
98
155
  once: true,
99
156
  });
100
157
  void browser.exited.then(resolve);
158
+ void stopped.then(resolve);
101
159
  });
102
160
  // With the window gone there is nothing to ask: what was reported before is in the file
103
161
  if (!windowClosed)
@@ -106,11 +164,7 @@ export async function recordSpec(options) {
106
164
  finally {
107
165
  await browser.close().catch(() => undefined);
108
166
  }
109
- const secrets = [
110
- ...new Set(events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
111
- ? [step.value.name]
112
- : [])),
113
- ];
167
+ const secrets = resultSecrets(events, addedSecrets);
114
168
  return { file, test: test.name, events, secrets };
115
169
  });
116
170
  }
@@ -0,0 +1,136 @@
1
+ import type { ElementHandle } from "../../script/element-handle.js";
2
+ import type { LocatorCall, LocatorSpec, Step } from "../../steps/model.js";
3
+ import { type Observation } from "../normalise.js";
4
+ import type { ActionReply, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
5
+ /** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
6
+ export interface Inspection {
7
+ info: PickInfo & {
8
+ ok: true;
9
+ };
10
+ /** `counts[i]` is the locator `info.counts[i]` describes */
11
+ counts: LocatorCall[];
12
+ }
13
+ /** Everything the controller does to the browser and the project; the recorder implements these */
14
+ export interface ControllerDeps {
15
+ /** Sends the state to every loaded top-level document's toolbar */
16
+ push(state: ToolbarState): void;
17
+ reply(id: number, result: PickInfo | ActionReply): void;
18
+ currentUrl(): Promise<string>;
19
+ rank(target: ElementHandle, candidates: LocatorCall[]): Promise<LocatorSpec | undefined>;
20
+ inspect(target: ElementHandle, locator: LocatorSpec, candidates: LocatorCall[]): Promise<Inspection>;
21
+ /** Types into the field with capture muted */
22
+ type(locator: LocatorSpec, value: string): Promise<void>;
23
+ mute(): Promise<void>;
24
+ unmute(): Promise<void>;
25
+ addVariable(name: string, value: string): Promise<void>;
26
+ setSecret(name: string, value: string): Promise<void>;
27
+ /** A secret was written to the project (called before its value is typed) */
28
+ secretAdded(name: string): void;
29
+ observe(observation: Observation): void;
30
+ stop(): void;
31
+ onStatus(status: {
32
+ state: "recording" | "paused";
33
+ toolbar: boolean;
34
+ }): void;
35
+ }
36
+ export interface ControllerOptions {
37
+ environment: string;
38
+ canAddVariables: boolean;
39
+ variables: Record<string, string | number | boolean>;
40
+ /** Name → value; undefined when not set */
41
+ secrets: Record<string, string | undefined>;
42
+ secretsIgnored: boolean;
43
+ /** Screenshot steps already in the test, for the default name */
44
+ screenshots: number;
45
+ }
46
+ type Message = ToolbarMessage & {
47
+ target?: ElementHandle;
48
+ };
49
+ /**
50
+ * The recorder's side of the toolbar: owns its state (pause, the rail, the environment's variables and
51
+ * secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
52
+ * their names and whether they are set.
53
+ */
54
+ export declare class ToolbarController {
55
+ private readonly deps;
56
+ private readonly options;
57
+ private status;
58
+ private collapsed;
59
+ private position;
60
+ private steps;
61
+ private screenshots;
62
+ private readonly variables;
63
+ private readonly secrets;
64
+ private pausedAt;
65
+ /** The URL the resume question offers; set while it is asked */
66
+ private question;
67
+ private toast;
68
+ private toastId;
69
+ /** The picked element an `assert` with the same id refers to */
70
+ private pick;
71
+ /** Ids of `assert`, `insert` and `add-insertable` being processed */
72
+ private readonly inFlight;
73
+ /** Ids `accept` let through whose `handle` hasn't started yet */
74
+ private readonly claimed;
75
+ /** Ids answered `{ ok: true }`: their step exists, a repeat is a double click */
76
+ private readonly done;
77
+ /** Bumped on every page load: work started for an older page is not answered */
78
+ private page;
79
+ private toolbar;
80
+ /** A `ready` arrived since the last load: it counts for the next one */
81
+ private readySinceLoad;
82
+ private watchdog;
83
+ private disposed;
84
+ constructor(deps: ControllerDeps, options: ControllerOptions);
85
+ get paused(): boolean;
86
+ private state;
87
+ private push;
88
+ private reply;
89
+ private reportStatus;
90
+ private setToolbar;
91
+ private showToast;
92
+ handle(message: Message): Promise<void>;
93
+ /** A top-level document finished loading: push state; start the 2 s ready watchdog */
94
+ loaded(): void;
95
+ /**
96
+ * A top-level navigation to a new document started: a `ready` from before it belongs to the old document
97
+ * and doesn't count for the coming load
98
+ */
99
+ navigating(): void;
100
+ /** A step was emitted (any source): count it */
101
+ stepAdded(step: Step): void;
102
+ /** The recording ended: no more pushes, replies or status */
103
+ dispose(): void;
104
+ /**
105
+ * The toolbar draws at document start, so its `ready` usually comes before the load; Firefox also runs it
106
+ * in a transient about:blank while navigating. A `ready` that arrives while the watchdog runs settles it;
107
+ * any other counts for the next load.
108
+ */
109
+ private ready;
110
+ private pause;
111
+ private resume;
112
+ private answer;
113
+ /** Runs an action that expects a reply; a second one with the same id while the first runs is dropped */
114
+ /**
115
+ * Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `insert` or
116
+ * `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
117
+ * dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
118
+ */
119
+ accept(message: Message): boolean;
120
+ /** Runs an action that expects a reply, unless it is a repeat (see `accept`) */
121
+ private once;
122
+ private picked;
123
+ /**
124
+ * A field may hold an inserted secret: its value (and any text or attribute equal to a secret) is left out,
125
+ * and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
126
+ */
127
+ private withoutSecrets;
128
+ private assert;
129
+ private insert;
130
+ private addInsertable;
131
+ /** Undefined when `work` succeeded, else the reply for its error */
132
+ private write;
133
+ private locate;
134
+ private typeIn;
135
+ }
136
+ export {};