rea/src/browser/PlaywrightScenarioEvents.ts

376 lines
11 KiB
TypeScript

import type {
ConsoleMessage,
Download,
Frame,
Page,
Request,
Response,
WebSocket,
Worker,
} from "playwright-core";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import {
browserScenarioEventSchema,
type BrowserScenarioEvent,
} from "../domain/browserScenarioCapture.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import type { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js";
import type { BrowserScenario } from "../domain/browserScenario.js";
type EventName =
| "console"
| "page-errors"
| "network"
| "websockets"
| "frames"
| "workers"
| "popups"
| "downloads";
type UnindexedEvent = BrowserScenarioEvent extends infer Event
? Event extends BrowserScenarioEvent
? Omit<Event, "sequence" | "step_index">
: never
: never;
type LimitedSection = "frames" | "workers" | "popups" | "websockets";
type TruncatedSection = "events" | LimitedSection;
interface EventCaptureOptions {
readonly page: Page;
readonly enabled: ReadonlySet<EventName>;
readonly limits: Pick<
BrowserScenario["limits"],
| "max_events"
| "max_frames"
| "max_workers"
| "max_popups"
| "max_websockets"
>;
readonly secrets: BrowserScenarioSecrets;
readonly budget: BrowserScenarioCaptureBudget;
readonly allowedOrigins: readonly string[];
}
/** Bounded, arrival-ordered Playwright event capture with current-step attribution. */
export class PlaywrightScenarioEvents {
private readonly items: BrowserScenarioEvent[] = [];
private readonly truncated = new Set<TruncatedSection>();
private readonly admitted = {
frames: new WeakSet<object>(),
workers: new WeakSet<object>(),
popups: new WeakSet<object>(),
websockets: new WeakSet<object>(),
};
private readonly rejected = {
frames: new WeakSet<object>(),
workers: new WeakSet<object>(),
popups: new WeakSet<object>(),
websockets: new WeakSet<object>(),
};
private readonly counts: Record<LimitedSection, number> = {
frames: 0,
workers: 0,
popups: 0,
websockets: 0,
};
private dropped = 0;
private stepIndex = 0;
private sequence = 0;
private readonly enabled: ReadonlySet<EventName>;
private readonly limits: EventCaptureOptions["limits"];
private readonly secrets: BrowserScenarioSecrets;
private readonly budget: BrowserScenarioCaptureBudget;
private readonly allowedOrigins: ReadonlySet<string>;
constructor(options: EventCaptureOptions) {
this.enabled = options.enabled;
this.limits = options.limits;
this.secrets = options.secrets;
this.budget = options.budget;
this.allowedOrigins = new Set(options.allowedOrigins);
this.observePage(options.page);
}
setStep(index: number): void {
this.stepIndex = index;
}
nextSequence(): number {
return this.sequence + 1;
}
lastSequence(): number {
return this.sequence;
}
result(): {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
} {
return {
retained: this.items.length,
dropped: this.dropped,
items: this.items,
};
}
truncationSections(): readonly TruncatedSection[] {
return [...this.truncated].sort();
}
private push(event: UnindexedEvent): void {
this.sequence += 1;
if (this.items.length >= this.limits.max_events) {
this.dropped += 1;
this.truncated.add("events");
return;
}
const parsed = browserScenarioEventSchema.parse({
...event,
sequence: this.sequence,
step_index: this.stepIndex,
});
if (!this.budget.claimMetadata(Buffer.byteLength(JSON.stringify(parsed)))) {
this.dropped += 1;
this.truncated.add("events");
return;
}
this.items.push(parsed);
}
private admit(
section: LimitedSection,
value: object,
maximum: number,
): boolean {
if (this.admitted[section].has(value)) return true;
if (this.rejected[section].has(value)) return false;
if (this.counts[section] >= maximum) {
this.rejected[section].add(value);
this.sequence += 1;
this.dropped += 1;
this.truncated.add("events");
this.truncated.add(section);
return false;
}
this.admitted[section].add(value);
this.counts[section] += 1;
return true;
}
private observePage(page: Page): void {
if (this.enabled.has("console"))
page.on("console", (message) => {
if (this.pageInScope(page)) this.console(message);
});
if (this.enabled.has("page-errors"))
page.on("pageerror", (error) => {
if (this.pageInScope(page))
this.push({
kind: "page-error",
message: this.secrets.redact(error.message).slice(0, 65_536),
stack:
error.stack === undefined
? null
: this.secrets.redact(error.stack).slice(0, 262_144),
});
});
if (this.enabled.has("network")) {
page.on("request", (request) => {
if (this.pageInScope(page)) this.request("request", request);
});
page.on("response", (response) => {
if (this.pageInScope(page)) this.response(response);
});
page.on("requestfailed", (request) => {
if (this.pageInScope(page)) this.request("request-failed", request);
});
}
if (this.enabled.has("websockets"))
page.on("websocket", (socket) => {
if (this.pageInScope(page)) this.webSocket(page, socket);
});
if (this.enabled.has("frames")) {
page.on("frameattached", (frame) => {
if (this.pageInScope(page)) this.frame("frame-attached", frame);
});
page.on("framedetached", (frame) => {
if (this.pageInScope(page)) this.frame("frame-detached", frame);
});
page.on("framenavigated", (frame) => {
if (this.pageInScope(page)) this.frame("frame-navigated", frame);
});
}
if (this.enabled.has("workers"))
page.on("worker", (worker) => {
if (this.pageInScope(page)) this.worker(page, worker);
});
if (this.enabled.has("popups"))
page.on("popup", (popup) => {
if (this.pageInScope(page)) this.popup(popup);
});
if (this.enabled.has("downloads"))
page.on("download", (download) => {
if (this.pageInScope(page)) this.download(download);
});
}
private pageInScope(page: Page): boolean {
try {
return this.allowedOrigins.has(new URL(page.url()).origin);
} catch {
return false;
}
}
private safeUrl(value: string) {
return sanitizeBrowserUrl(this.secrets.redact(value));
}
private console(message: ConsoleMessage): void {
const location = message.location();
this.push({
kind: "console",
level: this.secrets.redact(message.type()).slice(0, 64),
text: this.secrets.redact(message.text()).slice(0, 65_536),
url: location.url === "" ? null : this.safeUrl(location.url),
});
}
private request(kind: "request" | "request-failed", request: Request): void {
this.push({
kind,
method: this.secrets.redact(request.method()).slice(0, 32),
url: this.safeUrl(request.url()),
resource_type: this.secrets.redact(request.resourceType()).slice(0, 64),
status: null,
header_names: Object.keys(request.headers())
.map((name) => this.secrets.redact(name).slice(0, 256))
.sort()
.slice(0, 256),
failure:
kind === "request-failed"
? this.secrets
.redact(request.failure()?.errorText ?? "unknown")
.slice(0, 1_024)
: null,
});
}
private response(response: Response): void {
const request = response.request();
this.push({
kind: "response",
method: this.secrets.redact(request.method()).slice(0, 32),
url: this.safeUrl(response.url()),
resource_type: this.secrets.redact(request.resourceType()).slice(0, 64),
status: response.status(),
header_names: Object.keys(response.headers())
.map((name) => this.secrets.redact(name).slice(0, 256))
.sort()
.slice(0, 256),
failure: null,
});
}
private webSocket(page: Page, socket: WebSocket): void {
if (!this.admit("websockets", socket, this.limits.max_websockets)) return;
const url = this.safeUrl(socket.url());
this.push({ kind: "websocket-opened", url });
socket.on("framesent", ({ payload }) => {
if (this.pageInScope(page))
this.webSocketFrame("websocket-frame-sent", url, payload);
});
socket.on("framereceived", ({ payload }) => {
if (this.pageInScope(page))
this.webSocketFrame("websocket-frame-received", url, payload);
});
socket.on("close", () => {
if (this.pageInScope(page)) this.push({ kind: "websocket-closed", url });
});
}
private webSocketFrame(
kind: "websocket-frame-sent" | "websocket-frame-received",
url: ReturnType<typeof sanitizeBrowserUrl>,
payload: string | Buffer,
): void {
const bytes = Buffer.byteLength(payload);
const isText = typeof payload === "string";
const text = isText ? this.secrets.redact(payload).slice(0, 65_536) : null;
this.push({
kind,
url,
payload_type: isText ? "text" : "binary",
payload_bytes: bytes,
payload_text: text,
truncated: isText && text !== null && text.length < payload.length,
});
}
private frame(
kind: "frame-attached" | "frame-detached" | "frame-navigated",
frame: Frame,
): void {
if (!this.admit("frames", frame, this.limits.max_frames)) return;
this.push({
kind,
url: frame.url() === "" ? null : this.safeUrl(frame.url()),
name:
frame.name() === ""
? null
: this.secrets.redact(frame.name()).slice(0, 1_024),
});
}
private worker(page: Page, worker: Worker): void {
if (!this.admit("workers", worker, this.limits.max_workers)) return;
const details = {
url: this.safeUrl(worker.url()),
name: null,
};
this.push({ kind: "worker-created", ...details });
worker.on("close", () => {
if (this.pageInScope(page))
this.push({ kind: "worker-closed", ...details });
});
}
private popup(page: Page): void {
if (!this.pageInScope(page)) {
void page.close().catch(() => undefined);
return;
}
if (!this.admit("popups", page, this.limits.max_popups)) return;
const opened = {
url: page.url() === "" ? null : this.safeUrl(page.url()),
name: null,
};
this.push({ kind: "popup-opened", ...opened });
page.on("close", () =>
this.pageInScope(page)
? this.push({
kind: "popup-closed",
url: page.url() === "" ? null : this.safeUrl(page.url()),
name: null,
})
: undefined,
);
this.observePage(page);
}
private download(download: Download): void {
this.push({
kind: "download-cancelled",
suggested_filename: this.secrets
.redact(download.suggestedFilename())
.slice(0, 1_024),
url: this.safeUrl(download.url()),
});
void download.cancel().catch(() => undefined);
}
}