@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
@@ -35,6 +35,11 @@ interface RouteDeps {
35
35
  next: () => Promise<void>;
36
36
  /** Called once the route is answered, whichever way */
37
37
  settled: (route: Route) => void;
38
+ /**
39
+ * Whether a replaced body needs a `content-length` of its own. Firefox keeps the old one and cuts the new body
40
+ * off; Chrome sets the right one itself and hangs the request when it is given one.
41
+ */
42
+ sendsContentLength: boolean;
38
43
  }
39
44
  /**
40
45
  * A request the browser holds until it is answered. Answer it exactly once with `fulfill`, `continue`,
@@ -2,13 +2,16 @@ import { STATUS_CODES } from "node:http";
2
2
  import logger from "../logger/index.js";
3
3
  import { BiDiError } from "../transport/bidi-error.js";
4
4
  import { toRequest } from "./network-tracker.js";
5
- import { isExactString, matchUrl, samePattern } from "./url-match.js";
5
+ import { isExactString, matchUrl, samePattern, } from "./url-match.js";
6
6
  /** Time (ms) a handler may take to answer a request before the router lets it continue */
7
7
  const DEFAULT_ROUTE_TIMEOUT = 30000;
8
8
  /** How many handler errors the router keeps */
9
9
  const MAX_ERRORS = 100;
10
10
  function toHeaders(headers) {
11
- return Object.entries(headers).map(([name, value]) => ({ name, value: { type: "string", value } }));
11
+ return Object.entries(headers).map(([name, value]) => ({
12
+ name,
13
+ value: { type: "string", value },
14
+ }));
12
15
  }
13
16
  function toBytes(data) {
14
17
  return typeof data === "string"
@@ -44,7 +47,8 @@ export class Route {
44
47
  const text = options.json !== undefined ? JSON.stringify(options.json) : undefined;
45
48
  const body = options.body ?? text;
46
49
  const headers = { ...options.headers };
47
- const contentType = options.contentType ?? (text !== undefined ? "application/json" : undefined);
50
+ const contentType = options.contentType ??
51
+ (text !== undefined ? "application/json" : undefined);
48
52
  if (contentType && !hasHeader(headers, "content-type"))
49
53
  headers["content-type"] = contentType;
50
54
  if (!hasHeader(headers, "content-length")) {
@@ -67,14 +71,21 @@ export class Route {
67
71
  let headers = options.headers;
68
72
  if (options.postData !== undefined) {
69
73
  const withoutLength = Object.fromEntries(Object.entries(headers ?? this.info.headers).filter(([name]) => name.toLowerCase() !== "content-length"));
70
- headers = { ...withoutLength, "content-length": String(Buffer.byteLength(options.postData)) };
74
+ headers = this.deps.sendsContentLength
75
+ ? {
76
+ ...withoutLength,
77
+ "content-length": String(Buffer.byteLength(options.postData)),
78
+ }
79
+ : withoutLength;
71
80
  }
72
81
  await this.answer("network.continueRequest", {
73
82
  request: this.info.id,
74
83
  ...(options.url !== undefined && { url: options.url }),
75
84
  ...(options.method !== undefined && { method: options.method }),
76
85
  ...(headers !== undefined && { headers: toHeaders(headers) }),
77
- ...(options.postData !== undefined && { body: toBytes(options.postData) }),
86
+ ...(options.postData !== undefined && {
87
+ body: toBytes(options.postData),
88
+ }),
78
89
  });
79
90
  }
80
91
  /** Makes the request fail (the browser reports a network error to the page) */
@@ -146,7 +157,9 @@ export class Router {
146
157
  if (!(params.intercepts ?? []).some((id) => this.interceptIds.has(id)))
147
158
  return;
148
159
  const info = toRequest(params.request, params.navigation, params.isBlocked, null);
149
- const matching = this.routes.filter((entry) => matchUrl(entry.pattern, info.url)).reverse();
160
+ const matching = this.routes
161
+ .filter((entry) => matchUrl(entry.pattern, info.url))
162
+ .reverse();
150
163
  this.dispatch(info, matching, 0).catch((err) => logger.error("Handling the request to %s failed", info.url, { err }));
151
164
  };
152
165
  this.routeTimeout = options.routeTimeout ?? DEFAULT_ROUTE_TIMEOUT;
@@ -171,7 +184,8 @@ export class Router {
171
184
  }
172
185
  /** Removes the routes for `pattern` (only the one with `handler`, when given) */
173
186
  async unroute(pattern, handler) {
174
- const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) && (handler === undefined || route.handler === handler));
187
+ const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) &&
188
+ (handler === undefined || route.handler === handler));
175
189
  await this.remove(removed);
176
190
  }
177
191
  async unrouteAll() {
@@ -189,7 +203,9 @@ export class Router {
189
203
  return;
190
204
  this.routes = this.routes.filter((route) => !removed.includes(route));
191
205
  // Requests a removed route still holds would wait for nobody
192
- await Promise.all([...this.pending].filter((route) => removed.includes(route.entry)).map((route) => route.release()));
206
+ await Promise.all([...this.pending]
207
+ .filter((route) => removed.includes(route.entry))
208
+ .map((route) => route.release()));
193
209
  await this.enqueue(() => this.sync());
194
210
  }
195
211
  enqueue(work) {
@@ -209,7 +225,8 @@ export class Router {
209
225
  const id = await this.addIntercept(patterns);
210
226
  const previous = this.currentId;
211
227
  this.currentId = id;
212
- this.currentKey = patterns && !patternsUnsupported.has(this.connector) ? key : "all";
228
+ this.currentKey =
229
+ patterns && !patternsUnsupported.has(this.connector) ? key : "all";
213
230
  this.interceptIds.add(id);
214
231
  // The new intercept is in place before the old one goes: nothing slips through in between
215
232
  if (previous !== undefined)
@@ -243,7 +260,9 @@ export class Router {
243
260
  return intercept;
244
261
  }
245
262
  catch (err) {
246
- if (patterns && err instanceof BiDiError && err.code === "invalid argument") {
263
+ if (patterns &&
264
+ err instanceof BiDiError &&
265
+ err.code === "invalid argument") {
247
266
  patternsUnsupported.add(this.connector);
248
267
  return this.addIntercept(undefined);
249
268
  }
@@ -267,7 +286,9 @@ export class Router {
267
286
  return;
268
287
  this.connector.onEvent("network.beforeRequestSent", this.onBeforeRequestSent);
269
288
  try {
270
- this.subscription = await this.connector.subscribe(["network.beforeRequestSent"]);
289
+ this.subscription = await this.connector.subscribe([
290
+ "network.beforeRequestSent",
291
+ ]);
271
292
  }
272
293
  catch (err) {
273
294
  this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
@@ -296,6 +317,7 @@ export class Router {
296
317
  settled: (settled) => {
297
318
  this.pending.delete(settled);
298
319
  },
320
+ sendsContentLength: this.connector.browserName !== "chrome",
299
321
  };
300
322
  if (!entry) {
301
323
  // Nobody (left) to ask: an unmatched request, or every route fell back
@@ -0,0 +1,28 @@
1
+ /** Files bigger than this aren't stored while recording */
2
+ export declare const RECORD_LIMIT: number;
3
+ /** A picked file's name as a file name in the files folder: no folders, control characters or leading dots */
4
+ export declare function cleanFileName(name: string): string;
5
+ /** The files folder: absolute, and relative to the project with forward slashes */
6
+ export declare function filesFolder(projectDir: string, filesDir?: string): {
7
+ absolute: string;
8
+ relative: string;
9
+ };
10
+ /**
11
+ * Stores the bytes in `content` in the files folder under their own (cleaned) name, so the page sees at
12
+ * replay the name it saw while recording. If the name is free the file is copied there; if a file of that
13
+ * name holds the same content it is reused. If the name is taken by other content, the file goes in a
14
+ * folder named after the first 8 hex characters of its SHA-256 (`<H>/<name>`; 16, then all 64, on a
15
+ * collision). Different names with the same content are stored separately. Nothing is overwritten and
16
+ * nothing is written outside the folder. `content` is left in place. Returns the project-relative path.
17
+ */
18
+ export declare function storeFile(options: {
19
+ projectDir: string;
20
+ filesDir?: string;
21
+ name: string;
22
+ content: string;
23
+ }): Promise<{
24
+ path: string;
25
+ reused: boolean;
26
+ }>;
27
+ /** Whether the project's .gitignore ignores the folder (`files`, `files/`, `/files`, `files/*`, `files/**`) */
28
+ export declare function isFolderIgnored(projectDir: string, relative: string): Promise<boolean>;
@@ -0,0 +1,106 @@
1
+ import { constants, createReadStream } from "node:fs";
2
+ import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ /** Files bigger than this aren't stored while recording */
6
+ export const RECORD_LIMIT = 50 * 1024 * 1024;
7
+ const NAME_LIMIT = 120;
8
+ /** A picked file's name as a file name in the files folder: no folders, control characters or leading dots */
9
+ export function cleanFileName(name) {
10
+ let cleaned = (name.split(/[\\/]/).pop() ?? "")
11
+ .replace(/[\u0000-\u001f\u007f]/g, "")
12
+ .trim()
13
+ .replace(/^\.+/, "")
14
+ .trim();
15
+ if (cleaned.length > NAME_LIMIT) {
16
+ const ext = path.extname(cleaned);
17
+ const keep = ext.length > 0 && ext.length < 20 ? ext : "";
18
+ cleaned = cleaned.slice(0, NAME_LIMIT - keep.length) + keep;
19
+ }
20
+ return cleaned === "" ? "file" : cleaned;
21
+ }
22
+ /** The files folder: absolute, and relative to the project with forward slashes */
23
+ export function filesFolder(projectDir, filesDir = "files") {
24
+ const absolute = path.resolve(projectDir, filesDir);
25
+ return {
26
+ absolute,
27
+ relative: path.relative(projectDir, absolute).split(path.sep).join("/"),
28
+ };
29
+ }
30
+ function hashOf(file) {
31
+ return new Promise((resolve, reject) => {
32
+ const hash = createHash("sha256");
33
+ createReadStream(file)
34
+ .on("data", (chunk) => hash.update(chunk))
35
+ .on("end", () => resolve(hash.digest("hex")))
36
+ .on("error", reject);
37
+ });
38
+ }
39
+ function sameContent(candidate, size, hash) {
40
+ return stat(candidate).then(async (info) => info.size !== size ? false : (await hashOf(candidate)) === (await hash()));
41
+ }
42
+ /**
43
+ * Stores the bytes in `content` in the files folder under their own (cleaned) name, so the page sees at
44
+ * replay the name it saw while recording. If the name is free the file is copied there; if a file of that
45
+ * name holds the same content it is reused. If the name is taken by other content, the file goes in a
46
+ * folder named after the first 8 hex characters of its SHA-256 (`<H>/<name>`; 16, then all 64, on a
47
+ * collision). Different names with the same content are stored separately. Nothing is overwritten and
48
+ * nothing is written outside the folder. `content` is left in place. Returns the project-relative path.
49
+ */
50
+ export async function storeFile(options) {
51
+ const { absolute, relative } = filesFolder(options.projectDir, options.filesDir);
52
+ const projectPath = (...parts) => [relative, ...parts].filter((part) => part !== "").join("/");
53
+ const name = cleanFileName(options.name);
54
+ const { size } = await stat(options.content);
55
+ await mkdir(absolute, { recursive: true });
56
+ let hash;
57
+ const contentHash = async () => (hash ??= await hashOf(options.content));
58
+ /** Copies to the target, or reports it reused when it holds the same content; undefined when it holds other content */
59
+ const place = async (target, relativeParts) => {
60
+ try {
61
+ await copyFile(options.content, target, constants.COPYFILE_EXCL);
62
+ return { path: projectPath(...relativeParts), reused: false };
63
+ }
64
+ catch (err) {
65
+ if (err.code !== "EEXIST")
66
+ throw err;
67
+ }
68
+ return (await sameContent(target, size, contentHash))
69
+ ? { path: projectPath(...relativeParts), reused: true }
70
+ : undefined;
71
+ };
72
+ const direct = await place(path.join(absolute, name), [name]);
73
+ if (direct)
74
+ return direct;
75
+ const full = await contentHash();
76
+ for (const length of [8, 16, 64]) {
77
+ const folder = full.slice(0, length);
78
+ await mkdir(path.join(absolute, folder), { recursive: true });
79
+ const stored = await place(path.join(absolute, folder, name), [
80
+ folder,
81
+ name,
82
+ ]);
83
+ if (stored)
84
+ return stored;
85
+ }
86
+ throw new Error(`${options.name} can't be stored in ${relative}/`);
87
+ }
88
+ /** Whether the project's .gitignore ignores the folder (`files`, `files/`, `/files`, `files/*`, `files/**`) */
89
+ export async function isFolderIgnored(projectDir, relative) {
90
+ if (relative === "")
91
+ return false;
92
+ let text;
93
+ try {
94
+ text = await readFile(path.join(projectDir, ".gitignore"), "utf8");
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ return text.split(/\r?\n/).some((line) => {
100
+ const pattern = line
101
+ .trim()
102
+ .replace(/^\//, "")
103
+ .replace(/\/(\*\*?)?$/, "");
104
+ return pattern === relative;
105
+ });
106
+ }
@@ -1,3 +1,4 @@
1
1
  export { ConfigEditError, editConfig, hasConfigPath, propertyText, type ConfigValue, } from "./config-edit.js";
2
2
  export { addVariable, ExistsError, VARIABLE_NAME } from "./variables.js";
3
3
  export { isGitIgnored, SECRET_NAME, setSecret } from "./secrets-file.js";
4
+ export { cleanFileName, filesFolder, isFolderIgnored, RECORD_LIMIT, storeFile, } from "./files-store.js";
@@ -1,3 +1,4 @@
1
1
  export { ConfigEditError, editConfig, hasConfigPath, propertyText, } from "./config-edit.js";
2
2
  export { addVariable, ExistsError, VARIABLE_NAME } from "./variables.js";
3
3
  export { isGitIgnored, SECRET_NAME, setSecret } from "./secrets-file.js";
4
+ export { cleanFileName, filesFolder, isFolderIgnored, RECORD_LIMIT, storeFile, } from "./files-store.js";
@@ -1,10 +1,12 @@
1
1
  import { applyEdit } from "../steps/edit.js";
2
2
  /** Writes a recorder event into the spec source, as an edit of test number `test` */
3
3
  export function applyRecorderEvent(source, test, event) {
4
- return applyEdit(source, {
5
- op: event.op,
6
- test,
7
- index: event.index,
8
- step: event.step,
9
- });
4
+ return event.op === "remove"
5
+ ? applyEdit(source, { op: "remove", test, index: event.index })
6
+ : applyEdit(source, {
7
+ op: event.op,
8
+ test,
9
+ index: event.index,
10
+ step: event.step,
11
+ });
10
12
  }
@@ -0,0 +1,26 @@
1
+ /** The BiDi sandbox realm the file capture script runs in: page scripts can't reach its listeners or the files it keeps */
2
+ export declare const FILES_SANDBOX = "samurai-files";
3
+ /** `Symbol.for(FILES_KEY)` on the sandbox global holds the hook the recorder reads files through */
4
+ export declare const FILES_KEY = "samurai.files";
5
+ /** A file is read and sent in pieces of this many bytes */
6
+ export declare const CHUNK_BYTES: number;
7
+ /**
8
+ * Function declaration installed as a preload script in the `samurai-files` sandbox, called as `(send)` with
9
+ * the recorder's files channel. Top-level documents only. Reports:
10
+ *
11
+ * - `{ type: "files", via: "input", target, candidates, files }`: a person changed the files of an
12
+ * `<input type=file>` (no files: it was cleared).
13
+ * - `{ type: "files", via: "drop", target, candidates, files }`: a person dropped files on `target`.
14
+ * - `{ type: "picker" }`: a file input got a click (the page's `input.click()`, a label, or the input itself),
15
+ * which opens the file picker.
16
+ * - `{ type: "picker-cancel" }`: the picker was closed without choosing.
17
+ *
18
+ * `files` is `{ id, name, type, size }[]`. The `File` objects stay here, kept by id, until the recorder reads
19
+ * them through `globalThis[Symbol.for("samurai.files")].read(id, offset)` (base64 of up to 1 MB) and calls
20
+ * `release(id)`. A drop's file list can only be read during the event, so it is taken then.
21
+ * Events a person didn't cause (`isTrusted` false) are ignored, unless `trustSynthetic` (tests only).
22
+ * A string, so no transpiler helper can end up in it.
23
+ */
24
+ export declare function filesCaptureSource(options?: {
25
+ trustSynthetic?: boolean;
26
+ }): string;
@@ -0,0 +1,92 @@
1
+ // src/recorder/files-capture.ts
2
+ import { CANDIDATES_JS } from "./candidates.js";
3
+ /** The BiDi sandbox realm the file capture script runs in: page scripts can't reach its listeners or the files it keeps */
4
+ export const FILES_SANDBOX = "samurai-files";
5
+ /** `Symbol.for(FILES_KEY)` on the sandbox global holds the hook the recorder reads files through */
6
+ export const FILES_KEY = "samurai.files";
7
+ /** A file is read and sent in pieces of this many bytes */
8
+ export const CHUNK_BYTES = 1024 * 1024;
9
+ /**
10
+ * Function declaration installed as a preload script in the `samurai-files` sandbox, called as `(send)` with
11
+ * the recorder's files channel. Top-level documents only. Reports:
12
+ *
13
+ * - `{ type: "files", via: "input", target, candidates, files }`: a person changed the files of an
14
+ * `<input type=file>` (no files: it was cleared).
15
+ * - `{ type: "files", via: "drop", target, candidates, files }`: a person dropped files on `target`.
16
+ * - `{ type: "picker" }`: a file input got a click (the page's `input.click()`, a label, or the input itself),
17
+ * which opens the file picker.
18
+ * - `{ type: "picker-cancel" }`: the picker was closed without choosing.
19
+ *
20
+ * `files` is `{ id, name, type, size }[]`. The `File` objects stay here, kept by id, until the recorder reads
21
+ * them through `globalThis[Symbol.for("samurai.files")].read(id, offset)` (base64 of up to 1 MB) and calls
22
+ * `release(id)`. A drop's file list can only be read during the event, so it is taken then.
23
+ * Events a person didn't cause (`isTrusted` false) are ignored, unless `trustSynthetic` (tests only).
24
+ * A string, so no transpiler helper can end up in it.
25
+ */
26
+ export function filesCaptureSource(options = {}) {
27
+ return String.raw `(send) => {
28
+ if (window !== window.top) return;
29
+ const KEY = Symbol.for("${FILES_KEY}");
30
+ if (globalThis[KEY]) globalThis[KEY].dispose();
31
+
32
+ ${CANDIDATES_JS}
33
+
34
+ const TRUST_SYNTHETIC = ${options.trustSynthetic === true};
35
+ const trusted = (event) => event.isTrusted || TRUST_SYNTHETIC;
36
+ const files = new Map();
37
+ let nextId = 1;
38
+ const keep = (list) => Array.from(list || []).map((file) => {
39
+ const id = nextId++;
40
+ files.set(id, file);
41
+ return { id, name: file.name, type: file.type, size: file.size };
42
+ });
43
+ const isFileInput = (el) =>
44
+ el instanceof Element && el.tagName === "INPUT" && (el.getAttribute("type") || "").toLowerCase() === "file";
45
+ const listening = [];
46
+ const listen = (type, handler) => {
47
+ window.addEventListener(type, handler, true);
48
+ listening.push([type, handler]);
49
+ };
50
+
51
+ listen("change", (event) => {
52
+ const el = event.target;
53
+ if (!trusted(event) || !isFileInput(el)) return;
54
+ send({ type: "files", via: "input", target: el, candidates: candidatesOf(el), files: keep(el.files) });
55
+ });
56
+ listen("cancel", (event) => {
57
+ if (trusted(event) && isFileInput(event.target)) send({ type: "picker-cancel" });
58
+ });
59
+ // The page opens a picker with input.click(), which is not a trusted click: any click on a file input counts
60
+ listen("click", (event) => {
61
+ if (isFileInput(event.target)) send({ type: "picker" });
62
+ });
63
+ listen("drop", (event) => {
64
+ const el = event.target;
65
+ if (!trusted(event) || inToolbar(event) || !(el instanceof Element)) return;
66
+ const data = event.dataTransfer;
67
+ if (!data || Array.from(data.types || []).indexOf("Files") < 0 || data.files.length === 0) return;
68
+ send({ type: "files", via: "drop", target: el, candidates: candidatesOf(el), files: keep(data.files) });
69
+ });
70
+
71
+ // arrayBuffer() hands back a page-realm buffer the sandbox may not read (Xray wrapper); a data URL is a string
72
+ const toBase64 = (blob) => new Promise((resolve, reject) => {
73
+ const reader = new FileReader();
74
+ reader.onload = () => resolve(String(reader.result).slice(String(reader.result).indexOf(",") + 1));
75
+ reader.onerror = () => reject(reader.error || new Error("read failed"));
76
+ reader.readAsDataURL(blob);
77
+ });
78
+ globalThis[KEY] = {
79
+ read: async (id, offset) => {
80
+ const file = files.get(id);
81
+ if (!file) throw new Error("No file " + id);
82
+ return toBase64(file.slice(offset, offset + ${CHUNK_BYTES}));
83
+ },
84
+ release: (id) => { files.delete(id); },
85
+ dispose: () => {
86
+ for (const [type, handler] of listening.splice(0)) window.removeEventListener(type, handler, true);
87
+ files.clear();
88
+ if (globalThis[KEY] && globalThis[KEY].read) delete globalThis[KEY];
89
+ },
90
+ };
91
+ }`;
92
+ }
@@ -0,0 +1,7 @@
1
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
2
+ /** Reads a file the capture script kept, `CHUNK_BYTES` at a time, handing each piece to `onChunk` in order */
3
+ export declare function readPageFile(connector: BiDiConnector, context: string, id: number, size: number, onChunk: (bytes: Buffer) => unknown): Promise<void>;
4
+ /** Lets the capture script drop its reference to a file */
5
+ export declare function releasePageFile(connector: BiDiConnector, context: string, id: number): Promise<void>;
6
+ /** Takes the capture script's listeners off the loaded document (the recording ended) */
7
+ export declare function disposeFilesCapture(connector: BiDiConnector, context: string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ // src/recorder/files-reader.ts
2
+ import { callFunction } from "../script/call-function.js";
3
+ import { CHUNK_BYTES, FILES_KEY, FILES_SANDBOX } from "./files-capture.js";
4
+ const HOOK = `globalThis[Symbol.for(${JSON.stringify(FILES_KEY)})]`;
5
+ /** Reads a file the capture script kept, `CHUNK_BYTES` at a time, handing each piece to `onChunk` in order */
6
+ export async function readPageFile(connector, context, id, size, onChunk) {
7
+ for (let offset = 0; offset < size; offset += CHUNK_BYTES) {
8
+ const base64 = await callFunction(connector, context, `(id, offset) => ${HOOK}.read(id, offset)`, [id, offset], { sandbox: FILES_SANDBOX });
9
+ await onChunk(Buffer.from(base64, "base64"));
10
+ }
11
+ }
12
+ /** Lets the capture script drop its reference to a file */
13
+ export async function releasePageFile(connector, context, id) {
14
+ await callFunction(connector, context, `(id) => { ${HOOK}?.release(id); }`, [id], {
15
+ sandbox: FILES_SANDBOX,
16
+ awaitPromise: false,
17
+ });
18
+ }
19
+ /** Takes the capture script's listeners off the loaded document (the recording ended) */
20
+ export async function disposeFilesCapture(connector, context) {
21
+ await callFunction(connector, context, `() => { ${HOOK}?.dispose(); }`, [], {
22
+ sandbox: FILES_SANDBOX,
23
+ awaitPromise: false,
24
+ });
25
+ }
@@ -1,3 +1,3 @@
1
- export { Recorder, type RecorderOptions, type RecorderEvent, type ToolbarOptions, } from "./recorder.js";
1
+ export { Recorder, type RecorderOptions, type RecorderEvent, type ToolbarOptions, type FileRecording, type MissingFile, missingFileText, } from "./recorder.js";
2
2
  export { applyRecorderEvent } from "./apply.js";
3
3
  export { recordSpec, pickTest, type RecordSpecOptions, type RecordResult, } from "./session.js";
@@ -1,3 +1,3 @@
1
- export { Recorder, } from "./recorder.js";
1
+ export { Recorder, missingFileText, } from "./recorder.js";
2
2
  export { applyRecorderEvent } from "./apply.js";
3
3
  export { recordSpec, pickTest, } from "./session.js";
@@ -1,5 +1,5 @@
1
- import type { Expectation, LocatorSpec, Step } from "../steps/model.js";
2
- /** What the recorder does to the step list: a new step at `index`, or a step rewritten in place */
1
+ import type { Expectation, FileEntry, LocatorSpec, Step } from "../steps/model.js";
2
+ /** What the recorder does to the step list: a new step at `index`, a step rewritten in place, or one taken out */
3
3
  export type RecorderEvent = {
4
4
  op: "insert";
5
5
  index: number;
@@ -8,6 +8,9 @@ export type RecorderEvent = {
8
8
  op: "replace";
9
9
  index: number;
10
10
  step: Step;
11
+ } | {
12
+ op: "remove";
13
+ index: number;
11
14
  };
12
15
  /** A page event whose element already has its locators */
13
16
  export type Observation = {
@@ -45,6 +48,21 @@ export type Observation = {
45
48
  kind: "env" | "secret";
46
49
  name: string;
47
50
  };
51
+ }
52
+ /** A file input got a click: the click recorded just before it opened the file picker */
53
+ | {
54
+ type: "picker";
55
+ }
56
+ /** The file picker closed without a choice */
57
+ | {
58
+ type: "picker-cancel";
59
+ }
60
+ /** Files were set on an input or dropped on an element, and are stored (or listed as missing) */
61
+ | {
62
+ type: "files";
63
+ via: "input" | "drop";
64
+ locator: LocatorSpec;
65
+ files: FileEntry[];
48
66
  } | {
49
67
  type: "wait-idle";
50
68
  } | {
@@ -66,6 +84,8 @@ export interface NormaliserOptions {
66
84
  /** A navigation within this many ms of the last action is a consequence of it. @default 2000 */
67
85
  navigationWindow?: number;
68
86
  }
87
+ /** A picker that opens within this many ms of a click was opened by it */
88
+ export declare const PICKER_WINDOW = 1000;
69
89
  /** The name a secret gets in `secrets.<NAME>`: the field's name or id, upper snake case */
70
90
  export declare function secretNameFor(hint: string | undefined): string;
71
91
  export declare function expectationFor(text: string, value: string | undefined): Expectation;
@@ -83,6 +103,10 @@ export declare class Normaliser {
83
103
  private lastFill;
84
104
  private lastAction;
85
105
  private lastStep;
106
+ /** The last click, so a picker it opened can take its place */
107
+ private lastClick;
108
+ /** The click that opened the file picker that is open now */
109
+ private picker;
86
110
  private readonly window;
87
111
  constructor(options: NormaliserOptions);
88
112
  /** Index the next inserted step gets */
@@ -1,4 +1,6 @@
1
1
  const TEXT_LIMIT = 100;
2
+ /** A picker that opens within this many ms of a click was opened by it */
3
+ export const PICKER_WINDOW = 1000;
2
4
  /** The name a secret gets in `secrets.<NAME>`: the field's name or id, upper snake case */
3
5
  export function secretNameFor(hint) {
4
6
  const name = (hint ?? "")
@@ -38,6 +40,7 @@ export class Normaliser {
38
40
  }
39
41
  insert(step) {
40
42
  this.lastStep = step;
43
+ this.picker = undefined;
41
44
  return { op: "insert", index: this.next++, step };
42
45
  }
43
46
  /** The path (and query) of `url` when it is inside the base URL, else `url` */
@@ -90,12 +93,56 @@ export class Normaliser {
90
93
  this.lastFill = undefined;
91
94
  return [this.insert(fromToolbar)];
92
95
  }
96
+ // The sandbox reports a picker for any click on a file input, including ones a page script fires:
97
+ // it is only a hint, and only the very last step, recorded just before, may be replaced or removed
98
+ // (a page's own input.click() also counts as the opening click, bounded to the last step within PICKER_WINDOW)
99
+ if (observation.type === "picker") {
100
+ const click = this.lastClick;
101
+ this.picker =
102
+ click && click.index === this.next - 1 && at - click.at <= PICKER_WINDOW
103
+ ? click.index
104
+ : undefined;
105
+ return [];
106
+ }
107
+ if (observation.type === "picker-cancel") {
108
+ const index = this.picker;
109
+ this.picker = undefined;
110
+ if (index === undefined || index !== this.next - 1)
111
+ return [];
112
+ this.next--;
113
+ this.lastClick = undefined;
114
+ this.lastStep = undefined;
115
+ return [{ op: "remove", index }];
116
+ }
117
+ if (observation.type === "files") {
118
+ this.lastFill = undefined;
119
+ this.lastAction = at;
120
+ const step = {
121
+ kind: observation.via === "drop" ? "dropFiles" : "setInputFiles",
122
+ locator: observation.locator,
123
+ files: observation.files,
124
+ };
125
+ const index = this.picker;
126
+ if (observation.via === "input" &&
127
+ index !== undefined &&
128
+ index === this.next - 1) {
129
+ this.picker = undefined;
130
+ this.lastClick = undefined;
131
+ this.lastStep = step;
132
+ return [{ op: "replace", index, step }];
133
+ }
134
+ return [this.insert(step)];
135
+ }
93
136
  if (observation.type === "click") {
94
137
  if (observation.textEntry)
95
138
  return []; // focusing a field: the fill that follows needs no click
96
139
  this.lastFill = undefined;
97
140
  this.lastAction = at;
98
- return [this.insert({ kind: "click", locator: observation.locator })];
141
+ const events = [
142
+ this.insert({ kind: "click", locator: observation.locator }),
143
+ ];
144
+ this.lastClick = { index: this.next - 1, at };
145
+ return events;
99
146
  }
100
147
  if (observation.type === "press") {
101
148
  // The page may react by clearing the field: what is typed next is a new fill, not a rewrite of this one