@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.
- package/README.md +31 -12
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.js +2 -2
- package/dist/browser/browser.d.ts +11 -15
- package/dist/browser/browser.js +79 -58
- package/dist/browser/chrome-launch.d.ts +57 -0
- package/dist/browser/chrome-launch.js +127 -0
- package/dist/browser/download-folders.d.ts +27 -0
- package/dist/browser/download-folders.js +97 -0
- package/dist/browser/file-chooser.d.ts +4 -3
- package/dist/browser/file-chooser.js +9 -8
- package/dist/browser/generated-files.d.ts +30 -0
- package/dist/browser/generated-files.js +116 -0
- package/dist/browser/page-logs.d.ts +2 -0
- package/dist/browser/page-logs.js +25 -5
- package/dist/browser/page.d.ts +37 -0
- package/dist/browser/page.js +129 -2
- package/dist/browser/screenshot.js +16 -2
- package/dist/browser/session-capabilities.d.ts +12 -0
- package/dist/browser/session-capabilities.js +8 -0
- package/dist/browsers/index.d.ts +2 -2
- package/dist/browsers/index.js +2 -2
- package/dist/browsers/overrides.d.ts +2 -0
- package/dist/browsers/overrides.js +2 -0
- package/dist/browsers/pins.d.ts +1 -1
- package/dist/browsers/pins.js +2 -2
- package/dist/browsers/resolve.d.ts +2 -0
- package/dist/browsers/resolve.js +14 -2
- package/dist/browsers/store.d.ts +2 -0
- package/dist/browsers/store.js +8 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +20 -5
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +77 -10
- package/dist/network/data-collector.d.ts +1 -0
- package/dist/network/data-collector.js +23 -5
- package/dist/network/network-tracker.d.ts +8 -1
- package/dist/network/network-tracker.js +42 -3
- package/dist/network/router.d.ts +5 -0
- package/dist/network/router.js +33 -11
- package/dist/project/files-store.d.ts +28 -0
- package/dist/project/files-store.js +106 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/recorder/apply.js +8 -6
- package/dist/recorder/files-capture.d.ts +26 -0
- package/dist/recorder/files-capture.js +92 -0
- package/dist/recorder/files-reader.d.ts +7 -0
- package/dist/recorder/files-reader.js +25 -0
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/normalise.d.ts +26 -2
- package/dist/recorder/normalise.js +48 -1
- package/dist/recorder/recorder.d.ts +38 -0
- package/dist/recorder/recorder.js +160 -1
- package/dist/recorder/session.d.ts +14 -1
- package/dist/recorder/session.js +36 -9
- package/dist/recorder/toolbar/controller.d.ts +15 -5
- package/dist/recorder/toolbar/controller.js +61 -7
- package/dist/recorder/toolbar/protocol.d.ts +10 -0
- package/dist/recorder/toolbar/source.d.ts +1 -1
- package/dist/recorder/toolbar/source.js +35 -2
- package/dist/recorder/toolbar/styles.d.ts +1 -1
- package/dist/recorder/toolbar/styles.js +31 -0
- package/dist/runner/project.js +2 -0
- package/dist/script/preload.js +65 -0
- package/dist/steps/edit.js +38 -3
- package/dist/steps/emit.d.ts +3 -1
- package/dist/steps/emit.js +19 -0
- package/dist/steps/files.d.ts +8 -0
- package/dist/steps/files.js +33 -0
- package/dist/steps/index.d.ts +3 -2
- package/dist/steps/index.js +2 -1
- package/dist/steps/model.d.ts +28 -0
- package/dist/steps/parse.js +79 -0
- package/dist/transport/bidi-connection.d.ts +6 -0
- package/dist/transport/bidi-connection.js +13 -0
- package/dist/types/browser.d.ts +1 -1
- package/dist/types/config.d.ts +7 -2
- package/package.json +3 -2
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
package/dist/network/router.d.ts
CHANGED
|
@@ -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`,
|
package/dist/network/router.js
CHANGED
|
@@ -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]) => ({
|
|
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 ??
|
|
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 =
|
|
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 && {
|
|
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
|
|
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) &&
|
|
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]
|
|
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 =
|
|
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 &&
|
|
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([
|
|
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
|
+
}
|
package/dist/project/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/project/index.js
CHANGED
|
@@ -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";
|
package/dist/recorder/apply.js
CHANGED
|
@@ -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
|
|
5
|
-
op: event.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|
package/dist/recorder/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/recorder/index.js
CHANGED
|
@@ -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`,
|
|
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
|
-
|
|
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
|