@itmetsam/samurai-framework 0.7.0 → 0.8.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 +25 -6
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -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.d.ts +19 -0
- package/dist/browser/page.js +30 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +22 -7
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +56 -8
- 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 +16 -1
- package/dist/recorder/session.js +36 -6
- package/dist/recorder/toolbar/controller.d.ts +9 -0
- package/dist/recorder/toolbar/controller.js +22 -0
- 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/steps/edit.d.ts +3 -2
- package/dist/steps/edit.js +64 -5
- 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 +38 -0
- package/dist/steps/parse.js +102 -0
- package/dist/types/config.d.ts +7 -2
- package/package.json +1 -1
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
|
@@ -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
|
|
@@ -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;
|