Skip to content

Commit ff3f782

Browse files
authored
fix: avoid repeating adaptive navigation links (#2076)
1 parent 1ce6136 commit ff3f782

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

packages/runtime-core/src/browser-adaptive-exploration.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ export function planBrowserAdaptiveStateActions(state: BrowserAdaptiveState, con
204204
const addClicks = (descriptor: BrowserActionCorpusDescriptor, submit: boolean) => {
205205
const selector = descriptor.selector
206206
add("click", descriptor, [{ kind: "click", selector }])
207-
add("repeat", descriptor, [{ kind: "click", selector }, { kind: "click", selector }])
207+
const navigates = descriptor.kind === "link" && Boolean(descriptor.href)
208+
if (!navigates) add("repeat", descriptor, [{ kind: "click", selector }, { kind: "click", selector }])
208209
if (submit && descriptor.formId) {
209210
add("submit", descriptor, [{ kind: "click", selector }])
210211
add("double-submit", descriptor, [{ kind: "click", selector }, { kind: "click", selector }])

tests/browser-adaptive-exploration.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ test("adaptive contract normalizes every safety and exploration bound", () => {
103103
{ id: "input", kind: "input", selector: "#field", type: "text", formId: "form", frameId: "document" },
104104
{ id: "select", kind: "select", selector: "#choice", optionValues: ["one"], formId: "form", frameId: "document" },
105105
{ id: "submit", kind: "button", selector: "#submit", type: "submit", formId: "form", frameId: "document" },
106-
{ id: "link", kind: "link", selector: "#link", frameId: "document" },
106+
{ id: "link", kind: "link", selector: "#link", href: "https://example.test/next", frameId: "document" },
107107
],
108108
frames: [{ id: "document", url: "/fixture", scope: "document" }],
109109
visits: 1,
@@ -113,6 +113,39 @@ test("adaptive contract normalizes every safety and exploration bound", () => {
113113
assert.deepEqual(new Set(planned.map((action) => action.family)), new Set(["click", "fill", "select", "submit", "keyboard", "back", "reload", "repeat", "double-submit"]))
114114
})
115115

116+
test("adaptive planner repeats in-place controls but not navigation anchors", () => {
117+
const contract = browserAdaptiveExplorationContract({ seed: "navigation-semantics", startUrl: "https://example.test/start" })
118+
const descriptors = [
119+
{ id: "internal", kind: "link" as const, selector: "#internal", href: "https://example.test/next", frameId: "document" },
120+
{ id: "external", kind: "link" as const, selector: "#external", href: "https://outside.example/next", frameId: "document" },
121+
{ id: "hash", kind: "link" as const, selector: "#hash", href: "https://example.test/start#tab", role: "tab", frameId: "document" },
122+
{ id: "download", kind: "link" as const, selector: "#download", href: "https://example.test/report.pdf", frameId: "document" },
123+
{ id: "anchor-button", kind: "link" as const, selector: "#anchor-button", role: "button", frameId: "document" },
124+
{ id: "button", kind: "button" as const, selector: "#button", type: "button", frameId: "document" },
125+
{ id: "submit", kind: "button" as const, selector: "#submit", type: "submit", formId: "form", frameId: "document" },
126+
]
127+
const state = {
128+
digest: "state",
129+
url: "https://example.test/start",
130+
historyLength: 1,
131+
historyStateDigest: "history",
132+
descriptorDigest: "descriptors",
133+
descriptors,
134+
frames: [{ id: "document", url: "https://example.test/start", scope: "document" as const }],
135+
visits: 1,
136+
depth: 0,
137+
loadingIndicators: 0,
138+
}
139+
const planned = planBrowserAdaptiveStateActions(state, contract)
140+
const familiesFor = (id: string) => planned.filter((action) => action.descriptorId === id).map((action) => action.family)
141+
142+
for (const id of ["internal", "external", "hash", "download"]) assert.deepEqual(familiesFor(id), ["click"], id)
143+
assert.deepEqual(familiesFor("anchor-button"), ["click", "repeat"])
144+
assert.deepEqual(familiesFor("button"), ["click", "repeat"])
145+
assert.deepEqual(familiesFor("submit"), ["click", "repeat", "submit", "double-submit"])
146+
assert.deepEqual(planBrowserAdaptiveStateActions(state, contract), planned, "semantic filtering preserves deterministic action identity and order")
147+
})
148+
116149
test("adaptive planner selects actions by HTML input type capability", () => {
117150
const fillableTypes = ["text", "search", "tel", "url", "email", "password", "number"]
118151
const toggleTypes = ["checkbox", "radio"]
@@ -191,6 +224,80 @@ test("planned input actions execute safely in a real browser", async () => {
191224
}
192225
})
193226

227+
test("real-browser descriptors keep navigation single-click and in-place controls repeatable", async () => {
228+
const browser = await chromium.launch({ headless: true })
229+
const page = await browser.newPage()
230+
try {
231+
await page.addInitScript("globalThis.__name = value => value")
232+
await page.setContent(`<!doctype html>
233+
<style>a,button { display:block; width:180px; height:30px; margin:4px; }</style>
234+
<a id="internal" href="/next">Internal</a>
235+
<a id="external" href="https://outside.example/next">External</a>
236+
<a id="hash" href="#tab" role="tab">Hash tab</a>
237+
<a id="download" href="/report.pdf" download>Download</a>
238+
<button id="button" type="button">Increment</button>
239+
<form id="form"><button id="submit" type="submit">Submit</button></form>
240+
<output id="count">0</output>
241+
<script>
242+
document.querySelector('#button').addEventListener('click', () => { document.querySelector('#count').textContent = String(Number(document.querySelector('#count').textContent) + 1) });
243+
document.querySelector('#form').addEventListener('submit', (event) => event.preventDefault());
244+
</script>`)
245+
await page.evaluate("globalThis.__name = value => value")
246+
const discovery = await discoverBrowserActionCorpusDescriptors(page)
247+
const contract = browserAdaptiveExplorationContract({ seed: "browser-navigation-semantics", startUrl: page.url() })
248+
const planned = planBrowserAdaptiveStateActions({
249+
digest: "state",
250+
url: page.url(),
251+
historyLength: 1,
252+
historyStateDigest: "history",
253+
descriptorDigest: "descriptors",
254+
descriptors: discovery.descriptors,
255+
frames: [{ id: "document", url: page.url(), scope: "document" }],
256+
visits: 1,
257+
depth: 0,
258+
loadingIndicators: 0,
259+
}, contract)
260+
const actionFor = (selector: string, family: string) => planned.find((action) => action.steps[0]?.selector === selector && action.family === family)
261+
262+
for (const selector of ["#internal", "#external", "#hash", "#download"]) {
263+
assert(actionFor(selector, "click"), `${selector} remains single-clickable`)
264+
assert(!actionFor(selector, "repeat"), `${selector} must not receive a repeat action`)
265+
}
266+
const repeat = actionFor("#button", "repeat")
267+
assert(repeat)
268+
for (const step of repeat.steps) await executeBrowserInteractionStep(page, step, page.url(), 2_000, async () => ({ path: "unused", isDefault: false }))
269+
assert.equal(await page.locator("#count").textContent(), "2")
270+
assert.equal(actionFor("#submit", "double-submit")?.steps.length, 2, "double-submit remains unchanged")
271+
} finally {
272+
await browser.close()
273+
}
274+
})
275+
276+
test("repeat-only exploration never schedules a detached navigation target", async () => {
277+
let destinationRequests = 0
278+
const server = createServer((request, response) => {
279+
const path = new URL(request.url ?? "/", "http://preview.invalid").pathname
280+
response.setHeader("content-type", "text/html")
281+
if (path === "/destination") destinationRequests += 1
282+
response.end(path === "/" ? "<!doctype html><style>a{display:block;width:180px;height:30px}</style><a id='privacy' href='/destination'>Privacy Policy</a>" : "<!doctype html><h1>Destination</h1>")
283+
})
284+
const startUrl = await listenLocalHttpServer(server)
285+
try {
286+
const run = await runUrlFixture(startUrl, {
287+
seed: "detached-navigation-repeat",
288+
failOnFinding: false,
289+
actionFamilies: ["repeat"],
290+
budgets: { maxActions: 4, maxStates: 4, maxTransitions: 4, maxDurationMs: 10_000 },
291+
})
292+
const privacy = run.result.states[0]?.descriptors.find((descriptor) => descriptor.selector === "#privacy")
293+
assert(privacy?.href.endsWith("/destination"))
294+
assert.equal(run.result.transitions.length, 0)
295+
assert.equal(destinationRequests, 0, "repeat planning must not issue a first or detached second navigation click")
296+
} finally {
297+
await closeHttpServer(server)
298+
}
299+
})
300+
194301
test("rediscovery finds, minimizes, and replays a defect revealed by a dynamic modal", async () => {
195302
const run = await runFixture(modalFixture, {
196303
seed: "modal-seed",

0 commit comments

Comments
 (0)