Skip to content

Commit 090a993

Browse files
authored
Merge pull request #2027 from Automattic/fix/2022-unique-action-corpus-selectors
fix: generate unique action corpus selectors
2 parents 7585261 + fccb17e commit 090a993

2 files changed

Lines changed: 172 additions & 14 deletions

File tree

packages/runtime-playground/src/browser-actions-runner.ts

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,9 @@ export async function runBrowserActionsCommand({
198198
throw error
199199
}
200200
}
201-
const descriptors = await discoverBrowserActionCorpusDescriptors(page)
202-
actionCorpusArtifact = browserActionCorpusArtifact(runPlan.actionCorpus, descriptors, now())
201+
const discovery = await discoverBrowserActionCorpusDescriptors(page)
202+
actionCorpusArtifact = browserActionCorpusArtifact(runPlan.actionCorpus, discovery.descriptors, now())
203+
actionCorpusArtifact.plan.diagnostics.push(...discovery.diagnostics)
203204
await artifactSession.writeJson("actionCorpus", "action-corpus.json", actionCorpusArtifact)
204205
const corpusSteps = actionCorpusArtifact.plan.steps
205206
steps.unshift(...corpusSteps)
@@ -605,23 +606,87 @@ function browserActionCorpusFromArgs(args: string[]): BrowserActionCorpusContrac
605606
return browserActionCorpusContract(parsed)
606607
}
607608

