fix(desktop): clarify invalid browser input and capture failures

This commit is contained in:
LukeParkerDev 2026-09-03 19:34:14 +10:00
parent 2d388ab6b3
commit 30dce5d30a
5 changed files with 25 additions and 9 deletions

View file

@ -459,7 +459,7 @@ export function createBrowserPage(
).catch((error) => {
if (signal.aborted) throw error
throw new Error(
`browser.wait did not satisfy condition ${JSON.stringify(action.condition)} within ${action.timeoutMs ?? 10_000} ms. Inspect browser.snapshot({tabID}) and check text/frameID before retrying; timeoutMs can be increased up to 30000 for a genuinely slow page. Details: ${error instanceof Error ? error.message : String(error)}`,
`browser.wait failed for condition ${JSON.stringify(action.condition)} (timeoutMs: ${action.timeoutMs ?? 10_000}). Inspect browser.snapshot({tabID}) and check text/frameID before retrying; timeoutMs can be increased up to 30000 for a genuinely slow page. Details: ${error instanceof Error ? error.message : String(error)}`,
)
})
break

View file

@ -2,13 +2,16 @@ import type { Browser } from "@opencode-ai/plugin-browser/rpc"
export function protocolError(method: string, error: unknown) {
const detail = message(error)
const recovery = /(?:node|object).*(?:not found|not exist)|(?:find|resolve).*(?:node|object)|detached/i.test(detail)
? "The element may have detached. Call browser.snapshot({tabID}) and use a fresh ref from that tab."
: /context.*(?:destroyed|not found)|find.*context|session.*not found/i.test(detail)
? "The document or frame changed. Call browser.frames({tabID}) and browser.snapshot({tabID}); use current frame IDs and refs."
: /wasn't found|method not found|not implemented|not allowed/i.test(detail)
? "This Chromium target does not support or allow the operation. Check desktop/plugin compatibility and report it; do not retry unchanged or disable browser security."
: "Inspect browser.tabs.list({}) and the target tab before deciding to retry; a partially completed action is not automatically safe to repeat."
const recovery =
method === "DOM.setFileInputFiles" && /not.*file input/i.test(detail)
? "Target is not a file input. Call browser.snapshot({tabID}) and choose an input[type=file] ref for browser.files.upload, or use browser.files.drop for a drop area."
: /(?:node|object).*(?:not found|not exist)|(?:find|resolve).*(?:node|object)|detached/i.test(detail)
? "The element may have detached. Call browser.snapshot({tabID}) and use a fresh ref from that tab."
: /context.*(?:destroyed|not found)|find.*context|session.*not found/i.test(detail)
? "The document or frame changed. Call browser.frames({tabID}) and browser.snapshot({tabID}); use current frame IDs and refs."
: /wasn't found|method not found|not implemented|not allowed/i.test(detail)
? "This Chromium target does not support or allow the operation. Check desktop/plugin compatibility and report it; do not retry unchanged or disable browser security."
: "Inspect browser.tabs.list({}) and the target tab before deciding to retry; a partially completed action is not automatically safe to repeat."
return new Error(`${recovery} Chromium command ${method} failed: ${detail}`, { cause: error })
}

View file

