@warlock.js/core 5.33.0 → 5.34.1
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/CHANGELOG.md +36 -46
- package/esm/cli/commands/check/check-command.action.mjs +2 -1
- package/esm/cli/commands/check/check-command.action.mjs.map +1 -1
- package/esm/cli/commands/check.command.mjs +2 -2
- package/esm/cli/commands/check.command.mjs.map +1 -1
- package/esm/cli/commands/e2e.command.mjs +63 -0
- package/esm/cli/commands/e2e.command.mjs.map +1 -0
- package/esm/cli/commands/load-e2e-spec.mjs +47 -0
- package/esm/cli/commands/load-e2e-spec.mjs.map +1 -0
- package/esm/cli/framework-cli-commands.mjs +2 -0
- package/esm/cli/framework-cli-commands.mjs.map +1 -1
- package/esm/dev-server/health-checker/checkers/oxlint-health-checker.mjs +1 -1
- package/esm/dev-server/health-checker/default-health-checkers.mjs +1 -6
- package/esm/dev-server/health-checker/default-health-checkers.mjs.map +1 -1
- package/esm/dev-server/health-checker/file-health-checker.contract.d.mts +0 -2
- package/esm/dev-server/health-checker/file-health-checker.contract.d.mts.map +1 -1
- package/esm/dev-server/health-checker/file-health-result.d.mts +1 -1
- package/esm/dev-server/health-checker/file-health-result.mjs.map +1 -1
- package/esm/e2e/browser.d.mts +4 -0
- package/esm/e2e/browser.d.mts.map +1 -0
- package/esm/e2e/browser.mjs +88 -0
- package/esm/e2e/browser.mjs.map +1 -0
- package/esm/e2e/cdp.d.mts +152 -0
- package/esm/e2e/cdp.d.mts.map +1 -0
- package/esm/e2e/cdp.mjs +106 -0
- package/esm/e2e/cdp.mjs.map +1 -0
- package/esm/e2e/fixture.mjs +195 -0
- package/esm/e2e/fixture.mjs.map +1 -0
- package/esm/e2e/index.d.mts +5 -0
- package/esm/e2e/index.mjs +6 -0
- package/esm/e2e/owned-process.mjs +193 -0
- package/esm/e2e/owned-process.mjs.map +1 -0
- package/esm/e2e/page.d.mts +64 -0
- package/esm/e2e/page.d.mts.map +1 -0
- package/esm/e2e/page.mjs +376 -0
- package/esm/e2e/page.mjs.map +1 -0
- package/esm/e2e/run-e2e.d.mts +17 -0
- package/esm/e2e/run-e2e.d.mts.map +1 -0
- package/esm/e2e/run-e2e.mjs +143 -0
- package/esm/e2e/run-e2e.mjs.map +1 -0
- package/esm/e2e/types.d.mts +123 -0
- package/esm/e2e/types.d.mts.map +1 -0
- package/esm/e2e/types.mjs +24 -0
- package/esm/e2e/types.mjs.map +1 -0
- package/esm/generations/features/web.feature.mjs +7 -4
- package/esm/generations/features/web.feature.mjs.map +1 -1
- package/esm/generations/stubs.mjs +18 -35
- package/esm/generations/stubs.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/secrets/errors.d.mts +1 -1
- package/esm/secrets/errors.d.mts.map +1 -1
- package/esm/secrets/errors.mjs +2 -0
- package/esm/secrets/errors.mjs.map +1 -1
- package/esm/secrets/file-backend.mjs +1 -0
- package/esm/secrets/file-backend.mjs.map +1 -1
- package/esm/secrets/index.d.mts +2 -2
- package/esm/secrets/keyring-backend.mjs +1 -0
- package/esm/secrets/keyring-backend.mjs.map +1 -1
- package/esm/secrets/memory-backend.d.mts +1 -0
- package/esm/secrets/memory-backend.d.mts.map +1 -1
- package/esm/secrets/memory-backend.mjs +1 -0
- package/esm/secrets/memory-backend.mjs.map +1 -1
- package/esm/secrets/resolve-driver.mjs +14 -0
- package/esm/secrets/resolve-driver.mjs.map +1 -0
- package/esm/secrets/secret-store.d.mts +4 -2
- package/esm/secrets/secret-store.d.mts.map +1 -1
- package/esm/secrets/secret-store.mjs +22 -8
- package/esm/secrets/secret-store.mjs.map +1 -1
- package/esm/secrets/secrets.d.mts +2 -0
- package/esm/secrets/secrets.d.mts.map +1 -1
- package/esm/secrets/secrets.mjs +13 -1
- package/esm/secrets/secrets.mjs.map +1 -1
- package/esm/secrets/types.d.mts +8 -0
- package/esm/secrets/types.d.mts.map +1 -1
- package/esm/tooling/check/lint-step.d.mts +3 -3
- package/esm/tooling/check/lint-step.d.mts.map +1 -1
- package/esm/tooling/check/lint-step.mjs +3 -47
- package/esm/tooling/check/lint-step.mjs.map +1 -1
- package/esm/tooling/check/run-check.d.mts +2 -2
- package/esm/tooling/check/run-check.mjs +4 -2
- package/esm/tooling/check/run-check.mjs.map +1 -1
- package/esm/tooling/problem.d.mts +1 -1
- package/esm/tooling/problem.d.mts.map +1 -1
- package/esm/tooling/problem.mjs.map +1 -1
- package/esm/updater/package-manager.mjs +5 -1
- package/esm/updater/package-manager.mjs.map +1 -1
- package/esm/upgrade/codemods/index.mjs +2 -1
- package/esm/upgrade/codemods/index.mjs.map +1 -1
- package/esm/upgrade/codemods/oxc-toolchain.mjs +59 -10
- package/esm/upgrade/codemods/oxc-toolchain.mjs.map +1 -1
- package/esm/upgrade/codemods/oxlint-rule-schemas.mjs +6 -0
- package/esm/upgrade/codemods/oxlint-rule-schemas.mjs.map +1 -0
- package/esm/upgrade/codemods/port-eslint-rules.mjs +343 -0
- package/esm/upgrade/codemods/port-eslint-rules.mjs.map +1 -0
- package/esm/upgrade/codemods/toolchain-templates.mjs +1 -1
- package/esm/upgrade/codemods/toolchain-templates.mjs.map +1 -1
- package/esm/upgrade/codemods/validate-oxlint-rule.mjs +60 -0
- package/esm/upgrade/codemods/validate-oxlint-rule.mjs.map +1 -0
- package/esm/upgrade/notes.mjs +14 -7
- package/esm/upgrade/notes.mjs.map +1 -1
- package/esm/warlock-config/types.d.mts +0 -1
- package/esm/warlock-config/types.d.mts.map +1 -1
- package/llms-full.txt +269 -44
- package/llms.txt +4 -3
- package/package.json +19 -20
- package/skills/create-module/SKILL.md +2 -2
- package/skills/run-app/SKILL.md +3 -3
- package/skills/store-secrets/SKILL.md +60 -2
- package/skills/test-browser/SKILL.md +119 -0
- package/skills/upgrade-from-5-29/SKILL.md +70 -29
- package/skills/warlock-check/SKILL.md +3 -3
- package/skills/warlock-doctor/SKILL.md +2 -3
- package/skills/write-cli-command/SKILL.md +6 -2
- package/esm/dev-server/health-checker/checkers/eslint-health-checker.mjs +0 -152
- package/esm/dev-server/health-checker/checkers/eslint-health-checker.mjs.map +0 -1
- package/esm/dev-server/health-checker/eslint-support.mjs +0 -27
- package/esm/dev-server/health-checker/eslint-support.mjs.map +0 -1
- package/esm/dev-server/health-checker/workers/eslint-health.worker.d.mts +0 -1
- package/esm/dev-server/health-checker/workers/eslint-health.worker.mjs +0 -183
- package/esm/dev-server/health-checker/workers/eslint-health.worker.mjs.map +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
6
|
+
|
|
7
|
+
//#region ../../packages/core/src/e2e/owned-process.ts
|
|
8
|
+
const supervisorSource = `
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
process.on('SIGTERM', () => {});
|
|
11
|
+
process.on('SIGINT', () => {});
|
|
12
|
+
process.on('message', ({command}) => {
|
|
13
|
+
const child = spawn(command[0], command.slice(1), {cwd:process.cwd(),env:process.env,stdio:'inherit'});
|
|
14
|
+
child.once('spawn', () => process.send?.({type:'started',pid:child.pid}));
|
|
15
|
+
child.once('error', error => process.send?.({type:'failure',message:error.message}));
|
|
16
|
+
child.once('exit', (code,signal) => process.send?.({type:'exit',code,signal}));
|
|
17
|
+
});
|
|
18
|
+
`;
|
|
19
|
+
/** Small OS identity queries; every helper PID is retained as evidence too. */
|
|
20
|
+
function utility(command, args, records) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const child = execFile(command, args, { encoding: "utf8" }, (error, stdout) => {
|
|
23
|
+
if (record) record.stopped = true;
|
|
24
|
+
if (error) reject(error);
|
|
25
|
+
else resolve(stdout);
|
|
26
|
+
});
|
|
27
|
+
const record = child.pid ? {
|
|
28
|
+
role: "process-identity",
|
|
29
|
+
pid: child.pid,
|
|
30
|
+
command: [command, ...args],
|
|
31
|
+
ownerFile: ""
|
|
32
|
+
} : void 0;
|
|
33
|
+
if (record) records.push(record);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function commandLineOwns(commandLine, ownerFile) {
|
|
37
|
+
if (commandLine.includes("\0")) return commandLine.split("\0").includes(ownerFile);
|
|
38
|
+
return commandLine.includes(ownerFile);
|
|
39
|
+
}
|
|
40
|
+
var OwnedProcess = class OwnedProcess {
|
|
41
|
+
constructor(child, record, records) {
|
|
42
|
+
this.child = child;
|
|
43
|
+
this.records = records;
|
|
44
|
+
this.output = "";
|
|
45
|
+
this.record = record;
|
|
46
|
+
records.push(record);
|
|
47
|
+
const append = (chunk) => {
|
|
48
|
+
this.output = (this.output + chunk.toString()).slice(-1e6);
|
|
49
|
+
};
|
|
50
|
+
child.stdout?.on("data", append);
|
|
51
|
+
child.stderr?.on("data", append);
|
|
52
|
+
child.on("message", (message) => {
|
|
53
|
+
const data = message;
|
|
54
|
+
if (data.type === "exit") this.finished = {
|
|
55
|
+
code: data.code ?? null,
|
|
56
|
+
signal: data.signal ?? null
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
static async start(role, command, directory, env, ownerDirectory, records, onCreated) {
|
|
61
|
+
const ownerFile = join(ownerDirectory, `owner-${randomUUID()}.mjs`);
|
|
62
|
+
await writeFile(ownerFile, supervisorSource, { mode: 384 });
|
|
63
|
+
const child = spawn(process.execPath, [ownerFile], {
|
|
64
|
+
cwd: directory,
|
|
65
|
+
env,
|
|
66
|
+
detached: process.platform !== "win32",
|
|
67
|
+
stdio: [
|
|
68
|
+
"ignore",
|
|
69
|
+
"pipe",
|
|
70
|
+
"pipe",
|
|
71
|
+
"ipc"
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
if (!child.pid) {
|
|
75
|
+
await new Promise((_resolve, reject) => child.once("error", reject));
|
|
76
|
+
throw new Error(`Could not start ${role}`);
|
|
77
|
+
}
|
|
78
|
+
const owned = new OwnedProcess(child, {
|
|
79
|
+
role,
|
|
80
|
+
pid: child.pid,
|
|
81
|
+
command: [...command],
|
|
82
|
+
ownerFile
|
|
83
|
+
}, records);
|
|
84
|
+
onCreated?.(owned);
|
|
85
|
+
try {
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
child.removeListener("message", message);
|
|
90
|
+
child.removeListener("error", failed);
|
|
91
|
+
child.removeListener("exit", exited);
|
|
92
|
+
};
|
|
93
|
+
const failed = (error) => {
|
|
94
|
+
cleanup();
|
|
95
|
+
reject(error);
|
|
96
|
+
};
|
|
97
|
+
const exited = () => failed(/* @__PURE__ */ new Error(`${role} supervisor exited before startup`));
|
|
98
|
+
const message = (value) => {
|
|
99
|
+
const data = value;
|
|
100
|
+
if (data.type === "started" && typeof data.pid === "number") {
|
|
101
|
+
owned.record.childPid = data.pid;
|
|
102
|
+
cleanup();
|
|
103
|
+
resolve();
|
|
104
|
+
}
|
|
105
|
+
if (data.type === "failure") failed(/* @__PURE__ */ new Error(`${role}: ${data.message}`));
|
|
106
|
+
};
|
|
107
|
+
const timer = setTimeout(() => failed(/* @__PURE__ */ new Error(`${role} process startup timed out`)), 1e4);
|
|
108
|
+
child.on("message", message);
|
|
109
|
+
child.once("error", failed);
|
|
110
|
+
child.once("exit", exited);
|
|
111
|
+
child.send({ command }, (error) => {
|
|
112
|
+
if (error) failed(error);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
return owned;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
try {
|
|
118
|
+
await owned.stop();
|
|
119
|
+
} catch (cleanup) {
|
|
120
|
+
throw new AggregateError([error, cleanup], `Process startup and verified cleanup failed: ${String(error)}; ${String(cleanup)}`);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
logs() {
|
|
126
|
+
return this.output;
|
|
127
|
+
}
|
|
128
|
+
assertRunning() {
|
|
129
|
+
if (this.finished || this.child.exitCode !== null || this.child.signalCode !== null) throw new Error(`${this.record.role} exited: ${JSON.stringify(this.finished)}\n${this.output.slice(-8e3)}`);
|
|
130
|
+
}
|
|
131
|
+
async wait(timeoutMs, signal) {
|
|
132
|
+
const deadline = Date.now() + timeoutMs;
|
|
133
|
+
while (!this.finished && Date.now() < deadline) {
|
|
134
|
+
signal?.throwIfAborted();
|
|
135
|
+
await setTimeout$1(25);
|
|
136
|
+
}
|
|
137
|
+
if (!this.finished) throw new Error(`${this.record.role} timed out after ${timeoutMs}ms\n${this.output.slice(-8e3)}`);
|
|
138
|
+
if (this.finished.code !== 0) throw new Error(`${this.record.role} failed: ${JSON.stringify(this.finished)}\n${this.output.slice(-8e3)}`);
|
|
139
|
+
}
|
|
140
|
+
async verify() {
|
|
141
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) throw new Error(`Refusing cleanup: ${this.record.role} group anchor ${this.record.pid} exited unexpectedly`);
|
|
142
|
+
let commandLine;
|
|
143
|
+
if (process.platform === "linux") commandLine = await readFile(`/proc/${this.record.pid}/cmdline`, "utf8");
|
|
144
|
+
else if (process.platform === "win32") commandLine = await utility("powershell.exe", [
|
|
145
|
+
"-NoProfile",
|
|
146
|
+
"-NonInteractive",
|
|
147
|
+
"-Command",
|
|
148
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${this.record.pid}').CommandLine`
|
|
149
|
+
], this.records);
|
|
150
|
+
else commandLine = await utility("/bin/ps", [
|
|
151
|
+
"-p",
|
|
152
|
+
String(this.record.pid),
|
|
153
|
+
"-o",
|
|
154
|
+
"command="
|
|
155
|
+
], this.records);
|
|
156
|
+
if (!commandLineOwns(commandLine, this.record.ownerFile)) throw new Error(`Refusing cleanup: PID ${this.record.pid} command line does not contain its recorded owner file`);
|
|
157
|
+
}
|
|
158
|
+
stop() {
|
|
159
|
+
this.stopping ??= this.stopOwnedGroup().catch((error) => {
|
|
160
|
+
this.stopping = void 0;
|
|
161
|
+
throw error;
|
|
162
|
+
});
|
|
163
|
+
return this.stopping;
|
|
164
|
+
}
|
|
165
|
+
async stopOwnedGroup() {
|
|
166
|
+
await this.verify();
|
|
167
|
+
const exited = new Promise((resolve) => this.child.once("exit", () => resolve()));
|
|
168
|
+
if (process.platform === "win32") await utility("taskkill.exe", [
|
|
169
|
+
"/PID",
|
|
170
|
+
String(this.record.pid),
|
|
171
|
+
"/T",
|
|
172
|
+
"/F"
|
|
173
|
+
], this.records);
|
|
174
|
+
else {
|
|
175
|
+
process.kill(-this.record.pid, "SIGTERM");
|
|
176
|
+
await setTimeout$1(250);
|
|
177
|
+
await this.verify();
|
|
178
|
+
process.kill(-this.record.pid, "SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
await new Promise((resolve, reject) => {
|
|
181
|
+
const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Owned ${this.record.role} PID ${this.record.pid} did not exit`)), 5e3);
|
|
182
|
+
exited.then(() => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
resolve();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
this.record.stopped = true;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
export { OwnedProcess, commandLineOwns };
|
|
193
|
+
//# sourceMappingURL=owned-process.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"owned-process.mjs","names":["delay"],"sources":["../../../../../../../../packages/core/src/e2e/owned-process.ts"],"sourcesContent":["import { execFile, spawn, type ChildProcess } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport { readFile, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport type { Command, ProcessEvidence } from \"./types\";\n\n// A group anchor stays alive after the foreground command exits. This lets\n// teardown verify a live, unique command line before BOTH TERM and KILL,\n// including when the app crashed but left descendants behind.\nconst supervisorSource = `\nimport { spawn } from 'node:child_process';\nprocess.on('SIGTERM', () => {});\nprocess.on('SIGINT', () => {});\nprocess.on('message', ({command}) => {\n const child = spawn(command[0], command.slice(1), {cwd:process.cwd(),env:process.env,stdio:'inherit'});\n child.once('spawn', () => process.send?.({type:'started',pid:child.pid}));\n child.once('error', error => process.send?.({type:'failure',message:error.message}));\n child.once('exit', (code,signal) => process.send?.({type:'exit',code,signal}));\n});\n`;\n\n/** Small OS identity queries; every helper PID is retained as evidence too. */\nfunction utility(command: string, args: string[], records: ProcessEvidence[]): Promise<string> {\n return new Promise((resolve, reject) => {\n const child = execFile(command, args, { encoding: \"utf8\" }, (error, stdout) => {\n if (record) record.stopped = true;\n if (error) reject(error);\n else resolve(stdout);\n });\n const record: ProcessEvidence | undefined = child.pid\n ? { role: \"process-identity\", pid: child.pid, command: [command, ...args], ownerFile: \"\" }\n : undefined;\n if (record) records.push(record);\n });\n}\n\nexport function commandLineOwns(commandLine: string, ownerFile: string): boolean {\n if (commandLine.includes(\"\\0\")) return commandLine.split(\"\\0\").includes(ownerFile);\n // ps/CIM expose a rendered command line; the random owner path is unique\n // to this child, never an app name, binary name, PID search, or a port.\n return commandLine.includes(ownerFile);\n}\n\nexport class OwnedProcess {\n readonly record: ProcessEvidence;\n private output = \"\";\n private finished?: { code: number | null; signal: string | null };\n private stopping?: Promise<void>;\n private constructor(\n private child: ChildProcess,\n record: ProcessEvidence,\n private records: ProcessEvidence[],\n ) {\n this.record = record;\n records.push(record);\n const append = (chunk: Buffer) => {\n this.output = (this.output + chunk.toString()).slice(-1_000_000);\n };\n child.stdout?.on(\"data\", append);\n child.stderr?.on(\"data\", append);\n child.on(\"message\", (message: unknown) => {\n const data = message as { type?: string; code?: number | null; signal?: string | null };\n if (data.type === \"exit\")\n this.finished = { code: data.code ?? null, signal: data.signal ?? null };\n });\n }\n\n static async start(\n role: string,\n command: Command,\n directory: string,\n env: NodeJS.ProcessEnv,\n ownerDirectory: string,\n records: ProcessEvidence[],\n onCreated?: (owned: OwnedProcess) => void,\n ): Promise<OwnedProcess> {\n const ownerFile = join(ownerDirectory, `owner-${randomUUID()}.mjs`);\n await writeFile(ownerFile, supervisorSource, { mode: 0o600 });\n // detached makes the recorded supervisor PID its process-group ID on POSIX.\n // https://nodejs.org/download/release/v22.15.0/docs/api/child_process.html#optionsdetached\n const child = spawn(process.execPath, [ownerFile], {\n cwd: directory,\n env,\n detached: process.platform !== \"win32\",\n stdio: [\"ignore\", \"pipe\", \"pipe\", \"ipc\"],\n });\n if (!child.pid) {\n await new Promise<never>((_resolve, reject) => child.once(\"error\", reject));\n throw new Error(`Could not start ${role}`);\n }\n const owned = new OwnedProcess(\n child,\n { role, pid: child.pid, command: [...command], ownerFile },\n records,\n );\n onCreated?.(owned);\n try {\n await new Promise<void>((resolve, reject) => {\n const cleanup = () => {\n clearTimeout(timer);\n child.removeListener(\"message\", message);\n child.removeListener(\"error\", failed);\n child.removeListener(\"exit\", exited);\n };\n const failed = (error: Error) => {\n cleanup();\n reject(error);\n };\n const exited = () => failed(new Error(`${role} supervisor exited before startup`));\n const message = (value: unknown) => {\n const data = value as { type?: string; pid?: number; message?: string };\n if (data.type === \"started\" && typeof data.pid === \"number\") {\n owned.record.childPid = data.pid;\n cleanup();\n resolve();\n }\n if (data.type === \"failure\") failed(new Error(`${role}: ${data.message}`));\n };\n const timer = setTimeout(\n () => failed(new Error(`${role} process startup timed out`)),\n 10_000,\n );\n child.on(\"message\", message);\n child.once(\"error\", failed);\n child.once(\"exit\", exited);\n child.send({ command }, (error) => {\n if (error) failed(error);\n });\n });\n return owned;\n } catch (error) {\n try {\n await owned.stop();\n } catch (cleanup) {\n throw new AggregateError(\n [error, cleanup],\n `Process startup and verified cleanup failed: ${String(error)}; ${String(cleanup)}`,\n );\n }\n throw error;\n }\n }\n\n logs(): string {\n return this.output;\n }\n\n assertRunning(): void {\n if (this.finished || this.child.exitCode !== null || this.child.signalCode !== null) {\n throw new Error(\n `${this.record.role} exited: ${JSON.stringify(this.finished)}\\n${this.output.slice(-8000)}`,\n );\n }\n }\n\n async wait(timeoutMs: number, signal?: AbortSignal): Promise<void> {\n const deadline = Date.now() + timeoutMs;\n while (!this.finished && Date.now() < deadline) {\n signal?.throwIfAborted();\n await delay(25);\n }\n if (!this.finished)\n throw new Error(\n `${this.record.role} timed out after ${timeoutMs}ms\\n${this.output.slice(-8000)}`,\n );\n if (this.finished.code !== 0)\n throw new Error(\n `${this.record.role} failed: ${JSON.stringify(this.finished)}\\n${this.output.slice(-8000)}`,\n );\n }\n\n private async verify(): Promise<void> {\n if (this.child.exitCode !== null || this.child.signalCode !== null)\n throw new Error(\n `Refusing cleanup: ${this.record.role} group anchor ${this.record.pid} exited unexpectedly`,\n );\n let commandLine: string;\n if (process.platform === \"linux\")\n commandLine = await readFile(`/proc/${this.record.pid}/cmdline`, \"utf8\");\n else if (process.platform === \"win32\") {\n // PID is from spawn, interpolated as a number; no user text enters PowerShell.\n // https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-process\n commandLine = await utility(\n \"powershell.exe\",\n [\n \"-NoProfile\",\n \"-NonInteractive\",\n \"-Command\",\n `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${this.record.pid}').CommandLine`,\n ],\n this.records,\n );\n } else\n commandLine = await utility(\n \"/bin/ps\",\n [\"-p\", String(this.record.pid), \"-o\", \"command=\"],\n this.records,\n );\n if (!commandLineOwns(commandLine, this.record.ownerFile))\n throw new Error(\n `Refusing cleanup: PID ${this.record.pid} command line does not contain its recorded owner file`,\n );\n }\n\n stop(): Promise<void> {\n this.stopping ??= this.stopOwnedGroup().catch((error: unknown) => {\n this.stopping = undefined;\n throw error;\n });\n return this.stopping;\n }\n\n private async stopOwnedGroup(): Promise<void> {\n await this.verify();\n const exited = new Promise<void>((resolve) => this.child.once(\"exit\", () => resolve()));\n if (process.platform === \"win32\") {\n // Windows has no POSIX process groups. /T targets this verified PID's tree.\n // https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill\n await utility(\"taskkill.exe\", [\"/PID\", String(this.record.pid), \"/T\", \"/F\"], this.records);\n } else {\n process.kill(-this.record.pid, \"SIGTERM\");\n await delay(250);\n await this.verify();\n process.kill(-this.record.pid, \"SIGKILL\");\n }\n await new Promise<void>((resolve, reject) => {\n const timer = setTimeout(\n () => reject(new Error(`Owned ${this.record.role} PID ${this.record.pid} did not exit`)),\n 5000,\n );\n void exited.then(() => {\n clearTimeout(timer);\n resolve();\n });\n });\n this.record.stopped = true;\n }\n}\n"],"mappings":";;;;;;;AAUA,MAAM,mBAAmB;;;;;;;;;;;;AAazB,SAAS,QAAQ,SAAiB,MAAgB,SAA6C;CAC7F,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,QAAQ,SAAS,SAAS,MAAM,EAAE,UAAU,OAAO,IAAI,OAAO,WAAW;GAC7E,IAAI,QAAQ,OAAO,UAAU;GAC7B,IAAI,OAAO,OAAO,KAAK;QAClB,QAAQ,MAAM;EACrB,CAAC;EACD,MAAM,SAAsC,MAAM,MAC9C;GAAE,MAAM;GAAoB,KAAK,MAAM;GAAK,SAAS,CAAC,SAAS,GAAG,IAAI;GAAG,WAAW;EAAG,IACvF;EACJ,IAAI,QAAQ,QAAQ,KAAK,MAAM;CACjC,CAAC;AACH;AAEA,SAAgB,gBAAgB,aAAqB,WAA4B;CAC/E,IAAI,YAAY,SAAS,IAAI,GAAG,OAAO,YAAY,MAAM,IAAI,CAAC,CAAC,SAAS,SAAS;CAGjF,OAAO,YAAY,SAAS,SAAS;AACvC;AAEA,IAAa,eAAb,MAAa,aAAa;CAKxB,AAAQ,YACN,AAAQ,OACR,QACA,AAAQ,SACR;EAHQ;EAEA;gBANO;EAQf,KAAK,SAAS;EACd,QAAQ,KAAK,MAAM;EACnB,MAAM,UAAU,UAAkB;GAChC,KAAK,UAAU,KAAK,SAAS,MAAM,SAAS,EAAC,CAAE,MAAM,IAAU;EACjE;EACA,MAAM,QAAQ,GAAG,QAAQ,MAAM;EAC/B,MAAM,QAAQ,GAAG,QAAQ,MAAM;EAC/B,MAAM,GAAG,YAAY,YAAqB;GACxC,MAAM,OAAO;GACb,IAAI,KAAK,SAAS,QAChB,KAAK,WAAW;IAAE,MAAM,KAAK,QAAQ;IAAM,QAAQ,KAAK,UAAU;GAAK;EAC3E,CAAC;CACH;CAEA,aAAa,MACX,MACA,SACA,WACA,KACA,gBACA,SACA,WACuB;EACvB,MAAM,YAAY,KAAK,gBAAgB,SAAS,WAAW,EAAE,KAAK;EAClE,MAAM,UAAU,WAAW,kBAAkB,EAAE,MAAM,IAAM,CAAC;EAG5D,MAAM,QAAQ,MAAM,QAAQ,UAAU,CAAC,SAAS,GAAG;GACjD,KAAK;GACL;GACA,UAAU,QAAQ,aAAa;GAC/B,OAAO;IAAC;IAAU;IAAQ;IAAQ;GAAK;EACzC,CAAC;EACD,IAAI,CAAC,MAAM,KAAK;GACd,MAAM,IAAI,SAAgB,UAAU,WAAW,MAAM,KAAK,SAAS,MAAM,CAAC;GAC1E,MAAM,IAAI,MAAM,mBAAmB,MAAM;EAC3C;EACA,MAAM,QAAQ,IAAI,aAChB,OACA;GAAE;GAAM,KAAK,MAAM;GAAK,SAAS,CAAC,GAAG,OAAO;GAAG;EAAU,GACzD,OACF;EACA,YAAY,KAAK;EACjB,IAAI;GACF,MAAM,IAAI,SAAe,SAAS,WAAW;IAC3C,MAAM,gBAAgB;KACpB,aAAa,KAAK;KAClB,MAAM,eAAe,WAAW,OAAO;KACvC,MAAM,eAAe,SAAS,MAAM;KACpC,MAAM,eAAe,QAAQ,MAAM;IACrC;IACA,MAAM,UAAU,UAAiB;KAC/B,QAAQ;KACR,OAAO,KAAK;IACd;IACA,MAAM,eAAe,uBAAO,IAAI,MAAM,GAAG,KAAK,kCAAkC,CAAC;IACjF,MAAM,WAAW,UAAmB;KAClC,MAAM,OAAO;KACb,IAAI,KAAK,SAAS,aAAa,OAAO,KAAK,QAAQ,UAAU;MAC3D,MAAM,OAAO,WAAW,KAAK;MAC7B,QAAQ;MACR,QAAQ;KACV;KACA,IAAI,KAAK,SAAS,WAAW,uBAAO,IAAI,MAAM,GAAG,KAAK,IAAI,KAAK,SAAS,CAAC;IAC3E;IACA,MAAM,QAAQ,iBACN,uBAAO,IAAI,MAAM,GAAG,KAAK,2BAA2B,CAAC,GAC3D,GACF;IACA,MAAM,GAAG,WAAW,OAAO;IAC3B,MAAM,KAAK,SAAS,MAAM;IAC1B,MAAM,KAAK,QAAQ,MAAM;IACzB,MAAM,KAAK,EAAE,QAAQ,IAAI,UAAU;KACjC,IAAI,OAAO,OAAO,KAAK;IACzB,CAAC;GACH,CAAC;GACD,OAAO;EACT,SAAS,OAAO;GACd,IAAI;IACF,MAAM,MAAM,KAAK;GACnB,SAAS,SAAS;IAChB,MAAM,IAAI,eACR,CAAC,OAAO,OAAO,GACf,gDAAgD,OAAO,KAAK,EAAE,IAAI,OAAO,OAAO,GAClF;GACF;GACA,MAAM;EACR;CACF;CAEA,OAAe;EACb,OAAO,KAAK;CACd;CAEA,gBAAsB;EACpB,IAAI,KAAK,YAAY,KAAK,MAAM,aAAa,QAAQ,KAAK,MAAM,eAAe,MAC7E,MAAM,IAAI,MACR,GAAG,KAAK,OAAO,KAAK,WAAW,KAAK,UAAU,KAAK,QAAQ,EAAE,IAAI,KAAK,OAAO,MAAM,IAAK,GAC1F;CAEJ;CAEA,MAAM,KAAK,WAAmB,QAAqC;EACjE,MAAM,WAAW,KAAK,IAAI,IAAI;EAC9B,OAAO,CAAC,KAAK,YAAY,KAAK,IAAI,IAAI,UAAU;GAC9C,QAAQ,eAAe;GACvB,MAAMA,aAAM,EAAE;EAChB;EACA,IAAI,CAAC,KAAK,UACR,MAAM,IAAI,MACR,GAAG,KAAK,OAAO,KAAK,mBAAmB,UAAU,MAAM,KAAK,OAAO,MAAM,IAAK,GAChF;EACF,IAAI,KAAK,SAAS,SAAS,GACzB,MAAM,IAAI,MACR,GAAG,KAAK,OAAO,KAAK,WAAW,KAAK,UAAU,KAAK,QAAQ,EAAE,IAAI,KAAK,OAAO,MAAM,IAAK,GAC1F;CACJ;CAEA,MAAc,SAAwB;EACpC,IAAI,KAAK,MAAM,aAAa,QAAQ,KAAK,MAAM,eAAe,MAC5D,MAAM,IAAI,MACR,qBAAqB,KAAK,OAAO,KAAK,gBAAgB,KAAK,OAAO,IAAI,qBACxE;EACF,IAAI;EACJ,IAAI,QAAQ,aAAa,SACvB,cAAc,MAAM,SAAS,SAAS,KAAK,OAAO,IAAI,WAAW,MAAM;OACpE,IAAI,QAAQ,aAAa,SAG5B,cAAc,MAAM,QAClB,kBACA;GACE;GACA;GACA;GACA,uDAAuD,KAAK,OAAO,IAAI;EACzE,GACA,KAAK,OACP;OAEA,cAAc,MAAM,QAClB,WACA;GAAC;GAAM,OAAO,KAAK,OAAO,GAAG;GAAG;GAAM;EAAU,GAChD,KAAK,OACP;EACF,IAAI,CAAC,gBAAgB,aAAa,KAAK,OAAO,SAAS,GACrD,MAAM,IAAI,MACR,yBAAyB,KAAK,OAAO,IAAI,uDAC3C;CACJ;CAEA,OAAsB;EACpB,KAAK,aAAa,KAAK,eAAe,CAAC,CAAC,OAAO,UAAmB;GAChE,KAAK,WAAW;GAChB,MAAM;EACR,CAAC;EACD,OAAO,KAAK;CACd;CAEA,MAAc,iBAAgC;EAC5C,MAAM,KAAK,OAAO;EAClB,MAAM,SAAS,IAAI,SAAe,YAAY,KAAK,MAAM,KAAK,cAAc,QAAQ,CAAC,CAAC;EACtF,IAAI,QAAQ,aAAa,SAGvB,MAAM,QAAQ,gBAAgB;GAAC;GAAQ,OAAO,KAAK,OAAO,GAAG;GAAG;GAAM;EAAI,GAAG,KAAK,OAAO;OACpF;GACL,QAAQ,KAAK,CAAC,KAAK,OAAO,KAAK,SAAS;GACxC,MAAMA,aAAM,GAAG;GACf,MAAM,KAAK,OAAO;GAClB,QAAQ,KAAK,CAAC,KAAK,OAAO,KAAK,SAAS;EAC1C;EACA,MAAM,IAAI,SAAe,SAAS,WAAW;GAC3C,MAAM,QAAQ,iBACN,uBAAO,IAAI,MAAM,SAAS,KAAK,OAAO,KAAK,OAAO,KAAK,OAAO,IAAI,cAAc,CAAC,GACvF,GACF;GACA,AAAK,OAAO,WAAW;IACrB,aAAa,KAAK;IAClB,QAAQ;GACV,CAAC;EACH,CAAC;EACD,KAAK,OAAO,UAAU;CACxB;AACF"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CdpConnection } from "./cdp.mjs";
|
|
2
|
+
import { E2EEvidence, E2ESpec, Viewport } from "./types.mjs";
|
|
3
|
+
//#region ../../packages/core/src/e2e/page.d.ts
|
|
4
|
+
type WaitOptions = {
|
|
5
|
+
selector?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
type ViewportReport = {
|
|
11
|
+
requestedWidth: number;
|
|
12
|
+
innerWidth: number;
|
|
13
|
+
documentWidth: number;
|
|
14
|
+
bodyWidth: number;
|
|
15
|
+
offenders: {
|
|
16
|
+
tag: string;
|
|
17
|
+
id: string;
|
|
18
|
+
className: string;
|
|
19
|
+
width: number;
|
|
20
|
+
scrollWidth: number;
|
|
21
|
+
right: number;
|
|
22
|
+
left: number;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
export declare class BrowserPage {
|
|
26
|
+
private cdp;
|
|
27
|
+
private origin;
|
|
28
|
+
private outputDirectory;
|
|
29
|
+
private evidence;
|
|
30
|
+
private spec;
|
|
31
|
+
private viewport;
|
|
32
|
+
private loads;
|
|
33
|
+
private pendingRequests;
|
|
34
|
+
private requestUrls;
|
|
35
|
+
private lastNetworkActivity;
|
|
36
|
+
constructor(cdp: CdpConnection, origin: string, outputDirectory: string, evidence: E2EEvidence, spec: E2ESpec);
|
|
37
|
+
private completeRequest;
|
|
38
|
+
initialize(): Promise<void>;
|
|
39
|
+
step<T>(name: string, action: () => Promise<T>): Promise<T>;
|
|
40
|
+
evaluate<T = unknown>(expression: string, timeoutMs?: number): Promise<T>;
|
|
41
|
+
private poll;
|
|
42
|
+
goto(path: string, timeoutMs?: number): Promise<void>;
|
|
43
|
+
waitFor(options: WaitOptions): Promise<void>;
|
|
44
|
+
/** Arms the document/URL comparison BEFORE invoking the navigation action,
|
|
45
|
+
* covering full loads, reloads, hash changes and client-side routing.
|
|
46
|
+
*/
|
|
47
|
+
waitForNavigation(action: () => Promise<unknown>, options?: {
|
|
48
|
+
url?: string;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
click(selector: string, timeoutMs?: number): Promise<void>;
|
|
52
|
+
fill(selector: string, value: string): Promise<void>;
|
|
53
|
+
/** Appends Unicode text through the browser's text-input path. */
|
|
54
|
+
type(selector: string, text: string): Promise<void>;
|
|
55
|
+
setViewport(viewport: Viewport): Promise<void>;
|
|
56
|
+
assertViewport(): Promise<ViewportReport>;
|
|
57
|
+
screenshot(name: string): Promise<string>;
|
|
58
|
+
captureViewports(name: string, viewports?: readonly Viewport[]): Promise<void>;
|
|
59
|
+
waitForNetworkIdle(timeoutMs?: number): Promise<void>;
|
|
60
|
+
assertHealthy(): void;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export {};
|
|
64
|
+
//# sourceMappingURL=page.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/e2e/page.ts"],"mappings":";;;KAaK;EAAgB;EAAmB;EAAe;EAAc;;KAEhE;EACH;EACA;EACA;EACA;EACA;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;;qBAuDS;UAQD;UACA;UACA;UACA;UACA;UAXF;UACA;UACA;UACA;UACA;EAER,YACU,KAAK,eACL,gBACA,yBACA,UAAU,aACV,MAAM;UAgDR;EAMF,cAAc;EAQd,KAAK,GAAG,cAAc,cAAc,QAAQ,KAAK,QAAQ;EAqBzD,SAAS,aAAa,oBAAoB,qBAAqB,QAAQ;UAa/D;EAkBR,KAAK,cAAc,qBAAqB;EAkBxC,QAAQ,SAAS,cAAc;;;;EA0B/B,kBACJ,cAAc,kBACd;IAAW;IAAc;MACxB;EAkBG,MAAM,kBAAkB,qBAAqB;EA2E7C,KAAK,kBAAkB,gBAAgB;;EA4BvC,KAAK,kBAAkB,eAAe;EA0BtC,YAAY,UAAU,WAAW;EAuBjC,kBAAkB,QAAQ;EAoB1B,WAAW,eAAe;EAY1B,iBACJ,cACA,qBAAS,aACR;EAuBG,mBAAmB,qBAAqB;EAgB9C"}
|
package/esm/e2e/page.mjs
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { defaultViewports, errorMessage, matches } from "./types.mjs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
//#region ../../packages/core/src/e2e/page.ts
|
|
7
|
+
/** Runs inside the browser. Two paints plus cross-probe box comparison detect
|
|
8
|
+
* scroll/animation/layout races. The hit test also accepts target descendants.
|
|
9
|
+
*/
|
|
10
|
+
function clickProbe(selector) {
|
|
11
|
+
return (async () => {
|
|
12
|
+
const empty = (reason) => ({
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
width: 0,
|
|
16
|
+
height: 0,
|
|
17
|
+
reason
|
|
18
|
+
});
|
|
19
|
+
const element = document.querySelector(selector);
|
|
20
|
+
if (!(element instanceof HTMLElement || element instanceof SVGElement)) return empty("target is missing or is not an HTML/SVG element");
|
|
21
|
+
element.scrollIntoView({
|
|
22
|
+
block: "center",
|
|
23
|
+
inline: "center",
|
|
24
|
+
behavior: "instant"
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
27
|
+
if (!element.isConnected || document.querySelector(selector) !== element) return empty("target was replaced during layout");
|
|
28
|
+
const box = element.getBoundingClientRect();
|
|
29
|
+
const x = box.x + box.width / 2;
|
|
30
|
+
const y = box.y + box.height / 2;
|
|
31
|
+
const style = getComputedStyle(element);
|
|
32
|
+
let reason = "";
|
|
33
|
+
if (box.width <= 0 || box.height <= 0 || style.visibility !== "visible" || style.display === "none" || Number(style.opacity) === 0) reason = "target is hidden or has an empty box";
|
|
34
|
+
else if (element.matches(":disabled") || element.closest("[inert], [aria-disabled=\"true\"]")) reason = "target is disabled or inert";
|
|
35
|
+
else if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) reason = "target center is outside the viewport";
|
|
36
|
+
else {
|
|
37
|
+
const top = document.elementFromPoint(x, y);
|
|
38
|
+
if (!top || !element.contains(top)) reason = `target is covered by ${top?.tagName ?? "no element"}${top?.id ? "#" + top.id : ""}`;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
x,
|
|
42
|
+
y,
|
|
43
|
+
width: box.width,
|
|
44
|
+
height: box.height,
|
|
45
|
+
reason
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
}
|
|
49
|
+
function boxesStable(first, second) {
|
|
50
|
+
return !first.reason && !second.reason && [
|
|
51
|
+
"x",
|
|
52
|
+
"y",
|
|
53
|
+
"width",
|
|
54
|
+
"height"
|
|
55
|
+
].every((key) => Math.abs(first[key] - second[key]) < .25);
|
|
56
|
+
}
|
|
57
|
+
var BrowserPage = class {
|
|
58
|
+
constructor(cdp, origin, outputDirectory, evidence, spec) {
|
|
59
|
+
this.cdp = cdp;
|
|
60
|
+
this.origin = origin;
|
|
61
|
+
this.outputDirectory = outputDirectory;
|
|
62
|
+
this.evidence = evidence;
|
|
63
|
+
this.spec = spec;
|
|
64
|
+
this.viewport = { ...defaultViewports[0] };
|
|
65
|
+
this.loads = 0;
|
|
66
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
67
|
+
this.requestUrls = /* @__PURE__ */ new Map();
|
|
68
|
+
this.lastNetworkActivity = Date.now();
|
|
69
|
+
cdp.on("Page.loadEventFired", () => {
|
|
70
|
+
this.loads++;
|
|
71
|
+
});
|
|
72
|
+
cdp.on("Runtime.consoleAPICalled", ({ type, args }) => evidence.console.push({
|
|
73
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
74
|
+
level: type === "assert" ? "error" : type,
|
|
75
|
+
text: args.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ")
|
|
76
|
+
}));
|
|
77
|
+
cdp.on("Runtime.exceptionThrown", ({ exceptionDetails }) => evidence.console.push({
|
|
78
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
79
|
+
level: "error",
|
|
80
|
+
text: exceptionDetails.exception?.description ?? exceptionDetails.text
|
|
81
|
+
}));
|
|
82
|
+
cdp.on("Log.entryAdded", ({ entry }) => evidence.console.push({
|
|
83
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
84
|
+
level: entry.level,
|
|
85
|
+
text: entry.text
|
|
86
|
+
}));
|
|
87
|
+
cdp.on("Network.requestWillBeSent", ({ requestId, request }) => {
|
|
88
|
+
this.pendingRequests.set(requestId, request.url);
|
|
89
|
+
this.requestUrls.set(requestId, request.url);
|
|
90
|
+
this.lastNetworkActivity = Date.now();
|
|
91
|
+
});
|
|
92
|
+
cdp.on("Network.responseReceived", ({ requestId, response }) => {
|
|
93
|
+
this.requestUrls.set(requestId, response.url);
|
|
94
|
+
if (response.status >= 400) evidence.networkFailures.push({
|
|
95
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
96
|
+
url: response.url,
|
|
97
|
+
status: response.status
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
cdp.on("Network.loadingFailed", ({ requestId, errorText, canceled }) => {
|
|
101
|
+
evidence.networkFailures.push({
|
|
102
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
103
|
+
url: this.requestUrls.get(requestId) ?? `unknown request ${requestId}`,
|
|
104
|
+
errorText,
|
|
105
|
+
canceled
|
|
106
|
+
});
|
|
107
|
+
this.completeRequest(requestId);
|
|
108
|
+
});
|
|
109
|
+
cdp.on("Network.loadingFinished", ({ requestId }) => this.completeRequest(requestId));
|
|
110
|
+
}
|
|
111
|
+
completeRequest(requestId) {
|
|
112
|
+
this.pendingRequests.delete(requestId);
|
|
113
|
+
this.requestUrls.delete(requestId);
|
|
114
|
+
this.lastNetworkActivity = Date.now();
|
|
115
|
+
}
|
|
116
|
+
async initialize() {
|
|
117
|
+
await this.cdp.command("Page.enable", {});
|
|
118
|
+
await this.cdp.command("Runtime.enable", {});
|
|
119
|
+
await this.cdp.command("Network.enable", {});
|
|
120
|
+
await this.cdp.command("Log.enable", {});
|
|
121
|
+
await this.setViewport(this.spec.viewports?.[0] ?? defaultViewports[0]);
|
|
122
|
+
}
|
|
123
|
+
async step(name, action) {
|
|
124
|
+
const start = performance.now();
|
|
125
|
+
const entry = {
|
|
126
|
+
name,
|
|
127
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
128
|
+
durationMs: 0,
|
|
129
|
+
status: "passed",
|
|
130
|
+
error: void 0
|
|
131
|
+
};
|
|
132
|
+
this.evidence.steps.push(entry);
|
|
133
|
+
try {
|
|
134
|
+
return await action();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
entry.status = "failed";
|
|
137
|
+
entry.error = errorMessage(error);
|
|
138
|
+
throw error;
|
|
139
|
+
} finally {
|
|
140
|
+
entry.durationMs = Math.round(performance.now() - start);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async evaluate(expression, timeoutMs = 1e4) {
|
|
144
|
+
const response = await this.cdp.command("Runtime.evaluate", {
|
|
145
|
+
expression,
|
|
146
|
+
returnByValue: true,
|
|
147
|
+
awaitPromise: true,
|
|
148
|
+
silent: true
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
if (response.exceptionDetails) throw new Error(`Browser evaluation failed: ${response.exceptionDetails.exception?.description ?? response.exceptionDetails.text}`);
|
|
151
|
+
return response.result.value;
|
|
152
|
+
}
|
|
153
|
+
async poll(description, check, timeoutMs) {
|
|
154
|
+
const deadline = Date.now() + timeoutMs;
|
|
155
|
+
let reason = "condition stayed false";
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
try {
|
|
158
|
+
if (await check(Math.max(1, deadline - Date.now()))) return;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
reason = errorMessage(error);
|
|
161
|
+
}
|
|
162
|
+
await setTimeout(Math.min(50, Math.max(0, deadline - Date.now())));
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`${description} timed out after ${timeoutMs}ms: ${reason}`);
|
|
165
|
+
}
|
|
166
|
+
async goto(path, timeoutMs = 1e4) {
|
|
167
|
+
await this.step(`goto ${path}`, async () => {
|
|
168
|
+
const url = new URL(path, this.origin).href;
|
|
169
|
+
const before = this.loads;
|
|
170
|
+
const navigation = await this.cdp.command("Page.navigate", { url }, timeoutMs);
|
|
171
|
+
if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`);
|
|
172
|
+
await this.poll(`Navigation to ${url}`, (remaining) => this.evaluate(`document.readyState === 'complete' && ${navigation.loaderId ? this.loads > before : "true"}`, remaining), timeoutMs);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async waitFor(options) {
|
|
176
|
+
if (options.selector === void 0 && options.text === void 0 && options.url === void 0) throw new Error("waitFor requires a selector, text, or URL");
|
|
177
|
+
await this.step(`waitFor ${JSON.stringify(options)}`, () => this.poll("waitFor " + JSON.stringify(options), (remaining) => this.evaluate(`(() => {
|
|
178
|
+
const options = ${JSON.stringify(options)};
|
|
179
|
+
const element = options.selector ? document.querySelector(options.selector) : document.body;
|
|
180
|
+
if (!element) return false;
|
|
181
|
+
if (options.selector) { const box=element.getBoundingClientRect(); const css=getComputedStyle(element); if (!box.width || !box.height || css.visibility !== 'visible') return false; }
|
|
182
|
+
return (options.text === undefined || (element.innerText ?? element.textContent ?? '').includes(options.text)) &&
|
|
183
|
+
(options.url === undefined || location.href === new URL(options.url, ${JSON.stringify(this.origin)}).href);
|
|
184
|
+
})()`, remaining), options.timeoutMs ?? 1e4));
|
|
185
|
+
}
|
|
186
|
+
/** Arms the document/URL comparison BEFORE invoking the navigation action,
|
|
187
|
+
* covering full loads, reloads, hash changes and client-side routing.
|
|
188
|
+
*/
|
|
189
|
+
async waitForNavigation(action, options = {}) {
|
|
190
|
+
await this.step("waitForNavigation", async () => {
|
|
191
|
+
const before = await this.evaluate("({href:location.href,timeOrigin:performance.timeOrigin})");
|
|
192
|
+
await action();
|
|
193
|
+
await this.poll("Navigation", (remaining) => this.evaluate(`document.readyState === 'complete' && (location.href !== ${JSON.stringify(before.href)} || performance.timeOrigin !== ${before.timeOrigin}) && ${options.url ? `location.href === ${JSON.stringify(new URL(options.url, this.origin).href)}` : "true"}`, remaining), options.timeoutMs ?? 1e4);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
async click(selector, timeoutMs = 1e4) {
|
|
197
|
+
await this.step(`click ${selector}`, async () => {
|
|
198
|
+
const deadline = Date.now() + timeoutMs;
|
|
199
|
+
let previous;
|
|
200
|
+
let reason = "layout has not settled";
|
|
201
|
+
while (Date.now() < deadline) {
|
|
202
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
203
|
+
let box;
|
|
204
|
+
try {
|
|
205
|
+
box = await this.evaluate(`(${clickProbe.toString()})(${JSON.stringify(selector)})`, remaining());
|
|
206
|
+
} catch (error) {
|
|
207
|
+
reason = errorMessage(error);
|
|
208
|
+
previous = void 0;
|
|
209
|
+
await setTimeout(25);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
reason = box.reason || "target box is moving";
|
|
213
|
+
if (previous && boxesStable(previous, box)) {
|
|
214
|
+
let hovered;
|
|
215
|
+
try {
|
|
216
|
+
await this.cdp.command("Input.dispatchMouseEvent", {
|
|
217
|
+
type: "mouseMoved",
|
|
218
|
+
x: box.x,
|
|
219
|
+
y: box.y
|
|
220
|
+
}, remaining());
|
|
221
|
+
hovered = await this.evaluate(`(${clickProbe.toString()})(${JSON.stringify(selector)})`, remaining());
|
|
222
|
+
} catch (error) {
|
|
223
|
+
reason = errorMessage(error);
|
|
224
|
+
previous = void 0;
|
|
225
|
+
await setTimeout(Math.min(25, remaining()));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (Date.now() < deadline && boxesStable(box, hovered)) {
|
|
229
|
+
await this.cdp.command("Input.dispatchMouseEvent", {
|
|
230
|
+
type: "mousePressed",
|
|
231
|
+
x: hovered.x,
|
|
232
|
+
y: hovered.y,
|
|
233
|
+
button: "left",
|
|
234
|
+
buttons: 1,
|
|
235
|
+
clickCount: 1
|
|
236
|
+
}, remaining());
|
|
237
|
+
await this.cdp.command("Input.dispatchMouseEvent", {
|
|
238
|
+
type: "mouseReleased",
|
|
239
|
+
x: hovered.x,
|
|
240
|
+
y: hovered.y,
|
|
241
|
+
button: "left",
|
|
242
|
+
buttons: 0,
|
|
243
|
+
clickCount: 1
|
|
244
|
+
}, remaining());
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
reason = hovered.reason || "hover changed the target box";
|
|
248
|
+
previous = void 0;
|
|
249
|
+
} else previous = box;
|
|
250
|
+
await setTimeout(Math.min(50, remaining()));
|
|
251
|
+
}
|
|
252
|
+
throw new Error(`Cannot click ${JSON.stringify(selector)} within ${timeoutMs}ms: ${reason}`);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async fill(selector, value) {
|
|
256
|
+
await this.step(`fill ${selector}`, async () => {
|
|
257
|
+
await this.click(selector);
|
|
258
|
+
await this.evaluate(`(() => {
|
|
259
|
+
const element=document.querySelector(${JSON.stringify(selector)});
|
|
260
|
+
if (element !== document.activeElement) throw new Error('Fill target lost focus');
|
|
261
|
+
if ((element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && !element.readOnly && !element.disabled) {
|
|
262
|
+
const prototype=element instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
|
|
263
|
+
const setter=Object.getOwnPropertyDescriptor(prototype,'value')?.set;
|
|
264
|
+
if (!setter) throw new Error('Fill target has no native value setter');
|
|
265
|
+
setter.call(element,'');
|
|
266
|
+
} else if (element instanceof HTMLElement && element.isContentEditable) element.textContent='';
|
|
267
|
+
else throw new Error('Fill target must be an editable input, textarea or contenteditable');
|
|
268
|
+
element.dispatchEvent(new Event('input',{bubbles:true}));
|
|
269
|
+
})()`);
|
|
270
|
+
await this.evaluate(`document.activeElement === document.querySelector(${JSON.stringify(selector)}) || (() => { throw new Error("Fill target lost focus after input"); })()`);
|
|
271
|
+
if (value) await this.cdp.command("Input.insertText", { text: value });
|
|
272
|
+
await this.evaluate("document.activeElement?.dispatchEvent(new Event('change',{bubbles:true}))");
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/** Appends Unicode text through the browser's text-input path. */
|
|
276
|
+
async type(selector, text) {
|
|
277
|
+
await this.step(`type ${selector}`, async () => {
|
|
278
|
+
await this.click(selector);
|
|
279
|
+
if (await this.evaluate(`(() => { const element=document.querySelector(${JSON.stringify(selector)}); if (element !== document.activeElement || !((element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && !element.readOnly && !element.disabled || element instanceof HTMLElement && element.isContentEditable)) throw new Error('Type target is not focused and editable'); if(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { if (typeof element.selectionStart !== "number") return true; element.setSelectionRange(element.value.length,element.value.length); } else { const range=document.createRange(); range.selectNodeContents(element); range.collapse(false); const selection=getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } return false; })()`)) {
|
|
280
|
+
await this.cdp.command("Input.dispatchKeyEvent", {
|
|
281
|
+
type: "keyDown",
|
|
282
|
+
key: "End",
|
|
283
|
+
code: "End",
|
|
284
|
+
windowsVirtualKeyCode: 35
|
|
285
|
+
});
|
|
286
|
+
await this.cdp.command("Input.dispatchKeyEvent", {
|
|
287
|
+
type: "keyUp",
|
|
288
|
+
key: "End",
|
|
289
|
+
code: "End",
|
|
290
|
+
windowsVirtualKeyCode: 35
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
await this.cdp.command("Input.insertText", { text });
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async setViewport(viewport) {
|
|
297
|
+
if (!/^[a-z0-9_-]+$/i.test(viewport.name) || !Number.isInteger(viewport.width) || !Number.isInteger(viewport.height) || viewport.width <= 0 || viewport.height <= 0) throw new Error("Viewport requires a safe name and positive integer width/height");
|
|
298
|
+
await this.step(`viewport ${viewport.name} ${viewport.width}x${viewport.height}`, async () => {
|
|
299
|
+
await this.cdp.command("Emulation.setDeviceMetricsOverride", {
|
|
300
|
+
width: viewport.width,
|
|
301
|
+
height: viewport.height,
|
|
302
|
+
deviceScaleFactor: 1,
|
|
303
|
+
mobile: viewport.mobile ?? false
|
|
304
|
+
});
|
|
305
|
+
this.viewport = { ...viewport };
|
|
306
|
+
await this.evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))");
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
async assertViewport() {
|
|
310
|
+
return this.step(`assertViewport ${this.viewport.name}`, async () => {
|
|
311
|
+
const report = await this.evaluate(`({
|
|
312
|
+
requestedWidth:${this.viewport.width},innerWidth,documentWidth:document.documentElement.scrollWidth,bodyWidth:document.body?.scrollWidth ?? 0,
|
|
313
|
+
offenders:[...document.querySelectorAll('*')].map(element=>{const box=element.getBoundingClientRect();return {tag:element.tagName,id:element.id,className:element.getAttribute('class')??'',width:box.width,scrollWidth:element.scrollWidth,right:box.right,left:box.left}})
|
|
314
|
+
.filter(element=>element.right>${this.viewport.width} || element.left<0 || element.width>${this.viewport.width} || element.scrollWidth>${this.viewport.width})
|
|
315
|
+
.sort((a,b)=>Math.max(b.width,b.scrollWidth,b.right)-Math.max(a.width,a.scrollWidth,a.right)).slice(0,10)
|
|
316
|
+
})`);
|
|
317
|
+
if (report.innerWidth !== report.requestedWidth || report.documentWidth > report.requestedWidth || report.bodyWidth > report.requestedWidth) throw new Error(`Horizontal overflow or expanded viewport at ${this.viewport.name}: ${JSON.stringify(report)}`);
|
|
318
|
+
return report;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async screenshot(name) {
|
|
322
|
+
if (!/^[a-z0-9_-]+$/i.test(name)) throw new Error("Screenshot names must contain only letters, digits, '_' or '-'");
|
|
323
|
+
return this.step(`screenshot ${name}`, async () => {
|
|
324
|
+
const file = `${String(this.evidence.screenshots.length + 1).padStart(3, "0")}-${name}-${this.viewport.name}.png`;
|
|
325
|
+
const { data } = await this.cdp.command("Page.captureScreenshot", { format: "png" });
|
|
326
|
+
await writeFile(join(this.outputDirectory, file), Buffer.from(data, "base64"));
|
|
327
|
+
this.evidence.screenshots.push({
|
|
328
|
+
name,
|
|
329
|
+
file,
|
|
330
|
+
viewport: { ...this.viewport }
|
|
331
|
+
});
|
|
332
|
+
return join(this.outputDirectory, file);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
async captureViewports(name, viewports = this.spec.viewports ?? defaultViewports) {
|
|
336
|
+
const failures = [];
|
|
337
|
+
const original = this.viewport;
|
|
338
|
+
for (const viewport of viewports) try {
|
|
339
|
+
await this.setViewport(viewport);
|
|
340
|
+
try {
|
|
341
|
+
await this.assertViewport();
|
|
342
|
+
} finally {
|
|
343
|
+
await this.screenshot(name);
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
failures.push(error);
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
await this.setViewport(original);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
failures.push(error);
|
|
352
|
+
}
|
|
353
|
+
if (failures.length) throw new AggregateError(failures, failures.map(errorMessage).join("; "));
|
|
354
|
+
}
|
|
355
|
+
async waitForNetworkIdle(timeoutMs = 1e4) {
|
|
356
|
+
await this.step("waitForNetworkIdle", () => this.poll("Network idle", async () => {
|
|
357
|
+
const pending = [...this.pendingRequests.values()].filter((url) => !this.spec.allowPendingRequests?.some((pattern) => matches(pattern, url)));
|
|
358
|
+
if (pending.length) throw new Error(`Pending requests: ${pending.join(", ")}`);
|
|
359
|
+
return Date.now() - this.lastNetworkActivity >= 300;
|
|
360
|
+
}, timeoutMs));
|
|
361
|
+
}
|
|
362
|
+
assertHealthy() {
|
|
363
|
+
for (const entry of this.evidence.console) if (entry.level === "error") entry.allowed = this.spec.allowConsoleErrors?.some((pattern) => matches(pattern, entry.text)) ?? false;
|
|
364
|
+
for (const entry of this.evidence.networkFailures) entry.allowed = this.spec.allowNetworkFailures?.some((rule) => matches(rule.url, entry.url) && (rule.status === void 0 || rule.status === entry.status) && (rule.errorText === void 0 || matches(rule.errorText, entry.errorText ?? ""))) ?? false;
|
|
365
|
+
const consoleErrors = this.evidence.console.filter((entry) => entry.level === "error" && !entry.allowed);
|
|
366
|
+
const networkErrors = this.evidence.networkFailures.filter((entry) => !entry.allowed);
|
|
367
|
+
if (consoleErrors.length || networkErrors.length) throw new Error(`Browser health failed: ${consoleErrors.length} console errors, ${networkErrors.length} failed HTTP/network requests\n${JSON.stringify({
|
|
368
|
+
console: consoleErrors,
|
|
369
|
+
network: networkErrors
|
|
370
|
+
})}`);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
export { BrowserPage, boxesStable };
|
|
376
|
+
//# sourceMappingURL=page.mjs.map
|