From 500aa5669330cce2294a2a60c27d7158e99f139c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 03:55:38 +0000 Subject: [PATCH 01/12] feat(preview): opt-in accessible view for streamed browser tabs Streamed browser tabs (web, remote desktop, mobile) were a JPEG canvas plus a hidden input, so screen readers found one text field and nothing else. Viewers can now turn on an accessible view. The server enables Chromium's Accessibility domain for that tab only while someone subscribes, joins the per-frame trees (including out-of-process iframes), drops ignored wrappers while keeping their descendants, hides secret field values, and sends full coalesced snapshots with backpressure redelivery. Read-only viewers may subscribe; activate and set-value actions run only for the controlling viewer under SessionControl and only against the current document and node ids. A shared client-runtime renderer draws the snapshot as native headings, landmarks, links, buttons and form fields, updating in place by node id. Web adds a toggle to the streamed view's bar; mobile adds one to the full-screen viewer and renders inside the same WebView. Passive floating previews never subscribe. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/metro.config.js | 2 +- .../features/browser/PreviewStreamWebView.tsx | 37 +- .../browser/preview-stream-document.ts | 5 + .../browser/preview-stream.browser.ts | 58 +- apps/server/src/preview/ServerBrowser.test.ts | 205 +++++- apps/server/src/preview/ServerBrowser.ts | 54 ++ .../ServerBrowserAccessibility.test.ts | 233 ++++++ .../src/preview/ServerBrowserAccessibility.ts | 674 ++++++++++++++++++ .../src/preview/ServerBrowserStream.test.ts | 30 +- .../server/src/preview/ServerBrowserStream.ts | 11 +- .../web/src/browser/PreviewAccessibleView.tsx | 55 ++ apps/web/src/browser/ServerBrowserSurface.tsx | 56 +- packages/client-runtime/package.json | 4 + .../src/preview/accessibleDocument.test.ts | 93 +++ .../src/preview/accessibleDocument.ts | 516 ++++++++++++++ .../src/preview/serverBrowserStream.test.ts | 94 +++ .../src/preview/serverBrowserStream.ts | 48 +- packages/contracts/src/index.ts | 1 + .../contracts/src/previewAccessibility.ts | 113 +++ 19 files changed, 2266 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/preview/ServerBrowserAccessibility.test.ts create mode 100644 apps/server/src/preview/ServerBrowserAccessibility.ts create mode 100644 apps/web/src/browser/PreviewAccessibleView.tsx create mode 100644 packages/client-runtime/src/preview/accessibleDocument.test.ts create mode 100644 packages/client-runtime/src/preview/accessibleDocument.ts create mode 100644 packages/contracts/src/previewAccessibility.ts diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index afbef3362d6d5..e0f42260513ad 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -97,7 +97,7 @@ async function prepareStreamScripts() { ["packages/client-runtime/src/device", ["stream.ts", "hubAccess.ts"], generateAll], [ "packages/client-runtime/src/preview", - ["serverBrowserStream.ts"], + ["serverBrowserStream.ts", "accessibleDocument.ts"], generatePreviewStreamScript, ], ]) { diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index de7ecb1b0e690..187b3fed3253a 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -157,6 +157,8 @@ function AuthorizedPreviewStream({ ...props }: PreviewStreamConfiguration & NativeStreamBridge) { const [attempt, setAttempt] = useState(0); + // Outlives reconnects, which remount the document view. + const [accessible, setAccessible] = useState(false); const [previousAccess, setPreviousAccess] = useState(props.access); // Wait for refreshed access, including cookie credentials with unchanged JSON. if (props.access !== previousAccess) { @@ -185,6 +187,8 @@ function AuthorizedPreviewStream({ {...props} ref={ref} configuration={configuration} + accessible={accessible} + onAccessibleChange={setAccessible} onUnauthorized={() => { const refusals = ++unauthorized.current; if (refusals >= MAX_REFUSALS) return false; @@ -232,8 +236,12 @@ function PreviewStreamDocumentView({ onRetry, onStreaming, onRecoverProcess, + accessible, + onAccessibleChange, }: Omit & { readonly configuration: string; + readonly accessible: boolean; + readonly onAccessibleChange: (accessible: boolean) => void; readonly background: string; readonly onUnauthorized: () => boolean; readonly onRetry: () => void; @@ -268,6 +276,8 @@ function PreviewStreamDocumentView({ webView.current?.injectJavaScript( `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`, ); + const showAccessible = (next: boolean) => + webView.current?.injectJavaScript(`window.T3PreviewStream?.accessibility(${next}); true;`); // Reads here rather than in the screen: a tab switch during the iOS paste prompt unmounts // this view and nulls its WebView ref, so the text can never type into another tab. const paste = () => @@ -330,16 +340,27 @@ function PreviewStreamDocumentView({ {previewStreamControlLabel(control)} - {control?.canOperate ? ( + - ) : null} + {control?.canOperate ? ( + + ) : null} +
{pdfUrl ? ( @@ -738,6 +772,14 @@ export function ServerBrowserSurface(props: { className="absolute inset-0 z-10 size-full border-0" /> ) : null} + {accessibleView && !pdfUrl ? ( + clientRef.current?.send(action) ?? false} + /> + ) : null} planAccessibleNode(node, canAct); + +describe("planAccessibleNode", () => { + it("renders headings, links and landmarks as their native elements", () => { + expect(plan({ id: "1", role: "heading", name: "Cart", level: 9 })).toMatchObject({ + tag: "h6", + text: "Cart", + }); + expect( + plan({ id: "2", role: "link", name: "Docs", url: "https://t3.codes/docs" }), + ).toMatchObject({ + tag: "a", + attributes: { href: "https://t3.codes/docs" }, + control: "activate", + }); + // Script URLs never become live links in the viewer's own document. + expect(plan({ id: "3", role: "link", name: "Run", url: "javascript:alert(1)" })).toMatchObject({ + tag: "a", + attributes: { role: "link", tabindex: "0" }, + }); + expect(plan({ id: "4", role: "navigation", name: "Primary" })).toMatchObject({ + tag: "nav", + attributes: { "aria-label": "Primary" }, + }); + }); + + it("keeps controls reachable but inert for viewers without control", () => { + expect(plan({ id: "1", role: "button", name: "Pay", pressed: "true" }, false)).toMatchObject({ + tag: "button", + attributes: { type: "button", "aria-pressed": "true", "aria-disabled": "true" }, + control: "activate", + }); + expect(plan({ id: "2", role: "textbox", name: "Name", value: "Ada" }, false)).toMatchObject({ + tag: "input", + attributes: { type: "text", readonly: "" }, + value: "Ada", + }); + }); + + it("shows secret fields as empty password inputs and multiline fields as text areas", () => { + expect( + plan({ id: "1", role: "textbox", name: "Password", protected: true, editable: true }), + ).toMatchObject({ tag: "input", attributes: { type: "password" }, value: "" }); + expect(plan({ id: "2", role: "textbox", name: "Bio", multiline: true })).toMatchObject({ + tag: "textarea", + control: "edit", + }); + }); + + it("turns comboboxes with options into a native select and checkboxes into inputs", () => { + expect( + plan({ + id: "1", + role: "combobox", + name: "Size", + children: [ + { + id: "2", + role: "listbox", + children: [ + { id: "3", role: "option", name: "Small" }, + { id: "4", role: "option", name: "Large", selected: true }, + ], + }, + ], + }), + ).toMatchObject({ + tag: "select", + control: "select", + options: [ + { id: "3", label: "Small", selected: false }, + { id: "4", label: "Large", selected: true }, + ], + }); + expect(plan({ id: "5", role: "checkbox", name: "All", checked: "mixed" })).toMatchObject({ + tag: "input", + attributes: { type: "checkbox" }, + checked: false, + indeterminate: true, + }); + }); + + it("skips empty presentational nodes and keeps text", () => { + expect(plan({ id: "1", role: "image" })).toBeNull(); + expect(plan({ id: "2", role: "unknownwidget" })).toBeNull(); + expect(plan({ id: "3", role: "text", name: "Hello" })).toEqual({ kind: "text", text: "Hello" }); + }); +}); diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts new file mode 100644 index 0000000000000..3f210c4795f0f --- /dev/null +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -0,0 +1,516 @@ +import type { + PreviewAccessibilityAction, + PreviewAccessibilityDocument, + PreviewAccessibilityNode, +} from "@t3tools/contracts"; + +export type AccessibleControl = "activate" | "check" | "edit" | "select"; + +export interface AccessibleOption { + readonly id: string; + readonly label: string; + readonly selected: boolean; + readonly disabled: boolean; +} + +export type AccessiblePlan = + | { readonly kind: "text"; readonly text: string } + | { + readonly kind: "element"; + readonly tag: string; + readonly attributes: Readonly>; + /** Rendered as the element's own text; children are then not rendered. */ + readonly text?: string; + /** A visible label wrapped around form fields. */ + readonly label?: string; + readonly children: ReadonlyArray; + readonly control: AccessibleControl | null; + readonly value?: string; + readonly checked?: boolean; + readonly indeterminate?: boolean; + readonly options?: ReadonlyArray; + }; + +const LANDMARK_TAGS: Record = { + main: "main", + navigation: "nav", + banner: "header", + contentinfo: "footer", + complementary: "aside", + region: "section", + article: "article", + paragraph: "p", + list: "ul", + listitem: "li", + blockquote: "blockquote", + figure: "figure", + code: "code", + separator: "hr", + term: "dt", + definition: "dd", +}; +const ARIA_CONTAINER_ROLES = new Set([ + "table", + "grid", + "treegrid", + "row", + "rowgroup", + "cell", + "gridcell", + "columnheader", + "rowheader", + "form", + "search", + "dialog", + "alertdialog", + "alert", + "status", + "log", + "timer", + "tablist", + "tabpanel", + "menu", + "menubar", + "toolbar", + "tree", + "radiogroup", + "group", + "note", + "feed", +]); +const BUTTON_ROLES: Record = { + button: null, + PopUpButton: null, + ToggleButton: null, + DisclosureTriangle: null, + tab: "tab", + menuitem: "menuitem", + menuitemcheckbox: "menuitemcheckbox", + menuitemradio: "menuitemradio", + treeitem: "treeitem", + switch: "switch", + option: null, +}; +const FIELD_ROLES = new Set(["textbox", "searchbox", "spinbutton", "combobox"]); +const SAFE_LINK = /^(https?:|mailto:)/i; + +const textOf = (node: PreviewAccessibilityNode): string => + node.role === "text" + ? (node.name ?? "") + : (node.name ?? (node.children ?? []).map(textOf).join(" ").trim()); + +const descendants = (node: PreviewAccessibilityNode, role: string): PreviewAccessibilityNode[] => + (node.children ?? []).flatMap((child) => + child.role === role ? [child] : descendants(child, role), + ); + +const stateAttributes = (node: PreviewAccessibilityNode, inert: boolean) => { + const attributes: Record = {}; + if (node.description) attributes["aria-description"] = node.description; + if (node.expanded !== undefined) attributes["aria-expanded"] = String(node.expanded); + if (node.required) attributes["aria-required"] = "true"; + if (node.invalid) attributes["aria-invalid"] = "true"; + // aria-disabled keeps controls reachable so a reader can still find them. + if (inert) attributes["aria-disabled"] = "true"; + return attributes; +}; + +/** How one streamed node renders locally. Pure, so web and the mobile WebView agree. */ +export function planAccessibleNode( + node: PreviewAccessibilityNode, + canAct: boolean, +): AccessiblePlan | null { + if (node.role === "text") return node.name ? { kind: "text", text: node.name } : null; + const children = node.children ?? []; + const inert = !canAct || node.disabled === true; + const states = stateAttributes(node, inert); + const named = (attributes: Record) => + node.name ? { ...attributes, "aria-label": node.name } : attributes; + const element = ( + tag: string, + attributes: Record, + extra: Partial> = {}, + ): AccessiblePlan => ({ + kind: "element", + tag, + attributes: { ...states, ...attributes }, + children, + control: null, + ...extra, + }); + + if (node.role === "heading") { + const level = Math.min(Math.max(node.level ?? 2, 1), 6); + return element(`h${level}`, {}, children.length > 0 ? {} : { text: node.name ?? "" }); + } + if (node.role === "link") { + const safe = node.url !== undefined && SAFE_LINK.test(node.url); + return element("a", safe ? { href: node.url! } : { role: "link", tabindex: "0" }, { + text: textOf(node) || node.url || "Link", + children: [], + control: "activate", + }); + } + if (node.role in BUTTON_ROLES) { + const role = BUTTON_ROLES[node.role]; + const attributes: Record = { type: "button", ...(role ? { role } : {}) }; + if (node.pressed !== undefined) attributes["aria-pressed"] = node.pressed; + if (node.checked !== undefined && role) attributes["aria-checked"] = node.checked; + if (node.selected !== undefined && (role === "tab" || role === "treeitem")) { + attributes["aria-selected"] = String(node.selected); + } else if (node.selected) { + attributes["aria-current"] = "true"; + } + return element("button", attributes, { + text: textOf(node) || node.role, + children: [], + control: "activate", + }); + } + if (node.role === "checkbox" || node.role === "radio") { + return element( + "input", + { type: node.role }, + { + label: node.name ?? "", + children: [], + control: "check", + checked: node.checked === "true", + indeterminate: node.checked === "mixed", + }, + ); + } + const options = + node.role === "combobox" || node.role === "listbox" ? descendants(node, "option") : []; + if (options.length > 0) { + return element( + "select", + node.role === "listbox" ? { size: String(Math.min(Math.max(options.length, 2), 8)) } : {}, + { + label: node.name ?? "", + children: [], + control: "select", + options: options.map((option) => ({ + id: option.id, + label: textOf(option) || option.value || "Option", + selected: option.selected === true, + disabled: option.disabled === true, + })), + }, + ); + } + if (FIELD_ROLES.has(node.role) || (node.editable && children.length === 0)) { + const readOnly = inert || node.readonly === true; + const attributes: Record = readOnly ? { readonly: "" } : {}; + if (!node.multiline) { + attributes.type = node.protected ? "password" : node.role === "searchbox" ? "search" : "text"; + } + if (node.protected) attributes["aria-description"] = "Its value is hidden."; + return element(node.multiline ? "textarea" : "input", attributes, { + label: node.name ?? "", + children: [], + control: "edit", + value: node.protected ? "" : (node.value ?? ""), + }); + } + if (node.role === "image" || node.role === "img" || node.role === "graphics-symbol") { + return node.name + ? element("span", { role: "img", "aria-label": node.name }, { children: [] }) + : null; + } + if (node.role === "document") { + return element("section", { "aria-label": node.name ?? "Frame" }); + } + const tag = LANDMARK_TAGS[node.role]; + if (tag) { + const attributes = node.role === "region" || node.role === "navigation" ? named({}) : {}; + if (tag === "hr") return element("hr", {}, { children: [] }); + return element(tag, attributes); + } + if (ARIA_CONTAINER_ROLES.has(node.role)) { + return element("div", named({ role: node.role })); + } + const fallback = children.length === 0 ? [node.name, node.value].filter(Boolean).join(": ") : ""; + if (children.length === 0 && !fallback) return null; + return element("div", {}, fallback ? { text: fallback } : {}); +} + +export interface AccessibleDocumentView { + /** `null` shows a loading message. Updates keep elements, focus and edits in place. */ + readonly update: (document: PreviewAccessibilityDocument | null, canAct: boolean) => void; + readonly notice: (message: string) => void; + readonly destroy: () => void; +} + +export const ACCESSIBLE_VIEW_NOT_CONTROLLER = "Take control of the browser to use the page."; + +const STYLE = ` +.t3-accessible-document { font: inherit; line-height: 1.5; padding: 12px; overflow-wrap: anywhere; } +.t3-accessible-document :is(h1,h2,h3,h4,h5,h6) { font-weight: 600; margin: 0.75em 0 0.25em; } +.t3-accessible-document h1 { font-size: 1.5em; } .t3-accessible-document h2 { font-size: 1.3em; } +.t3-accessible-document h3 { font-size: 1.15em; } +.t3-accessible-document :is(p, ul, blockquote, figure, section, nav, main, article, aside, header, footer) { margin: 0.4em 0; } +.t3-accessible-document ul { list-style: disc; padding-left: 1.5em; } +.t3-accessible-document a { color: inherit; text-decoration: underline; } +.t3-accessible-document :is(button, input, textarea, select) { font: inherit; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; padding: 2px 6px; margin: 2px 0; } +.t3-accessible-document input[type=checkbox], .t3-accessible-document input[type=radio] { margin-right: 6px; } +.t3-accessible-document label { display: block; margin: 4px 0; } +.t3-accessible-document label > span { margin-right: 6px; } +.t3-accessible-document [aria-disabled=true] { opacity: 0.7; } +.t3-accessible-document [role=row] { display: flex; gap: 12px; } +.t3-accessible-document > [role=status] { font-size: 0.85em; opacity: 0.8; margin: 0 0 8px; } +`; + +interface Rendered { + readonly node: Node; + readonly signature: string; + readonly control: Element | null; +} + +/** Keyed DOM view of streamed documents. It never moves remote focus by itself. */ +export function createAccessibleDocumentView( + container: HTMLElement, + options: { readonly onAction: (action: PreviewAccessibilityAction) => boolean }, +): AccessibleDocumentView { + const doc = container.ownerDocument; + const style = doc.createElement("style"); + style.textContent = STYLE; + const status = doc.createElement("p"); + status.setAttribute("role", "status"); + const body = doc.createElement("div"); + container.classList.add("t3-accessible-document"); + container.replaceChildren(style, status, body); + const rendered = new Map(); + const nodes = new Map(); + let current: PreviewAccessibilityDocument | null = null; + let canAct = false; + let summary = ""; + + const say = (message: string) => { + status.textContent = message || summary; + }; + const act = (action: Omit) => { + if (!current) return; + const sent = + canAct && + options.onAction({ type: "accessibilityAction", document: current.document, ...action }); + if (sent) return; + // Put back local changes the page never saw, then say why. + view.update(current, canAct); + say(ACCESSIBLE_VIEW_NOT_CONTROLLER); + }; + + const render = (node: PreviewAccessibilityNode, seen: Set): Node | null => { + const plan = planAccessibleNode(node, canAct); + if (!plan) return null; + seen.add(node.id); + nodes.set(node.id, node); + const signature = + plan.kind === "text" ? "#text" : `${plan.tag}:${plan.control}:${plan.attributes.type ?? ""}`; + let entry = rendered.get(node.id); + if (!entry || entry.signature !== signature) { + entry = create(plan, node.id, signature); + rendered.set(node.id, entry); + } + if (plan.kind === "text") { + if (entry.node.textContent !== plan.text) entry.node.textContent = plan.text; + return entry.node; + } + const element = entry.control as HTMLElement; + // Copied: removing from the live attribute map while iterating it skips entries. + for (const attribute of Array.from(element.attributes)) { + if (attribute.name !== "data-node" && !(attribute.name in plan.attributes)) { + element.removeAttribute(attribute.name); + } + } + for (const [name, value] of Object.entries(plan.attributes)) { + if (element.getAttribute(name) !== value) element.setAttribute(name, value); + } + if (entry.node !== element) { + const label = (entry.node as HTMLElement).firstChild as HTMLElement; + if (label.textContent !== (plan.label ?? "")) label.textContent = plan.label ?? ""; + } + if (element instanceof HTMLInputElement && plan.control === "check") { + element.checked = plan.checked === true; + element.indeterminate = plan.indeterminate === true; + } else if ( + (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && + plan.control === "edit" + ) { + element.dataset.serverValue = plan.value ?? ""; + // An edit in progress wins over the page until it is committed or cancelled. + if (!(element.dataset.dirty && doc.activeElement === element)) { + delete element.dataset.dirty; + if (element.value !== plan.value) element.value = plan.value ?? ""; + } + } else if (element instanceof HTMLSelectElement && plan.options) { + syncChildren( + element, + plan.options.map((option) => { + const existing = [...element.options].find((each) => each.dataset.node === option.id); + const item = existing ?? doc.createElement("option"); + item.dataset.node = option.id; + item.value = option.id; + if (item.textContent !== option.label) item.textContent = option.label; + item.disabled = option.disabled; + item.selected = option.selected; + return item; + }), + ); + } + if (plan.text !== undefined) { + if (element.textContent !== plan.text) element.textContent = plan.text; + } else if (plan.control === null) { + syncChildren( + element, + plan.children.flatMap((child) => { + const childNode = render(child, seen); + return childNode ? [childNode] : []; + }), + ); + } + return entry.node; + }; + + const create = (plan: AccessiblePlan, id: string, signature: string): Rendered => { + if (plan.kind === "text") + return { node: doc.createTextNode(plan.text), signature, control: null }; + const element = doc.createElement(plan.tag); + element.dataset.node = id; + if (plan.label === undefined) return { node: element, signature, control: element }; + const label = doc.createElement("label"); + label.append(doc.createElement("span"), element); + return { node: label, signature, control: element }; + }; + + const syncChildren = (parent: Element, next: ReadonlyArray) => { + // Moving a focused element would blur it, so only misplaced nodes move. + next.forEach((child, index) => { + if (parent.childNodes[index] !== child) + parent.insertBefore(child, parent.childNodes[index] ?? null); + }); + while (parent.childNodes.length > next.length) parent.lastChild!.remove(); + }; + + const nodeOf = (target: EventTarget | null) => { + const element = target instanceof Element ? target.closest("[data-node]") : null; + const id = element?.dataset.node; + const node = id ? nodes.get(id) : undefined; + return node && element ? { node, element } : null; + }; + + const onClick = (event: Event) => { + const hit = nodeOf(event.target); + if (!hit) return; + const plan = planAccessibleNode(hit.node, canAct); + if (plan?.kind !== "element" || (plan.control !== "activate" && plan.control !== "check")) + return; + // Local links and checkboxes must not change on their own; the page answers. + event.preventDefault(); + if (hit.node.disabled) { + say("That control is unavailable."); + return; + } + act({ node: hit.node.id, action: "activate" }); + }; + const commit = (element: HTMLInputElement | HTMLTextAreaElement, submit: boolean) => { + const hit = nodeOf(element); + if (!hit || (!submit && element.value === element.dataset.serverValue)) return; + delete element.dataset.dirty; + act({ + node: hit.node.id, + action: "setValue", + value: element.value, + ...(submit ? { submit } : {}), + }); + }; + const onChange = (event: Event) => { + const target = event.target; + if (target instanceof HTMLSelectElement) { + const chosen = target.selectedOptions[0]?.dataset.node; + if (chosen) act({ node: chosen, action: "activate" }); + return; + } + if ( + (target instanceof HTMLInputElement && + target.type !== "checkbox" && + target.type !== "radio") || + target instanceof HTMLTextAreaElement + ) { + if (target.dataset.dirty) commit(target, false); + } + }; + const onInput = (event: Event) => { + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + target.dataset.dirty = "1"; + } + }; + const onKeyDown = (event: Event) => { + const keyboard = event as KeyboardEvent; + const target = keyboard.target; + if (keyboard.isComposing) return; + if (target instanceof HTMLInputElement && keyboard.key === "Enter" && target.dataset.node) { + if (target.type === "checkbox" || target.type === "radio") return; + keyboard.preventDefault(); + commit(target, true); + } else if ( + (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && + keyboard.key === "Escape" && + target.dataset.dirty + ) { + keyboard.preventDefault(); + keyboard.stopPropagation(); + delete target.dataset.dirty; + target.value = target.dataset.serverValue ?? ""; + } else if (keyboard.key === "Enter" || keyboard.key === " ") { + const hit = nodeOf(target); + if (hit?.element.getAttribute("role") === "link" && keyboard.key === "Enter") { + keyboard.preventDefault(); + act({ node: hit.node.id, action: "activate" }); + } + } + }; + container.addEventListener("click", onClick); + container.addEventListener("change", onChange); + container.addEventListener("input", onInput); + container.addEventListener("keydown", onKeyDown); + + const view: AccessibleDocumentView = { + update: (document, nextCanAct) => { + canAct = nextCanAct; + if (document?.document !== current?.document) { + rendered.clear(); + nodes.clear(); + } + current = document; + summary = !document + ? "Loading the page's accessible view..." + : document.truncated + ? "This page is long, so only its first part is shown." + : ""; + say(""); + if (!document) { + body.replaceChildren(); + return; + } + const seen = new Set(); + nodes.clear(); + const root = render(document.root, seen); + syncChildren(body, root ? [root] : []); + for (const id of rendered.keys()) if (!seen.has(id)) rendered.delete(id); + }, + notice: say, + destroy: () => { + container.removeEventListener("click", onClick); + container.removeEventListener("change", onChange); + container.removeEventListener("input", onInput); + container.removeEventListener("keydown", onKeyDown); + container.classList.remove("t3-accessible-document"); + container.replaceChildren(); + rendered.clear(); + nodes.clear(); + }, + }; + return view; +} diff --git a/packages/client-runtime/src/preview/serverBrowserStream.test.ts b/packages/client-runtime/src/preview/serverBrowserStream.test.ts index cfde00363a005..7b9ce787b56a2 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.test.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.test.ts @@ -166,3 +166,97 @@ describe("preview stream agent pointer", () => { }); }); }); + +describe("preview stream accessible view", () => { + beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket)); + afterEach(() => vi.unstubAllGlobals()); + const document = { + document: "1:loader", + version: 3, + truncated: false, + root: { id: "0.1", role: "document", children: [{ id: "0.2", role: "button", name: "Go" }] }, + }; + const connect = () => { + const onAccessibility = vi.fn(); + const onAccessibilityNotice = vi.fn(); + const onConnectedChange = vi.fn(); + const client = createPreviewStreamClient(target, { + onFrame: vi.fn(), + onAccessibility, + onAccessibilityNotice, + onViewport: vi.fn(), + onConnectedChange, + onUnauthorized: vi.fn(), + }); + const socket = FakeSocket.current; + socket.dispatchEvent(new Event("open")); + return { client, socket, onAccessibility, onAccessibilityNotice }; + }; + + it("lets a read-only viewer subscribe without granting it page actions", () => { + const { client, socket, onAccessibility } = connect(); + socket.control({ canOperate: false, controller: "agent", generation: 1, dialog: null }); + socket.message(JSON.stringify({ type: "accessibility", document })); + expect(onAccessibility).not.toHaveBeenCalled(); + client.accessibility(true); + expect(socket.sent).toEqual(['{"type":"accessibility","enabled":true}']); + socket.message(JSON.stringify({ type: "accessibility", document })); + expect(onAccessibility).toHaveBeenLastCalledWith(document); + expect( + client.send({ + type: "accessibilityAction", + document: document.document, + node: "0.2", + action: "activate", + }), + ).toBe(false); + expect(socket.sent).toHaveLength(1); + client.stop(); + }); + + it("drops documents that break the contract and clears on off, reset and disconnect", () => { + const { client, socket, onAccessibility, onAccessibilityNotice } = connect(); + client.accessibility(true); + socket.message( + JSON.stringify({ type: "accessibility", document: { ...document, root: { role: "x" } } }), + ); + socket.message( + JSON.stringify({ type: "accessibility", document: { ...document, version: -1 } }), + ); + expect(onAccessibility).not.toHaveBeenCalled(); + socket.message(JSON.stringify({ type: "accessibility", document })); + socket.message(JSON.stringify({ type: "accessibilityNotice", text: "The page changed." })); + expect(onAccessibilityNotice).toHaveBeenCalledWith("The page changed."); + socket.message(JSON.stringify({ type: "accessibility", document: null })); + expect(onAccessibility).toHaveBeenLastCalledWith(null); + + socket.message(JSON.stringify({ type: "accessibility", document })); + client.accessibility(false); + expect(onAccessibility).toHaveBeenLastCalledWith(null); + expect(socket.sent.at(-1)).toBe('{"type":"accessibility","enabled":false}'); + + client.accessibility(true); + socket.message(JSON.stringify({ type: "accessibility", document })); + onAccessibility.mockClear(); + socket.dispatchEvent(new CloseEvent("close", { code: 1012 })); + expect(onAccessibility).toHaveBeenCalledExactlyOnceWith(null); + client.stop(); + }); + + it("resubscribes after a reconnect", () => { + vi.useFakeTimers(); + try { + const { client, socket } = connect(); + client.accessibility(true); + socket.dispatchEvent(new CloseEvent("close", { code: 1012 })); + vi.advanceTimersByTime(500); + const next = FakeSocket.current; + expect(next).not.toBe(socket); + next.dispatchEvent(new Event("open")); + expect(next.sent).toEqual(['{"type":"accessibility","enabled":true}']); + client.stop(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index b71fff335f881..0fb222dcf1d5a 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -2,9 +2,13 @@ import { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts"; import { PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, + PreviewAccessibilityDocument, + type PreviewAccessibilityAction, type PreviewStreamHostSetup, type PreviewViewportSetting, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; export const PREVIEW_STREAM_BASE_PATH = "/api/preview-stream"; @@ -90,7 +94,8 @@ export type PreviewStreamInput = | { readonly type: "navigate"; readonly url: string } | { readonly type: "history"; readonly delta: -1 | 1 } | { readonly type: "reload"; readonly ignoreCache?: boolean } - | { readonly type: "probe"; readonly x: number; readonly y: number }; + | { readonly type: "probe"; readonly x: number; readonly y: number } + | PreviewAccessibilityAction; export interface PreviewStreamDownload { readonly fileName: string; @@ -197,13 +202,20 @@ export interface PreviewStreamEvents { readonly onUnauthorized: () => void; readonly onGone?: () => void; readonly onHostSetup?: (setup: PreviewStreamHostSetup) => void; + /** `null` clears the view: turned off, disconnected, or the server reset it. */ + readonly onAccessibility?: (document: PreviewAccessibilityDocument | null) => void; + readonly onAccessibilityNotice?: (message: string) => void; } export interface PreviewStreamClient { readonly send: (input: PreviewStreamInput) => boolean; + /** Not a page mutation, so read-only viewers may subscribe. Survives reconnects. */ + readonly accessibility: (enabled: boolean) => void; readonly stop: () => void; } +const decodeAccessibilityDocument = Schema.decodeUnknownOption(PreviewAccessibilityDocument); + const ACK_MESSAGE = JSON.stringify({ type: "ack" }); export function createPreviewStreamClient( target: PreviewStreamTarget, @@ -223,6 +235,13 @@ export function createPreviewStreamClient( let failures = 0; let control: PreviewStreamControl | null = null; let fileChooser: string | null = null; + let accessible = false; + let accessibilityShown = false; + const clearAccessibility = () => { + if (!accessibilityShown) return; + accessibilityShown = false; + events.onAccessibility?.(null); + }; const connect = () => { if (stopped) return; @@ -234,6 +253,7 @@ export function createPreviewStreamClient( if (socket !== ws) return; opened = true; events.onConnectedChange(true); + if (accessible) ws.send(JSON.stringify({ type: "accessibility", enabled: true })); }); ws.addEventListener("message", (event) => { if (socket !== ws) return; @@ -271,8 +291,21 @@ export function createPreviewStreamClient( phase, sequence, tabId, + document, } = message as Record; - if ( + if (type === "accessibility") { + if (!accessible) return; + if (document === null) { + clearAccessibility(); + return; + } + const decoded = decodeAccessibilityDocument(document); + if (Option.isNone(decoded)) return; + accessibilityShown = true; + events.onAccessibility?.(decoded.value); + } else if (type === "accessibilityNotice" && typeof text === "string") { + if (accessible) events.onAccessibilityNotice?.(text); + } else if ( type === "fileChooser" && typeof id === "string" && typeof multiple === "boolean" && @@ -346,6 +379,7 @@ export function createPreviewStreamClient( if (socket !== ws) return; socket = null; control = null; + clearAccessibility(); if (opened) events.onConnectedChange(false); if (stopped) return; if (event.code === 4404) { @@ -379,8 +413,18 @@ export function createPreviewStreamClient( socket.send(JSON.stringify(input)); return true; }, + accessibility: (enabled) => { + if (enabled === accessible) return; + accessible = enabled; + if (!enabled) clearAccessibility(); + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "accessibility", enabled })); + } + }, stop: () => { stopped = true; + accessible = false; + clearAccessibility(); if (retryTimer !== null) clearTimeout(retryTimer); const ws = socket; socket = null; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 38c28ab65a2ba..ff69fb101ef54 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -57,6 +57,7 @@ export * from "./browserImport.ts"; export * from "./browserProfile.ts"; export * from "./device.ts"; export * from "./preview.ts"; +export * from "./previewAccessibility.ts"; export * from "./previewAutomation.ts"; export * from "./previewCapture.ts"; export * from "./resourceTelemetry.ts"; diff --git a/packages/contracts/src/previewAccessibility.ts b/packages/contracts/src/previewAccessibility.ts new file mode 100644 index 0000000000000..2ec7d4ff100ed --- /dev/null +++ b/packages/contracts/src/previewAccessibility.ts @@ -0,0 +1,113 @@ +import { Schema } from "effect"; + +import { NonNegativeInt } from "./baseSchemas.ts"; + +export const PREVIEW_ACCESSIBILITY_MAX_NODES = 4_000; +export const PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS = 10_000; + +const Text = (max: number) => Schema.String.check(Schema.isMaxLength(max)); +const Tristate = Schema.Literals(["true", "false", "mixed"]); + +/** + * One node of a streamed page's accessibility tree. Ids are namespaced per + * capture session and never reused, so an id from a replaced document is stale. + */ +export interface PreviewAccessibilityNode { + readonly id: string; + readonly role: string; + readonly name?: string; + readonly description?: string; + /** Omitted for password and other secret fields; see `protected`. */ + readonly value?: string; + readonly url?: string; + readonly level?: number; + readonly checked?: "true" | "false" | "mixed"; + readonly pressed?: "true" | "false" | "mixed"; + readonly expanded?: boolean; + readonly selected?: boolean; + readonly disabled?: boolean; + readonly readonly?: boolean; + readonly required?: boolean; + readonly invalid?: boolean; + readonly focused?: boolean; + readonly editable?: boolean; + readonly multiline?: boolean; + readonly protected?: boolean; + readonly children?: ReadonlyArray; +} + +export const PreviewAccessibilityNode: Schema.Codec = Schema.Struct({ + id: Text(100), + role: Text(100), + name: Schema.optionalKey(Text(2_000)), + description: Schema.optionalKey(Text(2_000)), + value: Schema.optionalKey(Text(PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS)), + url: Schema.optionalKey(Text(2_048)), + level: Schema.optionalKey(NonNegativeInt), + checked: Schema.optionalKey(Tristate), + pressed: Schema.optionalKey(Tristate), + expanded: Schema.optionalKey(Schema.Boolean), + selected: Schema.optionalKey(Schema.Boolean), + disabled: Schema.optionalKey(Schema.Boolean), + readonly: Schema.optionalKey(Schema.Boolean), + required: Schema.optionalKey(Schema.Boolean), + invalid: Schema.optionalKey(Schema.Boolean), + focused: Schema.optionalKey(Schema.Boolean), + editable: Schema.optionalKey(Schema.Boolean), + multiline: Schema.optionalKey(Schema.Boolean), + protected: Schema.optionalKey(Schema.Boolean), + children: Schema.optionalKey( + Schema.Array( + Schema.suspend((): Schema.Codec => PreviewAccessibilityNode), + ), + ), +}); + +const countNodes = (root: PreviewAccessibilityNode) => { + let count = 0; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (++count > PREVIEW_ACCESSIBILITY_MAX_NODES) return count; + if (node.children) stack.push(...node.children); + } + return count; +}; + +/** A full replacement of the semantic page; viewers never patch an older one. */ +export const PreviewAccessibilityDocument = Schema.Struct({ + /** Changes whenever the top-level document or the capture session is replaced. */ + document: Text(200), + version: NonNegativeInt, + truncated: Schema.Boolean, + root: PreviewAccessibilityNode, +}).check( + Schema.makeFilter((value: { readonly root: PreviewAccessibilityNode }) => + countNodes(value.root) > PREVIEW_ACCESSIBILITY_MAX_NODES + ? `Accessibility documents must not exceed ${PREVIEW_ACCESSIBILITY_MAX_NODES} nodes.` + : undefined, + ), +); +export type PreviewAccessibilityDocument = typeof PreviewAccessibilityDocument.Type; + +/** Read-only viewers may subscribe; it never changes page state. */ +export const PreviewAccessibilitySubscription = Schema.Struct({ + type: Schema.Literal("accessibility"), + enabled: Schema.Boolean, +}); +export type PreviewAccessibilitySubscription = typeof PreviewAccessibilitySubscription.Type; + +export const PreviewAccessibilityActionKind = Schema.Literals(["activate", "setValue"]); +export type PreviewAccessibilityActionKind = typeof PreviewAccessibilityActionKind.Type; + +/** Page mutation: only the controlling viewer's actions run, against the current document. */ +export const PreviewAccessibilityAction = Schema.Struct({ + type: Schema.Literal("accessibilityAction"), + document: Text(200), + node: Text(100), + action: PreviewAccessibilityActionKind, + value: Schema.optionalKey(Text(PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS)), + /** After `setValue`, press Enter in the field. */ + submit: Schema.optionalKey(Schema.Boolean), +}); +export type PreviewAccessibilityAction = typeof PreviewAccessibilityAction.Type; From c06988acdca4c2a591802e30d1eb3178ed229b4b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 03:59:38 +0000 Subject: [PATCH 02/12] fix(preview): show accessibility action refusals --- apps/server/src/preview/ServerBrowserAccessibility.ts | 4 ++-- packages/client-runtime/src/preview/accessibleDocument.ts | 2 +- .../client-runtime/src/preview/serverBrowserStream.test.ts | 2 +- packages/client-runtime/src/preview/serverBrowserStream.ts | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index bb687dd0a3776..76dd5568d81eb 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -66,7 +66,7 @@ const HOISTED_ROLES = new Set([ ]); /** Duplicates of text their parent already carries. */ const SKIPPED_ROLES = new Set(["InlineTextBox", "ListMarker", "LineBreak"]); -export const ACTIVATABLE_ROLES: ReadonlySet = new Set([ +const ACTIVATABLE_ROLES: ReadonlySet = new Set([ "link", "button", "checkbox", @@ -110,7 +110,7 @@ const roleOf = (node: RawAccessibilityNode) => { }; /** Editable fields whose attributes must be read before their values may be sent. */ -export const valueFieldIds = (nodes: ReadonlyArray) => +const valueFieldIds = (nodes: ReadonlyArray) => nodes.flatMap((node) => !node.ignored && node.backendDOMNodeId !== undefined && EDITABLE_ROLES.has(roleOf(node)) ? [node.backendDOMNodeId] diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts index 3f210c4795f0f..0ebd7ab7243d3 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -242,7 +242,7 @@ export interface AccessibleDocumentView { readonly destroy: () => void; } -export const ACCESSIBLE_VIEW_NOT_CONTROLLER = "Take control of the browser to use the page."; +const ACCESSIBLE_VIEW_NOT_CONTROLLER = "Take control of the browser to use the page."; const STYLE = ` .t3-accessible-document { font: inherit; line-height: 1.5; padding: 12px; overflow-wrap: anywhere; } diff --git a/packages/client-runtime/src/preview/serverBrowserStream.test.ts b/packages/client-runtime/src/preview/serverBrowserStream.test.ts index 7b9ce787b56a2..b71fe54ddf1e1 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.test.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.test.ts @@ -225,7 +225,7 @@ describe("preview stream accessible view", () => { ); expect(onAccessibility).not.toHaveBeenCalled(); socket.message(JSON.stringify({ type: "accessibility", document })); - socket.message(JSON.stringify({ type: "accessibilityNotice", text: "The page changed." })); + socket.message(JSON.stringify({ type: "accessibilityNotice", message: "The page changed." })); expect(onAccessibilityNotice).toHaveBeenCalledWith("The page changed."); socket.message(JSON.stringify({ type: "accessibility", document: null })); expect(onAccessibility).toHaveBeenLastCalledWith(null); diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 0fb222dcf1d5a..bd93904d81816 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -292,6 +292,7 @@ export function createPreviewStreamClient( sequence, tabId, document, + message: noticeText, } = message as Record; if (type === "accessibility") { if (!accessible) return; @@ -303,8 +304,8 @@ export function createPreviewStreamClient( if (Option.isNone(decoded)) return; accessibilityShown = true; events.onAccessibility?.(decoded.value); - } else if (type === "accessibilityNotice" && typeof text === "string") { - if (accessible) events.onAccessibilityNotice?.(text); + } else if (type === "accessibilityNotice" && typeof noticeText === "string") { + if (accessible) events.onAccessibilityNotice?.(noticeText); } else if ( type === "fileChooser" && typeof id === "string" && From ac80fadaa9a809c9691a1070c4382e593ae795b0 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 04:13:43 +0000 Subject: [PATCH 03/12] fix(preview): keep accessible view edits, trees, frames and backlog intact - Editable field values are sent exactly (whitespace and label-equal text kept), still bounded and still withheld for secret or masked fields. - Tree items render as role=treeitem containers so nested items stay reachable and activatable; Enter and Space activate them. - Child sync removes obsolete nodes first and keeps the focused child in place while siblings move around it. - Out-of-process frame sessions enable the Accessibility domain and listen for changes; their listeners, domain and session are released on frame navigation, detach and unsubscribe. - Any frame navigation or detach clears action targets until the next capture, and a capture that spans a frame change cannot be acted on. - A viewer backlog reset keeps the newest queued accessibility document. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 27 ++- apps/server/src/preview/ServerBrowser.ts | 3 + .../ServerBrowserAccessibility.test.ts | 192 ++++++++++++++++-- .../src/preview/ServerBrowserAccessibility.ts | 74 +++++-- .../src/preview/accessibleDocument.test.ts | 119 ++++++++++- .../src/preview/accessibleDocument.ts | 55 +++-- 6 files changed, 411 insertions(+), 59 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index da2704e25b61e..85b6c4ef81258 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -87,7 +87,8 @@ function makeSession(frame?: ReturnType) { const events = new NodeEvents.EventEmitter(); return { on: vi.fn((name: string, listener: () => void) => events.on(name, listener)), - emit: (name: string) => events.emit(name), + off: vi.fn((name: string, listener: () => void) => events.off(name, listener)), + emit: (name: string, ...args: Array) => events.emit(name, ...args), detach: vi.fn(async () => {}), send: vi.fn(async (method: string, input?: unknown): Promise> => { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; @@ -1481,3 +1482,27 @@ it.live("turning the view off or leaving releases the Accessibility domain and i }), ).pipe(Effect.provide(layer)), ); + +it.live("a viewer whose backlog is replaced still gets the page's semantics", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const operator = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* operator.accessibility(true); + const session = contexts[0]!.sessions.find((each) => + each.on.mock.calls.some(([name]) => name === "Page.screencastFrame"), + )!; + // A stalled viewer: frames fill its queue behind the accepted document. + for (let index = 0; index < 80; index += 1) { + session.emit("Page.screencastFrame", { data: "ZnJhbWU=", sessionId: index }); + } + yield* operator.input({ type: "takeControl" }); + const backlog = yield* Queue.takeAll(operator.output); + expect(backlog.map((output) => output._tag)).toContain("control"); + expect(backlog.filter((output) => output._tag === "accessibility")).toEqual([ + expect.objectContaining({ document: expect.objectContaining({ version: 1 }) }), + ]); + yield* operator.input({ type: "releaseControl" }); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0de3fe504f197..f9810b752ed3b 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2317,6 +2317,9 @@ const make = Effect.gen(function* () { const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + // Semantics are only resent when they change, so the newest must survive. + const semantics = dropped.value.findLast((item) => item._tag === "accessibility"); + if (semantics && next._tag !== "gone") Queue.offerUnsafe(output, semantics); for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, diff --git a/apps/server/src/preview/ServerBrowserAccessibility.test.ts b/apps/server/src/preview/ServerBrowserAccessibility.test.ts index 36c8b907024cd..1073c31e71d21 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.test.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.test.ts @@ -187,26 +187,10 @@ it("treats password and one-time-code fields as secret", () => { it("redelivers the newest document to a viewer whose queue was full", async () => { vi.useFakeTimers(); try { - const events = new NodeEvents.EventEmitter(); - const session = { - on: vi.fn(), - detach: vi.fn(async () => {}), - send: vi.fn(async (method: string) => { - if (method === "Page.getFrameTree") - return { frameTree: { frame: { id: "main", loaderId: "l" } } }; - if (method === "Accessibility.getFullAXTree") { - return { nodes: [node("1", "RootWebArea", { label: "Page" })] }; - } - return {}; - }), - }; - const page = { - context: () => ({ newCDPSession: async () => session }), - mainFrame: () => page, - frames: () => [page], - on: (name: string, listener: () => void) => events.on(name, listener), - off: (name: string, listener: () => void) => events.off(name, listener), - }; + const session = fakeSession(() => [node("1", "RootWebArea", { label: "Page" })], { + id: "main", + }); + const page = fakePage(session, new Map()); const mirror = new ServerBrowserAccessibility(page as unknown as Page); const offer = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); await mirror.subscribe("viewer", offer); @@ -216,13 +200,14 @@ it("redelivers the newest document to a viewer whose queue was full", async () = expect(offer.mock.calls[1]![0]).toMatchObject({ version: 1, root: { name: "Page" } }); await mirror.unsubscribe("viewer"); - expect(events.eventNames()).toEqual([]); + expect(page.events.eventNames()).toEqual([]); + expect(session.events.eventNames()).toEqual([]); expect(session.send).toHaveBeenCalledWith("Accessibility.disable"); expect(session.detach).toHaveBeenCalled(); expect( await mirror.act({ type: "accessibilityAction", - document: "1:l", + document: "1:main-loader", node: "0.1", action: "activate", }), @@ -231,3 +216,166 @@ it("redelivers the newest document to a viewer whose queue was full", async () = vi.useRealTimers(); } }); + +it("sends editable values exactly as typed, even when they match the field's name", () => { + const { root } = buildAccessibilityTree([ + frame("0", [ + node("1", "RootWebArea", { childIds: ["2", "3", "4"] }), + node("2", "textbox", { + label: "Code", + backendDOMNodeId: 2, + value: { value: " indented\n" }, + }), + node("3", "textbox", { label: "Word", backendDOMNodeId: 3, value: { value: "Word" } }), + node("4", "textbox", { + label: "Spaces", + backendDOMNodeId: 4, + value: { value: " ".repeat(20_000) }, + }), + ]), + ]); + expect(root!.children!.map((child) => child.value)).toEqual([ + " indented\n", + "Word", + " ".repeat(10_000), + ]); +}); + +type Listener = (...args: Array) => void; +const fakeSession = ( + nodes: () => ReadonlyArray, + frame: { readonly id: string; readonly parentId?: string }, +) => { + const events = new NodeEvents.EventEmitter(); + return { + events, + on: vi.fn((name: string, listener: Listener) => events.on(name, listener)), + off: vi.fn((name: string, listener: Listener) => events.off(name, listener)), + detach: vi.fn(async () => {}), + send: vi.fn(async (method: string, _input?: unknown) => { + if (method === "Page.getFrameTree") { + return { frameTree: { frame: { ...frame, loaderId: `${frame.id}-loader` } } }; + } + if (method === "Accessibility.getFullAXTree") return { nodes: nodes() }; + if (method === "DOM.getFrameOwner") return { backendNodeId: 7 }; + if (method === "DOM.resolveNode") return { object: { objectId: "object" } }; + return {}; + }), + }; +}; +const fakePage = ( + main: ReturnType, + children: ReadonlyMap | null>, +) => { + const events = new NodeEvents.EventEmitter(); + const page = { + events, + context: () => ({ + newCDPSession: async (target: object) => { + if (target === page) return main; + const session = children.get(target); + if (!session) throw new Error("This frame does not have a separate CDP session."); + return session; + }, + }), + mainFrame: () => page, + frames: () => [page, ...children.keys()], + on: (name: string, listener: Listener) => events.on(name, listener), + off: (name: string, listener: Listener) => events.off(name, listener), + }; + return page; +}; +const captures = (session: ReturnType) => + session.send.mock.calls.filter(([method]) => method === "Accessibility.getFullAXTree").length; + +it("follows semantic changes inside out-of-process frames and releases their sessions", async () => { + vi.useFakeTimers(); + try { + const mainNodes = () => [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", "Iframe", { backendDOMNodeId: 7 }), + ]; + const main = fakeSession(mainNodes, { id: "main" }); + let label = "Before"; + const childNodes = () => [node("1", "RootWebArea", { label })]; + const child = fakeSession(childNodes, { id: "child", parentId: "main" }); + const childFrame = {}; + const page = fakePage(main, new Map([[childFrame, child]])); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + const offer = vi.fn((_document: unknown) => true); + await mirror.subscribe("viewer", offer); + expect(child.send).toHaveBeenCalledWith("Accessibility.enable"); + expect(offer.mock.lastCall![0]).toMatchObject({ root: { children: [{ name: "Before" }] } }); + + // An ARIA-only change inside the frame repaints nothing in the main page. + label = "After"; + child.events.emit("Accessibility.nodesUpdated", { nodes: [] }); + await vi.advanceTimersByTimeAsync(1_000); + expect(offer.mock.lastCall![0]).toMatchObject({ root: { children: [{ name: "After" }] } }); + + page.events.emit("framedetached", childFrame); + await vi.advanceTimersByTimeAsync(0); + expect(child.events.eventNames()).toEqual([]); + expect(child.send).toHaveBeenCalledWith("Accessibility.disable"); + expect(child.detach).toHaveBeenCalledTimes(1); + await mirror.unsubscribe("viewer"); + + // A navigated frame's session is released at once, and its successor with the view. + const before = fakeSession(childNodes, { id: "child", parentId: "main" }); + const after = fakeSession(childNodes, { id: "child", parentId: "main" }); + const sessions = new Map([[childFrame, before]]); + const navigated = fakePage(main, sessions); + const second = new ServerBrowserAccessibility(navigated as unknown as Page); + await second.subscribe("viewer", offer); + sessions.set(childFrame, after); + navigated.events.emit("framenavigated", childFrame); + await vi.advanceTimersByTimeAsync(0); + expect(before.events.eventNames()).toEqual([]); + expect(before.send).toHaveBeenCalledWith("Accessibility.disable"); + expect(before.detach).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_000); + expect(after.send).toHaveBeenCalledWith("Accessibility.enable"); + await second.unsubscribe("viewer"); + expect(after.events.eventNames()).toEqual([]); + expect(after.send).toHaveBeenCalledWith("Accessibility.disable"); + expect(after.detach).toHaveBeenCalledTimes(1); + expect(main.events.eventNames()).toEqual([]); + expect(navigated.events.eventNames()).toEqual([]); + } finally { + vi.useRealTimers(); + } +}); + +it("refuses actions on a document whose frame navigated until it is read again", async () => { + vi.useFakeTimers(); + try { + const nodes = () => [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", "button", { label: "Buy", backendDOMNodeId: 9 }), + ]; + const main = fakeSession(nodes, { id: "main" }); + const sameProcessFrame = {}; + const page = fakePage(main, new Map([[sameProcessFrame, null]])); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + const offer = vi.fn((_document: unknown) => true); + await mirror.subscribe("viewer", offer); + const action = { + type: "accessibilityAction" as const, + document: "1:main-loader", + node: "0.2", + action: "activate" as const, + }; + + page.events.emit("framenavigated", sameProcessFrame); + expect(await mirror.act(action)).toBe("The page changed. Try again."); + expect(main.send).not.toHaveBeenCalledWith("Runtime.callFunctionOn", expect.anything()); + + await vi.advanceTimersByTimeAsync(1_000); + expect(captures(main)).toBe(2); + expect(await mirror.act(action)).toBeNull(); + expect(main.send).toHaveBeenCalledWith("Runtime.callFunctionOn", expect.anything()); + await mirror.unsubscribe("viewer"); + } finally { + vi.useRealTimers(); + } +}); diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index 76dd5568d81eb..225ff5558bcfc 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -101,6 +101,12 @@ const text = (value: unknown, max = TEXT_MAX_CHARS) => { const trimmed = value.trim(); return trimmed.length === 0 ? undefined : trimmed.slice(0, max); }; +/** What the user typed, kept exactly; only the length is bounded. */ +const fieldValue = (value: unknown) => { + if (typeof value === "number") return String(value); + if (typeof value !== "string" || value.length === 0) return undefined; + return value.slice(0, PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS); +}; const truthy = (value: unknown) => value === true || value === "true"; const tristate = (value: unknown) => value === "mixed" ? "mixed" : value === undefined ? undefined : truthy(value) ? "true" : "false"; @@ -201,11 +207,13 @@ export function buildAccessibilityTree( const properties = new Map( raw.properties?.map((property) => [property.name, property.value.value]), ); - const value = text(raw.value?.value, PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS); + const editable = properties.has("editable") || EDITABLE_ROLES.has(role); + const value = editable + ? fieldValue(raw.value?.value) + : text(raw.value?.value, PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS); const secret = raw.backendDOMNodeId !== undefined && frame.secretNodes.has(raw.backendDOMNodeId); - const hidden = secret || (value !== undefined && MASKED_VALUE.test(value)); - const editable = properties.has("editable") || EDITABLE_ROLES.has(role); + const hidden = secret || (value !== undefined && MASKED_VALUE.test(value.trim())); const level = properties.get("level"); const node: { -readonly [K in keyof PreviewAccessibilityNode]: PreviewAccessibilityNode[K] } = { id, @@ -215,7 +223,7 @@ export function buildAccessibilityTree( const description = text(raw.description?.value); if (description !== undefined && role !== "text") node.description = description; if (hidden) node.protected = true; - else if (value !== undefined && value !== name) node.value = value; + else if (value !== undefined && (editable || value !== name)) node.value = value; const url = text(properties.get("url"), 2_048); if (url !== undefined && role === "link") node.url = url; if (typeof level === "number" && Number.isInteger(level) && level >= 0) node.level = level; @@ -337,6 +345,8 @@ export class ServerBrowserAccessibility { private run = 0; private sessionSerial = 0; private version = 0; + /** Bumped by any frame navigation; a capture that spans one cannot be acted on. */ + private frameChanges = 0; private latest: Capture | null = null; private chain: Promise = Promise.resolve(); private queued: Promise | null = null; @@ -443,13 +453,25 @@ export class ServerBrowserAccessibility { } this.main = session; this.sessions.set("0", session); - session.on("Accessibility.nodesUpdated", this.invalidate); - session.on("Accessibility.loadComplete", this.invalidate); this.page.on("framenavigated", this.onFrameNavigated); this.page.on("framedetached", this.onFrameDetached); this.page.on("domcontentloaded", this.invalidate); this.page.on("load", this.invalidate); - await session.send("Accessibility.enable").catch(constVoid); + await this.watch(session); + } + + /** Each out-of-process frame reports its own semantic changes. */ + private watch(session: CDPSession) { + session.on("Accessibility.nodesUpdated", this.invalidate); + session.on("Accessibility.loadComplete", this.invalidate); + return session.send("Accessibility.enable").then(constVoid, constVoid); + } + + private async release(session: CDPSession) { + session.off("Accessibility.nodesUpdated", this.invalidate); + session.off("Accessibility.loadComplete", this.invalidate); + await session.send("Accessibility.disable").catch(constVoid); + await session.detach().catch(constVoid); } private async stop() { @@ -465,29 +487,33 @@ export class ServerBrowserAccessibility { this.page.off("domcontentloaded", this.invalidate); this.page.off("load", this.invalidate); this.main = null; - const sessions = [...this.sessions.entries()]; + const sessions = [...this.sessions.values()]; this.sessions.clear(); this.frameSessions.clear(); - await Promise.all( - sessions.map(async ([key, session]) => { - if (key === "0") await session.send("Accessibility.disable").catch(constVoid); - await session.detach().catch(constVoid); - }), - ); + await Promise.all(sessions.map((session) => this.release(session))); } private readonly onFrameNavigated = (frame: Frame) => { - // Actions name nodes of the document a viewer saw; a new one invalidates them all. if (frame === this.page.mainFrame()) this.latest = null; else this.dropFrameSession(frame); - this.invalidate(); + this.forgetTargets(); }; private readonly onFrameDetached = (frame: Frame) => { this.dropFrameSession(frame); - this.invalidate(); + this.forgetTargets(); }; + /** + * Same-process frames share their parent's node ids, so any frame change may + * leave an id naming a replaced element. Actions wait for the next capture. + */ + private forgetTargets() { + this.frameChanges += 1; + if (this.latest) this.latest = { ...this.latest, targets: new Map() }; + this.invalidate(); + } + private dropFrameSession(frame: Frame) { const entry = this.frameSessions.get(frame); if (!entry) return; @@ -496,7 +522,7 @@ export class ServerBrowserAccessibility { .then((key) => { const session = key === null ? undefined : this.sessions.get(key); if (key !== null) this.sessions.delete(key); - return session?.detach(); + return session && this.release(session); }) .catch(constVoid); } @@ -510,13 +536,14 @@ export class ServerBrowserAccessibility { .context() .newCDPSession(frame) .then( - (session) => { + async (session) => { if (run !== this.run) { void session.detach().catch(constVoid); return null; } const key = String(++this.sessionSerial); this.sessions.set(key, session); + await this.watch(session); return key; }, () => null, @@ -528,6 +555,7 @@ export class ServerBrowserAccessibility { private async captureOnce() { const run = this.run; + const frameChanges = this.frameChanges; const main = this.main; if (!main) return; this.lastCaptureAt = Date.now(); @@ -536,9 +564,13 @@ export class ServerBrowserAccessibility { return null; }); if (!captured || run !== this.run || !captured.root) return; + const targets = + frameChanges === this.frameChanges + ? captured.targets + : new Map(); const json = JSON.stringify([captured.token, captured.truncated, captured.root]); if (this.latest?.json === json) { - this.latest = { ...this.latest, targets: captured.targets }; + this.latest = { ...this.latest, targets }; return; } this.latest = { @@ -549,7 +581,7 @@ export class ServerBrowserAccessibility { root: captured.root, }, json, - targets: captured.targets, + targets, }; for (const subscriber of this.subscribers.values()) subscriber.pending = true; this.deliver(); diff --git a/packages/client-runtime/src/preview/accessibleDocument.test.ts b/packages/client-runtime/src/preview/accessibleDocument.test.ts index bad3f671e0550..c8f7094b08c32 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.test.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.test.ts @@ -1,7 +1,7 @@ import type { PreviewAccessibilityNode } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { planAccessibleNode } from "./accessibleDocument.ts"; +import { planAccessibleNode, syncChildren } from "./accessibleDocument.ts"; const plan = (node: PreviewAccessibilityNode, canAct = true) => planAccessibleNode(node, canAct); @@ -85,9 +85,126 @@ describe("planAccessibleNode", () => { }); }); + it("keeps an expanded tree item's nested items as their own controls", () => { + const file = { id: "4", role: "treeitem", name: "index.ts", selected: false }; + const folder = { + id: "1", + role: "treeitem", + name: "src", + expanded: true, + selected: true, + children: [ + { id: "2", role: "text", name: "src" }, + { id: "3", role: "group", children: [file] }, + ], + }; + const shown = plan(folder); + expect(shown).toMatchObject({ + tag: "div", + attributes: { + role: "treeitem", + tabindex: "0", + "aria-label": "src", + "aria-expanded": "true", + "aria-selected": "true", + }, + control: "activate", + children: folder.children, + }); + expect(shown).not.toHaveProperty("text"); + expect(plan(file)).toMatchObject({ + tag: "div", + attributes: { role: "treeitem", tabindex: "0", "aria-selected": "false" }, + text: "index.ts", + control: "activate", + }); + }); + it("skips empty presentational nodes and keeps text", () => { expect(plan({ id: "1", role: "image" })).toBeNull(); expect(plan({ id: "2", role: "unknownwidget" })).toBeNull(); expect(plan({ id: "3", role: "text", name: "Hello" })).toEqual({ kind: "text", text: "Hello" }); }); }); + +interface FakeNode { + readonly name: string; + parentNode: FakeParent | null; + readonly contains: (other: FakeNode | null) => boolean; + readonly nextSibling: FakeNode | null; +} +interface FakeParent { + readonly childNodes: Array; + readonly insertBefore: (child: FakeNode, before: FakeNode | null) => FakeNode; + readonly removeChild: (child: FakeNode) => FakeNode; +} + +/** Records every placement, since the DOM blurs a focused element whenever it moves. */ +const fakeParent = (names: ReadonlyArray) => { + const moved: Array = []; + const parent: FakeParent = { + childNodes: [], + insertBefore: (child, before) => { + if (child.parentNode) parent.removeChild(child); + moved.push(child.name); + const index = before ? parent.childNodes.indexOf(before) : parent.childNodes.length; + parent.childNodes.splice(index, 0, child); + child.parentNode = parent; + return child; + }, + removeChild: (child) => { + parent.childNodes.splice(parent.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + }, + }; + const make = (name: string): FakeNode => { + const node: FakeNode = { + name, + parentNode: null, + contains: (other) => other === node, + get nextSibling() { + const siblings = node.parentNode?.childNodes ?? []; + return siblings[siblings.indexOf(node) + 1] ?? null; + }, + }; + return node; + }; + const nodes = new Map(names.map((name) => [name, make(name)])); + for (const node of nodes.values()) parent.insertBefore(node, null); + moved.length = 0; + const get = (name: string) => nodes.get(name) ?? nodes.set(name, make(name)).get(name)!; + const sync = (next: ReadonlyArray, focused?: string) => + syncChildren( + parent as unknown as Element, + next.map(get) as unknown as ReadonlyArray, + (focused ? get(focused) : null) as unknown as Node | null, + ); + const order = () => parent.childNodes.map((node) => node.name); + return { moved, sync, order }; +}; + +describe("syncChildren", () => { + it("never moves a focused child that keeps its place among survivors", () => { + const removal = fakeParent(["A", "B"]); + removal.sync(["B"], "B"); + expect(removal.order()).toEqual(["B"]); + expect(removal.moved).toEqual([]); + + const swap = fakeParent(["A", "B", "C"]); + swap.sync(["B", "C", "A", "D"], "B"); + expect(swap.order()).toEqual(["B", "C", "A", "D"]); + expect(swap.moved).not.toContain("B"); + + const forward = fakeParent(["C", "D", "B"]); + forward.sync(["B", "C", "D"], "B"); + expect(forward.order()).toEqual(["B", "C", "D"]); + expect(forward.moved).not.toContain("B"); + }); + + it("reorders, inserts and removes children to match", () => { + const list = fakeParent(["A", "B", "C", "D"]); + list.sync(["D", "E", "B"]); + expect(list.order()).toEqual(["D", "E", "B"]); + }); +}); diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts index 0ebd7ab7243d3..b393d9d908d09 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -87,7 +87,6 @@ const BUTTON_ROLES: Record = { menuitem: "menuitem", menuitemcheckbox: "menuitemcheckbox", menuitemradio: "menuitemradio", - treeitem: "treeitem", switch: "switch", option: null, }; @@ -151,12 +150,25 @@ export function planAccessibleNode( control: "activate", }); } + if (node.role === "treeitem") { + // Not a button: an expanded item holds nested items that stay their own controls. + const attributes = named({ role: "treeitem", tabindex: "0" }); + if (node.selected !== undefined) attributes["aria-selected"] = String(node.selected); + if (node.checked !== undefined) attributes["aria-checked"] = node.checked; + return element( + "div", + attributes, + children.length > 0 + ? { control: "activate" } + : { text: node.name || "Tree item", control: "activate" }, + ); + } if (node.role in BUTTON_ROLES) { const role = BUTTON_ROLES[node.role]; const attributes: Record = { type: "button", ...(role ? { role } : {}) }; if (node.pressed !== undefined) attributes["aria-pressed"] = node.pressed; if (node.checked !== undefined && role) attributes["aria-checked"] = node.checked; - if (node.selected !== undefined && (role === "tab" || role === "treeitem")) { + if (node.selected !== undefined && role === "tab") { attributes["aria-selected"] = String(node.selected); } else if (node.selected) { attributes["aria-current"] = "true"; @@ -235,6 +247,27 @@ export function planAccessibleNode( return element("div", {}, fallback ? { text: fallback } : {}); } +/** + * Moving an element blurs it, so obsolete children go first and the child + * holding focus stays put while its siblings move around it. + */ +export function syncChildren(parent: Element, next: ReadonlyArray, focused: Node | null) { + const keep = new Set(next); + for (const child of Array.from(parent.childNodes)) { + if (!keep.has(child)) parent.removeChild(child); + } + next.forEach((child, index) => { + if (focused && child.parentNode === parent && child.contains(focused)) { + while (parent.childNodes[index] !== child) { + parent.insertBefore(parent.childNodes[index]!, child.nextSibling); + } + } + if (parent.childNodes[index] !== child) { + parent.insertBefore(child, parent.childNodes[index] ?? null); + } + }); +} + export interface AccessibleDocumentView { /** `null` shows a loading message. Updates keep elements, focus and edits in place. */ readonly update: (document: PreviewAccessibilityDocument | null, canAct: boolean) => void; @@ -356,17 +389,19 @@ export function createAccessibleDocumentView( item.selected = option.selected; return item; }), + null, ); } if (plan.text !== undefined) { if (element.textContent !== plan.text) element.textContent = plan.text; - } else if (plan.control === null) { + } else if (plan.control === null || plan.control === "activate") { syncChildren( element, plan.children.flatMap((child) => { const childNode = render(child, seen); return childNode ? [childNode] : []; }), + doc.activeElement, ); } return entry.node; @@ -383,15 +418,6 @@ export function createAccessibleDocumentView( return { node: label, signature, control: element }; }; - const syncChildren = (parent: Element, next: ReadonlyArray) => { - // Moving a focused element would blur it, so only misplaced nodes move. - next.forEach((child, index) => { - if (parent.childNodes[index] !== child) - parent.insertBefore(child, parent.childNodes[index] ?? null); - }); - while (parent.childNodes.length > next.length) parent.lastChild!.remove(); - }; - const nodeOf = (target: EventTarget | null) => { const element = target instanceof Element ? target.closest("[data-node]") : null; const id = element?.dataset.node; @@ -465,7 +491,8 @@ export function createAccessibleDocumentView( target.value = target.dataset.serverValue ?? ""; } else if (keyboard.key === "Enter" || keyboard.key === " ") { const hit = nodeOf(target); - if (hit?.element.getAttribute("role") === "link" && keyboard.key === "Enter") { + const role = hit?.element === target ? hit.element.getAttribute("role") : null; + if (hit && (role === "treeitem" || (role === "link" && keyboard.key === "Enter"))) { keyboard.preventDefault(); act({ node: hit.node.id, action: "activate" }); } @@ -497,7 +524,7 @@ export function createAccessibleDocumentView( const seen = new Set(); nodes.clear(); const root = render(document.root, seen); - syncChildren(body, root ? [root] : []); + syncChildren(body, root ? [root] : [], doc.activeElement); for (const id of rendered.keys()) if (!seen.has(id)) rendered.delete(id); }, notice: say, From f267683b2a8505ba25546178a92c15de141ba43e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 04:23:41 +0000 Subject: [PATCH 04/12] fix(preview): preserve drafts and stop edits after navigation --- .../preview-accessible-document.test.ts | 42 ++++++++ apps/mobile/vite.config.ts | 1 + .../ServerBrowserAccessibility.test.ts | 96 +++++++++++++++++++ .../src/preview/ServerBrowserAccessibility.ts | 66 ++++++++++--- .../src/preview/accessibleDocument.ts | 7 +- 5 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/browser/preview-accessible-document.test.ts diff --git a/apps/mobile/src/features/browser/preview-accessible-document.test.ts b/apps/mobile/src/features/browser/preview-accessible-document.test.ts new file mode 100644 index 0000000000000..26d3a865f6cec --- /dev/null +++ b/apps/mobile/src/features/browser/preview-accessible-document.test.ts @@ -0,0 +1,42 @@ +import { createAccessibleDocumentView } from "@t3tools/client-runtime/preview/accessible-document"; +import type { PreviewAccessibilityDocument } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +it("keeps an unsent field edit through unrelated page updates and cancels it on Escape", () => { + const container = document.createElement("div"); + document.body.append(container); + const onAction = vi.fn(() => true); + const view = createAccessibleDocumentView(container, { onAction }); + const page: PreviewAccessibilityDocument = { + document: "page", + version: 1, + truncated: false, + root: { + id: "root", + role: "document", + children: [{ id: "field", role: "textbox", name: "Name", value: "Server" }], + }, + }; + try { + view.update(page, true); + const input = container.querySelector("input")!; + input.focus(); + input.value = "Unsent draft"; + input.setSelectionRange(3, 6); + input.dispatchEvent(new Event("input", { bubbles: true })); + + view.update({ ...page, version: 2, root: { ...page.root, name: "Updated title" } }, true); + + expect(document.activeElement).toBe(input); + expect(input.value).toBe("Unsent draft"); + expect([input.selectionStart, input.selectionEnd]).toEqual([3, 6]); + expect(onAction).not.toHaveBeenCalled(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(input.value).toBe("Server"); + expect(onAction).not.toHaveBeenCalled(); + } finally { + view.destroy(); + container.remove(); + } +}); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 77fffb6cb6117..3bdfa22897bd7 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/browser/preview-accessible-document.test.ts", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", "src/features/threads/thread-inspector-navigation.test.tsx", diff --git a/apps/server/src/preview/ServerBrowserAccessibility.test.ts b/apps/server/src/preview/ServerBrowserAccessibility.test.ts index 1073c31e71d21..78d1207925c18 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.test.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.test.ts @@ -259,6 +259,7 @@ const fakeSession = ( if (method === "Accessibility.getFullAXTree") return { nodes: nodes() }; if (method === "DOM.getFrameOwner") return { backendNodeId: 7 }; if (method === "DOM.resolveNode") return { object: { objectId: "object" } }; + if (method === "DOM.describeNode") return { node: { nodeName: "INPUT", attributes: [] } }; return {}; }), }; @@ -288,6 +289,101 @@ const fakePage = ( const captures = (session: ReturnType) => session.send.mock.calls.filter(([method]) => method === "Accessibility.getFullAXTree").length; +it.each([ + "DOM.scrollIntoViewIfNeeded", + "DOM.focus", + "DOM.resolveNode", + "Runtime.callFunctionOn", + "Input.insertText", +])("refuses the remaining edit after navigation during %s", async (interruptedMethod) => { + const main = fakeSession( + () => [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", "textbox", { label: "Name", backendDOMNodeId: 9 }), + ], + { id: "main" }, + ); + const page = fakePage(main, new Map()); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + try { + const offer = vi.fn(() => true); + await mirror.subscribe("viewer", offer); + expect(offer).toHaveBeenCalledOnce(); + const send = main.send.getMockImplementation()!; + main.send.mockImplementation(async (method, input) => { + const result = await send(method, input); + if (method === interruptedMethod) page.events.emit("framenavigated", page); + return result; + }); + main.send.mockClear(); + + expect( + await mirror.act({ + type: "accessibilityAction", + document: "1:main-loader", + node: "0.2", + action: "setValue", + value: "Old page draft", + submit: true, + }), + ).toBe("The page changed. Try again."); + + const methods = main.send.mock.calls.map(([method]) => method); + expect(methods).toContain(interruptedMethod); + const later = methods.slice(methods.indexOf(interruptedMethod) + 1); + expect(later).not.toContain("Input.insertText"); + expect(later).not.toContain("Input.dispatchKeyEvent"); + expect(later).not.toContain("Runtime.callFunctionOn"); + expect(later).not.toContain("DOM.focus"); + } finally { + await mirror.close(); + } +}); + +it.each(["activate", "setValue"] as const)("allows %s to navigate the page", async (action) => { + const main = fakeSession( + () => [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", action === "activate" ? "link" : "textbox", { backendDOMNodeId: 9 }), + ], + { id: "main" }, + ); + const page = fakePage(main, new Map()); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + try { + await mirror.subscribe("viewer", () => true); + const send = main.send.getMockImplementation()!; + const navigatingMethod = + action === "activate" ? "Runtime.callFunctionOn" : "Input.dispatchKeyEvent"; + main.send.mockImplementation(async (method, input) => { + const result = await send(method, input); + if (method === navigatingMethod) page.events.emit("framenavigated", page); + return result; + }); + main.send.mockClear(); + + expect( + await mirror.act({ + type: "accessibilityAction", + document: "1:main-loader", + node: "0.2", + action, + value: "Draft", + submit: true, + }), + ).toBeNull(); + expect(main.send).toHaveBeenCalledWith(navigatingMethod, expect.anything()); + if (action === "setValue") { + expect(main.send).toHaveBeenCalledWith("Input.insertText", { text: "Draft" }); + expect(main.send.mock.calls.filter(([method]) => method === navigatingMethod)).toHaveLength( + 1, + ); + } + } finally { + await mirror.close(); + } +}); + it("follows semantic changes inside out-of-process frames and releases their sessions", async () => { vi.useFakeTimers(); try { diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index 225ff5558bcfc..4c97910911e08 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -424,20 +424,43 @@ export class ServerBrowserAccessibility { const session = target ? this.sessions.get(target.key) : undefined; if (!target || !session) return AccessibilityNotice.stale; if (target.disabled) return AccessibilityNotice.disabled; + const frameChanges = this.frameChanges; + const run = this.run; + const interrupted = new Error(AccessibilityNotice.stale); + const isCurrent = () => + this.run === run && + this.frameChanges === frameChanges && + this.main === main && + this.sessions.get(target.key) === session; + const assertCurrent = () => { + if (!isCurrent()) { + throw interrupted; + } + }; const backendNodeId = target.backendNodeId; - if (action.action === "activate") { - if (!ACTIVATABLE_ROLES.has(target.role)) return AccessibilityNotice.notActivatable; - await session.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(constVoid); - await callOn(session, backendNodeId, ACTIVATE_FUNCTION); - } else { - if (!target.editable || target.readonly) return AccessibilityNotice.notEditable; - await session.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(constVoid); - await session.send("DOM.focus", { backendNodeId }); - await callOn(session, backendNodeId, SELECT_CONTENTS_FUNCTION); - // Real input events keep framework-controlled fields in sync. - if (action.value) await main.send("Input.insertText", { text: action.value }); - else await pressKey(main, "Backspace", 8); - if (action.submit) await pressKey(main, "Enter", 13, "\r"); + try { + if (action.action === "activate") { + if (!ACTIVATABLE_ROLES.has(target.role)) return AccessibilityNotice.notActivatable; + await session.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(constVoid); + assertCurrent(); + await callOn(session, backendNodeId, ACTIVATE_FUNCTION, assertCurrent); + } else { + if (!target.editable || target.readonly) return AccessibilityNotice.notEditable; + await session.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(constVoid); + assertCurrent(); + await session.send("DOM.focus", { backendNodeId }); + assertCurrent(); + await callOn(session, backendNodeId, SELECT_CONTENTS_FUNCTION, assertCurrent); + assertCurrent(); + // Real input events keep framework-controlled fields in sync. + if (action.value) await main.send("Input.insertText", { text: action.value }); + else await pressKey(main, "Backspace", 8, undefined, isCurrent); + assertCurrent(); + if (action.submit) await pressKey(main, "Enter", 13, "\r", isCurrent); + } + } catch (cause) { + if (cause === interrupted) return AccessibilityNotice.stale; + throw cause; } // Outside the caller's control queue, so a large page never delays the next input. void this.refresh(); @@ -674,10 +697,16 @@ async function secretFields(session: CDPSession, nodes: ReadonlyArray void, +) { const { object } = await session.send("DOM.resolveNode", { backendNodeId }); if (!object.objectId) throw new Error("The element is gone."); try { + assertCurrent(); await session.send("Runtime.callFunctionOn", { objectId: object.objectId, functionDeclaration, @@ -689,7 +718,13 @@ async function callOn(session: CDPSession, backendNodeId: number, functionDeclar } } -async function pressKey(session: CDPSession, key: string, keyCode: number, text?: string) { +async function pressKey( + session: CDPSession, + key: string, + keyCode: number, + text: string | undefined, + isCurrent: () => boolean, +) { await session.send("Input.dispatchKeyEvent", { type: text ? "keyDown" : "rawKeyDown", key, @@ -697,6 +732,7 @@ async function pressKey(session: CDPSession, key: string, keyCode: number, text? windowsVirtualKeyCode: keyCode, ...(text ? { text, unmodifiedText: text } : {}), }); + if (!isCurrent()) return; await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts index b393d9d908d09..7eeba8f283b71 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -352,7 +352,12 @@ export function createAccessibleDocumentView( const element = entry.control as HTMLElement; // Copied: removing from the live attribute map while iterating it skips entries. for (const attribute of Array.from(element.attributes)) { - if (attribute.name !== "data-node" && !(attribute.name in plan.attributes)) { + if ( + attribute.name !== "data-node" && + attribute.name !== "data-dirty" && + attribute.name !== "data-server-value" && + !(attribute.name in plan.attributes) + ) { element.removeAttribute(attribute.name); } } From d58a98ae1f7fe0e36a7555010dfb476eb8d7f365 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:58:22 +0000 Subject: [PATCH 05/12] fix(preview): preserve accessible fields and report incomplete pages --- .../browser/preview-stream-document.ts | 2 +- .../ServerBrowserAccessibility.test.ts | 48 +++++++++++++++++++ .../src/preview/ServerBrowserAccessibility.ts | 16 +++++-- apps/web/src/browser/ServerBrowserSurface.tsx | 9 +--- .../src/preview/accessibleDocument.ts | 2 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream-document.ts b/apps/mobile/src/features/browser/preview-stream-document.ts index 677db626e024b..0e5da2e51c60e 100644 --- a/apps/mobile/src/features/browser/preview-stream-document.ts +++ b/apps/mobile/src/features/browser/preview-stream-document.ts @@ -68,7 +68,7 @@ export function previewStreamDocument(configuration: string, script: string) { .agent-cursor .ping { animation: none; opacity: 0; } } /* Pinned so focus never scrolls; 16px keeps iOS from zooming on focus. */ - textarea { + body > textarea { position: fixed; left: 0; top: 0; width: 1px; height: 1px; padding: 0; margin: -1px; border: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; font-size: 16px; diff --git a/apps/server/src/preview/ServerBrowserAccessibility.test.ts b/apps/server/src/preview/ServerBrowserAccessibility.test.ts index 78d1207925c18..51c165b816f8e 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.test.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.test.ts @@ -33,6 +33,41 @@ const frame = ( ): AccessibilityFrameTree => ({ key, nodes, secretNodes: new Set(), ...extra }); describe("buildAccessibilityTree", () => { + it("omits secret field descendants as well as their values", () => { + const { root } = buildAccessibilityTree([ + frame( + "0", + [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", "textbox", { + label: "Code", + backendDOMNodeId: 2, + childIds: ["3"], + value: { value: "654321" }, + }), + node("3", "generic", { childIds: ["4"] }), + node("4", "StaticText", { label: "654321" }), + ], + { secretNodes: new Set([2]) }, + ), + ]); + expect(root!.children).toEqual([ + { id: "0.2", role: "textbox", name: "Code", editable: true, protected: true }, + ]); + }); + + it("preserves spaces around inline text and links", () => { + const { root } = buildAccessibilityTree([ + frame("0", [ + node("1", "RootWebArea", { childIds: ["2", "3", "4"] }), + node("2", "StaticText", { label: "Read " }), + node("3", "link", { label: "the docs" }), + node("4", "StaticText", { label: " for help." }), + ]), + ]); + expect(root!.children!.map((child) => child.name).join("")).toBe("Read the docs for help."); + }); + it("keeps descendants of ignored and generic wrappers in reading order", () => { const { root } = buildAccessibilityTree([ frame("0", [ @@ -289,6 +324,19 @@ const fakePage = ( const captures = (session: ReturnType) => session.send.mock.calls.filter(([method]) => method === "Accessibility.getFullAXTree").length; +it("marks the document incomplete when an embedded frame cannot be read", async () => { + const main = fakeSession(() => [node("1", "RootWebArea", { label: "Page" })], { id: "main" }); + const page = fakePage(main, new Map([[{}, null]])); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + const offer = vi.fn(() => true); + try { + await mirror.subscribe("viewer", offer); + expect(offer).toHaveBeenCalledWith(expect.objectContaining({ truncated: true })); + } finally { + await mirror.close(); + } +}); + it.each([ "DOM.scrollIntoViewIfNeeded", "DOM.focus", diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index 4c97910911e08..1c896d56d9c47 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -197,7 +197,10 @@ export function buildAccessibilityTree( return [...nested, ...owned]; }; if (raw.ignored || HOISTED_ROLES.has(role)) return children(); - const name = text(raw.name?.value); + const name = + role === "text" && typeof raw.name?.value === "string" + ? raw.name.value.slice(0, TEXT_MAX_CHARS) + : text(raw.name?.value); if (role === "text" && name === undefined) return []; if (count >= maxNodes) { truncated = true; @@ -239,7 +242,7 @@ export function buildAccessibilityTree( const invalid = properties.get("invalid"); if (invalid !== undefined && invalid !== "false" && invalid !== false) node.invalid = true; if (editable) node.editable = true; - if (role !== "text") { + if (role !== "text" && !hidden) { const kids = children(); if (kids.length > 0) node.children = kids; } @@ -680,7 +683,14 @@ export class ServerBrowserAccessibility { const [main_, ...rest] = trees; if (!main_) throw new Error("The page's accessibility tree is unavailable."); const built = buildAccessibilityTree([main_, ...rest.filter((tree) => tree !== null)]); - return { token, ...built }; + return { + token, + ...built, + truncated: + built.truncated || + trees.some((tree) => tree === null) || + documents.size < this.page.frames().length, + }; } } diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 0a46b79c4e4ac..d25dd30145d8f 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -20,7 +20,6 @@ import type { PreviewStreamHostSetup, PreviewViewportSetting, } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; import { type FormEvent, type KeyboardEvent, @@ -38,7 +37,6 @@ import { CommandBlock } from "~/components/CommandBlock"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { toastManager } from "~/components/ui/toast"; -import { useLocalStorage } from "~/hooks/useLocalStorage"; import { cn } from "~/lib/utils"; import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; import { observeResize } from "~/lib/observeResize"; @@ -65,7 +63,6 @@ const TAP_SLOP_PX = 8; const MULTI_CLICK_MS = 500; const MULTI_CLICK_SLOP_PX = 4; const WHEEL_LINE_PX = 16; -const ACCESSIBLE_VIEW_STORAGE_KEY = "t3code.previewAccessibleView"; type MouseInput = Extract; type WheelInput = Extract; @@ -177,11 +174,7 @@ export function ServerBrowserSurface(props: { const [promptText, setPromptText] = useState(""); const [fileChooser, setFileChooser] = useState(null); const [agentCursor, setAgentCursor] = useState(null); - const [accessibleView, setAccessibleView] = useLocalStorage( - ACCESSIBLE_VIEW_STORAGE_KEY, - false, - Schema.Boolean, - ); + const [accessibleView, setAccessibleView] = useState(false); const [accessibleDocument, setAccessibleDocument] = useState( null, ); diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts index 7eeba8f283b71..a2b97381663cd 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -519,7 +519,7 @@ export function createAccessibleDocumentView( summary = !document ? "Loading the page's accessible view..." : document.truncated - ? "This page is long, so only its first part is shown." + ? "Some page content could not be shown. The page may be too long or contain an unavailable frame." : ""; say(""); if (!document) { From 6e0ed16c7f34bc72ef6cffb994fdd8cea8671e5d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 03:03:29 +0000 Subject: [PATCH 06/12] fix(preview): keep accessible keyboard navigation in the viewer --- apps/mobile/src/features/browser/preview-stream-document.ts | 1 + apps/mobile/src/features/browser/preview-stream.browser.ts | 3 ++- apps/web/src/browser/ServerBrowserSurface.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream-document.ts b/apps/mobile/src/features/browser/preview-stream-document.ts index 0e5da2e51c60e..8d73400232842 100644 --- a/apps/mobile/src/features/browser/preview-stream-document.ts +++ b/apps/mobile/src/features/browser/preview-stream-document.ts @@ -48,6 +48,7 @@ export function previewStreamDocument(configuration: string, script: string) { canvas { touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; } video { pointer-events: none; } .accessible-view { + box-sizing: border-box; position: fixed; inset: 0; z-index: 2; overflow: auto; -webkit-overflow-scrolling: touch; background: inherit; font: -apple-system-body; font-family: system-ui, sans-serif; } diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index c67be1a069599..5f3c5c8da7aa5 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -234,7 +234,7 @@ export function start(configuration: PreviewStreamConfiguration) { if (nextControl.controller === "you") agentCursor.style.opacity = "0"; if (nextControl.controller !== "you") clearInput(); else { - input.disabled = !interactive; + input.disabled = !interactive || accessible; if (interactive && size && previous?.controller !== "you") next.send({ type: "resize", ...size }); } @@ -707,6 +707,7 @@ export function start(configuration: PreviewStreamConfiguration) { // Passive previews never subscribe, even if asked. if (!interactive || enabled === accessible) return; accessible = enabled; + input.disabled = enabled || control?.controller !== "you"; client?.accessibility(enabled); accessibleContainer.hidden = !enabled; container.setAttribute("aria-hidden", String(enabled)); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index d25dd30145d8f..7a63d788d7c4f 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -791,7 +791,7 @@ export function ServerBrowserSurface(props: { autoComplete="off" autoCorrect="off" spellCheck={false} - disabled={pdfUrl !== null || control?.controller !== "you"} + disabled={accessibleView || pdfUrl !== null || control?.controller !== "you"} defaultValue={INPUT_SENTINEL} onFocus={(event) => resetInput(event.currentTarget)} className="sr-only top-0 left-0 text-base" From d54e0422d089f8de446872dcd464ba5d022a94f6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 23:13:43 +0000 Subject: [PATCH 07/12] fix(preview): use current effect lint rule --- apps/web/src/browser/PreviewAccessibleView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/browser/PreviewAccessibleView.tsx b/apps/web/src/browser/PreviewAccessibleView.tsx index c891abca57b80..627fef0a23b28 100644 --- a/apps/web/src/browser/PreviewAccessibleView.tsx +++ b/apps/web/src/browser/PreviewAccessibleView.tsx @@ -22,7 +22,7 @@ export function PreviewAccessibleView(props: { const viewRef = useRef(null); const act = useEffectEvent((action: PreviewAccessibilityAction) => props.onAction(action)); - // oxlint-disable-next-line t3code/react-effect-budget -- Owns the imperative DOM the shared renderer keys by node id. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Owns the imperative DOM the shared renderer keys by node id. useEffect(() => { const container = containerRef.current; if (!container) return; @@ -34,12 +34,12 @@ export function PreviewAccessibleView(props: { }; }, []); - // oxlint-disable-next-line t3code/react-effect-budget -- Updates in place so focus and edits survive new snapshots. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Updates in place so focus and edits survive new snapshots. useEffect(() => { viewRef.current?.update(document, canAct); }, [document, canAct]); - // oxlint-disable-next-line t3code/react-effect-budget -- Server notices go to the view's own live region. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Server notices go to the view's own live region. useEffect(() => { if (notice) viewRef.current?.notice(notice.message); }, [notice]); From 63c8128b63b2482dcc5c749c6aaeb08a3c7443c6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 21:51:34 +0000 Subject: [PATCH 08/12] test(preview): include accessibility in browser stream mock --- apps/web/src/browser/ServerBrowserSurface.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx index 39f151674b648..5ba9f47c612da 100644 --- a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -25,7 +25,7 @@ vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async ( createPreviewStreamClient: (_options, events) => { mocks.events = events; mocks.connect(); - return { send: mocks.send, stop: () => undefined } as never; + return { send: mocks.send, accessibility: () => undefined, stop: () => undefined } as never; }, })); From 22011ad2e7a4f4591a5af64ac552afb270e8e717 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:32:36 +0000 Subject: [PATCH 09/12] chore(preview): address accessibility review conventions --- .../src/preview/accessibleDocument.test.ts | 23 ++++--------------- .../contracts/src/previewAccessibility.ts | 2 +- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/client-runtime/src/preview/accessibleDocument.test.ts b/packages/client-runtime/src/preview/accessibleDocument.test.ts index c8f7094b08c32..ecdb46635f01c 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.test.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.test.ts @@ -6,27 +6,12 @@ import { planAccessibleNode, syncChildren } from "./accessibleDocument.ts"; const plan = (node: PreviewAccessibilityNode, canAct = true) => planAccessibleNode(node, canAct); describe("planAccessibleNode", () => { - it("renders headings, links and landmarks as their native elements", () => { - expect(plan({ id: "1", role: "heading", name: "Cart", level: 9 })).toMatchObject({ - tag: "h6", - text: "Cart", - }); - expect( - plan({ id: "2", role: "link", name: "Docs", url: "https://t3.codes/docs" }), - ).toMatchObject({ - tag: "a", - attributes: { href: "https://t3.codes/docs" }, - control: "activate", - }); - // Script URLs never become live links in the viewer's own document. - expect(plan({ id: "3", role: "link", name: "Run", url: "javascript:alert(1)" })).toMatchObject({ - tag: "a", + it("does not turn script URLs into live links in the viewer", () => { + const link = plan({ id: "3", role: "link", name: "Run", url: "javascript:alert(1)" }); + expect(link).toMatchObject({ attributes: { role: "link", tabindex: "0" }, }); - expect(plan({ id: "4", role: "navigation", name: "Primary" })).toMatchObject({ - tag: "nav", - attributes: { "aria-label": "Primary" }, - }); + expect(link).not.toHaveProperty("attributes.href"); }); it("keeps controls reachable but inert for viewers without control", () => { diff --git a/packages/contracts/src/previewAccessibility.ts b/packages/contracts/src/previewAccessibility.ts index 2ec7d4ff100ed..de9e5645c9746 100644 --- a/packages/contracts/src/previewAccessibility.ts +++ b/packages/contracts/src/previewAccessibility.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { NonNegativeInt } from "./baseSchemas.ts"; From fd507f1bc6fa267814cd21297e4fd5dd4ff353eb Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:49:07 +0000 Subject: [PATCH 10/12] fix(preview): accessible view keeps multi-select listboxes and flags deep trees The accessible view rendered every listbox as a single select, so a page's multiple selection collapsed to one option. The capture now carries CDP's multiselectable property, the view renders those listboxes as a multiple select, and each locally toggled option sends its own activate action, which the page toggles without clearing the rest. Trees cut at the depth limit now mark the document truncated. Heading and safe-link test assertions are back. Co-Authored-By: Claude Opus 5.5 --- .../preview-accessible-document.test.ts | 40 +++++++++++++++++- .../ServerBrowserAccessibility.test.ts | 34 ++++++++++++++- .../src/preview/ServerBrowserAccessibility.ts | 15 ++++++- .../src/preview/accessibleDocument.test.ts | 32 +++++++++++++- .../src/preview/accessibleDocument.ts | 42 +++++++++++-------- .../contracts/src/previewAccessibility.ts | 3 ++ 6 files changed, 143 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-accessible-document.test.ts b/apps/mobile/src/features/browser/preview-accessible-document.test.ts index 26d3a865f6cec..3dc043c1f78ab 100644 --- a/apps/mobile/src/features/browser/preview-accessible-document.test.ts +++ b/apps/mobile/src/features/browser/preview-accessible-document.test.ts @@ -1,5 +1,5 @@ import { createAccessibleDocumentView } from "@t3tools/client-runtime/preview/accessible-document"; -import type { PreviewAccessibilityDocument } from "@t3tools/contracts"; +import type { PreviewAccessibilityAction, PreviewAccessibilityDocument } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; it("keeps an unsent field edit through unrelated page updates and cancels it on Escape", () => { @@ -40,3 +40,41 @@ it("keeps an unsent field edit through unrelated page updates and cancels it on container.remove(); } }); + +it("toggles one option of a multiple select without clearing the others", () => { + const container = document.createElement("div"); + document.body.append(container); + const onAction = vi.fn((_action: PreviewAccessibilityAction) => true); + const view = createAccessibleDocumentView(container, { onAction }); + const option = (id: string, selected: boolean) => ({ id, role: "option", name: id, selected }); + const page = (selected: ReadonlyArray): PreviewAccessibilityDocument => ({ + document: "page", + version: 1, + truncated: false, + root: { + id: "root", + role: "listbox", + name: "Windows", + multiselectable: true, + children: ["morning", "afternoon", "evening"].map((id, index) => + option(id, selected[index]!), + ), + }, + }); + try { + view.update(page([true, true, false]), true); + const select = container.querySelector("select")!; + const selected = () => [...select.options].map((each) => each.selected); + expect(selected()).toEqual([true, true, false]); + + select.options[2]!.selected = true; + select.dispatchEvent(new Event("change", { bubbles: true })); + select.options[0]!.selected = false; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(onAction.mock.calls.map(([action]) => action.node)).toEqual(["evening", "morning"]); + expect(selected()).toEqual([false, true, true]); + } finally { + view.destroy(); + container.remove(); + } +}); diff --git a/apps/server/src/preview/ServerBrowserAccessibility.test.ts b/apps/server/src/preview/ServerBrowserAccessibility.test.ts index 51c165b816f8e..f358fbe23092d 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.test.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.test.ts @@ -139,7 +139,7 @@ describe("buildAccessibilityTree", () => { frame( "0", [ - node("1", "RootWebArea", { childIds: ["2", "3", "4", "5"] }), + node("1", "RootWebArea", { childIds: ["2", "3", "4", "5", "6"] }), node("2", "textbox", { label: "Password", backendDOMNodeId: 2, @@ -164,6 +164,15 @@ describe("buildAccessibilityTree", () => { { name: "disabled", value: { value: true } }, ], }), + node("6", "listbox", { + label: "Windows", + childIds: ["7"], + properties: [{ name: "multiselectable", value: { value: true } }], + }), + node("7", "option", { + label: "Morning", + properties: [{ name: "selected", value: { value: true } }], + }), ], { secretNodes: new Set([2]) }, ), @@ -182,12 +191,19 @@ describe("buildAccessibilityTree", () => { editable: true, }, { id: "0.5", role: "checkbox", name: "Remember", checked: "mixed", disabled: true }, + { + id: "0.6", + role: "listbox", + name: "Windows", + multiselectable: true, + children: [{ id: "0.7", role: "option", name: "Morning", selected: true }], + }, ]); expect(targets.get("0.4")).toMatchObject({ readonly: true, editable: true }); expect(targets.get("0.5")).toMatchObject({ disabled: true }); }); - it("stops at the node budget, marks the document truncated, and stays a valid contract", () => { + it("stops at the node and depth budgets, marks the document truncated, and stays a valid contract", () => { const children = Array.from({ length: 10 }, (_, index) => `${index + 2}`); const built = buildAccessibilityTree( [ @@ -208,6 +224,20 @@ describe("buildAccessibilityTree", () => { root: built.root, }).root.children, ).toHaveLength(3); + + // Hoisted wrappers still count toward depth, so a deep stack of them cuts the tree. + const wrappers = Array.from({ length: 300 }, (_, index) => `${index + 2}`); + const deep = buildAccessibilityTree([ + frame("0", [ + node("1", "RootWebArea", { childIds: ["2"] }), + ...wrappers.map((id, index) => + node(id, "generic", { childIds: [wrappers[index + 1] ?? "button"] }), + ), + node("button", "button", { label: "Deep" }), + ]), + ]); + expect(deep.truncated).toBe(true); + expect(deep.root).toEqual({ id: "0.1", role: "document" }); }); }); diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index 1c896d56d9c47..0c83a6c34a260 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -179,7 +179,11 @@ export function buildAccessibilityTree( ): Array => { const id = `${frame.key}.${raw.nodeId}`; // In-process frames can appear in their parent's tree too. - if (visited.has(id) || depth > MAX_DEPTH) return []; + if (visited.has(id)) return []; + if (depth > MAX_DEPTH) { + truncated = true; + return []; + } visited.add(id); const role = roleOf(raw); if (SKIPPED_ROLES.has(role)) return []; @@ -236,7 +240,14 @@ export function buildAccessibilityTree( if (pressed !== undefined) node.pressed = pressed; if (properties.has("expanded")) node.expanded = truthy(properties.get("expanded")); if (properties.has("selected")) node.selected = truthy(properties.get("selected")); - for (const flag of ["disabled", "readonly", "required", "focused", "multiline"] as const) { + for (const flag of [ + "disabled", + "readonly", + "required", + "focused", + "multiline", + "multiselectable", + ] as const) { if (truthy(properties.get(flag))) node[flag] = true; } const invalid = properties.get("invalid"); diff --git a/packages/client-runtime/src/preview/accessibleDocument.test.ts b/packages/client-runtime/src/preview/accessibleDocument.test.ts index ecdb46635f01c..60407999949b9 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.test.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.test.ts @@ -6,9 +6,22 @@ import { planAccessibleNode, syncChildren } from "./accessibleDocument.ts"; const plan = (node: PreviewAccessibilityNode, canAct = true) => planAccessibleNode(node, canAct); describe("planAccessibleNode", () => { - it("does not turn script URLs into live links in the viewer", () => { + it("clamps heading levels and only gives safe URLs a live link", () => { + expect(plan({ id: "1", role: "heading", name: "Cart", level: 9 })).toMatchObject({ + tag: "h6", + text: "Cart", + }); + expect( + plan({ id: "2", role: "link", name: "Docs", url: "https://t3.codes/docs" }), + ).toMatchObject({ + tag: "a", + attributes: { href: "https://t3.codes/docs" }, + control: "activate", + }); + // Script URLs never become live links in the viewer's own document. const link = plan({ id: "3", role: "link", name: "Run", url: "javascript:alert(1)" }); expect(link).toMatchObject({ + tag: "a", attributes: { role: "link", tabindex: "0" }, }); expect(link).not.toHaveProperty("attributes.href"); @@ -62,6 +75,23 @@ describe("planAccessibleNode", () => { { id: "4", label: "Large", selected: true }, ], }); + const listbox = (multiselectable?: boolean) => + plan({ + id: "6", + role: "listbox", + name: "Windows", + ...(multiselectable === undefined ? {} : { multiselectable }), + children: [ + { id: "7", role: "option", name: "Morning", selected: true }, + { id: "8", role: "option", name: "Evening", selected: true }, + ], + }); + expect(listbox(true)).toMatchObject({ + tag: "select", + attributes: { size: "2", multiple: "" }, + options: [{ selected: true }, { selected: true }], + }); + expect(listbox()).not.toHaveProperty("attributes.multiple"); expect(plan({ id: "5", role: "checkbox", name: "All", checked: "mixed" })).toMatchObject({ tag: "input", attributes: { type: "checkbox" }, diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts index a2b97381663cd..61e710c1a8871 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -195,21 +195,20 @@ export function planAccessibleNode( const options = node.role === "combobox" || node.role === "listbox" ? descendants(node, "option") : []; if (options.length > 0) { - return element( - "select", - node.role === "listbox" ? { size: String(Math.min(Math.max(options.length, 2), 8)) } : {}, - { - label: node.name ?? "", - children: [], - control: "select", - options: options.map((option) => ({ - id: option.id, - label: textOf(option) || option.value || "Option", - selected: option.selected === true, - disabled: option.disabled === true, - })), - }, - ); + const attributes: Record = + node.role === "listbox" ? { size: String(Math.min(Math.max(options.length, 2), 8)) } : {}; + if (node.role === "listbox" && node.multiselectable) attributes.multiple = ""; + return element("select", attributes, { + label: node.name ?? "", + children: [], + control: "select", + options: options.map((option) => ({ + id: option.id, + label: textOf(option) || option.value || "Option", + selected: option.selected === true, + disabled: option.disabled === true, + })), + }); } if (FIELD_ROLES.has(node.role) || (node.editable && children.length === 0)) { const readOnly = inert || node.readonly === true; @@ -392,6 +391,7 @@ export function createAccessibleDocumentView( if (item.textContent !== option.label) item.textContent = option.label; item.disabled = option.disabled; item.selected = option.selected; + item.dataset.selected = String(option.selected); return item; }), null, @@ -458,8 +458,16 @@ export function createAccessibleDocumentView( const onChange = (event: Event) => { const target = event.target; if (target instanceof HTMLSelectElement) { - const chosen = target.selectedOptions[0]?.dataset.node; - if (chosen) act({ node: chosen, action: "activate" }); + // Each activation toggles one remote option; a single select deselects the rest itself. + const toggled = [...target.options].filter( + (option) => + option.selected !== (option.dataset.selected === "true") && + (target.multiple || option.selected), + ); + for (const option of target.options) option.dataset.selected = String(option.selected); + for (const option of toggled) { + if (option.dataset.node) act({ node: option.dataset.node, action: "activate" }); + } return; } if ( diff --git a/packages/contracts/src/previewAccessibility.ts b/packages/contracts/src/previewAccessibility.ts index de9e5645c9746..bf69507a2aaf4 100644 --- a/packages/contracts/src/previewAccessibility.ts +++ b/packages/contracts/src/previewAccessibility.ts @@ -25,6 +25,8 @@ export interface PreviewAccessibilityNode { readonly pressed?: "true" | "false" | "mixed"; readonly expanded?: boolean; readonly selected?: boolean; + /** A listbox whose options toggle independently. */ + readonly multiselectable?: boolean; readonly disabled?: boolean; readonly readonly?: boolean; readonly required?: boolean; @@ -48,6 +50,7 @@ export const PreviewAccessibilityNode: Schema.Codec = pressed: Schema.optionalKey(Tristate), expanded: Schema.optionalKey(Schema.Boolean), selected: Schema.optionalKey(Schema.Boolean), + multiselectable: Schema.optionalKey(Schema.Boolean), disabled: Schema.optionalKey(Schema.Boolean), readonly: Schema.optionalKey(Schema.Boolean), required: Schema.optionalKey(Schema.Boolean), From 3ff8eb7b96ea378308cb8ea4ed83f608ac9a6a60 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:09:20 +0000 Subject: [PATCH 11/12] fix(preview): preserve unedited and oversized field values --- .../preview-accessible-document.test.ts | 31 +++++- .../ServerBrowserAccessibility.test.ts | 101 ++++++++++-------- .../src/preview/ServerBrowserAccessibility.ts | 20 +++- .../src/preview/accessibleDocument.ts | 5 +- 4 files changed, 106 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-accessible-document.test.ts b/apps/mobile/src/features/browser/preview-accessible-document.test.ts index 3dc043c1f78ab..eae60d9858b85 100644 --- a/apps/mobile/src/features/browser/preview-accessible-document.test.ts +++ b/apps/mobile/src/features/browser/preview-accessible-document.test.ts @@ -2,7 +2,7 @@ import { createAccessibleDocumentView } from "@t3tools/client-runtime/preview/ac import type { PreviewAccessibilityAction, PreviewAccessibilityDocument } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; -it("keeps an unsent field edit through unrelated page updates and cancels it on Escape", () => { +it("preserves drafts and submits only values the viewer edited", () => { const container = document.createElement("div"); document.body.append(container); const onAction = vi.fn(() => true); @@ -35,6 +35,35 @@ it("keeps an unsent field edit through unrelated page updates and cancels it on input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(input.value).toBe("Server"); expect(onAction).not.toHaveBeenCalled(); + + view.update( + { ...page, version: 3, root: { id: "secret", role: "textbox", protected: true } }, + true, + ); + const secret = container.querySelector("input")!; + secret.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(onAction).toHaveBeenLastCalledWith({ + type: "accessibilityAction", + document: "page", + node: "secret", + action: "setValue", + submit: true, + }); + for (const value of ["Replacement", ""]) { + secret.value = value; + secret.dispatchEvent(new Event("input", { bubbles: true })); + if (value) + secret.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + else secret.dispatchEvent(new Event("change", { bubbles: true })); + expect(onAction).toHaveBeenLastCalledWith({ + type: "accessibilityAction", + document: "page", + node: "secret", + action: "setValue", + ...(value ? { submit: true } : {}), + value, + }); + } } finally { view.destroy(); container.remove(); diff --git a/apps/server/src/preview/ServerBrowserAccessibility.test.ts b/apps/server/src/preview/ServerBrowserAccessibility.test.ts index f358fbe23092d..16be235ed030c 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.test.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.test.ts @@ -282,8 +282,8 @@ it("redelivers the newest document to a viewer whose queue was full", async () = } }); -it("sends editable values exactly as typed, even when they match the field's name", () => { - const { root } = buildAccessibilityTree([ +it("preserves editable values and makes truncated values read-only", () => { + const { root, targets } = buildAccessibilityTree([ frame("0", [ node("1", "RootWebArea", { childIds: ["2", "3", "4"] }), node("2", "textbox", { @@ -304,6 +304,9 @@ it("sends editable values exactly as typed, even when they match the field's nam "Word", " ".repeat(10_000), ]); + expect(root!.children![2]!.readonly).toBe(true); + expect(targets.get("0.4")!.readonly).toBe(true); + expect(targets.get("0.3")!.readonly).toBe(false); }); type Listener = (...args: Array) => void; @@ -418,49 +421,61 @@ it.each([ } }); -it.each(["activate", "setValue"] as const)("allows %s to navigate the page", async (action) => { - const main = fakeSession( - () => [ - node("1", "RootWebArea", { childIds: ["2"] }), - node("2", action === "activate" ? "link" : "textbox", { backendDOMNodeId: 9 }), - ], - { id: "main" }, - ); - const page = fakePage(main, new Map()); - const mirror = new ServerBrowserAccessibility(page as unknown as Page); - try { - await mirror.subscribe("viewer", () => true); - const send = main.send.getMockImplementation()!; - const navigatingMethod = - action === "activate" ? "Runtime.callFunctionOn" : "Input.dispatchKeyEvent"; - main.send.mockImplementation(async (method, input) => { - const result = await send(method, input); - if (method === navigatingMethod) page.events.emit("framenavigated", page); - return result; - }); - main.send.mockClear(); +it.each(["activate", "setValue", "submit"] as const)( + "allows %s to navigate the page", + async (action) => { + const main = fakeSession( + () => [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", action === "activate" ? "link" : "textbox", { backendDOMNodeId: 9 }), + ], + { id: "main" }, + ); + const page = fakePage(main, new Map()); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + try { + await mirror.subscribe("viewer", () => true); + const send = main.send.getMockImplementation()!; + const navigatingMethod = + action === "activate" ? "Runtime.callFunctionOn" : "Input.dispatchKeyEvent"; + main.send.mockImplementation(async (method, input) => { + const result = await send(method, input); + if (method === navigatingMethod) page.events.emit("framenavigated", page); + return result; + }); + main.send.mockClear(); - expect( - await mirror.act({ - type: "accessibilityAction", - document: "1:main-loader", - node: "0.2", - action, - value: "Draft", - submit: true, - }), - ).toBeNull(); - expect(main.send).toHaveBeenCalledWith(navigatingMethod, expect.anything()); - if (action === "setValue") { - expect(main.send).toHaveBeenCalledWith("Input.insertText", { text: "Draft" }); - expect(main.send.mock.calls.filter(([method]) => method === navigatingMethod)).toHaveLength( - 1, - ); + expect( + await mirror.act({ + type: "accessibilityAction", + document: "1:main-loader", + node: "0.2", + action: action === "submit" ? "setValue" : action, + ...(action === "setValue" ? { value: "Draft" } : {}), + submit: true, + }), + ).toBeNull(); + expect(main.send).toHaveBeenCalledWith(navigatingMethod, expect.anything()); + if (action === "setValue") { + expect(main.send).toHaveBeenCalledWith("Input.insertText", { text: "Draft" }); + expect(main.send.mock.calls.filter(([method]) => method === navigatingMethod)).toHaveLength( + 1, + ); + } else if (action === "submit") { + expect(main.send.mock.calls.map(([method]) => method)).not.toContain("Input.insertText"); + expect(main.send.mock.calls.map(([method]) => method)).not.toContain( + "Runtime.callFunctionOn", + ); + expect(main.send).toHaveBeenCalledWith( + "Input.dispatchKeyEvent", + expect.objectContaining({ key: "Enter" }), + ); + } + } finally { + await mirror.close(); } - } finally { - await mirror.close(); - } -}); + }, +); it("follows semantic changes inside out-of-process frames and releases their sessions", async () => { vi.useFakeTimers(); diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index 0c83a6c34a260..93d11d7b13741 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -250,6 +250,14 @@ export function buildAccessibilityTree( ] as const) { if (truthy(properties.get(flag))) node[flag] = true; } + if ( + editable && + !hidden && + typeof raw.value?.value === "string" && + raw.value.value.length > PREVIEW_ACCESSIBILITY_VALUE_MAX_CHARS + ) { + node.readonly = true; + } const invalid = properties.get("invalid"); if (invalid !== undefined && invalid !== "false" && invalid !== false) node.invalid = true; if (editable) node.editable = true; @@ -464,11 +472,13 @@ export class ServerBrowserAccessibility { assertCurrent(); await session.send("DOM.focus", { backendNodeId }); assertCurrent(); - await callOn(session, backendNodeId, SELECT_CONTENTS_FUNCTION, assertCurrent); - assertCurrent(); - // Real input events keep framework-controlled fields in sync. - if (action.value) await main.send("Input.insertText", { text: action.value }); - else await pressKey(main, "Backspace", 8, undefined, isCurrent); + if (action.value !== undefined) { + await callOn(session, backendNodeId, SELECT_CONTENTS_FUNCTION, assertCurrent); + assertCurrent(); + // Real input events keep framework-controlled fields in sync. + if (action.value) await main.send("Input.insertText", { text: action.value }); + else await pressKey(main, "Backspace", 8, undefined, isCurrent); + } assertCurrent(); if (action.submit) await pressKey(main, "Enter", 13, "\r", isCurrent); } diff --git a/packages/client-runtime/src/preview/accessibleDocument.ts b/packages/client-runtime/src/preview/accessibleDocument.ts index 61e710c1a8871..d2e8fc0ad9ab7 100644 --- a/packages/client-runtime/src/preview/accessibleDocument.ts +++ b/packages/client-runtime/src/preview/accessibleDocument.ts @@ -446,12 +446,13 @@ export function createAccessibleDocumentView( }; const commit = (element: HTMLInputElement | HTMLTextAreaElement, submit: boolean) => { const hit = nodeOf(element); - if (!hit || (!submit && element.value === element.dataset.serverValue)) return; + const edited = element.dataset.dirty !== undefined; + if (!hit || (!submit && !edited)) return; delete element.dataset.dirty; act({ node: hit.node.id, action: "setValue", - value: element.value, + ...(edited ? { value: element.value } : {}), ...(submit ? { submit } : {}), }); }; From 5f91be7f9ad2ccf3551e97b5e4aa63d95afd2b64 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:19:33 +0000 Subject: [PATCH 12/12] fix(preview): accessible view reports a failed first read and leaks no frame sessions A failed first capture was only logged, so the viewer waited on a document that never came. It now rejects the subscription, which the stream already reports as an unavailable notice. A capture that outlived its subscription could open and watch iframe sessions for the stopped run; frame sessions now belong to the capture's own run and stale ones are refused. Toggling the view also clears its previous notice. Co-Authored-By: Claude Opus 5.5 --- .../ServerBrowserAccessibility.test.ts | 72 +++++++++++++++++++ .../src/preview/ServerBrowserAccessibility.ts | 36 ++++++---- apps/web/src/browser/ServerBrowserSurface.tsx | 1 + 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserAccessibility.test.ts b/apps/server/src/preview/ServerBrowserAccessibility.test.ts index 16be235ed030c..c4f6fe735cd11 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.test.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.test.ts @@ -568,3 +568,75 @@ it("refuses actions on a document whose frame navigated until it is read again", vi.useRealTimers(); } }); + +it("rejects the subscription when the first capture fails, so the viewer is told", async () => { + let readable = false; + const main = fakeSession( + () => { + if (!readable) throw new Error("Target closed"); + return [node("1", "RootWebArea", { label: "Page" })]; + }, + { id: "main" }, + ); + const page = fakePage(main, new Map()); + const onError = vi.fn(); + const mirror = new ServerBrowserAccessibility(page as unknown as Page, onError); + const offer = vi.fn(() => true); + try { + await expect(mirror.subscribe("viewer", offer)).rejects.toThrow(); + expect(offer).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(mirror.active).toBe(false); + expect(page.events.eventNames()).toEqual([]); + expect(main.detach).toHaveBeenCalledOnce(); + + readable = true; + await mirror.subscribe("viewer", offer); + expect(offer).toHaveBeenCalledWith( + expect.objectContaining({ root: { id: "0.1", role: "document", name: "Page" } }), + ); + } finally { + await mirror.close(); + } +}); + +it("opens no frame session for a capture that outlived its subscription", async () => { + const main = fakeSession( + () => [ + node("1", "RootWebArea", { childIds: ["2"] }), + node("2", "Iframe", { backendDOMNodeId: 7 }), + ], + { id: "main" }, + ); + const child = fakeSession(() => [node("1", "RootWebArea", { label: "Ad" })], { + id: "child", + parentId: "main", + }); + const page = fakePage(main, new Map([[{}, child]])); + const reached = Promise.withResolvers(); + const frameTree = Promise.withResolvers(); + const send = main.send.getMockImplementation()!; + main.send.mockImplementation(async (method, input) => { + if (method === "Page.getFrameTree") { + reached.resolve(); + await frameTree.promise; + } + return send(method, input); + }); + const mirror = new ServerBrowserAccessibility(page as unknown as Page); + const offer = vi.fn(() => true); + try { + const subscribed = mirror.subscribe("viewer", offer); + await reached.promise; + await mirror.unsubscribe("viewer"); + frameTree.resolve(); + await subscribed; + + expect(offer).not.toHaveBeenCalled(); + expect(child.send).not.toHaveBeenCalledWith("Accessibility.enable"); + expect(child.events.eventNames()).toEqual([]); + expect(main.events.eventNames()).toEqual([]); + } finally { + await mirror.close(); + } +}); diff --git a/apps/server/src/preview/ServerBrowserAccessibility.ts b/apps/server/src/preview/ServerBrowserAccessibility.ts index 93d11d7b13741..9a443fa4d4bc8 100644 --- a/apps/server/src/preview/ServerBrowserAccessibility.ts +++ b/apps/server/src/preview/ServerBrowserAccessibility.ts @@ -397,8 +397,16 @@ export class ServerBrowserAccessibility { throw cause; } if (!this.subscribers.has(id)) return; - if (this.latest) this.deliver(); - else await this.refresh(); + if (this.latest) { + this.deliver(); + return; + } + try { + await this.refresh(); + } catch (cause) { + await this.unsubscribe(id); + throw cause; + } } /** Resolves once the last subscriber's domain and sessions are released. */ @@ -419,11 +427,11 @@ export class ServerBrowserAccessibility { const delay = Math.max(SETTLE_MS, this.lastCaptureAt + MIN_CAPTURE_INTERVAL_MS - Date.now()); this.settleTimer = setTimeout(() => { this.settleTimer = null; - void this.refresh(); + void this.refresh().catch(constVoid); }, delay); }; - /** Resolves after a capture that started after this call. */ + /** Settles after a capture that started after this call; rejects when that capture failed. */ refresh(): Promise { if (this.queued) return this.queued; const next = this.chain.then(() => { @@ -487,7 +495,7 @@ export class ServerBrowserAccessibility { throw cause; } // Outside the caller's control queue, so a large page never delays the next input. - void this.refresh(); + void this.refresh().catch(constVoid); return null; } @@ -575,10 +583,10 @@ export class ServerBrowserAccessibility { } /** Out-of-process frames need their own session; in-process ones are read through their parent's. */ - private frameSession(frame: Frame) { + private frameSession(frame: Frame, run: number) { + if (run !== this.run) return Promise.resolve(null); let entry = this.frameSessions.get(frame); if (!entry) { - const run = this.run; entry = this.page .context() .newCDPSession(frame) @@ -589,9 +597,10 @@ export class ServerBrowserAccessibility { return null; } const key = String(++this.sessionSerial); + // Registered first so a stop during enable releases it. this.sessions.set(key, session); await this.watch(session); - return key; + return run === this.run ? key : null; }, () => null, ); @@ -606,9 +615,10 @@ export class ServerBrowserAccessibility { const main = this.main; if (!main) return; this.lastCaptureAt = Date.now(); - const captured = await this.capture(main).catch((cause: unknown) => { + const captured = await this.capture(main, run).catch((cause: unknown) => { + if (run !== this.run) return null; this.onError(cause); - return null; + throw cause; }); if (!captured || run !== this.run || !captured.root) return; const targets = @@ -651,7 +661,7 @@ export class ServerBrowserAccessibility { } } - private async capture(main: CDPSession) { + private async capture(main: CDPSession, run: number) { const documents = new Map(); const walk = (tree: FrameTreeNode, key: string, session: CDPSession) => { documents.set(tree.frame.id, { @@ -663,11 +673,11 @@ export class ServerBrowserAccessibility { for (const child of tree.childFrames ?? []) walk(child, key, session); }; const { frameTree } = (await main.send("Page.getFrameTree")) as { frameTree: FrameTreeNode }; - const token = `${this.run}:${frameTree.frame.loaderId}`; + const token = `${run}:${frameTree.frame.loaderId}`; walk(frameTree, "0", main); for (const frame of this.page.frames()) { if (frame === this.page.mainFrame()) continue; - const key = await this.frameSession(frame); + const key = await this.frameSession(frame, run); const session = key === null ? undefined : this.sessions.get(key); if (!key || !session) continue; const own = (await session.send("Page.getFrameTree").catch(() => null)) as { diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 567fe79914e5a..35ed035d561d8 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -776,6 +776,7 @@ export function ServerBrowserSurface(props: { onClick={() => { const next = !accessibleView; setAccessibleView(next); + setAccessibleNotice(null); clientRef.current?.accessibility(next); }} >