mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 21:14:35 +00:00
fix(desktop): give browser failures actionable recovery steps
This commit is contained in:
parent
02477c0ad1
commit
2d388ab6b3
11 changed files with 414 additions and 71 deletions
|
|
@ -230,21 +230,37 @@ export function createBrowserPage(
|
|||
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
await ready
|
||||
abortError(signal)
|
||||
if (closed) throw new Error("Browser tab was closed.")
|
||||
if (closed)
|
||||
throw new Error(
|
||||
"Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID; do not reuse the closed tab's refs.",
|
||||
)
|
||||
if (
|
||||
command.generation !== undefined &&
|
||||
command.generation !== generation &&
|
||||
!retainedOperations.has(command.action.type)
|
||||
)
|
||||
throw new Error("The document changed. Take a new snapshot and retry deliberately.")
|
||||
throw new Error(
|
||||
"The document changed before this operation ran. Call browser.tabs.list({}) to check its current URL, then browser.snapshot({tabID}) for fresh refs. Reconsider the action before retrying on the new page.",
|
||||
)
|
||||
if (dialog && command.action.type !== "dialog")
|
||||
throw new Error("A JavaScript dialog is open. Use browser.dialog before continuing.")
|
||||
throw new Error(
|
||||
'A JavaScript dialog is open. Inspect it with browser.dialog({tabID,action:"get"}), then explicitly accept or dismiss it before continuing.',
|
||||
)
|
||||
const modal = Promise.withResolvers<never>()
|
||||
const cancelled = Promise.withResolvers<never>()
|
||||
const cancel = () => cancelled.reject(new Error("Browser operation was cancelled."))
|
||||
const cancel = () =>
|
||||
cancelled.reject(
|
||||
new Error(
|
||||
"Browser operation was cancelled. Inspect the tab before deciding to repeat an action; cancellation does not undo changes already made.",
|
||||
),
|
||||
)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
const reject = () =>
|
||||
modal.reject(new Error("A JavaScript dialog opened. Use browser.dialog to accept or dismiss it."))
|
||||
modal.reject(
|
||||
new Error(
|
||||
'A JavaScript dialog opened while the action was running. Inspect it with browser.dialog({tabID,action:"get"}) and accept or dismiss it. Do not repeat the original action just to close the dialog.',
|
||||
),
|
||||
)
|
||||
if (command.action.type !== "dialog") dialogs.add(reject)
|
||||
try {
|
||||
return await Promise.race([execute(command.action, command.files, signal), modal.promise, cancelled.promise])
|
||||
|
|
@ -274,7 +290,9 @@ export function createBrowserPage(
|
|||
const result = (value: unknown, attached: Browser.File[] = []): Browser.Result => {
|
||||
const json = Schema.decodeUnknownSync(Schema.Json)(value)
|
||||
if (JSON.stringify(json).length > 512_000)
|
||||
throw new Error("Result is too large. Request fewer entries or a smaller snapshot.")
|
||||
throw new Error(
|
||||
"Browser result exceeds 512000 JSON characters. Request fewer entries, reduce snapshot depth, or return only selected fields from the evaluation script. Repeating the same request will not reduce its output.",
|
||||
)
|
||||
return { value: json, files: attached }
|
||||
}
|
||||
switch (action.type) {
|
||||
|
|
@ -309,7 +327,10 @@ export function createBrowserPage(
|
|||
return result({ tab: state(), ...(await snapshot(action)) })
|
||||
case "evaluate": {
|
||||
const context = action.frameID ? contexts.get(action.frameID) : undefined
|
||||
if (action.frameID && !context) throw new Error("Frame context is unavailable. Call browser.frames again.")
|
||||
if (action.frameID && !context)
|
||||
throw new Error(
|
||||
"Frame context is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab, or omit frameID to target the main frame.",
|
||||
)
|
||||
const value = await cdp.send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
|
|
@ -322,7 +343,9 @@ export function createBrowserPage(
|
|||
context?.sessionID,
|
||||
)
|
||||
if (value.exceptionDetails)
|
||||
throw new Error(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text)
|
||||
throw new Error(
|
||||
`Page JavaScript threw an exception. Check the script and frameID; inspect the page before repeating code with side effects. Details: ${(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text).slice(0, 800)}`,
|
||||
)
|
||||
abortError(signal)
|
||||
return result({ tab: state(), value: value.result.value ?? null })
|
||||
}
|
||||
|
|
@ -408,12 +431,18 @@ export function createBrowserPage(
|
|||
break
|
||||
}
|
||||
case "wait": {
|
||||
if (action.condition !== "load" && !action.text) throw new Error("text is required for a text/textGone wait.")
|
||||
if (action.condition !== "load" && !action.text)
|
||||
throw new Error(
|
||||
'browser.wait requires non-empty text for condition "text" or "textGone". Use condition "load" without text to wait for loading.',
|
||||
)
|
||||
await waitFor(
|
||||
async () => {
|
||||
if (action.condition === "load") return !contents.isLoading()
|
||||
const context = action.frameID ? contexts.get(action.frameID) : undefined
|
||||
if (action.frameID && !context) throw new Error("Frame context is unavailable.")
|
||||
if (action.frameID && !context)
|
||||
throw new Error(
|
||||
"Frame context is unavailable. Call browser.frames({tabID}) and use a frameID from this tab.",
|
||||
)
|
||||
const value = await cdp.send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
|
|
@ -427,11 +456,19 @@ export function createBrowserPage(
|
|||
},
|
||||
signal,
|
||||
action.timeoutMs,
|
||||
)
|
||||
).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)}`,
|
||||
)
|
||||
})
|
||||
break
|
||||
}
|
||||
case "screenshot": {
|
||||
if (action.ref && action.fullPage) throw new Error("Choose an element ref or fullPage, not both.")
|
||||
if (action.ref && action.fullPage)
|
||||
throw new Error(
|
||||
"Choose either ref for an element screenshot or fullPage:true for the whole page. Remove the other argument before retrying.",
|
||||
)
|
||||
await waitFor(() => view.getVisible() && win.isVisible() && !win.isMinimized(), signal, 3_000).catch(
|
||||
(error) => {
|
||||
if (signal.aborted) throw error
|
||||
|
|
@ -458,7 +495,10 @@ export function createBrowserPage(
|
|||
}
|
||||
const pixelRatio = contents.getZoomFactor() * electron.screen.getDisplayMatching(win.getBounds()).scaleFactor
|
||||
const scale = Math.min(1, (action.maxWidth ?? 2000) / (bounds.width * pixelRatio))
|
||||
if (bounds.width <= 0 || bounds.height <= 0) throw new Error("Element or page has no visible screenshot area.")
|
||||
if (bounds.width <= 0 || bounds.height <= 0)
|
||||
throw new Error(
|
||||
"Element or page has no visible screenshot area. Take a fresh snapshot and choose a visible element, or omit ref to capture the viewport.",
|
||||
)
|
||||
if (bounds.width * bounds.height * (scale * pixelRatio) ** 2 > 16_000_000)
|
||||
throw new Error("Screenshot exceeds 16 megapixels; capture an element or use a smaller maxWidth.")
|
||||
const format = action.format ?? "png"
|
||||
|
|
@ -473,7 +513,10 @@ export function createBrowserPage(
|
|||
}
|
||||
case "dialog": {
|
||||
if (action.action !== "get") {
|
||||
if (!dialog) throw new Error("This tab has no JavaScript dialog.")
|
||||
if (!dialog)
|
||||
throw new Error(
|
||||
'This tab has no JavaScript dialog to handle. browser.dialog({tabID,action:"get"}) returns null when none is open; continue without accepting or dismissing one.',
|
||||
)
|
||||
await cdp.send("Page.handleJavaScriptDialog", {
|
||||
accept: action.action === "accept",
|
||||
promptText: action.promptText,
|
||||
|
|
@ -484,7 +527,10 @@ export function createBrowserPage(
|
|||
}
|
||||
case "files.upload":
|
||||
case "files.drop": {
|
||||
if (!transfers.length) throw new Error("Upload did not include file bytes from the server.")
|
||||
if (!transfers.length)
|
||||
throw new Error(
|
||||
"Upload command has no file bytes. Supply server-local paths to browser.files.upload/drop; do not call the desktop RPC directly with desktop paths. If paths were supplied, report a client/server transfer mismatch.",
|
||||
)
|
||||
const local = await Promise.all(
|
||||
transfers.map(async (file) => files.get(await files.save(file.name, file.mime, file.data)).path),
|
||||
)
|
||||
|
|
@ -546,7 +592,9 @@ export function createBrowserPage(
|
|||
)
|
||||
}
|
||||
default:
|
||||
throw new Error("Tab management is handled by the browser session, not a page.")
|
||||
throw new Error(
|
||||
"This operation was routed to a page instead of the tab manager. Report a desktop/plugin routing mismatch; changing tab IDs or repeating the operation will not fix it.",
|
||||
)
|
||||
}
|
||||
abortError(signal)
|
||||
return result(state())
|
||||
|
|
@ -554,7 +602,10 @@ export function createBrowserPage(
|
|||
|
||||
function target(ref: Browser.Ref): Element {
|
||||
const value = refs.get(ref.replace(/^@/, ""))
|
||||
if (!value) throw new Error("Element ref is stale or belongs to another tab. Take a new snapshot.")
|
||||
if (!value)
|
||||
throw new Error(
|
||||
"Element ref is stale or belongs to another tab. Call browser.snapshot({tabID}) and use a ref from that tab's newest snapshot. Do not reuse refs after navigation or a newer snapshot.",
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +638,10 @@ export function createBrowserPage(
|
|||
async function call(element: Element, functionDeclaration: string, args: unknown[] = []) {
|
||||
const object = await cdp.send("DOM.resolveNode", { backendNodeId: element.backendID }, element.sessionID)
|
||||
const objectId = object.object.objectId
|
||||
if (!objectId) throw new Error("Element is no longer available.")
|
||||
if (!objectId)
|
||||
throw new Error(
|
||||
"Element is no longer available. Call browser.snapshot({tabID}) and use a fresh ref; the page may have replaced the element.",
|
||||
)
|
||||
try {
|
||||
const result = await cdp.send(
|
||||
"Runtime.callFunctionOn",
|
||||
|
|
@ -672,9 +726,12 @@ export function createBrowserPage(
|
|||
async function fill(element: Element, value: string) {
|
||||
const editable = await call(
|
||||
element,
|
||||
"function() { return (this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement || this.isContentEditable) && !this.disabled && !this.readOnly; }",
|
||||
"function() { const input = this instanceof HTMLInputElement && !['file','checkbox','radio','button','submit','reset','image','hidden','range','color'].includes(this.type); return (input || this instanceof HTMLTextAreaElement || this.isContentEditable) && !this.disabled && !this.readOnly; }",
|
||||
)
|
||||
if (!editable) throw new Error("Element is not an editable field.")
|
||||
if (!editable)
|
||||
throw new Error(
|
||||
"Target is not an enabled editable text field. Take a fresh snapshot and choose a textbox; use browser.select for dropdowns, browser.check for checkboxes/radios, or browser.files.upload for file inputs.",
|
||||
)
|
||||
await cdp.send("DOM.focus", { backendNodeId: element.backendID }, element.sessionID)
|
||||
await key(process.platform === "darwin" ? "Meta+A" : "Control+A")
|
||||
await key("Backspace")
|
||||
|
|
@ -684,7 +741,7 @@ export function createBrowserPage(
|
|||
async function select(element: Element, values: readonly string[]) {
|
||||
await call(
|
||||
element,
|
||||
`function(values) { if (!(this instanceof HTMLSelectElement) || this.disabled) throw new Error('Element is not an enabled select.'); if (!this.multiple && values.length !== 1) throw new Error('Select accepts one value.'); for (const value of values) if (!Array.from(this.options).some(option => option.value === value && !option.disabled)) throw new Error('Option value was not found.'); for (const option of this.options) option.selected = values.includes(option.value); this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`,
|
||||
`function(values) { if (!(this instanceof HTMLSelectElement) || this.disabled) throw new Error('Target is not an enabled HTML select. Take a fresh snapshot and choose an enabled dropdown ref.'); if (!this.multiple && values.length !== 1) throw new Error('This dropdown accepts exactly one value; pass a one-item values array.'); for (const value of values) if (!Array.from(this.options).some(option => option.value === value && !option.disabled)) throw new Error('Option value was not found or is disabled. Inspect option values with browser.evaluate before retrying browser.select; values are not visible labels.'); for (const option of this.options) option.selected = values.includes(option.value); this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`,
|
||||
[values],
|
||||
)
|
||||
}
|
||||
|
|
@ -692,20 +749,29 @@ export function createBrowserPage(
|
|||
async function check(element: Element, checked: boolean) {
|
||||
const current = await call(
|
||||
element,
|
||||
"function() { if (!(this instanceof HTMLInputElement) || !['checkbox','radio'].includes(this.type) || this.disabled) throw new Error('Element is not an enabled checkbox or radio.'); return this.checked; }",
|
||||
"function(checked) { if (!(this instanceof HTMLInputElement) || !['checkbox','radio'].includes(this.type) || this.disabled) throw new Error('Target is not an enabled checkbox or radio. Take a fresh snapshot and choose the correct ref.'); if (this.type === 'radio' && this.checked && !checked) throw new Error('A selected radio cannot be cleared by clicking it. Select a different radio in its group instead.'); return this.checked; }",
|
||||
[checked],
|
||||
)
|
||||
if (current !== checked) await click(element)
|
||||
if ((await call(element, "function() { return this.checked; }")) !== checked)
|
||||
throw new Error("Element did not reach the requested checked state.")
|
||||
throw new Error(
|
||||
"The page did not keep the requested checked state. Inspect the current snapshot and page validation before retrying; do not blindly toggle the control again.",
|
||||
)
|
||||
}
|
||||
|
||||
async function key(chord: string) {
|
||||
if (!chord) throw new Error("A key or key chord is required.")
|
||||
if (!chord)
|
||||
throw new Error(
|
||||
"A key is required. Use a named key such as Enter or ArrowDown, a single character, or a chord such as Control+A.",
|
||||
)
|
||||
const parts = (chord.endsWith("+") ? chord.slice(0, -1) : chord).split("+")
|
||||
const key = parts.pop() || "+"
|
||||
const modifiers = parts.reduce((mask, key) => {
|
||||
const bit = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key]
|
||||
if (!bit) throw new Error(`Unknown key modifier: ${key}`)
|
||||
if (!bit)
|
||||
throw new Error(
|
||||
`Unknown key modifier ${JSON.stringify(key)}. Supported modifiers are Alt, Control, Meta, and Shift; for example Control+A. Use Meta for macOS command shortcuts.`,
|
||||
)
|
||||
return mask | bit
|
||||
}, 0)
|
||||
const codes: Record<string, number> = {
|
||||
|
|
@ -731,7 +797,10 @@ export function createBrowserPage(
|
|||
: /^F([1-9]|1[0-2])$/.test(key)
|
||||
? 111 + Number(key.slice(1))
|
||||
: undefined)
|
||||
if (code === undefined) throw new Error(`Unknown key: ${key}`)
|
||||
if (code === undefined)
|
||||
throw new Error(
|
||||
`Unknown key ${JSON.stringify(key)}. Use Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, PageUp/Down, Home, End, Space, F1–F12, or one character. Use browser.fill for text.`,
|
||||
)
|
||||
const params = {
|
||||
key: key === "Space" ? " " : key,
|
||||
windowsVirtualKeyCode: code,
|
||||
|
|
@ -747,13 +816,18 @@ export function createBrowserPage(
|
|||
const selected = action.type === "snapshot" && action.ref ? target(action.ref) : undefined
|
||||
const frameID = selected?.frameID ?? action.frameID ?? tree[0]?.id
|
||||
if (!frameID || !tree.some((frame) => frame.id === frameID))
|
||||
throw new Error("Frame is unavailable. Call browser.frames again.")
|
||||
throw new Error(
|
||||
"Frame is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab; omit frameID for the main frame.",
|
||||
)
|
||||
const sessionID = sessions.get(frameID)
|
||||
const depth = action.type === "snapshot" ? (action.depth ?? 8) : 8
|
||||
const ax = await cdp.send("Accessibility.getFullAXTree", { frameId: frameID, depth }, sessionID)
|
||||
const nodes = new Map(ax.nodes.map((node) => [node.nodeId, node]))
|
||||
const root = selected ? ax.nodes.find((node) => node.backendDOMNodeId === selected.backendID) : ax.nodes[0]
|
||||
if (!root) throw new Error("Element is absent from the accessibility snapshot.")
|
||||
if (!root)
|
||||
throw new Error(
|
||||
"Element is absent from this frame's accessibility snapshot. Retry browser.snapshot with the same tabID and no ref to refresh the frame, then choose a returned ref.",
|
||||
)
|
||||
refs.clear()
|
||||
const lines: string[] = []
|
||||
let truncated = false
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Deferred, Effect, ManagedRuntime, Queue, Schema, Stream } from "effect"
|
|||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
|
||||
import { createBrowserPage, destinationOrigin, type BrowserPage } from "./browser-chromium"
|
||||
import { browserFailure } from "./browser/errors"
|
||||
import { emitIpcEvent } from "./ipc-events"
|
||||
|
||||
type Entry = {
|
||||
|
|
@ -125,11 +126,7 @@ export function createBrowserPane() {
|
|||
})
|
||||
const outcome: Browser.Outcome = await execute(entry, command, abort.signal).then(
|
||||
(result) => ({ type: "success" as const, result }),
|
||||
(error: unknown) => ({
|
||||
type: "failure" as const,
|
||||
code: "operation_failed",
|
||||
message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
|
||||
}),
|
||||
(error: unknown) => browserFailure(command.action, error),
|
||||
)
|
||||
Queue.offerUnsafe(
|
||||
outbound,
|
||||
|
|
@ -244,7 +241,10 @@ export function createBrowserPane() {
|
|||
|
||||
async function closePage(entry: Entry, tabID: Browser.TabID, error?: string) {
|
||||
const page = entry.pages.get(tabID)
|
||||
if (!page) throw new Error("Browser tab is unavailable.")
|
||||
if (!page)
|
||||
throw new Error(
|
||||
"This tab is no longer available. Call browser.tabs.list({}) and use an existing tabID from this session.",
|
||||
)
|
||||
const focused = entry.focusedTabID === tabID
|
||||
entry.requests.forEach((request) => {
|
||||
if (request.tabID === tabID) request.abort.abort()
|
||||
|
|
@ -307,7 +307,10 @@ export function createBrowserPane() {
|
|||
tabs: Array.from(entry.pages.values(), (page) => page.state()),
|
||||
focusedTabID: entry.focusedTabID,
|
||||
})
|
||||
if (signal.aborted) throw new Error("Browser request was cancelled.")
|
||||
if (signal.aborted)
|
||||
throw new Error(
|
||||
"Browser request was cancelled. Do not repeat a mutating action until you have inspected its outcome.",
|
||||
)
|
||||
if (action.type === "tabs.list") return { value: state(), files: [] }
|
||||
if (action.type === "tabs.open") {
|
||||
const page = create(entry)
|
||||
|
|
@ -321,7 +324,10 @@ export function createBrowserPane() {
|
|||
return { value: page.state(), files: [] }
|
||||
}
|
||||
const page = entry.pages.get(action.tabID)
|
||||
if (!page) throw new Error("Browser tab is unavailable. Call browser.tabs.list.")
|
||||
if (!page)
|
||||
throw new Error(
|
||||
"Browser tab is unavailable. Call browser.tabs.list({}) and use an existing tabID from this session; a closed tab is not replaced automatically.",
|
||||
)
|
||||
if (action.type === "tabs.focus") {
|
||||
focus(entry, action.tabID)
|
||||
return { value: page.state(), files: [] }
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@ const Heap = Schema.Struct({
|
|||
})
|
||||
|
||||
export function analyzeTrace(value: unknown, limit = 100) {
|
||||
const trace = Schema.decodeUnknownSync(Trace)(value)
|
||||
const decoded = Schema.decodeUnknownOption(Trace)(value)
|
||||
if (decoded._tag === "None")
|
||||
throw new Error(
|
||||
"Selected file is not a Chromium performance trace. Use a fileID returned by browser.trace.stop for this tab; CPU profiles and heap snapshots use their own analysis tools.",
|
||||
)
|
||||
const trace = decoded.value
|
||||
const events = new Map<string, { name: string; count: number; totalMs: number; maxMs: number }>()
|
||||
const longTasks: number[] = []
|
||||
trace.traceEvents.forEach((event) => {
|
||||
|
|
@ -74,7 +79,12 @@ export function analyzeTrace(value: unknown, limit = 100) {
|
|||
}
|
||||
|
||||
export function analyzeCpu(value: unknown, limit = 100) {
|
||||
const profile = Schema.decodeUnknownSync(Cpu)(value)
|
||||
const decoded = Schema.decodeUnknownOption(Cpu)(value)
|
||||
if (decoded._tag === "None")
|
||||
throw new Error(
|
||||
"Selected file is not a CPU profile. Use a fileID returned by browser.cpu.stop for this tab, not a trace or heap snapshot.",
|
||||
)
|
||||
const profile = decoded.value
|
||||
const times = new Map<number, number>()
|
||||
profile.samples?.forEach((id, index) =>
|
||||
times.set(id, (times.get(id) ?? 0) + (profile.timeDeltas?.[index] ?? 0) / 1000),
|
||||
|
|
@ -95,7 +105,12 @@ export function analyzeCpu(value: unknown, limit = 100) {
|
|||
}
|
||||
|
||||
export function parseHeap(value: unknown) {
|
||||
const heap = Schema.decodeUnknownSync(Heap)(value)
|
||||
const decoded = Schema.decodeUnknownOption(Heap)(value)
|
||||
if (decoded._tag === "None")
|
||||
throw new Error(
|
||||
"Selected file is not a V8 heap snapshot. Use a fileID returned by browser.heap.snapshot for this tab, not a trace or CPU profile.",
|
||||
)
|
||||
const heap = decoded.value
|
||||
const fields = heap.snapshot.meta.node_fields
|
||||
const edgeFields = heap.snapshot.meta.edge_fields
|
||||
const width = fields.length
|
||||
|
|
@ -117,10 +132,15 @@ export function parseHeap(value: unknown) {
|
|||
heap.nodes.length % width ||
|
||||
heap.edges.length % edgeWidth
|
||||
)
|
||||
throw new Error("Unsupported heap snapshot layout.")
|
||||
throw new Error(
|
||||
"Heap snapshot layout is unsupported or incomplete. Use a complete capture from browser.heap.snapshot; if this tool produced it, report a parser/Chromium compatibility issue instead of repeatedly capturing the same heap.",
|
||||
)
|
||||
const types = heap.snapshot.meta.node_types[indexes.type]
|
||||
const edgeTypes = heap.snapshot.meta.edge_types[indexes.edgeType]
|
||||
if (!Array.isArray(types) || !Array.isArray(edgeTypes)) throw new Error("Unsupported heap snapshot types.")
|
||||
if (!Array.isArray(types) || !Array.isArray(edgeTypes))
|
||||
throw new Error(
|
||||
"Heap snapshot type tables are unsupported. Use a complete capture from browser.heap.snapshot and report the compatibility issue if it persists.",
|
||||
)
|
||||
const node = (offset: number) => ({
|
||||
id: heap.nodes[offset + indexes.id],
|
||||
name: (heap.strings[heap.nodes[offset + indexes.name]] ?? "").slice(0, 100_000),
|
||||
|
|
@ -161,7 +181,10 @@ export function parseHeap(value: unknown) {
|
|||
},
|
||||
object(id: number, limit = 100) {
|
||||
const target = heap.nodes.findIndex((value, index) => index % width === indexes.id && value === id) - indexes.id
|
||||
if (target < 0) throw new Error("Object ID was not found in this heap snapshot.")
|
||||
if (target < 0)
|
||||
throw new Error(
|
||||
"Object ID was not found in this heap snapshot. Call browser.heap.query with the same tabID and fileID, then copy an exact returned object id. Object IDs cannot be reused across snapshots.",
|
||||
)
|
||||
const references: { name: string; node: ReturnType<typeof node> }[] = []
|
||||
const retainers: { name: string; node: ReturnType<typeof node> }[] = []
|
||||
let edgeOffset = 0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { WebContents } from "electron"
|
||||
import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js"
|
||||
import { protocolError } from "./errors"
|
||||
|
||||
export type Cdp = ReturnType<typeof createCdp>
|
||||
|
||||
|
|
@ -23,10 +24,17 @@ export function createCdp(contents: WebContents) {
|
|||
params: object = {},
|
||||
sessionID?: string,
|
||||
): Promise<ProtocolMapping.Commands[Method]["returnType"]> {
|
||||
if (contents.isDestroyed()) throw new Error("Browser tab was closed.")
|
||||
if (!contents.debugger.isAttached()) contents.debugger.attach("1.3")
|
||||
// Electron is the CDP boundary. Its native response follows the selected protocol method.
|
||||
return contents.debugger.sendCommand(method, params, sessionID)
|
||||
if (contents.isDestroyed())
|
||||
throw new Error(
|
||||
"Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID, or browser.tabs.open({}) if no tabs remain.",
|
||||
)
|
||||
// attach can throw synchronously; sendCommand can reject asynchronously.
|
||||
try {
|
||||
if (!contents.debugger.isAttached()) contents.debugger.attach("1.3")
|
||||
return await contents.debugger.sendCommand(method, params, sessionID)
|
||||
} catch (error) {
|
||||
throw protocolError(method, error)
|
||||
}
|
||||
},
|
||||
on<Method extends keyof ProtocolMapping.Events>(
|
||||
method: Method,
|
||||
|
|
@ -50,7 +58,10 @@ export function createCdp(contents: WebContents) {
|
|||
}
|
||||
|
||||
export function abortError(signal: AbortSignal) {
|
||||
if (signal.aborted) throw new Error("Browser operation was cancelled.")
|
||||
if (signal.aborted)
|
||||
throw new Error(
|
||||
"Browser operation was cancelled. Inspect the tab before deciding to repeat an action; cancellation does not undo changes already made.",
|
||||
)
|
||||
}
|
||||
|
||||
export async function waitFor(check: () => boolean | Promise<boolean>, signal: AbortSignal, timeoutMs = 10_000) {
|
||||
|
|
|
|||
|
|
@ -187,7 +187,10 @@ export function createDiagnostics(cdp: Cdp) {
|
|||
},
|
||||
async get(input: Extract<Browser.Action, { type: "network.get" }>) {
|
||||
const request = requests.get(input.id)
|
||||
if (!request) throw new Error("Request is no longer retained in this tab. Call browser.network.list.")
|
||||
if (!request)
|
||||
throw new Error(
|
||||
"Request ID is no longer retained in this tab or belongs to another tab. Call browser.network.list({tabID}) and copy a current id into browser.network.get with the same tabID. Do not reload or resend a request just to inspect it.",
|
||||
)
|
||||
const max = input.maxBodyChars ?? 20_000
|
||||
const text = (value: string): Browser.Body => ({
|
||||
state: "text",
|
||||
|
|
|
|||
45
packages/desktop/src/main/browser/errors.ts
Normal file
45
packages/desktop/src/main/browser/errors.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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."
|
||||
return new Error(`${recovery} Chromium command ${method} failed: ${detail}`, { cause: error })
|
||||
}
|
||||
|
||||
export function browserFailure(action: Browser.Action, error: unknown): Extract<Browser.Outcome, { type: "failure" }> {
|
||||
const detail = message(error, 1_700)
|
||||
const navigation = ["tabs.open", "navigate", "back", "forward", "reload"].includes(action.type)
|
||||
const network = navigation ? detail.match(/\bERR_[A-Z_]+\b/)?.[0] : undefined
|
||||
const hint =
|
||||
network === "ERR_CONNECTION_REFUSED"
|
||||
? "The desktop could not connect to the site. Check its hostname/port and that the site is reachable from the desktop. localhost means the desktop, not the remote server."
|
||||
: network === "ERR_NAME_NOT_RESOLVED"
|
||||
? "The desktop could not resolve the hostname. Check the URL spelling and the desktop's DNS/network connection."
|
||||
: network?.startsWith("ERR_CERT_") || network?.startsWith("ERR_SSL_")
|
||||
? "The desktop rejected the site's TLS connection. Ask the user to fix the certificate or trust configuration; do not bypass certificate checks."
|
||||
: network === "ERR_ABORTED"
|
||||
? "Navigation was interrupted or became a download. Inspect browser.tabs.list({}) and browser.files.list({tabID}) before deciding to navigate again."
|
||||
: network
|
||||
? "The site failed to load from the desktop. Check the URL and desktop connectivity before retrying; inspect the current tab first."
|
||||
: action.type === "screenshot" && /UnknownVizError|capture.*(?:failed|unavailable)/i.test(detail)
|
||||
? "Chromium could not capture a rendered frame. Call browser.tabs.focus({tabID}) and keep the desktop window visible. If it is already visible, report the capture failure instead of repeating it unchanged."
|
||||
: undefined
|
||||
return {
|
||||
type: "failure",
|
||||
code: network ? "navigation_failed" : "operation_failed",
|
||||
message: `browser.${action.type} failed. ${hint ? `${hint} Details: ${detail.slice(0, 400)}` : detail}`.slice(
|
||||
0,
|
||||
2_048,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function message(error: unknown, limit = 400) {
|
||||
return (error instanceof Error ? error.message : String(error)).slice(0, limit)
|
||||
}
|
||||
|
|
@ -42,26 +42,61 @@ export function createBrowserFiles() {
|
|||
},
|
||||
async save(name: string, mime: string, data: Uint8Array) {
|
||||
if (data.byteLength > Browser.MAX_FILE_BYTES)
|
||||
throw new Error("File exceeds the 5 MiB transfer limit. Capture a smaller file.")
|
||||
throw new Error(
|
||||
"Capture exceeds the 5 MiB transfer limit. Reduce screenshot maxWidth/quality or trace duration; for a heap snapshot, use a smaller page/test case. Do not retry an identical capture.",
|
||||
)
|
||||
await ready
|
||||
const file = this.add(name, mime)
|
||||
await writeFile(file.path, data)
|
||||
await writeFile(file.path, data).catch((error: unknown) => {
|
||||
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 },
|
||||
)
|
||||
})
|
||||
file.bytes = data.byteLength
|
||||
file.state = "completed"
|
||||
return file.id
|
||||
},
|
||||
get(id: Browser.FileID) {
|
||||
const file = files.get(id)
|
||||
if (!file || file.state !== "completed")
|
||||
throw new Error("File is not available in this tab. Call browser.files.list.")
|
||||
if (!file)
|
||||
throw new Error(
|
||||
"File ID is not retained in this tab. Call browser.files.list({tabID}) and use an exact returned fileID from the same tab, not a server path or request ID.",
|
||||
)
|
||||
if (file.state === "pending")
|
||||
throw new Error(
|
||||
"File is still being downloaded or captured. Check browser.files.list({tabID}) again and wait for state completed; do not start a duplicate download.",
|
||||
)
|
||||
if (file.state === "failed")
|
||||
throw new Error(
|
||||
"The download or capture failed, so this file cannot be read. Inspect browser.console and browser.network.list for the cause before deciding to start it again.",
|
||||
)
|
||||
return file
|
||||
},
|
||||
async transfer(id: Browser.FileID): Promise<Browser.File> {
|
||||
const file = this.get(id)
|
||||
if ((await stat(file.path)).size > Browser.MAX_FILE_BYTES)
|
||||
throw new Error("File exceeds the 5 MiB transfer limit.")
|
||||
return { id, name: file.name, mime: file.mime, data: new Uint8Array(await readFile(file.path)) }
|
||||
if (
|
||||
(
|
||||
await stat(file.path).catch((error: unknown) => {
|
||||
throw unavailableFile(error)
|
||||
})
|
||||
).size > Browser.MAX_FILE_BYTES
|
||||
)
|
||||
throw new Error(
|
||||
"File exceeds the 5 MiB transfer limit. Choose a smaller completed file; repeating browser.files.get for this file will not help.",
|
||||
)
|
||||
const data = await readFile(file.path).catch((error: unknown) => {
|
||||
throw unavailableFile(error)
|
||||
})
|
||||
return { id, name: file.name, mime: file.mime, data: new Uint8Array(data) }
|
||||
},
|
||||
dispose: () => ready.then(() => rm(directory, { recursive: true, force: true })),
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableFile(error: unknown) {
|
||||
return new Error(
|
||||
"The retained file cannot be read on the desktop. Its temporary copy may have been removed. Use an already exported server-local path if available, or deliberately create a new capture.",
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ export async function audit(contents: WebContents, files: BrowserFiles, cdp: Cdp
|
|||
disableFullPageScreenshot: true,
|
||||
},
|
||||
})
|
||||
if (!result) throw new Error("Lighthouse did not produce a report.")
|
||||
if (!result)
|
||||
throw new Error(
|
||||
"Lighthouse did not produce a report. Check browser.console and browser.network.list for page failures and confirm the tab has loaded. Report an audit failure if the page is healthy; do not repeat the audit unchanged.",
|
||||
)
|
||||
return {
|
||||
scores: Object.values(result.lhr.categories).map((category) => ({
|
||||
id: category.id,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { WebContents } from "electron"
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { gzipSync, gunzipSync } from "node:zlib"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { Schema } from "effect"
|
||||
import type { Cdp } from "./cdp"
|
||||
import type { BrowserFiles } from "./files"
|
||||
|
|
@ -28,14 +27,26 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
| undefined
|
||||
let takingHeap = false
|
||||
const json = async (id: Browser.FileID) => {
|
||||
const file = files.get(id)
|
||||
const data = await readFile(file.path)
|
||||
return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
(file.name.endsWith(".gz") ? gunzipSync(data, { maxOutputLength: 128 * 1024 * 1024 }) : data).toString("utf8"),
|
||||
)
|
||||
const file = await files.transfer(id)
|
||||
try {
|
||||
const data = Buffer.from(file.data)
|
||||
return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
(file.name.endsWith(".gz") ? gunzipSync(data, { maxOutputLength: 128 * 1024 * 1024 }) : data).toString("utf8"),
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Selected file cannot be decoded as a JSON capture, or expands beyond the 128 MiB analysis limit. Call browser.files.list({tabID}) and choose the fileID from the matching trace, CPU, or heap capture, not a screenshot/download. Do not retry the same invalid file.",
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
const stopCpu = () => {
|
||||
if (!cpu) return Promise.reject(new Error("No CPU profile has been started in this tab."))
|
||||
if (!cpu)
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"No CPU profile has been started in this tab. Call browser.cpu.start({tabID}), perform the interaction to inspect, then browser.cpu.stop({tabID}).",
|
||||
),
|
||||
)
|
||||
if (cpu.result) return cpu.result
|
||||
clearTimeout(cpu.timer)
|
||||
cpu.result = cdp.send("Profiler.stop").then(async ({ profile }) => ({
|
||||
|
|
@ -46,7 +57,12 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
}
|
||||
return {
|
||||
async startTrace(durationMs = 10_000) {
|
||||
if (recording) throw new Error("Another performance trace is active. Do not stop another tab's recording.")
|
||||
if (recording)
|
||||
throw new Error(
|
||||
recording.owner === contents
|
||||
? "A performance trace is already active in this tab. Use browser.trace.stop({tabID}) to finish it before starting another."
|
||||
: "Another tab owns the active performance trace. Wait for its owner to finish; do not stop or replace another tab's recording.",
|
||||
)
|
||||
const complete = Promise.withResolvers<{ stream?: string; dataLossOccurred: boolean }>()
|
||||
const off = cdp.on("Tracing.tracingComplete", (event) => complete.resolve(event))
|
||||
const owner = {
|
||||
|
|
@ -61,7 +77,12 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
const durationMs = performance.now() - owner.started
|
||||
const deadline = Promise.withResolvers<never>()
|
||||
const timeout = setTimeout(
|
||||
() => deadline.reject(new Error("Chromium did not finish flushing the trace within 10 seconds.")),
|
||||
() =>
|
||||
deadline.reject(
|
||||
new Error(
|
||||
"Chromium did not finish flushing the trace within 10 seconds. No complete export is confirmed. Check browser.files.list({tabID}); do not start another recording until the current trace has finished or the user resolves the failure.",
|
||||
),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
try {
|
||||
|
|
@ -69,7 +90,10 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
cdp.send("Tracing.end").then(() => complete.promise),
|
||||
deadline.promise,
|
||||
])
|
||||
if (!result.stream) throw new Error("Chromium did not return a trace stream.")
|
||||
if (!result.stream)
|
||||
throw new Error(
|
||||
"Chromium stopped tracing without returning a trace stream. No export is available. Check desktop/plugin compatibility and report the failure; repeating trace.stop cannot recover a missing stream.",
|
||||
)
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
try {
|
||||
|
|
@ -77,7 +101,10 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
const part = await cdp.send("IO.read", { handle: result.stream, size: 256 * 1024 })
|
||||
const buffer = Buffer.from(part.data, part.base64Encoded ? "base64" : "utf8")
|
||||
bytes += buffer.byteLength
|
||||
if (bytes > 64 * 1024 * 1024) throw new Error("Trace exceeded its 64 MiB local capture limit.")
|
||||
if (bytes > 64 * 1024 * 1024)
|
||||
throw new Error(
|
||||
"Trace exceeded its 64 MiB desktop capture limit. Record a shorter interaction with a smaller durationMs in browser.trace.start; do not repeat the same recording unchanged.",
|
||||
)
|
||||
chunks.push(buffer)
|
||||
if (part.eof) break
|
||||
}
|
||||
|
|
@ -148,10 +175,17 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
stopTrace() {
|
||||
if (recording?.owner === contents) return recording.finish()
|
||||
if (trace) return trace
|
||||
return Promise.reject(new Error("This tab has no performance trace to stop."))
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"This tab has no performance trace to stop. Call browser.trace.start({tabID}), perform the interaction to inspect, then browser.trace.stop({tabID}).",
|
||||
),
|
||||
)
|
||||
},
|
||||
async startCpu() {
|
||||
if (cpu && !cpu.result) throw new Error("A CPU profile is already active in this tab.")
|
||||
if (cpu && !cpu.result)
|
||||
throw new Error(
|
||||
"A CPU profile is already active in this tab. Use browser.cpu.stop({tabID}) before starting another profile.",
|
||||
)
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.start")
|
||||
cpu = { started: Date.now() }
|
||||
|
|
@ -161,7 +195,10 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
},
|
||||
stopCpu,
|
||||
async heap() {
|
||||
if (takingHeap) throw new Error("A heap snapshot is already being captured in this tab.")
|
||||
if (takingHeap)
|
||||
throw new Error(
|
||||
"A heap snapshot is already being captured in this tab. Await that call before taking another; do not capture the same tab's heaps in parallel.",
|
||||
)
|
||||
takingHeap = true
|
||||
const chunks: string[] = []
|
||||
let size = 0
|
||||
|
|
@ -178,7 +215,10 @@ export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserF
|
|||
takingHeap = false
|
||||
off()
|
||||
}
|
||||
if (overflow) throw new Error("Heap snapshot exceeded the 128 MiB local capture limit.")
|
||||
if (overflow)
|
||||
throw new Error(
|
||||
"Heap snapshot exceeded the 128 MiB desktop capture limit. Use a smaller page/test case or ask the user to inspect the heap with desktop developer tools; this tool has no size override.",
|
||||
)
|
||||
return files.save("heap.heapsnapshot.gz", "application/gzip", gzipSync(chunks.join("")))
|
||||
},
|
||||
async analyze(
|
||||
|
|
|
|||
74
packages/desktop/test/browser-errors.test.ts
Normal file
74
packages/desktop/test/browser-errors.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { browserFailure, protocolError } from "../src/main/browser/errors"
|
||||
import { createBrowserFiles } from "../src/main/browser/files"
|
||||
import { analyzeCpu, analyzeTrace, parseHeap } from "../src/main/browser/analysis"
|
||||
|
||||
const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
|
||||
|
||||
test("navigation failures explain the desktop network and never recommend disabling TLS", () => {
|
||||
const action: Browser.Action = { type: "navigate", tabID, url: "https://example.com" }
|
||||
const refused = browserFailure(action, new Error("net::ERR_CONNECTION_REFUSED"))
|
||||
expect(refused.code).toBe("navigation_failed")
|
||||
expect(refused.message).toContain("localhost means the desktop")
|
||||
expect(refused.message).toContain("hostname/port")
|
||||
const tls = browserFailure(action, new Error("net::ERR_CERT_AUTHORITY_INVALID"))
|
||||
expect(tls.message).toContain("do not bypass certificate checks")
|
||||
const aborted = browserFailure(action, new Error("net::ERR_ABORTED"))
|
||||
expect(aborted.message).toContain("browser.files.list({tabID})")
|
||||
})
|
||||
|
||||
test("native protocol errors keep the cause and give a valid recovery operation", () => {
|
||||
expect(protocolError("DOM.resolveNode", new Error("Could not find node with given id")).message).toContain(
|
||||
"browser.snapshot({tabID})",
|
||||
)
|
||||
expect(protocolError("Runtime.evaluate", new Error("Cannot find context with specified id")).message).toContain(
|
||||
"browser.frames({tabID})",
|
||||
)
|
||||
const unsupported = protocolError("Target.getBrowserContexts", new Error("Not allowed"))
|
||||
expect(unsupported.message).toContain("does not support or allow")
|
||||
expect(unsupported.message).toContain("do not retry unchanged")
|
||||
expect(unsupported.cause).toBeInstanceOf(Error)
|
||||
const failure = browserFailure({ type: "screenshot", tabID }, new Error("UnknownVizError"))
|
||||
expect(failure.message).toContain("browser.tabs.focus({tabID})")
|
||||
expect(failure.message).toContain("report the capture failure")
|
||||
})
|
||||
|
||||
test("long page errors retain the operation context without classifying script text as a navigation error", () => {
|
||||
const failure = browserFailure(
|
||||
{ type: "evaluate", tabID, script: "throw Error()" },
|
||||
new Error("ERR_CONNECTION_REFUSED " + "x".repeat(10_000)),
|
||||
)
|
||||
expect(failure.code).toBe("operation_failed")
|
||||
expect(failure.message.startsWith("browser.evaluate failed.")).toBe(true)
|
||||
expect(failure.message.length).toBeLessThanOrEqual(2_048)
|
||||
})
|
||||
|
||||
test("files distinguish pending, failed, unknown and missing desktop copies", async () => {
|
||||
const files = createBrowserFiles()
|
||||
await files.ready
|
||||
try {
|
||||
const pending = files.add("download.txt", "text/plain")
|
||||
expect(() => files.get(pending.id)).toThrow("do not start a duplicate download")
|
||||
pending.state = "failed"
|
||||
expect(() => files.get(pending.id)).toThrow("Inspect browser.console")
|
||||
expect(() => files.get(Browser.FileID.make(`file_${crypto.randomUUID()}`))).toThrow(
|
||||
"not a server path or request ID",
|
||||
)
|
||||
const id = await files.save("capture.json", "application/json", new TextEncoder().encode("{}"))
|
||||
await rm(files.get(id).path)
|
||||
await expect(files.transfer(id)).rejects.toThrow("on the desktop")
|
||||
await expect(
|
||||
files.save("large.bin", "application/octet-stream", new Uint8Array(Browser.MAX_FILE_BYTES + 1)),
|
||||
).rejects.toThrow("Do not retry an identical capture")
|
||||
} finally {
|
||||
await files.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("wrong capture formats identify the matching capture tool", () => {
|
||||
expect(() => analyzeTrace({ nodes: [] })).toThrow("browser.trace.stop")
|
||||
expect(() => analyzeCpu({ traceEvents: [] })).toThrow("browser.cpu.stop")
|
||||
expect(() => parseHeap({ traceEvents: [] })).toThrow("browser.heap.snapshot")
|
||||
})
|
||||
|
|
@ -114,6 +114,14 @@ async function main() {
|
|||
visited.add(name)
|
||||
return Schema.decodeUnknownSync(operation.output)(JSON.parse(result.output)) as Output<Name>
|
||||
}
|
||||
async function fails(name: Browser.Method, input: object, expected: RegExp) {
|
||||
const result = await rpc.execute(
|
||||
{ sessionID: session.id, code: `return await tools.browser.${name}(${JSON.stringify(input)})` },
|
||||
{ location },
|
||||
)
|
||||
assert.equal(result.error, true, `Expected browser.${name} to fail`)
|
||||
assert.match(result.output, expected)
|
||||
}
|
||||
try {
|
||||
await pane.register(win, "suite", {
|
||||
sessionID: session.id,
|
||||
|
|
@ -123,6 +131,13 @@ 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
|
||||
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("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/)
|
||||
await fails("files.get", { tabID, fileID: `file_${crypto.randomUUID()}` }, /not a server path or request ID/)
|
||||
await call("evaluate", {
|
||||
tabID,
|
||||
script: `(async () => {
|
||||
|
|
@ -144,6 +159,10 @@ async function main() {
|
|||
return Browser.Ref.make(match)
|
||||
}
|
||||
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("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/)
|
||||
await call("hover", { tabID, ref: ref("Apply") })
|
||||
await call("click", { tabID, ref: ref("Apply") })
|
||||
assert.equal(
|
||||
|
|
@ -202,6 +221,7 @@ async function main() {
|
|||
)
|
||||
const found = await call("find", { tabID, text: "Apply" })
|
||||
assert(found.content.includes("Apply"))
|
||||
await fails("screenshot", { tabID }, /Screenshot needs a visible tab/)
|
||||
await call("tabs.focus", { tabID })
|
||||
const screenshot = await call("screenshot", { tabID, fullPage: true, maxWidth: 1000 })
|
||||
const screenshotBytes = await rpc.read({ path: screenshot.files[0].path }, { location })
|
||||
|
|
@ -217,6 +237,7 @@ async function main() {
|
|||
assert(network.requests.length)
|
||||
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
|
||||
|
|
@ -271,6 +292,8 @@ async function main() {
|
|||
await call("back", { tabID })
|
||||
await call("forward", { tabID })
|
||||
await call("trace.start", { tabID, durationMs: 30_000 })
|
||||
await fails("trace.start", { tabID }, /browser\.trace\.stop/)
|
||||
await fails("trace.start", { tabID: second.id }, /do not stop or replace another tab's recording/)
|
||||
await call("reload", { tabID })
|
||||
await call("evaluate", {
|
||||
tabID,
|
||||
|
|
@ -280,6 +303,7 @@ async function main() {
|
|||
const traceAnalysis = await call("trace.analyze", { tabID, fileID: trace.files[0].id })
|
||||
assert(traceAnalysis.metrics[0].value > 0)
|
||||
await call("cpu.start", { tabID })
|
||||
await fails("cpu.start", { tabID }, /browser\.cpu\.stop/)
|
||||
await call("evaluate", { tabID, script: "Array.from({length:100000},(_,i)=>Math.sqrt(i)).reduce((a,b)=>a+b,0)" })
|
||||
const cpu = await call("cpu.stop", { tabID })
|
||||
await call("cpu.analyze", { tabID, fileID: cpu.files[0].id })
|
||||
|
|
@ -289,6 +313,11 @@ async function main() {
|
|||
const query = await call("heap.query", { tabID, fileID: heapID, name: "Object", limit: 3 })
|
||||
assert(query.nodes.length)
|
||||
await call("heap.object", { tabID, fileID: heapID, id: query.nodes[0].id })
|
||||
await fails(
|
||||
"heap.object",
|
||||
{ tabID, fileID: heapID, id: Number.MAX_SAFE_INTEGER },
|
||||
/Object IDs cannot be reused across snapshots/,
|
||||
)
|
||||
assert.equal((await call("heap.compare", { tabID, before: heapID, after: heapID })).classes.length, 0)
|
||||
const audit = await call("lighthouse", { tabID })
|
||||
assert(audit.scores.some((score) => score.id === "accessibility"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue