@itmetsam/samurai-framework 0.7.1 → 0.9.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 (83) hide show
  1. package/README.md +31 -12
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/browser-finder.js +2 -2
  5. package/dist/browser/browser.d.ts +11 -15
  6. package/dist/browser/browser.js +79 -58
  7. package/dist/browser/chrome-launch.d.ts +57 -0
  8. package/dist/browser/chrome-launch.js +127 -0
  9. package/dist/browser/download-folders.d.ts +27 -0
  10. package/dist/browser/download-folders.js +97 -0
  11. package/dist/browser/file-chooser.d.ts +4 -3
  12. package/dist/browser/file-chooser.js +9 -8
  13. package/dist/browser/generated-files.d.ts +30 -0
  14. package/dist/browser/generated-files.js +116 -0
  15. package/dist/browser/page-logs.d.ts +2 -0
  16. package/dist/browser/page-logs.js +25 -5
  17. package/dist/browser/page.d.ts +37 -0
  18. package/dist/browser/page.js +129 -2
  19. package/dist/browser/screenshot.js +16 -2
  20. package/dist/browser/session-capabilities.d.ts +12 -0
  21. package/dist/browser/session-capabilities.js +8 -0
  22. package/dist/browsers/index.d.ts +2 -2
  23. package/dist/browsers/index.js +2 -2
  24. package/dist/browsers/overrides.d.ts +2 -0
  25. package/dist/browsers/overrides.js +2 -0
  26. package/dist/browsers/pins.d.ts +1 -1
  27. package/dist/browsers/pins.js +2 -2
  28. package/dist/browsers/resolve.d.ts +2 -0
  29. package/dist/browsers/resolve.js +14 -2
  30. package/dist/browsers/store.d.ts +2 -0
  31. package/dist/browsers/store.js +8 -0
  32. package/dist/cli/main.d.ts +1 -1
  33. package/dist/cli/main.js +20 -5
  34. package/dist/locator/locator.d.ts +17 -5
  35. package/dist/locator/locator.js +77 -10
  36. package/dist/network/data-collector.d.ts +1 -0
  37. package/dist/network/data-collector.js +23 -5
  38. package/dist/network/network-tracker.d.ts +8 -1
  39. package/dist/network/network-tracker.js +42 -3
  40. package/dist/network/router.d.ts +5 -0
  41. package/dist/network/router.js +33 -11
  42. package/dist/project/files-store.d.ts +28 -0
  43. package/dist/project/files-store.js +106 -0
  44. package/dist/project/index.d.ts +1 -0
  45. package/dist/project/index.js +1 -0
  46. package/dist/recorder/apply.js +8 -6
  47. package/dist/recorder/files-capture.d.ts +26 -0
  48. package/dist/recorder/files-capture.js +92 -0
  49. package/dist/recorder/files-reader.d.ts +7 -0
  50. package/dist/recorder/files-reader.js +25 -0
  51. package/dist/recorder/index.d.ts +1 -1
  52. package/dist/recorder/index.js +1 -1
  53. package/dist/recorder/normalise.d.ts +26 -2
  54. package/dist/recorder/normalise.js +48 -1
  55. package/dist/recorder/recorder.d.ts +38 -0
  56. package/dist/recorder/recorder.js +160 -1
  57. package/dist/recorder/session.d.ts +14 -1
  58. package/dist/recorder/session.js +36 -9
  59. package/dist/recorder/toolbar/controller.d.ts +15 -5
  60. package/dist/recorder/toolbar/controller.js +61 -7
  61. package/dist/recorder/toolbar/protocol.d.ts +10 -0
  62. package/dist/recorder/toolbar/source.d.ts +1 -1
  63. package/dist/recorder/toolbar/source.js +35 -2
  64. package/dist/recorder/toolbar/styles.d.ts +1 -1
  65. package/dist/recorder/toolbar/styles.js +31 -0
  66. package/dist/runner/project.js +2 -0
  67. package/dist/script/preload.js +65 -0
  68. package/dist/steps/edit.js +38 -3
  69. package/dist/steps/emit.d.ts +3 -1
  70. package/dist/steps/emit.js +19 -0
  71. package/dist/steps/files.d.ts +8 -0
  72. package/dist/steps/files.js +33 -0
  73. package/dist/steps/index.d.ts +3 -2
  74. package/dist/steps/index.js +2 -1
  75. package/dist/steps/model.d.ts +28 -0
  76. package/dist/steps/parse.js +79 -0
  77. package/dist/transport/bidi-connection.d.ts +6 -0
  78. package/dist/transport/bidi-connection.js +13 -0
  79. package/dist/types/browser.d.ts +1 -1
  80. package/dist/types/config.d.ts +7 -2
  81. package/package.json +3 -2
  82. package/dist/browser/file-paths.d.ts +0 -4
  83. package/dist/browser/file-paths.js +0 -18
