Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions packages/runtime/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ function createMockBrowserTree() {
const mockAriaSnapshot = vi.fn(() =>
Promise.resolve('- heading "mock" [level=1]'),
);
const mockEvaluate = vi.fn((fn: unknown) => {
const mockEvaluate = vi.fn((fn: unknown, arg?: unknown) => {
if (typeof fn !== "function") {
return Promise.resolve(undefined);
}
const src = Function.prototype.toString.call(fn);
if (src.includes('type="password"')) {
return Promise.resolve(Array.isArray(arg) ? undefined : []);
}
if (src.includes("innerText")) {
return Promise.resolve("hello summary");
}
Expand Down Expand Up @@ -82,6 +85,8 @@ function createMockBrowserTree() {
mockClick,
mockFill,
mockContent,
mockEvaluate,
mockAriaSnapshot,
contextClose,
browserClose,
};
Expand Down Expand Up @@ -139,7 +144,8 @@ describe("BrowserRuntime", () => {
});

it("snapshots HTML with summarizeHtml and layered fields", async () => {
const { browser } = createMockBrowserTree();
const { browser, mockEvaluate, mockContent, mockAriaSnapshot } =
createMockBrowserTree();
mockChromiumLaunch.mockResolvedValueOnce(browser);

const rt = new BrowserRuntime({ headless: true });
Expand All @@ -150,6 +156,14 @@ describe("BrowserRuntime", () => {
expect(snap.accessibilityTree).toBe('- heading "mock" [level=1]');
expect(snap.links).toEqual([]);
expect(snap.landmarks).toEqual([]);
const blankCallOrder = mockEvaluate.mock.invocationCallOrder[0];
const contentOrder = mockContent.mock.invocationCallOrder[0];
const ariaOrder = mockAriaSnapshot.mock.invocationCallOrder[0];
expect(typeof blankCallOrder).toBe("number");
expect(typeof contentOrder).toBe("number");
expect(typeof ariaOrder).toBe("number");
expect(blankCallOrder as number).toBeLessThan(contentOrder as number);
expect(blankCallOrder as number).toBeLessThan(ariaOrder as number);
await rt.shutdown();
});

Expand Down Expand Up @@ -211,7 +225,7 @@ describe("BrowserRuntime", () => {
});

it("maps snapshot content errors to COMMAND_FAILED", async () => {
const { browser, mockContent } = createMockBrowserTree();
const { browser, mockContent, mockEvaluate } = createMockBrowserTree();
mockContent.mockRejectedValueOnce(new Error("content-boom"));
mockChromiumLaunch.mockResolvedValueOnce(browser);

Expand All @@ -224,6 +238,10 @@ describe("BrowserRuntime", () => {
code: "COMMAND_FAILED",
message: "content-boom",
});
const restoreCalls = mockEvaluate.mock.calls.filter((call) =>
Array.isArray(call[1]),
);
expect(restoreCalls.length).toBeGreaterThan(0);
} finally {
await rt.shutdown();
}
Expand Down
103 changes: 57 additions & 46 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
mapPlaywrightLaunchError,
WebchainRuntimeError,
} from "./runtime-error.js";
import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js";
import {
extractLandmarks,
extractPageLinks,
withPasswordFieldsRedacted,
} from "./snapshot-helpers.js";

export interface BrowserRuntimeOptions {
headless?: boolean;
Expand Down Expand Up @@ -113,51 +117,9 @@ export class BrowserRuntime {
async snapshot(command: SnapshotCommand): Promise<SnapshotResult> {
const session = this.getSession(command.sessionId);
try {
const page = session.page;
const html = await page.content();
/** Covers sync throws (some Playwright APIs throw before returning a Promise). */
const safe = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
try {
return await run();
} catch {
return undefined;
}
};
const [accessibilityTree, domSummary, links, landmarks] =
await Promise.all([
/** Playwright 1.58+ removed `page.accessibility`; use ARIA snapshot on `body`. */
safe(() => page.locator("body").ariaSnapshot()),
safe(() =>
page.evaluate(() => {
const t = document.body?.innerText ?? "";
return t.replace(/\s+/g, " ").trim().slice(0, 2000);
}),
),
safe(() => extractPageLinks(page, 80)),
safe(() => extractLandmarks(page)),
]);
let accessibilityJson: unknown = accessibilityTree ?? undefined;
if (accessibilityJson !== undefined) {
try {
accessibilityJson = JSON.parse(
JSON.stringify(accessibilityJson, (_k, v) =>
typeof v === "bigint" ? v.toString() : v,
),
);
} catch {
accessibilityJson = undefined;
}
}
return {
sessionId: command.sessionId,
url: page.url(),
title: await page.title(),
htmlSnippet: summarizeHtml(html),
domSummary,
accessibilityTree: accessibilityJson,
links,
landmarks,
};
return await withPasswordFieldsRedacted(session.page, () =>
capturePageSnapshot(session.page, command.sessionId),
);
} catch (error) {
throw mapCommandFailure(error);
}
Expand Down Expand Up @@ -247,6 +209,55 @@ export {
WebchainRuntimeError,
} from "./runtime-error.js";

async function capturePageSnapshot(
page: Page,
sessionId: string,
): Promise<SnapshotResult> {
const html = await page.content();
/** Covers sync throws (some Playwright APIs throw before returning a Promise). */
const safe = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
try {
return await run();
} catch {
return undefined;
}
};
const [accessibilityTree, domSummary, links, landmarks] = await Promise.all([
/** Playwright 1.58+ removed `page.accessibility`; use ARIA snapshot on `body`. */
safe(() => page.locator("body").ariaSnapshot()),
safe(() =>
page.evaluate(() => {
const t = document.body?.innerText ?? "";
return t.replace(/\s+/g, " ").trim().slice(0, 2000);
}),
),
safe(() => extractPageLinks(page, 80)),
safe(() => extractLandmarks(page)),
]);
let accessibilityJson: unknown = accessibilityTree ?? undefined;
if (accessibilityJson !== undefined) {
try {
accessibilityJson = JSON.parse(
JSON.stringify(accessibilityJson, (_k, v) =>
typeof v === "bigint" ? v.toString() : v,
),
);
} catch {
accessibilityJson = undefined;
}
}
return {
sessionId,
url: page.url(),
title: await page.title(),
htmlSnippet: summarizeHtml(html),
domSummary,
accessibilityTree: accessibilityJson,
links,
landmarks,
};
}

export function summarizeHtml(html: string, limit = 1600) {
return html.replace(/\s+/g, " ").trim().slice(0, limit);
}
Expand Down
93 changes: 92 additions & 1 deletion packages/runtime/src/snapshot-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js";
import {
backupAndBlankPasswordFieldsInPage,
extractLandmarks,
extractPageLinks,
PASSWORD_FIELD_SELECTOR,
restorePasswordFieldsInPage,
withPasswordFieldsRedacted,
} from "./snapshot-helpers.js";

describe("extractPageLinks", () => {
it("maps anchors and trims text, skipping empty href", async () => {
Expand Down Expand Up @@ -86,3 +93,87 @@ describe("extractLandmarks", () => {
expect(lm).toEqual([]);
});
});

function makePasswordInput(value: string, attrValue: string | null) {
let attr = attrValue;
return {
value,
getAttribute: (name: string) => (name === "value" ? attr : null),
setAttribute: (name: string, next: string) => {
if (name === "value") {
attr = next;
}
},
removeAttribute: (name: string) => {
if (name === "value") {
attr = null;
}
},
};
}

describe("password snapshot redaction", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("targets password and autocomplete password fields", () => {
expect(PASSWORD_FIELD_SELECTOR).toContain('input[type="password"]');
expect(PASSWORD_FIELD_SELECTOR).toContain("current-password");
expect(PASSWORD_FIELD_SELECTOR).toContain("new-password");
expect(PASSWORD_FIELD_SELECTOR).toContain("one-time-password");
});
afterEach(() => {
vi.unstubAllGlobals();
});

it("blanks matching inputs and restores value plus attribute", () => {
const pw = makePasswordInput("secret", "secret");
vi.stubGlobal("document", {
querySelectorAll: () => [pw],
});

const backups = backupAndBlankPasswordFieldsInPage();
expect(pw.value).toBe("");
expect(pw.getAttribute("value")).toBeNull();
restorePasswordFieldsInPage(backups);
expect(pw.value).toBe("secret");
expect(pw.getAttribute("value")).toBe("secret");
});

it("blanks during capture and restores afterward", async () => {
const pw = makePasswordInput("typed-secret", null);
vi.stubGlobal("document", {
querySelectorAll: () => [pw],
});
const page = {
evaluate: (fn: (...args: never[]) => unknown, arg?: unknown) =>
Promise.resolve(arg === undefined ? fn() : fn(arg as never)),
};

await withPasswordFieldsRedacted(page as never, async () => {
expect(pw.value).toBe("");
expect(pw.getAttribute("value")).toBeNull();
});
expect(pw.value).toBe("typed-secret");
});

it("restores fields when capture throws", async () => {
const pw = makePasswordInput("keep-me", "keep-me");
vi.stubGlobal("document", {
querySelectorAll: () => [pw],
});
const page = {
evaluate: (fn: (...args: never[]) => unknown, arg?: unknown) =>
Promise.resolve(arg === undefined ? fn() : fn(arg as never)),
};

await expect(
withPasswordFieldsRedacted(page as never, async () => {
throw new Error("capture-boom");
}),
).rejects.toThrow("capture-boom");
expect(pw.value).toBe("keep-me");
expect(pw.getAttribute("value")).toBe("keep-me");
});
});
84 changes: 84 additions & 0 deletions packages/runtime/src/snapshot-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,89 @@
import type { Page } from "playwright";

export type PasswordFieldBackup = {
value: string;
attrValue: string | null;
};

/**
* CSS selector for fields whose values must not appear in snapshots.
* Duplicated inside in-page evaluate helpers so Playwright can serialize them.
*/
export const PASSWORD_FIELD_SELECTOR =
'input[type="password"], input[autocomplete="current-password"], input[autocomplete="new-password"], input[autocomplete="one-time-password"]';

/**
* Blank password inputs in the page document. Must stay serializable for Playwright.
*
* @example
* const backups = backupAndBlankPasswordFieldsInPage();
*/
export function backupAndBlankPasswordFieldsInPage(): PasswordFieldBackup[] {
const selector =
'input[type="password"], input[autocomplete="current-password"], input[autocomplete="new-password"], input[autocomplete="one-time-password"]';
const inputs = Array.from(document.querySelectorAll(selector));
const backups: PasswordFieldBackup[] = [];
for (let i = 0; i < inputs.length; i++) {
const el = inputs[i] as HTMLInputElement;
backups.push({
value: el.value,
attrValue: el.getAttribute("value"),
});
el.value = "";
el.removeAttribute("value");
}
return backups;
}

/**
* Restore values saved by backupAndBlankPasswordFieldsInPage.
*
* @example
* restorePasswordFieldsInPage(backups);
*/
export function restorePasswordFieldsInPage(
backups: PasswordFieldBackup[],
): void {
const selector =
'input[type="password"], input[autocomplete="current-password"], input[autocomplete="new-password"], input[autocomplete="one-time-password"]';
const inputs = Array.from(document.querySelectorAll(selector));
for (let i = 0; i < backups.length; i++) {
const el = inputs[i] as HTMLInputElement | undefined;
const backup = backups[i];
if (!el || !backup) {
continue;
}
el.value = backup.value;
if (backup.attrValue === null) {
el.removeAttribute("value");
} else {
el.setAttribute("value", backup.attrValue);
}
}
}

/**
* Run a snapshot capture with password fields blanked, then restore them.
*
* @example
* const snap = await withPasswordFieldsRedacted(page, () => page.content());
*/
export async function withPasswordFieldsRedacted<T>(
page: Page,
run: () => Promise<T>,
): Promise<T> {
const backups = await page.evaluate(backupAndBlankPasswordFieldsInPage);
try {
return await run();
} finally {
try {
await page.evaluate(restorePasswordFieldsInPage, backups);
} catch {
// Capture already finished; the page may have closed.
}
}
}

/** Collect up to `limit` anchor href/text pairs for agent navigation hints. */
export async function extractPageLinks(page: Page, limit: number) {
return page.$$eval(
Expand Down
Loading
Loading