Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-malformed-snapshot-unicode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

Repair malformed UTF-16 snapshot text before it reaches model prompts.
11 changes: 8 additions & 3 deletions packages/core/lib/v3/understudy/a11y/snapshot/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Interval = { start: number; end: number };
type ExclusionIntervalsByFrame = Map<string, Interval[]>;
type ChildFrameHost = { childFrameId: string; hostBackendNodeId: number };
type ChildFramesByParent = Map<string, ChildFrameHost[]>;
type WellFormedString = string & { toWellFormed(): string };

/**
* Capture a hybrid DOM + Accessibility snapshot for the provided page.
Expand Down Expand Up @@ -278,14 +279,16 @@ export async function tryScopedSnapshot(

const scopedUrlMap: Record<string, string> = { ...urlMap };

const wellFormedOutline = (outline as WellFormedString).toWellFormed();

const snapshot: HybridSnapshot = {
combinedTree: outline,
combinedTree: wellFormedOutline,
combinedXpathMap: scopedXpathMap,
combinedUrlMap: scopedUrlMap,
perFrame: [
{
frameId: targetFrameId,
outline,
outline: wellFormedOutline,
xpathMap,
urlMap,
},
Expand Down Expand Up @@ -846,7 +849,9 @@ export function mergeFramesIntoSnapshot(
perFrameOutlines.find((o) => o.frameId === context.rootId)?.outline ??
perFrameOutlines[0]?.outline ??
"";
const combinedTree = injectSubtrees(rootOutline, idToTree);
const combinedTree = (
injectSubtrees(rootOutline, idToTree) as WellFormedString
).toWellFormed();
Comment thread
monadoid marked this conversation as resolved.

return {
combinedTree,
Expand Down
54 changes: 54 additions & 0 deletions packages/core/tests/integration/unicode-well-formed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
import { V3 } from "../../lib/v3/v3.js";
import {
closeV3,
createScriptedAisdkTestLlmClient,
promptToText,
} from "./testUtils.js";
import { getV3TestConfig } from "./v3.config.js";

function encodeHtml(html: string): string {
return `data:text/html,${encodeURIComponent(html)}`;
}

test("repairs page text before local SDK model calls", async () => {
let observedPromptText = "";
const llmClient = createScriptedAisdkTestLlmClient({
modelId: "mock/unicode-well-formed",
jsonResponses: {
Observation: (options) => {
observedPromptText = promptToText(options.prompt);
expect(
(
observedPromptText as string & { isWellFormed(): boolean }
).isWellFormed(),
).toBe(true);
expect(observedPromptText).toContain("Draw Again. \uFFFD");
return { elements: [] };
},
},
});

const v3 = new V3(getV3TestConfig({ llmClient }));
await v3.init();

try {
const page = v3.context.pages()[0];
await page.goto(
encodeHtml(`<!doctype html>
<meta charset="utf-8">
<h1 id="target"></h1>
<script>
document.getElementById("target").textContent =
"Draw Again. " + String.fromCharCode(0xd83c);
</script>`),
);

const observed = await v3.observe("Find the promo banner text");

expect(observed).toEqual([]);
expect(observedPromptText).not.toBe("");
} finally {
await closeV3(v3);
}
});
41 changes: 41 additions & 0 deletions packages/core/tests/unit/snapshot-frame-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,47 @@ describe("mergeFramesIntoSnapshot", () => {
expect(snapshot.combinedTree).toBe("[child] frame2");
});

it("repairs malformed surrogate pairs in the combined tree", () => {
const context: FrameContext = {
rootId: "frame-1",
frames: ["frame-1"],
parentByFrame: new Map([["frame-1", null]]),
};

const perFrameMaps = new Map<string, FrameDomMaps>([
[
"frame-1",
{
tagNameMap: {},
scrollableMap: {},
urlMap: {},
xpathMap: {},
},
],
]);

const snapshot = mergeFramesIntoSnapshot(
context,
perFrameMaps,
[
{
frameId: "frame-1",
outline: `Draw Again. ${String.fromCharCode(0xd83c)}`,
},
],
new Map([["frame-1", ""]]),
new Map(),
context.frames,
);

expect(
(
snapshot.combinedTree as string & { isWellFormed(): boolean }
).isWellFormed(),
).toBe(true);
expect(snapshot.combinedTree).toBe("Draw Again. \uFFFD");
});

it("overwrites duplicate iframe host entries when multiple children map to the same parent", () => {
const context: FrameContext = {
rootId: "frame-1",
Expand Down
Loading