@@ -21,6 +21,24 @@ export type ToolbarOptions = ControllerOptions & {
21
21
  */
22
22
  onStop?: () => void;
23
23
  };
24
+ /** Where uploads made while recording are stored */
25
+ export interface FileRecording {
26
+ /** The project folder */
27
+ projectDir: string;
28
+ /** Relative to the project folder. @default "files" */
29
+ filesDir?: string;
30
+ /** A file that isn't stored (over 50 MB, or unreadable): its step points at `path`, which doesn't exist */
31
+ onMissing?: (file: MissingFile) => void;
32
+ /** @internal Tests only: also record events no person caused (`isTrusted` false) */
33
+ trustSynthetic?: boolean;
34
+ }
35
+ export interface MissingFile {
36
+ /** Project-relative, as the step has it */
37
+ path: string;
38
+ name: string;
39
+ size: number;
40
+ reason: "too-big" | "unreadable";
41
+ }
24
42
  export interface RecorderOptions {
25
43
  /** Called for every step the tester produces, in order. Steps get indexes from `at` on */
26
44
  onEvent: (event: RecorderEvent) => void;
@@ -36,6 +54,8 @@ export interface RecorderOptions {
36
54
  baseURL?: string;
37
55
  /** Shows the recorder toolbar in the page (pause, assert, insert, wait, screenshot, stop). Without it, no toolbar */
38
56
  toolbar?: ToolbarOptions;
57
+ /** Records uploads (file inputs and drops) into the project's files folder. Without it, uploads aren't recorded */
58
+ files?: FileRecording;
39
59
  }
40
60
  /**
41
61
  * Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
@@ -55,9 +75,21 @@ export declare class Recorder {
55
75
  private readonly queue;
56
76
  private readonly known;
57
77
  private stopped;
78
+ /** Set when stopping begins: files messages after it are not taken */
79
+ private ending;
58
80
  private stopping;
59
81
  private controller;
60
82
  private constructor();
83
+ /** The files capture script's channel, handed only to its sandbox */
84
+ private readonly filesChannel;
85
+ private filesPreload;
86
+ private filesRealms;
87
+ private readonly stored;
88
+ private readonly missing;
89
+ /** Files stored or reused in the files folder by this recording, project-relative */
90
+ get storedFiles(): string[];
91
+ /** Files whose steps point at a path that wasn't written */
92
+ get missingFiles(): MissingFile[];
61
93
  /** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
62
94
  private toolbarRealms;
63
95
  /** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
@@ -75,6 +107,7 @@ export declare class Recorder {
75
107
  * holds if a page realm ever got the toolbar's channel.
76
108
  */
77
109
  private readonly onMessage;
110
+ private listFilesRealms;
78
111
  private listToolbarRealms;
79
112
  /** A message from the toolbar's own sandbox */
80
113
  private fromToolbar;
@@ -91,6 +124,9 @@ export declare class Recorder {
91
124
  private emit;
92
125
  private locatorFor;
93
126
  private handle;
127
+ private fromFiles;
128
+ /** Saves one kept file to the files folder; returns the path its step gets */
129
+ private storePageFile;
94
130
  private release;
95
131
  /**
96
132
  * Pauses capture in the loaded document: clicks and typing are not reported until `unmute`.
@@ -104,3 +140,5 @@ export declare class Recorder {
104
140
  stop(): Promise<void>;
105
141
  private end;
106
142
  }
143
+ /** The warning for a file that isn't saved, as the toolbar and the CLI show it */
144
+ export declare function missingFileText(file: MissingFile, folder: string): string;
@@ -1,12 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { mkdtemp, open, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import logger from "../logger/index.js";
3
6
  import { addPreload, runInLoadedContexts } from "../script/preload.js";
4
7
  import { callFunction } from "../script/call-function.js";
5
8
  import { ChannelArg, fromRemoteValue } from "../script/serialize.js";
6
9
  import { ElementHandle } from "../script/element-handle.js";
7
10
  import { locatorFromSpec } from "../steps/build.js";
8
- import { addVariable, setSecret } from "../project/index.js";
11
+ import { addVariable, cleanFileName, filesFolder, RECORD_LIMIT, setSecret, storeFile, } from "../project/index.js";
12
+ import { humanSize } from "../steps/files.js";
9
13
  import { CAPTURE_MUTE, CAPTURE_OFF, CAPTURE_SOURCE, CAPTURE_UNMUTE, } from "./capture.js";
14
+ import { FILES_SANDBOX, filesCaptureSource } from "./files-capture.js";
15
+ import { disposeFilesCapture, readPageFile, releasePageFile, } from "./files-reader.js";
10
16
  import { Normaliser, } from "./normalise.js";
11
17
  import { rankLocator } from "./ranking.js";
12
18
  import { ToolbarController, } from "./toolbar/controller.js";
@@ -36,6 +42,13 @@ export class Recorder {
36
42
  this.queue = { tail: Promise.resolve() };
37
43
  this.known = new Map();
38
44
  this.stopped = false;
45
+ /** Set when stopping begins: files messages after it are not taken */
46
+ this.ending = false;
47
+ /** The files capture script's channel, handed only to its sandbox */
48
+ this.filesChannel = `samurai-files-${randomUUID()}`;
49
+ this.filesRealms = new Set();
50
+ this.stored = new Set();
51
+ this.missing = [];
39
52
  /** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
40
53
  this.toolbarRealms = new Set();
41
54
  /** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
@@ -56,6 +69,23 @@ export class Recorder {
56
69
  this.enqueue(async () => this.handle(message));
57
70
  return;
58
71
  }
72
+ if (params.channel === this.filesChannel) {
73
+ if (this.ending)
74
+ return;
75
+ const realm = params.source.realm ?? "";
76
+ const data = params.data;
77
+ // In order with the page's messages: a picker follows the click that opened it
78
+ this.enqueue(async () => {
79
+ if (!this.filesRealms.has(realm))
80
+ await this.listFilesRealms();
81
+ if (!this.filesRealms.has(realm)) {
82
+ logger.warn("Recorder ignored a files message from outside the files sandbox");
83
+ return;
84
+ }
85
+ await this.fromFiles(fromRemoteValue(data));
86
+ });
87
+ return;
88
+ }
59
89
  if (params.channel !== this.toolbarChannel || !this.controller)
60
90
  return;
61
91
  const realm = params.source.realm ?? "";
@@ -96,6 +126,14 @@ export class Recorder {
96
126
  });
97
127
  };
98
128
  }
129
+ /** Files stored or reused in the files folder by this recording, project-relative */
130
+ get storedFiles() {
131
+ return [...this.stored];
132
+ }
133
+ /** Files whose steps point at a path that wasn't written */
134
+ get missingFiles() {
135
+ return [...this.missing];
136
+ }
99
137
  static async start(target, options) {
100
138
  const at = options.at ?? 0;
101
139
  const normaliser = new Normaliser({
@@ -108,6 +146,15 @@ export class Recorder {
108
146
  await recorder.begin(options.initialGoto ?? true);
109
147
  return recorder;
110
148
  }
149
+ async listFilesRealms() {
150
+ const { realms } = await this.connector.send("script.getRealms", {
151
+ context: this.context,
152
+ type: "window",
153
+ });
154
+ this.filesRealms = new Set(realms
155
+ .filter((info) => info.type === "window" && info.sandbox === FILES_SANDBOX)
156
+ .map((info) => info.realm));
157
+ }
111
158
  async listToolbarRealms() {
112
159
  const { realms } = await this.connector.send("script.getRealms", {
113
160
  context: this.context,
@@ -157,6 +204,17 @@ export class Recorder {
157
204
  arguments: [new ChannelArg(this.channel)],
158
205
  onRunError: "log",
159
206
  });
207
+ if (this.options.files) {
208
+ this.filesPreload = await addPreload(this.connector, {
209
+ source: filesCaptureSource({
210
+ trustSynthetic: this.options.files.trustSynthetic === true,
211
+ }),
212
+ sandbox: FILES_SANDBOX,
213
+ contexts: [this.context],
214
+ arguments: [new ChannelArg(this.filesChannel)],
215
+ onRunError: "log",
216
+ });
217
+ }
160
218
  if (this.controller) {
161
219
  this.toolbarPreload = await addPreload(this.connector, {
162
220
  source: TOOLBAR_SOURCE,
@@ -225,6 +283,8 @@ export class Recorder {
225
283
  this.options.onEvent(event);
226
284
  if (event.op === "insert")
227
285
  this.controller?.stepAdded(event.step);
286
+ else if (event.op === "remove")
287
+ this.controller?.stepRemoved();
228
288
  }
229
289
  }
230
290
  async locatorFor(message) {
@@ -269,6 +329,93 @@ export class Recorder {
269
329
  };
270
330
  this.emit(this.normaliser.observe(observation, Date.now()));
271
331
  }
332
+ async fromFiles(message) {
333
+ const now = Date.now();
334
+ if (message.type !== "files") {
335
+ if (!this.controller?.paused)
336
+ this.emit(this.normaliser.observe(message, now));
337
+ return;
338
+ }
339
+ const release = (from) => Promise.allSettled(message.files
340
+ .slice(from)
341
+ .map((file) => releasePageFile(this.connector, this.context, file.id)));
342
+ if (this.controller?.paused)
343
+ return void (await release(0));
344
+ // storePageFile releases its own file; whatever it never reached is released here, on any exit
345
+ let started = 0;
346
+ try {
347
+ const locator = await this.locatorFor(message);
348
+ if (!locator) {
349
+ logger.warn("Recorder found no locator that matches only the element the files were %s; the upload was skipped", message.via === "drop" ? "dropped on" : "set on");
350
+ return;
351
+ }
352
+ const files = [];
353
+ for (const file of message.files) {
354
+ started++;
355
+ files.push({ path: await this.storePageFile(file) });
356
+ }
357
+ this.emit(this.normaliser.observe({ type: "files", via: message.via, locator, files }, now));
358
+ }
359
+ finally {
360
+ await release(started);
361
+ }
362
+ }
363
+ /** Saves one kept file to the files folder; returns the path its step gets */
364
+ async storePageFile(file) {
365
+ const recording = this.options.files;
366
+ const folder = filesFolder(recording.projectDir, recording.filesDir);
367
+ const notSaved = (reason) => {
368
+ const missing = {
369
+ path: folder.relative
370
+ ? `${folder.relative}/${cleanFileName(file.name)}`
371
+ : cleanFileName(file.name),
372
+ name: file.name,
373
+ size: file.size,
374
+ reason,
375
+ };
376
+ this.missing.push(missing);
377
+ recording.onMissing?.(missing);
378
+ this.controller?.warn(missingFileText(missing, folder.relative));
379
+ return missing.path;
380
+ };
381
+ if (file.size > RECORD_LIMIT) {
382
+ await releasePageFile(this.connector, this.context, file.id).catch(() => undefined);
383
+ return notSaved("too-big");
384
+ }
385
+ this.controller?.saving(file.name);
386
+ let temp;
387
+ try {
388
+ temp = await mkdtemp(path.join(tmpdir(), "samurai-upload-"));
389
+ const content = path.join(temp, "content");
390
+ const out = await open(content, "w");
391
+ try {
392
+ await readPageFile(this.connector, this.context, file.id, file.size, (bytes) => out.write(bytes));
393
+ }
394
+ finally {
395
+ await out.close();
396
+ }
397
+ const stored = await storeFile({
398
+ projectDir: recording.projectDir,
399
+ ...(recording.filesDir !== undefined && {
400
+ filesDir: recording.filesDir,
401
+ }),
402
+ name: file.name,
403
+ content,
404
+ });
405
+ this.stored.add(stored.path);
406
+ return stored.path;
407
+ }
408
+ catch (err) {
409
+ logger.warn("Recorder could not save %s", file.name, { err });
410
+ return notSaved("unreadable");
411
+ }
412
+ finally {
413
+ this.controller?.saving(undefined);
414
+ if (temp)
415
+ await rm(temp, { recursive: true, force: true }).catch(() => undefined);
416
+ await releasePageFile(this.connector, this.context, file.id).catch(() => undefined);
417
+ }
418
+ }
272
419
  async release() {
273
420
  this.connector.offEvent("script.message", this.onMessage);
274
421
  this.connector.offEvent("browsingContext.navigationStarted", this.onNavigation);
@@ -277,6 +424,7 @@ export class Recorder {
277
424
  this.subscription?.unsubscribe(),
278
425
  this.preload?.dispose(),
279
426
  this.toolbarPreload?.dispose(),
427
+ this.filesPreload?.dispose(),
280
428
  ]);
281
429
  }
282
430
  /**
@@ -305,6 +453,7 @@ export class Recorder {
305
453
  async end() {
306
454
  if (this.stopped)
307
455
  return;
456
+ this.ending = true;
308
457
  this.controller?.dispose();
309
458
  await runInLoadedContexts(this.connector, {
310
459
  source: CAPTURE_OFF,
@@ -324,7 +473,17 @@ export class Recorder {
324
473
  }).catch(() => undefined);
325
474
  }
326
475
  await this.queue.tail;
476
+ // Only now: started and queued transfers need the kept Files
477
+ if (this.filesPreload)
478
+ await disposeFilesCapture(this.connector, this.context).catch(() => undefined);
327
479
  this.stopped = true;
328
480
  await this.release();
329
481
  }
330
482
  }
483
+ /** The warning for a file that isn't saved, as the toolbar and the CLI show it */
484
+ export function missingFileText(file, folder) {
485
+ const where = folder === "" ? "the project folder" : `${folder}/`;
486
+ return file.reason === "too-big"
487
+ ? `${file.name} is ${humanSize(file.size)}. Files over 50 MB aren't saved: add it to ${where} yourself, or use a generated file.`
488
+ : `${file.name} couldn't be read, so it isn't saved: add it to ${where} yourself, or use a generated file.`;
489
+ }
@@ -5,6 +5,7 @@ import { type ProjectOptions } from "../runner/project.js";
5
5
  import type { SamuraiTestConfig } from "../types/config.js";
6
6
  import type { ControllerOptions } from "./toolbar/controller.js";
7
7
  import type { RecorderEvent } from "./normalise.js";
8
+ import { type MissingFile } from "./recorder.js";
8
9
  export interface RecordSpecOptions extends ProjectOptions {
9
10
  /** The spec file, absolute or relative to the project folder. Created when `create` is given and it doesn't exist */
10
11
  file: string;
@@ -35,6 +36,8 @@ export interface RecordSpecOptions extends ProjectOptions {
35
36
  state: "recording" | "paused";
36
37
  toolbar: boolean;
37
38
  }) => void;
39
+ /** A picked or dropped file wasn't saved (over 50 MB, or unreadable); `text` is the warning to show */
40
+ onMissingFile?: (file: MissingFile, text: string) => void;
38
41
  /** Called once the browser is open and recording */
39
42
  onStarted?: (context: {
40
43
  page: Page;
@@ -50,11 +53,21 @@ export interface RecordResult {
50
53
  secrets: string[];
51
54
  /** Those of `secrets` that have no value in the recording's environment yet, read when the recording ended */
52
55
  unsetSecrets: string[];
56
+ /** Files stored in (or reused from) the files folder, project-relative */
57
+ files: string[];
58
+ /** Files the steps point at that weren't saved: they must be added before the test can run */
59
+ missingFiles: MissingFile[];
60
+ /** The files folder is git-ignored: CI won't have the files */
61
+ filesIgnored: boolean;
62
+ /** The files folder, relative to the project with forward slashes ("" for the project root) */
63
+ filesFolder: string;
64
+ /** The files folder was created by this recording */
65
+ firstFiles: boolean;
53
66
  }
54
67
  /** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
55
68
  export declare function toolbarOptions(run: Pick<PreparedRun, "settings" | "secrets">, source: string, dir: string, config: Pick<SamuraiTestConfig, "environments">, testIndex: number): Promise<ControllerOptions>;
56
69
  /** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
57
- export declare function resultSecrets(events: readonly Pick<RecorderEvent, "step">[], added: Iterable<string>): string[];
70
+ export declare function resultSecrets(events: readonly RecorderEvent[], added: Iterable<string>): string[];
58
71
  /** The index of the test `selector` names (see `RecordSpecOptions.test`) */
59
72
  export declare function pickTest(tests: readonly {
60
73
  name: string;
@@ -2,12 +2,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Browser } from "../browser/browser.js";
4
4
  import { ensureWarm } from "../browser/warm-up.js";
5
- import { loadConfig, projectDir } from "../config/config.js";
5
+ import { loadConfig, projectDir, readConfig } from "../config/config.js";
6
6
  import { loadSecrets } from "../config/secrets.js";
7
- import { isGitIgnored } from "../project/index.js";
7
+ import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
8
8
  import { withProject } from "../runner/project.js";
9
9
  import { parseSpec } from "../steps/parse.js";
10
10
  import { applyRecorderEvent } from "./apply.js";
11
+ import { missingFileText, } from "./recorder.js";
11
12
  const NEW_SPEC_HEADER = `import { expect, test } from "@itmetsam/samurai-framework";\n\n`;
12
13
  function addTest(source, title) {
13
14
  const header = source.trim() === ""
@@ -49,8 +50,10 @@ export async function toolbarOptions(run, source, dir, config, testIndex) {
49
50
  export function resultSecrets(events, added) {
50
51
  return [
51
52
  ...new Set([
52
- ...events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
53
- ? [step.value.name]
53
+ ...events.flatMap((event) => event.op !== "remove" &&
54
+ event.step.kind === "fill" &&
55
+ event.step.value.kind === "secret"
56
+ ? [event.step.value.name]
54
57
  : []),
55
58
  ...added,
56
59
  ]),
@@ -98,7 +101,8 @@ export async function recordSpec(options) {
98
101
  throw new Error(`${options.file} does not exist; give a title with --new to create it`);
99
102
  }
100
103
  // Before the spec is touched: a recording without its browser changes nothing
101
- await ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
104
+ const browserName = (await readConfig("browser")) ?? "firefox";
105
+ await ensureWarm(browserName, { port: options.port ?? 9223 }, options.signal);
102
106
  options.signal?.throwIfAborted();
103
107
  source ??= "";
104
108
  if (options.create !== undefined &&
@@ -114,10 +118,14 @@ export async function recordSpec(options) {
114
118
  if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
115
119
  throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
116
120
  }
117
- const { browser, page } = await Browser.launch("firefox", {
121
+ const { browser, page } = await Browser.launch(browserName, {
118
122
  port: options.port ?? 9223,
119
123
  headless: options.headless ?? settings.headless,
120
124
  }, options.signal);
125
+ const loaded = await loadConfig();
126
+ const folder = filesFolder(projectDir(), loaded.filesDir);
127
+ const hadFolder = existsSync(folder.absolute);
128
+ let recorder;
121
129
  const events = [];
122
130
  const addedSecrets = new Set();
123
131
  let stopRequested;
@@ -128,17 +136,22 @@ export async function recordSpec(options) {
128
136
  await page.goto(options.url);
129
137
  else if (settings.baseURL !== undefined)
130
138
  await page.navigateTo(settings.baseURL);
131
- const recorder = await page.record({
139
+ recorder = await page.record({
132
140
  at,
133
141
  initialGoto: at === 0 && start !== undefined,
134
142
  ...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
135
143
  toolbar: {
136
- ...(await toolbarOptions(run, source, projectDir(), await loadConfig(), testIndex)),
144
+ ...(await toolbarOptions(run, source, projectDir(), loaded, testIndex)),
137
145
  projectDir: projectDir(),
138
146
  ...(options.onStatus && { onStatus: options.onStatus }),
139
147
  onSecretAdded: (name) => addedSecrets.add(name),
140
148
  onStop: () => stopRequested(),
141
149
  },
150
+ files: {
151
+ projectDir: projectDir(),
152
+ ...(loaded.filesDir !== undefined && { filesDir: loaded.filesDir }),
153
+ onMissing: (file) => options.onMissingFile?.(file, missingFileText(file, folder.relative)),
154
+ },
142
155
  onEvent: (event) => {
143
156
  source = applyRecorderEvent(source, testIndex, event);
144
157
  writeFileSync(file, source);
@@ -169,6 +182,20 @@ export async function recordSpec(options) {
169
182
  // Read again: the toolbar may have written new ones to the .env file
170
183
  const set = loadSecrets(settings.environment);
171
184
  const unsetSecrets = secrets.filter((name) => !set.has(name));
172
- return { file, test: test.name, events, secrets, unsetSecrets };
185
+ const files = recorder?.storedFiles ?? [];
186
+ const missingFiles = recorder?.missingFiles ?? [];
187
+ return {
188
+ file,
189
+ test: test.name,
190
+ events,
191
+ secrets,
192
+ unsetSecrets,
193
+ files,
194
+ missingFiles,
195
+ filesFolder: folder.relative,
196
+ filesIgnored: files.length + missingFiles.length > 0 &&
197
+ (await isFolderIgnored(projectDir(), folder.relative)),
198
+ firstFiles: !hadFolder && files.length > 0,
199
+ };
173
200
  });
174
201
  }
@@ -46,11 +46,6 @@ export interface ControllerOptions {
46
46
  type Message = ToolbarMessage & {
47
47
  target?: ElementHandle;
48
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
49
  export declare class ToolbarController {
55
50
  private readonly deps;
56
51
  private readonly options;
@@ -66,6 +61,9 @@ export declare class ToolbarController {
66
61
  private question;
67
62
  private toast;
68
63
  private toastId;
64
+ private savingName;
65
+ private warnings;
66
+ private warningId;
69
67
  /** The picked element an `assert` with the same id refers to */
70
68
  private pick;
71
69
  /** Ids of `assert`, `insert` and `add-insertable` being processed */
@@ -76,6 +74,8 @@ export declare class ToolbarController {
76
74
  private readonly done;
77
75
  /** Bumped on every page load: work started for an older page is not answered */
78
76
  private page;
77
+ /** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
78
+ private pageGone;
79
79
  private toolbar;
80
80
  /** A `ready` arrived since the last load: it counts for the next one */
81
81
  private readySinceLoad;
@@ -88,6 +88,10 @@ export declare class ToolbarController {
88
88
  private reply;
89
89
  private reportStatus;
90
90
  private setToolbar;
91
+ /** A picked or dropped file is being saved; undefined when done */
92
+ saving(name: string | undefined): void;
93
+ /** Shows a warning that stays until the tester dismisses it */
94
+ warn(text: string): void;
91
95
  private showToast;
92
96
  handle(message: Message): Promise<void>;
93
97
  /** A top-level document finished loading: push state; start the 2 s ready watchdog */
@@ -97,8 +101,14 @@ export declare class ToolbarController {
97
101
  * and doesn't count for the coming load
98
102
  */
99
103
  navigating(): void;
104
+ /** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
105
+ private leavePage;
106
+ /** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
107
+ private whileOnPage;
100
108
  /** A step was emitted (any source): count it */
101
109
  stepAdded(step: Step): void;
110
+ /** A recorded step was taken out again (a cancelled file picker's click) */
111
+ stepRemoved(): void;
102
112
  /** The recording ended: no more pushes, replies or status */
103
113
  dispose(): void;
104
114
  /**
@@ -22,6 +22,8 @@ const fail = (error, field) => ({
22
22
  * secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
23
23
  * their names and whether they are set.
24
24
  */
25
+ /** What a wait on the page ends with when the page was left first */
26
+ const LEFT = Symbol("left the page");
25
27
  export class ToolbarController {
26
28
  constructor(deps, options) {
27
29
  this.deps = deps;
@@ -31,6 +33,8 @@ export class ToolbarController {
31
33
  this.position = null;
32
34
  this.steps = 0;
33
35
  this.toastId = 0;
36
+ this.warnings = [];
37
+ this.warningId = 0;
34
38
  /** Ids of `assert`, `insert` and `add-insertable` being processed */
35
39
  this.inFlight = new Set();
36
40
  /** Ids `accept` let through whose `handle` hasn't started yet */
@@ -39,6 +43,8 @@ export class ToolbarController {
39
43
  this.done = new Set();
40
44
  /** Bumped on every page load: work started for an older page is not answered */
41
45
  this.page = 0;
46
+ /** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
47
+ this.pageGone = new Set();
42
48
  this.toolbar = true;
43
49
  /** A `ready` arrived since the last load: it counts for the next one */
44
50
  this.readySinceLoad = false;
@@ -70,6 +76,8 @@ export class ToolbarController {
70
76
  resumeQuestion: this.question !== undefined,
71
77
  nextScreenshot: `screenshot-${this.screenshots + 1}`,
72
78
  ...(this.toast && { toast: this.toast }),
79
+ ...(this.savingName !== undefined && { saving: this.savingName }),
80
+ warnings: this.warnings.map((w) => ({ ...w })),
73
81
  };
74
82
  }
75
83
  push() {
@@ -90,6 +98,16 @@ export class ToolbarController {
90
98
  this.toolbar = loaded;
91
99
  this.reportStatus();
92
100
  }
101
+ /** A picked or dropped file is being saved; undefined when done */
102
+ saving(name) {
103
+ this.savingName = name;
104
+ this.push();
105
+ }
106
+ /** Shows a warning that stays until the tester dismisses it */
107
+ warn(text) {
108
+ this.warnings.push({ id: ++this.warningId, text });
109
+ this.push();
110
+ }
93
111
  showToast(text) {
94
112
  this.toast = { id: ++this.toastId, text };
95
113
  }
@@ -116,6 +134,9 @@ export class ToolbarController {
116
134
  this.collapsed = message.collapsed === true;
117
135
  this.pick = undefined;
118
136
  return this.push();
137
+ case "dismiss":
138
+ this.warnings = this.warnings.filter((w) => w.id !== message.id);
139
+ return this.push();
119
140
  case "pick":
120
141
  return this.picked(message);
121
142
  case "assert":
@@ -156,6 +177,7 @@ export class ToolbarController {
156
177
  if (this.disposed)
157
178
  return;
158
179
  this.page++;
180
+ this.leavePage();
159
181
  this.pick = undefined;
160
182
  this.toast = undefined;
161
183
  this.push();
@@ -180,6 +202,28 @@ export class ToolbarController {
180
202
  */
181
203
  navigating() {
182
204
  this.readySinceLoad = false;
205
+ this.leavePage();
206
+ }
207
+ /** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
208
+ leavePage() {
209
+ const waiting = [...this.pageGone];
210
+ this.pageGone.clear();
211
+ for (const wake of waiting)
212
+ wake();
213
+ }
214
+ /** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
215
+ async whileOnPage(work, gone) {
216
+ let wake;
217
+ const left = new Promise((resolve) => {
218
+ wake = () => resolve(gone);
219
+ this.pageGone.add(wake);
220
+ });
221
+ try {
222
+ return await Promise.race([work(), left]);
223
+ }
224
+ finally {
225
+ this.pageGone.delete(wake);
226
+ }
183
227
  }
184
228
  /** A step was emitted (any source): count it */
185
229
  stepAdded(step) {
@@ -188,6 +232,11 @@ export class ToolbarController {
188
232
  this.screenshots++;
189
233
  this.push();
190
234
  }
235
+ /** A recorded step was taken out again (a cancelled file picker's click) */
236
+ stepRemoved() {
237
+ this.steps = Math.max(0, this.steps - 1);
238
+ this.push();
239
+ }
191
240
  /** The recording ended: no more pushes, replies or status */
192
241
  dispose() {
193
242
  this.disposed = true;
@@ -288,19 +337,24 @@ export class ToolbarController {
288
337
  return this.reply(message.id, fail(PAUSED));
289
338
  let found;
290
339
  try {
291
- const locator = message.target &&
292
- (await this.deps.rank(message.target, message.candidates));
293
- if (locator) {
294
- found = await this.deps.inspect(message.target, locator, message.candidates);
340
+ // Chrome never answers a command into a document that is being replaced, so a pick of the old page must not
341
+ // keep the messages behind it waiting
342
+ found = await this.whileOnPage(async () => {
343
+ const locator = message.target &&
344
+ (await this.deps.rank(message.target, message.candidates));
345
+ if (!locator)
346
+ return undefined;
347
+ const inspected = await this.deps.inspect(message.target, locator, message.candidates);
295
348
  if (page === this.page)
296
- this.pick = { id: message.id, locator, counts: found.counts };
297
- }
349
+ this.pick = { id: message.id, locator, counts: inspected.counts };
350
+ return inspected;
351
+ }, LEFT);
298
352
  }
299
353
  catch {
300
354
  found = undefined; // the element went away while it was inspected
301
355
  }
302
356
  // The page that asked is gone: its successor's toolbar isn't waiting for this
303
- if (page !== this.page)
357
+ if (found === LEFT || page !== this.page)
304
358
  return;
305
359
  this.reply(message.id, found ? this.withoutSecrets(found.info) : fail(UNIDENTIFIABLE));
306
360
  }