@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import logger from "../logger/index.js";
|
|
4
|
+
/** `report.csv` becomes `report(1).csv`, `report(2).csv`, … (the names Firefox gives) until a name is free in `dir` */
|
|
5
|
+
export function uniqueName(dir, name) {
|
|
6
|
+
if (!existsSync(path.join(dir, name)))
|
|
7
|
+
return name;
|
|
8
|
+
const { name: stem, ext } = path.parse(name);
|
|
9
|
+
for (let n = 1;; n++) {
|
|
10
|
+
const candidate = `${stem}(${n})${ext}`;
|
|
11
|
+
if (!existsSync(path.join(dir, candidate)))
|
|
12
|
+
return candidate;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Chrome saves a download under its suggested name and replaces a file that has it already, where Firefox picks a
|
|
17
|
+
* free name. This gives every download a folder of its own while it is written (the next folder is set when a download
|
|
18
|
+
* begins) and moves the finished file into the downloads folder under a free name, so no download replaces another.
|
|
19
|
+
* Must be started before the download trackers: it rewrites `filepath` of `downloadEnd` for them.
|
|
20
|
+
*/
|
|
21
|
+
export class ChromeDownloadFolders {
|
|
22
|
+
constructor(connector, dir) {
|
|
23
|
+
this.connector = connector;
|
|
24
|
+
this.dir = dir;
|
|
25
|
+
this.counter = 0;
|
|
26
|
+
this.disposed = false;
|
|
27
|
+
this.onBegin = (_params) => {
|
|
28
|
+
if (this.disposed)
|
|
29
|
+
return;
|
|
30
|
+
this.rotate().catch((err) => logger.error("Could not set a new download folder", { err }));
|
|
31
|
+
};
|
|
32
|
+
this.onEnd = (params) => {
|
|
33
|
+
if (this.disposed || params.status !== "complete")
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
const from = params.filepath;
|
|
37
|
+
const to = path.join(this.dir, uniqueName(this.dir, path.basename(from)));
|
|
38
|
+
renameSync(from, to);
|
|
39
|
+
params.filepath = to;
|
|
40
|
+
this.removeIfEmpty(path.dirname(from));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
logger.error("Could not move a finished download", { err });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async start() {
|
|
48
|
+
this.connector.onEvent("browsingContext.downloadWillBegin", this.onBegin);
|
|
49
|
+
this.connector.onEvent("browsingContext.downloadEnd", this.onEnd);
|
|
50
|
+
try {
|
|
51
|
+
this.subscription = await this.connector.subscribe([
|
|
52
|
+
"browsingContext.downloadWillBegin",
|
|
53
|
+
"browsingContext.downloadEnd",
|
|
54
|
+
]);
|
|
55
|
+
await this.rotate();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
await this.dispose();
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Idempotent */
|
|
63
|
+
async dispose() {
|
|
64
|
+
if (this.disposed)
|
|
65
|
+
return;
|
|
66
|
+
this.disposed = true;
|
|
67
|
+
this.connector.offEvent("browsingContext.downloadWillBegin", this.onBegin);
|
|
68
|
+
this.connector.offEvent("browsingContext.downloadEnd", this.onEnd);
|
|
69
|
+
await this.subscription?.unsubscribe().catch(() => undefined);
|
|
70
|
+
if (this.staging)
|
|
71
|
+
this.removeIfEmpty(this.staging);
|
|
72
|
+
this.removeIfEmpty(this.stagingRoot());
|
|
73
|
+
}
|
|
74
|
+
stagingRoot() {
|
|
75
|
+
return path.join(this.dir, ".staging");
|
|
76
|
+
}
|
|
77
|
+
/** Points the browser at a new, empty folder for the download after this one */
|
|
78
|
+
async rotate() {
|
|
79
|
+
const previous = this.staging;
|
|
80
|
+
this.staging = path.join(this.stagingRoot(), String(this.counter++));
|
|
81
|
+
mkdirSync(this.staging, { recursive: true });
|
|
82
|
+
await this.connector.send("browser.setDownloadBehavior", {
|
|
83
|
+
downloadBehavior: { type: "allowed", destinationFolder: this.staging },
|
|
84
|
+
});
|
|
85
|
+
if (previous)
|
|
86
|
+
this.removeIfEmpty(previous);
|
|
87
|
+
}
|
|
88
|
+
removeIfEmpty(dir) {
|
|
89
|
+
try {
|
|
90
|
+
if (readdirSync(dir).length === 0)
|
|
91
|
+
rmSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// gone already, or not empty: leave it
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BiDiConnector } from "../transport/bidi-connection.js";
|
|
2
2
|
import type { FileDialogInfo } from "../types/bidi-modules/input.js";
|
|
3
3
|
import type { ContextTree } from "./context-tree.js";
|
|
4
|
+
import { type FileInput } from "./generated-files.js";
|
|
4
5
|
/**
|
|
5
6
|
* A native file picker the page asked for (a click on an `<input type=file>` or its label). Nothing appears on screen:
|
|
6
7
|
* answer it with `setFiles()`.
|
|
@@ -12,10 +13,10 @@ export declare class FileChooser {
|
|
|
12
13
|
/** Whether the input lets the user pick several files */
|
|
13
14
|
isMultiple(): boolean;
|
|
14
15
|
/**
|
|
15
|
-
* Picks the files (an empty list picks none).
|
|
16
|
-
* machine the browser runs on.
|
|
16
|
+
* Picks the files (an empty list picks none). Relative paths resolve against the project folder and must exist on
|
|
17
|
+
* the machine the browser runs on; `generatedFile(…)` is accepted too.
|
|
17
18
|
*/
|
|
18
|
-
setFiles(files:
|
|
19
|
+
setFiles(files: FileInput | FileInput[]): Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
interface EventMap {
|
|
21
22
|
filechooser: [FileChooser];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveFileInputs } from "./generated-files.js";
|
|
4
4
|
/**
|
|
5
5
|
* A native file picker the page asked for (a click on an `<input type=file>` or its label). Nothing appears on screen:
|
|
6
6
|
* answer it with `setFiles()`.
|
|
@@ -15,16 +15,15 @@ export class FileChooser {
|
|
|
15
15
|
return this.info.multiple;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Picks the files (an empty list picks none).
|
|
19
|
-
* machine the browser runs on.
|
|
18
|
+
* Picks the files (an empty list picks none). Relative paths resolve against the project folder and must exist on
|
|
19
|
+
* the machine the browser runs on; `generatedFile(…)` is accepted too.
|
|
20
20
|
*/
|
|
21
21
|
async setFiles(files) {
|
|
22
|
-
const sharedId = this.info.element
|
|
22
|
+
const sharedId = this.info.element
|
|
23
|
+
?.sharedId;
|
|
23
24
|
if (!sharedId)
|
|
24
25
|
throw new Error("file chooser has no element reference");
|
|
25
|
-
const paths =
|
|
26
|
-
for (const file of paths)
|
|
27
|
-
await assertIsFile(file, "setFiles");
|
|
26
|
+
const paths = await resolveFileInputs(files, "setFiles");
|
|
28
27
|
if (paths.length > 1 && !this.info.multiple) {
|
|
29
28
|
throw new Error("file chooser does not accept multiple files");
|
|
30
29
|
}
|
|
@@ -61,7 +60,9 @@ export class FileChooserTracker {
|
|
|
61
60
|
const tracker = new FileChooserTracker(connector, tree, contextId);
|
|
62
61
|
connector.onEvent("input.fileDialogOpened", tracker.onOpened);
|
|
63
62
|
try {
|
|
64
|
-
tracker.subscription = await connector.subscribe([
|
|
63
|
+
tracker.subscription = await connector.subscribe([
|
|
64
|
+
"input.fileDialogOpened",
|
|
65
|
+
]);
|
|
65
66
|
}
|
|
66
67
|
catch (err) {
|
|
67
68
|
connector.offEvent("input.fileDialogOpened", tracker.onOpened);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
declare const GENERATED: unique symbol;
|
|
2
|
+
/** A file `setInputFiles` and `dropFiles` write when the step runs, instead of one stored in the project */
|
|
3
|
+
export interface GeneratedFile {
|
|
4
|
+
readonly [GENERATED]: true;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** Bytes */
|
|
7
|
+
readonly size: number;
|
|
8
|
+
readonly type?: string;
|
|
9
|
+
}
|
|
10
|
+
/** What `setInputFiles` and `dropFiles` take: a path (relative to the project folder) or a generated file */
|
|
11
|
+
export type FileInput = string | GeneratedFile;
|
|
12
|
+
/**
|
|
13
|
+
* A file of `size` written when the step runs: seeded random bytes, the same in every run and not
|
|
14
|
+
* compressible, under `result/files/`, removed when the run ends. For upload limits and load tests.
|
|
15
|
+
* `size` is bytes or a text with a unit (`"60 MB"`, `"1.5 GB"`; powers of 1024). `type` is the MIME type
|
|
16
|
+
* `dropFiles` gives the page; for `setInputFiles` the browser takes it from the name's extension.
|
|
17
|
+
* @example
|
|
18
|
+
* await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
|
|
19
|
+
*/
|
|
20
|
+
export declare function generatedFile(options: {
|
|
21
|
+
name: string;
|
|
22
|
+
size: number | string;
|
|
23
|
+
type?: string;
|
|
24
|
+
}): GeneratedFile;
|
|
25
|
+
export declare const isGeneratedFile: (value: unknown) => value is GeneratedFile;
|
|
26
|
+
/** Removes this run's generated files (`withProject` calls it when the run ends) */
|
|
27
|
+
export declare function removeGeneratedFiles(): Promise<void>;
|
|
28
|
+
/** Absolute paths for `setInputFiles` / `dropFiles`: generated files are written, project files must exist */
|
|
29
|
+
export declare function resolveFileInputs(files: FileInput | FileInput[], operation: string): Promise<string[]>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, rm, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { projectDir } from "../config/config.js";
|
|
5
|
+
import { parseSize } from "../steps/files.js";
|
|
6
|
+
const GENERATED = Symbol.for("samurai.generatedFile");
|
|
7
|
+
const CHUNK = 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* A file of `size` written when the step runs: seeded random bytes, the same in every run and not
|
|
10
|
+
* compressible, under `result/files/`, removed when the run ends. For upload limits and load tests.
|
|
11
|
+
* `size` is bytes or a text with a unit (`"60 MB"`, `"1.5 GB"`; powers of 1024). `type` is the MIME type
|
|
12
|
+
* `dropFiles` gives the page; for `setInputFiles` the browser takes it from the name's extension.
|
|
13
|
+
* @example
|
|
14
|
+
* await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
|
|
15
|
+
*/
|
|
16
|
+
export function generatedFile(options) {
|
|
17
|
+
const size = parseSize(options.size);
|
|
18
|
+
if (size === undefined)
|
|
19
|
+
throw new Error(`generatedFile(): size must be a number of bytes or a text like "60 MB", got ${JSON.stringify(options.size)}`);
|
|
20
|
+
const name = path.basename(String(options.name ?? ""));
|
|
21
|
+
if (name === "" || name === "." || name === "..")
|
|
22
|
+
throw new Error(`generatedFile(): name must be a file name, got ${JSON.stringify(options.name)}`);
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
[GENERATED]: true,
|
|
25
|
+
name,
|
|
26
|
+
size,
|
|
27
|
+
...(options.type !== undefined && { type: options.type }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export const isGeneratedFile = (value) => typeof value === "object" &&
|
|
31
|
+
value !== null &&
|
|
32
|
+
value[GENERATED] === true;
|
|
33
|
+
let folder;
|
|
34
|
+
const written = new Map();
|
|
35
|
+
/** xorshift32, seeded from the name and size */
|
|
36
|
+
function seedOf(file) {
|
|
37
|
+
const seed = createHash("sha256")
|
|
38
|
+
.update(`${file.size}:${file.name}`)
|
|
39
|
+
.digest()
|
|
40
|
+
.readUInt32LE(0);
|
|
41
|
+
return seed === 0 ? 1 : seed;
|
|
42
|
+
}
|
|
43
|
+
async function write(target, size, seed) {
|
|
44
|
+
const handle = await open(target, "w");
|
|
45
|
+
try {
|
|
46
|
+
const chunk = Buffer.alloc(CHUNK);
|
|
47
|
+
let state = seed;
|
|
48
|
+
for (let done = 0; done < size; done += CHUNK) {
|
|
49
|
+
for (let i = 0; i < CHUNK; i += 4) {
|
|
50
|
+
state ^= state << 13;
|
|
51
|
+
state ^= state >>> 17;
|
|
52
|
+
state ^= state << 5;
|
|
53
|
+
state >>>= 0;
|
|
54
|
+
chunk.writeUInt32LE(state, i);
|
|
55
|
+
}
|
|
56
|
+
await handle.write(chunk, 0, Math.min(CHUNK, size - done));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The generated file on disk; written once per run for the same name and size */
|
|
64
|
+
function generatedPath(file) {
|
|
65
|
+
folder ??= path.join(projectDir(), "result", "files");
|
|
66
|
+
const key = `${file.size}/${file.name}`;
|
|
67
|
+
let pending = written.get(key);
|
|
68
|
+
if (!pending) {
|
|
69
|
+
const dir = folder;
|
|
70
|
+
pending = (async () => {
|
|
71
|
+
const target = path.join(dir, key);
|
|
72
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
73
|
+
await write(target, file.size, seedOf(file));
|
|
74
|
+
return target;
|
|
75
|
+
})();
|
|
76
|
+
written.set(key, pending);
|
|
77
|
+
pending.catch(() => written.delete(key));
|
|
78
|
+
}
|
|
79
|
+
return pending;
|
|
80
|
+
}
|
|
81
|
+
/** Removes this run's generated files (`withProject` calls it when the run ends) */
|
|
82
|
+
export async function removeGeneratedFiles() {
|
|
83
|
+
const dir = folder;
|
|
84
|
+
folder = undefined;
|
|
85
|
+
const pending = [...written.values()];
|
|
86
|
+
written.clear();
|
|
87
|
+
await Promise.allSettled(pending);
|
|
88
|
+
if (dir)
|
|
89
|
+
await rm(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
/** Absolute paths for `setInputFiles` / `dropFiles`: generated files are written, project files must exist */
|
|
92
|
+
export async function resolveFileInputs(files, operation) {
|
|
93
|
+
const paths = [];
|
|
94
|
+
for (const file of Array.isArray(files) ? files : [files]) {
|
|
95
|
+
if (isGeneratedFile(file)) {
|
|
96
|
+
paths.push(await generatedPath(file));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (typeof file !== "string")
|
|
100
|
+
throw new TypeError(`${operation}(): expected a path or generatedFile(…), got ${typeof file}`);
|
|
101
|
+
const absolute = path.resolve(projectDir(), file);
|
|
102
|
+
let info;
|
|
103
|
+
try {
|
|
104
|
+
info = await stat(absolute);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(path.isAbsolute(file)
|
|
108
|
+
? `${operation}(): file not found: ${absolute}`
|
|
109
|
+
: `${operation}(): ${file.replace(/\\/g, "/")} doesn't exist in the project`);
|
|
110
|
+
}
|
|
111
|
+
if (!info.isFile())
|
|
112
|
+
throw new Error(`${operation}(): not a file: ${absolute}`);
|
|
113
|
+
paths.push(absolute);
|
|
114
|
+
}
|
|
115
|
+
return paths;
|
|
116
|
+
}
|
|
@@ -39,6 +39,8 @@ export declare class ConsoleMessage {
|
|
|
39
39
|
/** Where the call was made, when the browser says */
|
|
40
40
|
location(): SourceLocation | undefined;
|
|
41
41
|
}
|
|
42
|
+
/** The text of a page error as it reads on every browser */
|
|
43
|
+
export declare function pageErrorText(text: string): string;
|
|
42
44
|
/** An exception nobody caught in the page */
|
|
43
45
|
export declare class PageError extends Error {
|
|
44
46
|
readonly timestamp: number;
|
|
@@ -28,19 +28,37 @@ export class ConsoleMessage {
|
|
|
28
28
|
/** Where the call was made, when the browser says */
|
|
29
29
|
location() {
|
|
30
30
|
const frame = this.stackTrace?.callFrames[0];
|
|
31
|
-
return frame && {
|
|
31
|
+
return (frame && {
|
|
32
|
+
url: frame.url,
|
|
33
|
+
lineNumber: frame.lineNumber,
|
|
34
|
+
columnNumber: frame.columnNumber,
|
|
35
|
+
});
|
|
32
36
|
}
|
|
33
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Chrome starts the text of an unhandled promise rejection with this; Firefox does not. Removed, so a page error
|
|
40
|
+
* reads the same on both browsers.
|
|
41
|
+
*/
|
|
42
|
+
const REJECTION_PREFIX = "Uncaught (in promise) ";
|
|
43
|
+
/** The text of a page error as it reads on every browser */
|
|
44
|
+
export function pageErrorText(text) {
|
|
45
|
+
return text.startsWith(REJECTION_PREFIX)
|
|
46
|
+
? text.slice(REJECTION_PREFIX.length)
|
|
47
|
+
: text;
|
|
48
|
+
}
|
|
34
49
|
/** An exception nobody caught in the page */
|
|
35
50
|
export class PageError extends Error {
|
|
36
51
|
constructor(text, timestamp, stackTrace) {
|
|
37
52
|
super(text);
|
|
38
53
|
this.name = "PageError";
|
|
39
54
|
this.timestamp = timestamp;
|
|
40
|
-
this.stack = [
|
|
55
|
+
this.stack = [
|
|
56
|
+
text,
|
|
57
|
+
...(stackTrace?.callFrames ?? []).map(formatFrame),
|
|
58
|
+
].join("\n");
|
|
41
59
|
}
|
|
42
60
|
}
|
|
43
|
-
function formatFrame({ functionName, url, lineNumber, columnNumber }) {
|
|
61
|
+
function formatFrame({ functionName, url, lineNumber, columnNumber, }) {
|
|
44
62
|
const where = `${url}:${lineNumber}:${columnNumber}`;
|
|
45
63
|
return functionName ? ` at ${functionName} (${where})` : ` at ${where}`;
|
|
46
64
|
}
|
|
@@ -68,7 +86,9 @@ export class PageLogs {
|
|
|
68
86
|
const record = {
|
|
69
87
|
level: entry.level,
|
|
70
88
|
type: entry.type,
|
|
71
|
-
...(entry.type === "console" && {
|
|
89
|
+
...(entry.type === "console" && {
|
|
90
|
+
method: entry.method,
|
|
91
|
+
}),
|
|
72
92
|
text,
|
|
73
93
|
timestamp: entry.timestamp,
|
|
74
94
|
};
|
|
@@ -82,7 +102,7 @@ export class PageLogs {
|
|
|
82
102
|
this.emit("console", new ConsoleMessage(entry.level, consoleEntry.method, text, (consoleEntry.args ?? []).map((arg) => fromRemoteValue(arg)), entry.timestamp, entry.stackTrace));
|
|
83
103
|
}
|
|
84
104
|
else if (entry.type === "javascript") {
|
|
85
|
-
const error = new PageError(text, entry.timestamp, entry.stackTrace);
|
|
105
|
+
const error = new PageError(pageErrorText(text), entry.timestamp, entry.stackTrace);
|
|
86
106
|
this.uncaught.push(error);
|
|
87
107
|
if (this.uncaught.length > this.maxEntries)
|
|
88
108
|
this.uncaught.shift();
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -46,6 +46,16 @@ interface PageEvents extends NetworkEvents {
|
|
|
46
46
|
pageerror: [PageError];
|
|
47
47
|
}
|
|
48
48
|
export type PageEventName = keyof PageEvents;
|
|
49
|
+
/** Events `page.waitForEvent` can wait for */
|
|
50
|
+
export type WaitableEventName = "download" | "filechooser" | "request" | "response";
|
|
51
|
+
export interface WaitForEventOptions<T> {
|
|
52
|
+
/** Only an event this accepts counts */
|
|
53
|
+
predicate?: (item: T) => boolean;
|
|
54
|
+
/** Time (ms) to wait. @default config `navigation.timeout`, then 30000 */
|
|
55
|
+
timeout?: number;
|
|
56
|
+
/** Runs once the wait is set up; what it causes cannot be missed */
|
|
57
|
+
trigger?: () => Promise<unknown>;
|
|
58
|
+
}
|
|
49
59
|
export default class Page {
|
|
50
60
|
private biDiConnector;
|
|
51
61
|
private id;
|
|
@@ -161,6 +171,17 @@ export default class Page {
|
|
|
161
171
|
/** Goes forward one entry in the history. False when there is none */
|
|
162
172
|
goForward(): Promise<boolean>;
|
|
163
173
|
private traverse;
|
|
174
|
+
/**
|
|
175
|
+
* Whether going back would only reach the blank page a tab starts with. Firefox does not keep that page in the
|
|
176
|
+
* history, Chrome does; the Navigation API (Chrome only) lets Chrome say the same as Firefox.
|
|
177
|
+
*/
|
|
178
|
+
private atFirstEntry;
|
|
179
|
+
/**
|
|
180
|
+
* Starts listening for the end of a history traversal (the new entry committed, or the history or fragment changed
|
|
181
|
+
* within the document). `done` resolves at the first such event, or after a second without one (a page restored from
|
|
182
|
+
* the back-forward cache may say nothing); it never rejects.
|
|
183
|
+
*/
|
|
184
|
+
private watchHistoryTraversal;
|
|
164
185
|
/** The URL of the page now */
|
|
165
186
|
url(): Promise<string>;
|
|
166
187
|
/** The title of the document now */
|
|
@@ -175,6 +196,13 @@ export default class Page {
|
|
|
175
196
|
height: number;
|
|
176
197
|
devicePixelRatio?: number;
|
|
177
198
|
} | null): Promise<void>;
|
|
199
|
+
/**
|
|
200
|
+
* Runs `change`, then waits until the page has stopped resizing. Chrome answers a viewport reset before the window
|
|
201
|
+
* has its own size back, so a read right after it can still show the old size. The wait ends 50 ms after the last
|
|
202
|
+
* `resize` event, and after 300 ms when the size does not change at all (no event comes). Never fails: a page that
|
|
203
|
+
* cannot be watched is simply not waited for.
|
|
204
|
+
*/
|
|
205
|
+
private untilResized;
|
|
178
206
|
/** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
|
|
179
207
|
applyDefaultViewport(): Promise<void>;
|
|
180
208
|
/**
|
|
@@ -297,6 +325,15 @@ export default class Page {
|
|
|
297
325
|
timeout?: number;
|
|
298
326
|
trigger?: () => Promise<unknown>;
|
|
299
327
|
}): Promise<FileChooser>;
|
|
328
|
+
/**
|
|
329
|
+
* Playwright-style wait for one page event: `download`, `filechooser`, `request` or `response`. Same as the named
|
|
330
|
+
* `waitFor…` method, with an optional predicate (a function, or `predicate` in the options). Call it before the event
|
|
331
|
+
* is caused, or pass `trigger`, which runs once the wait is set up.
|
|
332
|
+
* @example
|
|
333
|
+
* const chooser = await page.waitForEvent("filechooser", { trigger: () => page.getByText("Upload file").click() });
|
|
334
|
+
* await chooser.setFiles("./myfile.pdf");
|
|
335
|
+
*/
|
|
336
|
+
waitForEvent<E extends WaitableEventName>(event: E, optionsOrPredicate?: ((item: PageEvents[E][0]) => boolean) | WaitForEventOptions<PageEvents[E][0]>): Promise<PageEvents[E][0]>;
|
|
300
337
|
/**
|
|
301
338
|
* Starts keeping response bodies, which is what makes `response.body()` possible: the browser only keeps a body
|
|
302
339
|
* when this ran before the request started. `page.on("response")` and `waitForResponse` call it; config
|
package/dist/browser/page.js
CHANGED
|
@@ -31,6 +31,8 @@ import { LoadStateTimeoutError, NavigationError } from "./navigation-error.js";
|
|
|
31
31
|
import { callFunction } from "../script/call-function.js";
|
|
32
32
|
/** Time (ms) a navigation or a wait for a load state may take */
|
|
33
33
|
const DEFAULT_NAVIGATION_TIMEOUT = 30000;
|
|
34
|
+
/** Time (ms) a history traversal may go without a sign that the entry is shown before it is taken as done */
|
|
35
|
+
const HISTORY_SETTLE_TIMEOUT = 1000;
|
|
34
36
|
/** Time (ms) without requests in flight that counts as idle */
|
|
35
37
|
const DEFAULT_IDLE_TIME = 500;
|
|
36
38
|
/** The size a page gets unless config `use.viewport` says otherwise */
|
|
@@ -336,11 +338,17 @@ export default class Page {
|
|
|
336
338
|
}
|
|
337
339
|
async traverse(delta) {
|
|
338
340
|
this.assertOpen();
|
|
341
|
+
if (delta === -1 && (await this.atFirstEntry()))
|
|
342
|
+
return false;
|
|
343
|
+
const settled = await this.watchHistoryTraversal();
|
|
339
344
|
try {
|
|
340
345
|
await this.biDiConnector.send("browsingContext.traverseHistory", {
|
|
341
346
|
context: this.id,
|
|
342
347
|
delta,
|
|
343
348
|
});
|
|
349
|
+
// Firefox answers once the entry is shown; Chrome answers before, and a script call in between can hit the
|
|
350
|
+
// document that is going away
|
|
351
|
+
await settled.done;
|
|
344
352
|
return true;
|
|
345
353
|
}
|
|
346
354
|
catch (err) {
|
|
@@ -348,6 +356,66 @@ export default class Page {
|
|
|
348
356
|
return false;
|
|
349
357
|
throw err;
|
|
350
358
|
}
|
|
359
|
+
finally {
|
|
360
|
+
await settled.stop();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Whether going back would only reach the blank page a tab starts with. Firefox does not keep that page in the
|
|
365
|
+
* history, Chrome does; the Navigation API (Chrome only) lets Chrome say the same as Firefox.
|
|
366
|
+
*/
|
|
367
|
+
async atFirstEntry() {
|
|
368
|
+
try {
|
|
369
|
+
return await callFunction(this.biDiConnector, this.id, `() => {
|
|
370
|
+
const nav = globalThis.navigation;
|
|
371
|
+
if (!nav) return false;
|
|
372
|
+
const entries = nav.entries();
|
|
373
|
+
const index = nav.currentEntry.index;
|
|
374
|
+
return index === 0 || (index === 1 && entries[0].url === "about:blank");
|
|
375
|
+
}`, [], { awaitPromise: false });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
logger.debug("Could not read the history position", { err });
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Starts listening for the end of a history traversal (the new entry committed, or the history or fragment changed
|
|
384
|
+
* within the document). `done` resolves at the first such event, or after a second without one (a page restored from
|
|
385
|
+
* the back-forward cache may say nothing); it never rejects.
|
|
386
|
+
*/
|
|
387
|
+
async watchHistoryTraversal() {
|
|
388
|
+
const events = [
|
|
389
|
+
"browsingContext.navigationCommitted",
|
|
390
|
+
"browsingContext.historyUpdated",
|
|
391
|
+
"browsingContext.fragmentNavigated",
|
|
392
|
+
];
|
|
393
|
+
let subscription;
|
|
394
|
+
try {
|
|
395
|
+
subscription = await this.biDiConnector.subscribe([...events]);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
logger.debug("Could not watch a history traversal", { err });
|
|
399
|
+
return { done: Promise.resolve(), stop: async () => { } };
|
|
400
|
+
}
|
|
401
|
+
let finish;
|
|
402
|
+
const done = new Promise((resolve) => (finish = resolve));
|
|
403
|
+
const timer = setTimeout(finish, HISTORY_SETTLE_TIMEOUT);
|
|
404
|
+
const listener = (info) => {
|
|
405
|
+
if (info.context === this.id)
|
|
406
|
+
finish();
|
|
407
|
+
};
|
|
408
|
+
for (const event of events)
|
|
409
|
+
this.biDiConnector.onEvent(event, listener);
|
|
410
|
+
return {
|
|
411
|
+
done,
|
|
412
|
+
stop: async () => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
for (const event of events)
|
|
415
|
+
this.biDiConnector.offEvent(event, listener);
|
|
416
|
+
await subscription?.unsubscribe().catch(() => undefined);
|
|
417
|
+
},
|
|
418
|
+
};
|
|
351
419
|
}
|
|
352
420
|
/** The URL of the page now */
|
|
353
421
|
async url() {
|
|
@@ -367,11 +435,11 @@ export default class Page {
|
|
|
367
435
|
async setViewport(viewport) {
|
|
368
436
|
this.assertOpen();
|
|
369
437
|
if (viewport === null) {
|
|
370
|
-
await this.biDiConnector.send("browsingContext.setViewport", {
|
|
438
|
+
await this.untilResized(() => this.biDiConnector.send("browsingContext.setViewport", {
|
|
371
439
|
context: this.id,
|
|
372
440
|
viewport: null,
|
|
373
441
|
devicePixelRatio: null,
|
|
374
|
-
});
|
|
442
|
+
}));
|
|
375
443
|
return;
|
|
376
444
|
}
|
|
377
445
|
const { width, height, devicePixelRatio } = viewport;
|
|
@@ -393,6 +461,35 @@ export default class Page {
|
|
|
393
461
|
...(devicePixelRatio !== undefined && { devicePixelRatio }),
|
|
394
462
|
});
|
|
395
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Runs `change`, then waits until the page has stopped resizing. Chrome answers a viewport reset before the window
|
|
466
|
+
* has its own size back, so a read right after it can still show the old size. The wait ends 50 ms after the last
|
|
467
|
+
* `resize` event, and after 300 ms when the size does not change at all (no event comes). Never fails: a page that
|
|
468
|
+
* cannot be watched is simply not waited for.
|
|
469
|
+
*/
|
|
470
|
+
async untilResized(change) {
|
|
471
|
+
const sandbox = { sandbox: "samurai-viewport" };
|
|
472
|
+
const watching = await callFunction(this.biDiConnector, this.id, `() => {
|
|
473
|
+
globalThis.__resized = new Promise((resolve) => {
|
|
474
|
+
let timer;
|
|
475
|
+
const finish = () => {
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
removeEventListener("resize", onResize);
|
|
478
|
+
resolve();
|
|
479
|
+
};
|
|
480
|
+
const onResize = () => {
|
|
481
|
+
clearTimeout(timer);
|
|
482
|
+
timer = setTimeout(finish, 50);
|
|
483
|
+
};
|
|
484
|
+
addEventListener("resize", onResize);
|
|
485
|
+
timer = setTimeout(finish, 300);
|
|
486
|
+
});
|
|
487
|
+
}`, [], { ...sandbox, awaitPromise: false }).then(() => true, () => false);
|
|
488
|
+
await change();
|
|
489
|
+
if (!watching)
|
|
490
|
+
return;
|
|
491
|
+
await callFunction(this.biDiConnector, this.id, "() => globalThis.__resized", [], sandbox).catch(() => undefined);
|
|
492
|
+
}
|
|
396
493
|
/** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
|
|
397
494
|
async applyDefaultViewport() {
|
|
398
495
|
const { viewport } = this.settings?.use ?? (await readSection("use"));
|
|
@@ -784,6 +881,36 @@ export default class Page {
|
|
|
784
881
|
await this.startFileChooserTracking();
|
|
785
882
|
return this.waitForPageEvent("filechooser", () => true, options, (timeout) => new FileChooserTimeoutError(timeout));
|
|
786
883
|
}
|
|
884
|
+
/**
|
|
885
|
+
* Playwright-style wait for one page event: `download`, `filechooser`, `request` or `response`. Same as the named
|
|
886
|
+
* `waitFor…` method, with an optional predicate (a function, or `predicate` in the options). Call it before the event
|
|
887
|
+
* is caused, or pass `trigger`, which runs once the wait is set up.
|
|
888
|
+
* @example
|
|
889
|
+
* const chooser = await page.waitForEvent("filechooser", { trigger: () => page.getByText("Upload file").click() });
|
|
890
|
+
* await chooser.setFiles("./myfile.pdf");
|
|
891
|
+
*/
|
|
892
|
+
async waitForEvent(event, optionsOrPredicate = {}) {
|
|
893
|
+
const { predicate, ...options } = typeof optionsOrPredicate === "function"
|
|
894
|
+
? { predicate: optionsOrPredicate }
|
|
895
|
+
: optionsOrPredicate;
|
|
896
|
+
const accepts = (predicate ?? (() => true));
|
|
897
|
+
switch (event) {
|
|
898
|
+
case "request":
|
|
899
|
+
return this.waitForRequest(accepts, options);
|
|
900
|
+
case "response":
|
|
901
|
+
return this.waitForResponse(accepts, options);
|
|
902
|
+
case "download":
|
|
903
|
+
this.assertOpen();
|
|
904
|
+
await this.startDownloadTracking();
|
|
905
|
+
return this.waitForPageEvent("download", accepts, options, (timeout) => new DownloadWaitTimeoutError(timeout));
|
|
906
|
+
case "filechooser":
|
|
907
|
+
this.assertOpen();
|
|
908
|
+
await this.startFileChooserTracking();
|
|
909
|
+
return this.waitForPageEvent("filechooser", accepts, options, (timeout) => new FileChooserTimeoutError(timeout));
|
|
910
|
+
default:
|
|
911
|
+
throw new Error(`page.waitForEvent() does not support "${String(event)}": use page.on()`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
787
914
|
/**
|
|
788
915
|
* Starts keeping response bodies, which is what makes `response.body()` possible: the browser only keeps a body
|
|
789
916
|
* when this ran before the request started. `page.on("response")` and `waitForResponse` call it; config
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { callFunction } from "../script/call-function.js";
|
|
4
|
+
import { ElementHandle } from "../script/element-handle.js";
|
|
3
5
|
const MIME = { png: "image/png", jpeg: "image/jpeg" };
|
|
4
6
|
/** The `format` of a capture command; validates the type/quality combination */
|
|
5
7
|
function imageFormat({ type, quality, }) {
|
|
@@ -23,12 +25,17 @@ function imageFormat({ type, quality, }) {
|
|
|
23
25
|
*/
|
|
24
26
|
export async function takeScreenshot(connector, context, options = {}, element) {
|
|
25
27
|
const format = imageFormat(options);
|
|
28
|
+
// An element is captured as its box in the document: Chrome cannot capture an `element` clip that is not
|
|
29
|
+
// wholly in the viewport, and this works the same for an element taller than the viewport
|
|
26
30
|
const clip = element
|
|
27
|
-
? {
|
|
31
|
+
? {
|
|
32
|
+
type: "box",
|
|
33
|
+
...(await documentBox(connector, context, element)),
|
|
34
|
+
}
|
|
28
35
|
: options.clip && { type: "box", ...options.clip };
|
|
29
36
|
const { data } = await connector.send("browsingContext.captureScreenshot", {
|
|
30
37
|
context,
|
|
31
|
-
...(options.fullPage && { origin: "document" }),
|
|
38
|
+
...((options.fullPage || element) && { origin: "document" }),
|
|
32
39
|
...(format && { format }),
|
|
33
40
|
...(clip && { clip }),
|
|
34
41
|
});
|
|
@@ -36,6 +43,13 @@ export async function takeScreenshot(connector, context, options = {}, element)
|
|
|
36
43
|
await writeOutput(options.path, image);
|
|
37
44
|
return image;
|
|
38
45
|
}
|
|
46
|
+
/** The element's box in document coordinates (CSS pixels) */
|
|
47
|
+
async function documentBox(connector, context, element) {
|
|
48
|
+
return callFunction(connector, context, `(el) => {
|
|
49
|
+
const r = el.getBoundingClientRect();
|
|
50
|
+
return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
|
|
51
|
+
}`, [new ElementHandle(element.sharedId)]);
|
|
52
|
+
}
|
|
39
53
|
/** Writes `data` to `file`, creating the folders; nothing happens without a path */
|
|
40
54
|
export async function writeOutput(file, data) {
|
|
41
55
|
if (!file)
|