-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(extension): report editable element ids alongside the a11y snapshot #2951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import type { Protocol } from "devtools-protocol"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import type { CDPSessionLike } from "../understudy/cdp.js"; | ||
| import { a11yForFrame } from "../understudy/a11y/snapshot/a11yTree.js"; | ||
|
|
||
| function axNode( | ||
| nodeId: string, | ||
| backendDOMNodeId: number, | ||
| role: string, | ||
| name: string, | ||
| extra: Partial<Protocol.Accessibility.AXNode> = {}, | ||
| ): Protocol.Accessibility.AXNode { | ||
| return { | ||
| nodeId, | ||
| backendDOMNodeId, | ||
| ignored: false, | ||
| role: { type: "role", value: role }, | ||
| name: { type: "computedString", value: name }, | ||
| ...extra, | ||
| }; | ||
| } | ||
|
|
||
| function editable(value: string): Protocol.Accessibility.AXProperty[] { | ||
| return [{ name: "editable", value: { type: "token", value } }]; | ||
| } | ||
|
|
||
| describe("a11yForFrame editable ids", () => { | ||
| it("reports editable nodes by encoded id without changing the outline", async () => { | ||
| const nodes = [ | ||
| axNode("1", 1, "RootWebArea", "Editor", { childIds: ["2", "3", "4", "5"] }), | ||
| axNode("2", 2, "textbox", "Title", { parentId: "1", properties: editable("plaintext") }), | ||
| // A contenteditable div: its role says nothing about being typeable. | ||
| axNode("3", 3, "generic", "Body", { parentId: "1", properties: editable("richtext") }), | ||
| axNode("4", 4, "button", "Save", { parentId: "1" }), | ||
| axNode("5", 5, "textbox", "Hidden", { parentId: "1", properties: editable("plaintext") }), | ||
| ]; | ||
| const session = { | ||
| send: async (method: string) => (method === "Accessibility.getFullAXTree" ? { nodes } : {}), | ||
| } as unknown as CDPSessionLike; | ||
|
|
||
| const result = await a11yForFrame(session, undefined, { | ||
| tagNameMap: {}, | ||
| scrollableMap: {}, | ||
| encode: (backendNodeId) => `0-${backendNodeId}`, | ||
| isIgnoredBackendNode: (backendNodeId) => backendNodeId === 5, | ||
| }); | ||
|
|
||
| expect(result.editableIds).toEqual(["0-2", "0-3"]); | ||
| expect(result.outline).toContain("button: Save"); | ||
| expect(result.outline).not.toContain("editable"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -224,22 +224,26 @@ export async function tryScopedSnapshot( | |
| sameSessionAsParent, | ||
| ); | ||
|
|
||
| const { outline, urlMap, scopeApplied } = await a11yForFrame(owningSess, targetFrameId, { | ||
| focusLocator: tailSelector | ||
| ? { | ||
| selector: tailSelector, | ||
| ...(focusNth === undefined ? {} : { nth: focusNth }), | ||
| } | ||
| : undefined, | ||
| isIgnoredBackendNode: makeIsIgnoredBackendNode( | ||
| targetFrameId, | ||
| ownerSessionIndexForFrame(page, targetFrameId, sessionToIndex), | ||
| exclusionIntervalsByFrame, | ||
| ), | ||
| tagNameMap, | ||
| scrollableMap, | ||
| encode: (backendNodeId) => `${page.getOrdinal(targetFrameId)}-${backendNodeId}`, | ||
| }); | ||
| const { outline, urlMap, scopeApplied, editableIds } = await a11yForFrame( | ||
| owningSess, | ||
| targetFrameId, | ||
| { | ||
| focusLocator: tailSelector | ||
| ? { | ||
| selector: tailSelector, | ||
| ...(focusNth === undefined ? {} : { nth: focusNth }), | ||
| } | ||
| : undefined, | ||
| isIgnoredBackendNode: makeIsIgnoredBackendNode( | ||
| targetFrameId, | ||
| ownerSessionIndexForFrame(page, targetFrameId, sessionToIndex), | ||
| exclusionIntervalsByFrame, | ||
| ), | ||
| tagNameMap, | ||
| scrollableMap, | ||
| encode: (backendNodeId) => `${page.getOrdinal(targetFrameId)}-${backendNodeId}`, | ||
| }, | ||
| ); | ||
|
|
||
| const scopedXpathMap: Record<string, string> = {}; | ||
| const isIgnoredBackendNode = makeIsIgnoredBackendNode( | ||
|
|
@@ -275,12 +279,14 @@ export async function tryScopedSnapshot( | |
| combinedTree: wellFormedOutline, | ||
| combinedXpathMap: scopedXpathMap, | ||
| combinedUrlMap: scopedUrlMap, | ||
| combinedEditableIds: editableIds ?? [], | ||
| perFrame: [ | ||
| { | ||
| frameId: targetFrameId, | ||
| outline: wellFormedOutline, | ||
| xpathMap, | ||
| urlMap, | ||
| editableIds: editableIds ?? [], | ||
| }, | ||
| ], | ||
| }; | ||
|
|
@@ -381,15 +387,15 @@ export async function collectPerFrameMaps( | |
| if (idx.scrollByBe.get(be)) scrollableMap[key] = true; | ||
| } | ||
|
|
||
| const { outline, urlMap } = await a11yForFrame(sess, frameId, { | ||
| const { outline, urlMap, editableIds } = await a11yForFrame(sess, frameId, { | ||
| isIgnoredBackendNode, | ||
| tagNameMap, | ||
| scrollableMap, | ||
| encode: (backendNodeId) => `${page.getOrdinal(frameId)}-${backendNodeId}`, | ||
| }); | ||
|
|
||
| perFrameOutlines.push({ frameId, outline }); | ||
| perFrameMaps.set(frameId, { tagNameMap, xpathMap, scrollableMap, urlMap }); | ||
| perFrameMaps.set(frameId, { tagNameMap, xpathMap, scrollableMap, urlMap, editableIds }); | ||
| } | ||
|
|
||
| return { perFrameMaps, perFrameOutlines }; | ||
|
|
@@ -835,13 +841,15 @@ export function mergeFramesIntoSnapshot( | |
| combinedTree, | ||
| combinedXpathMap, | ||
| combinedUrlMap, | ||
| combinedEditableIds: [...perFrameMaps.values()].flatMap((maps) => maps.editableIds ?? []), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Add capture-level regression tests for aggregation across frames and for the scoped snapshot path. The current feature test leaves this wiring unverified, so a future propagation regression can ship while the editable-node test remains green. (Based on your team's feedback about unit tests for new behavior.) Prompt for AI agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing this — the cross-frame aggregation is a one-line spread of per-frame results, and the a11yForFrame boundary (where the ids are computed) has its own test; capture-level tests need full CDP session mocks that this stack does not introduce. The editable ids are exercised end to end by the pipeline tests in #2953 (contenteditable targets). |
||
| perFrame: perFrameOutlines.map(({ frameId, outline }) => { | ||
| const maps = perFrameMaps.get(frameId); | ||
| return { | ||
| frameId, | ||
| outline: toWellFormed(outline), | ||
| xpathMap: maps?.xpathMap ?? {}, | ||
| urlMap: maps?.urlMap ?? {}, | ||
| editableIds: maps?.editableIds ?? [], | ||
| }; | ||
| }), | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The new per-frame editable IDs are dropped before consumers can read them. Add
editableIdstoPerFrameSnapshotand copy it into both per-frame snapshot object literals.Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed on the branch:
PerFrameSnapshot.editableIdsadded and copied into both per-frame literals (scoped and merged paths).