608-
async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<BrowserActionCorpusDescriptor[]> {
609+
export async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<{
610+
descriptors: BrowserActionCorpusDescriptor[]
611+
diagnostics: Array<{ code: string; message: string; metadata?: Record<string, unknown> }>
612+
}> {
609613
return await page.evaluate(() => {
614+
const MAX_REJECTION_DIAGNOSTICS = 20
610615
const descriptors: BrowserActionCorpusDescriptor[] = []
616+
const rejected: Array<{ kind: string; tag: string; label: string }> = []
611617
const cssEscape = (value: string) => {
612618
const escapeFn = (globalThis as typeof globalThis & { CSS?: { escape?: (raw: string) => string } }).CSS?.escape
613619
return escapeFn ? escapeFn(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&")
614620
}
615621
const text = (value: string | null | undefined) => (value || "").replace(/\s+/g, " ").trim().slice(0, 120)
616-
const selectorFor = (element: Element) => {
622+
const attributeSelector = (name: string, value: string) => `[${name}=${cssEscape(value)}]`
623+
const uniquelySelects = (selector: string, element: Element) => {
624+
try {
625+
const matches = document.querySelectorAll(selector)
626+
return matches.length === 1 && matches[0] === element
627+
} catch {
628+
return false
629+
}
630+
}
631+
const selectorFor = (element: Element): string | undefined => {
632+
const tag = element.tagName.toLowerCase()
633+
const candidates: string[] = []
634+
const seen = new Set<string>()
635+
const addCandidate = (selector: string) => {
636+
if (!seen.has(selector)) {
637+
seen.add(selector)
638+
candidates.push(selector)
639+
}
640+
}
617641
const id = element.getAttribute("id")
618-
if (id) return `#${cssEscape(id)}`
619-
const name = element.getAttribute("name")
620-
if (name) return `${element.tagName.toLowerCase()}[name="${cssEscape(name)}"]`
621-
const aria = element.getAttribute("aria-label")
622-
if (aria) return `${element.tagName.toLowerCase()}[aria-label="${cssEscape(aria)}"]`
623-
const type = element.getAttribute("type")
624-
return `${element.tagName.toLowerCase()}${type ? `[type="${cssEscape(type)}"]` : ""}:nth-of-type(${Array.from(element.parentElement?.children || []).filter((child) => child.tagName === element.tagName).indexOf(element) + 1})`
642+
if (id) addCandidate(`#${cssEscape(id)}`)
643+
644+
const attributes = new Map(["aria-label", "aria-labelledby", "name", "type", "value", "title", "href", "role"].flatMap((name) => {
645+
const value = element.getAttribute(name)
646+
return value ? [[name, value] as const] : []
647+
}))
648+
const addAttributeCombination = (...names: string[]) => {
649+
if (names.every((name) => attributes.has(name))) {
650+
addCandidate(`${tag}${names.map((name) => attributeSelector(name, attributes.get(name)!)).join("")}`)
651+
}
652+
}
653+
for (const names of [
654+
["aria-label"], ["aria-labelledby"],
655+
["name", "type", "value"], ["name", "type"], ["name", "value"], ["name"],
656+
["href"], ["title"],
657+
["role", "type", "value"], ["role", "type"], ["role"],
658+
["type", "value"], ["value"], ["type"],
659+
]) {
660+
addAttributeCombination(...names)
661+
}
662+
663+
const form = (element as HTMLInputElement).form
664+
const formId = form?.getAttribute("id")
665+
if (form && formId) {
666+
const formSelector = `#${cssEscape(formId)}`
667+
if (uniquelySelects(formSelector, form)) {
668+
for (const candidate of [...candidates]) addCandidate(`${formSelector} ${candidate}`)
669+
}
670+
}
671+
for (const candidate of candidates) {
672+
if (uniquelySelects(candidate, element)) return candidate
673+
}
674+
675+
const parts: string[] = []
676+
let current: Element | null = element
677+
while (current) {
678+
let part = current.tagName.toLowerCase()
679+
const parent: Element | null = current.parentElement
680+
if (parent) {
681+
const sameTagSiblings = Array.from(parent.children).filter((child) => child.tagName === current?.tagName)
682+
if (sameTagSiblings.length > 1) part += `:nth-of-type(${sameTagSiblings.indexOf(current) + 1})`
683+
}
684+
parts.unshift(part)
685+
const candidate = parts.join(" > ")
686+
if (uniquelySelects(candidate, element)) return candidate
687+
current = parent
688+
}
689+
return undefined
625690
}
626691
const labelFor = (element: Element) => {
627692
const labelledBy = element.getAttribute("aria-labelledby")
@@ -636,7 +701,7 @@ async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<Brows
636701
if (label && text(label.textContent)) return text(label.textContent)
637702
return text(element.textContent)
638703
}
639-
const descriptorId = (kind: string, element: Element) => `${kind}:${selectorFor(element)}:${element.getAttribute("name") || ""}:${labelFor(element)}`
704+
const descriptorId = (kind: string, selector: string, element: Element) => `${kind}:${selector}:${element.getAttribute("name") || ""}:${labelFor(element)}`
640705
const visible = (element: Element) => {
641706
const htmlElement = element as HTMLElement
642707
const style = window.getComputedStyle(htmlElement)
@@ -649,8 +714,12 @@ async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<Brows
649714
const input = element as HTMLInputElement
650715
const kind = tag === "a" ? "link" : tag === "button" ? "button" : tag === "textarea" ? "textarea" : tag === "select" ? "select" : "input"
651716
const selector = selectorFor(element)
717+
if (!selector) {
718+
rejected.push({ kind, tag, label: labelFor(element) })
719+
return
720+
}
652721
const descriptor: BrowserActionCorpusDescriptor = {
653-
id: descriptorId(kind, element),
722+
id: descriptorId(kind, selector, element),
654723
kind,
655724
selector,
656725
label: labelFor(element),
@@ -665,7 +734,19 @@ async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<Brows
665734
}
666735
descriptors.push(descriptor)
667736
})
668-
return descriptors
737+
const diagnostics: Array<{ code: string; message: string; metadata?: Record<string, unknown> }> = rejected.slice(0, MAX_REJECTION_DIAGNOSTICS).map((item) => ({
738+
code: "browser_action_corpus_selector_not_unique",
739+
message: "An actionable control was rejected because discovery could not produce a selector resolving uniquely to that element.",
740+
metadata: item,
741+
}))
742+
if (rejected.length > MAX_REJECTION_DIAGNOSTICS) {
743+
diagnostics[MAX_REJECTION_DIAGNOSTICS - 1] = {
744+
code: "browser_action_corpus_selector_rejections_truncated",
745+
message: "Additional actionable controls without unique selectors were omitted from diagnostics.",
746+
metadata: { rejected: rejected.length, retainedDiagnostics: MAX_REJECTION_DIAGNOSTICS - 1 },
747+
}
748+
}
749+
return { descriptors, diagnostics }
669750
})
670751
}
671752

tests/browser-action-corpus.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert/strict"
22
import test from "node:test"
3+
import { chromium } from "playwright"
34

45
import { getCommandDefinition } from "../packages/runtime-core/src/command-registry.js"
56
import {
@@ -10,6 +11,7 @@ import {
1011
planBrowserActionCorpus,
1112
type BrowserActionCorpusDescriptor,
1213
} from "../packages/runtime-core/src/browser-interaction.js"
14+
import { discoverBrowserActionCorpusDescriptors } from "../packages/runtime-playground/src/browser-actions-runner.js"
1315

1416
const representativeFormDescriptors: BrowserActionCorpusDescriptor[] = [
1517
{ id: "input:#title::Title", kind: "input", selector: "#title", label: "Title", name: "title", type: "text", formId: "post" },
@@ -60,3 +62,78 @@ test("browser action corpus artifact records replayable steps and correlated obs
6062
assert.equal(artifact.plan.replay.startUrl, "/wp-admin/post-new.php")
6163
assert.ok(artifact.plan.descriptors.some((descriptor) => descriptor.formId === "post"))
6264
})
65+
66+
test("browser action corpus discovery retains only uniquely resolvable deterministic selectors", async () => {
67+
const browser = await chromium.launch({ headless: true })
68+
try {
69+
const page = await browser.newPage()
70+
await page.setContent(`
71+
<style>a, button, input { display: inline-block; width: 100px; height: 20px; }</style>
72+
<nav><a href="/shared">Open</a><a href="/news">News</a></nav>
73+
<main><a href="/shared">Open</a><section><a href="/feature">Feature</a></section></main>
74+
<form id="primary-form">
75+
<label>Search <input name="query" type="search"></label>
76+
<button aria-label='Save "draft" \\ copy' type="button">Save</button>
77+
</form>
78+
<form id="secondary-form">
79+
<label>Search <input name="query" type="search"></label>
80+
<button aria-label="Save" type="button">Save</button>
81+
</form>
82+
<input id="unique-email" name="contact" type="email">
83+
`)
84+
// tsx names nested functions with this helper before Playwright serializes page callbacks.
85+
await page.evaluate("globalThis.__name = value => value")
86+
87+
const first = await discoverBrowserActionCorpusDescriptors(page)
88+
const second = await discoverBrowserActionCorpusDescriptors(page)
89+
90+
assert.deepEqual(second, first)
91+
assert.equal(first.diagnostics.length, 0)
92+
assert.ok(first.descriptors.length >= 9)
93+
assert.ok(first.descriptors.some((descriptor) => descriptor.selector === "#unique-email"))
94+
assert.equal(new Set(first.descriptors.map((descriptor) => descriptor.id)).size, first.descriptors.length)
95+
for (const descriptor of first.descriptors) {
96+
const matches = await page.locator(descriptor.selector).count()
97+
assert.equal(matches, 1, `${descriptor.selector} must resolve uniquely`)
98+
}
99+
100+
const sharedLinks = first.descriptors.filter((descriptor) => descriptor.href?.endsWith("/shared"))
101+
assert.equal(sharedLinks.length, 2)
102+
assert.ok(sharedLinks.every((descriptor) => descriptor.selector !== "a:nth-of-type(1)"))
103+
const repeatedInputs = first.descriptors.filter((descriptor) => descriptor.name === "query")
104+
assert.equal(repeatedInputs.length, 2)
105+
assert.equal(new Set(repeatedInputs.map((descriptor) => descriptor.selector)).size, 2)
106+
107+
const contract = browserActionCorpusContract({ seed: "unique-selector-seed", context: "browser", maxSteps: 9 })
108+
assert.deepEqual(planBrowserActionCorpus(contract, second.descriptors), planBrowserActionCorpus(contract, first.descriptors))
109+
} finally {
110+
await browser.close()
111+
}
112+
})
113+
114+
test("browser action corpus discovery rejects controls that lose unique identity", async () => {
115+
const browser = await chromium.launch({ headless: true })
116+
try {
117+
const page = await browser.newPage()
118+
await page.setContent('<style>button { width: 100px; height: 20px; }</style><button id="stable">Stable</button><button id="rerendered">Rerendered</button>')
119+
await page.evaluate("globalThis.__name = value => value")
120+
await page.evaluate(() => {
121+
const element = document.querySelector("#rerendered")!
122+
const getAttribute = element.getAttribute.bind(element)
123+
element.getAttribute = (name: string) => {
124+
const value = getAttribute(name)
125+
if (name === "id") element.remove()
126+
return value
127+
}
128+
})
129+
130+
const discovery = await discoverBrowserActionCorpusDescriptors(page)
131+
132+
assert.deepEqual(discovery.descriptors.map((descriptor) => descriptor.selector), ["#stable"])
133+
assert.equal(discovery.diagnostics.length, 1)
134+
assert.equal(discovery.diagnostics[0]?.code, "browser_action_corpus_selector_not_unique")
135+
assert.equal(discovery.diagnostics[0]?.metadata?.label, "Rerendered")
136+
} finally {
137+
await browser.close()
138+
}
139+
})

0 commit comments

Comments
 (0)