@ -48,6 +48,7 @@ export function createBrowserFiles() {
await ready
const file = this.add(name, mime)
await writeFile(file.path, data).catch((error: unknown) => {
file.state = "failed"
throw new Error(
"Cannot write the capture on the desktop. Ask the user to check desktop temporary-directory access and free space before retrying.",
{ cause: error },

View file

@ -62,6 +62,10 @@ test("files distinguish pending, failed, unknown and missing desktop copies", as
await expect(
files.save("large.bin", "application/octet-stream", new Uint8Array(Browser.MAX_FILE_BYTES + 1)),
).rejects.toThrow("Do not retry an identical capture")
await expect(files.save(".", "text/plain", new Uint8Array([1]))).rejects.toThrow(
"Cannot write the capture on the desktop",
)
expect(files.list().find((file) => file.name === ".")?.state).toBe("failed")
} finally {
await files.dispose()
}

View file

@ -131,9 +131,11 @@ async function main() {
const second = await call("tabs.open", { url: `${fixture}/other`, focus: false })
assert.equal((await call("tabs.list", {})).tabs.length, 2)
const tabID = first.id
const upload = await rpc.write({ text: "server upload bytes" }, { location })
await fails("trace.stop", { tabID }, /browser\.trace\.start/)
await fails("cpu.stop", { tabID }, /browser\.cpu\.start/)
await fails("evaluate", { tabID, frameID: "missing-frame", script: "1" }, /browser\.frames/)
await fails("evaluate", { tabID, script: "throw new Error('fixture exception')" }, /Check the script and frameID/)
await fails("press", { tabID, key: "ControlOrMeta+A" }, /Supported modifiers are Alt, Control, Meta, and Shift/)
await fails("wait", { tabID, condition: "text" }, /requires non-empty text/)
await fails("wait", { tabID, condition: "text", text: "not-on-the-page", timeoutMs: 1 }, /check text\/frameID/)
@ -141,6 +143,7 @@ async function main() {
await call("evaluate", {
tabID,
script: `(async () => {
const radio=document.createElement('input'); radio.type='radio'; radio.checked=true; radio.setAttribute('aria-label','Selected radio'); document.querySelector('select').after(radio);
const source=document.createElement('div'); source.draggable=true; source.tabIndex=0; source.setAttribute('role','button'); source.textContent='Drag source'; source.ondragstart=e=>e.dataTransfer.setData('text/plain','element dropped'); document.body.prepend(source);
const drop=document.createElement('div'); drop.tabIndex=0; drop.setAttribute('role','button'); drop.textContent='Drop target'; drop.style.cssText='height:40px;width:300px;background:#ddd'; drop.ondragover=e=>e.preventDefault(); drop.ondrop=async e=>{e.preventDefault();document.querySelector('output').textContent=e.dataTransfer.files.length?await e.dataTransfer.files[0].text():e.dataTransfer.getData('text/plain')}; document.body.prepend(drop);
await new Promise(resolve=>{const frame=document.querySelector('iframe'); frame.onload=()=>resolve(null); frame.src=${JSON.stringify(`http://localhost:${address.port}/frame`)};});
@ -160,6 +163,10 @@ async function main() {
}
await call("fill", { tabID, ref: ref("Name"), text: "remote browser" })
await fails("fill", { tabID, ref: ref("Apply"), text: "wrong target" }, /choose a textbox/)
await fails("fill", { tabID, ref: ref("Upload"), text: "wrong target" }, /browser\.files\.upload/)
await fails("check", { tabID, ref: ref("Selected radio"), checked: false }, /Select a different radio/)
await call("check", { tabID, ref: ref("Selected radio"), checked: true })
await fails("files.upload", { tabID, ref: ref("Apply"), paths: [upload] }, /choose an input\[type=file\] ref/)
await fails("select", { tabID, ref: ref("Color"), values: ["missing-option"] }, /values are not visible labels/)
await fails("click", { tabID, ref: "e999999999" }, /tab's newest snapshot/)
await fails("screenshot", { tabID, ref: ref("Name"), fullPage: true }, /Remove the other argument/)
@ -238,7 +245,6 @@ async function main() {
const detail = await call("network.get", { tabID, id: network.requests[0].id, includeBody: true })
assert.equal(detail.responseBody.state, "text")
await fails("network.get", { tabID, id: "unknown-request" }, /Do not reload or resend/)
const upload = await rpc.write({ text: "server upload bytes" }, { location })
const fileSnap = await call("snapshot", { tabID })
const input = fileSnap.content
.split("\n")
@ -285,7 +291,9 @@ async function main() {
)
await call("evaluate", { tabID, script: "setTimeout(()=>alert('hello dialog'),0); null" })
await until(async () => (await call("dialog", { tabID, action: "get" })).dialog)
await fails("evaluate", { tabID, script: "1" }, /Inspect it with browser\.dialog/)
await call("dialog", { tabID, action: "dismiss" })
await fails("dialog", { tabID, action: "accept" }, /no JavaScript dialog to handle/)
await call("evaluate", { tabID, script: "window.open('/frame'); null" })
await until(async () => (await call("tabs.list", {})).tabs.length === 3)
await call("navigate", { tabID, url: `${fixture}/next` })