diff --git a/extension/firefox-compat.js b/extension/firefox-compat.js index 976e5c5..43ae3de 100644 --- a/extension/firefox-compat.js +++ b/extension/firefox-compat.js @@ -442,6 +442,7 @@ const networkConditionsByTab = new Map(); const userAgentByTab = new Map(); const bypassCspTabs = new Set(); + const dynamicEvalBlockedFrames = new Set(); const emulationStateByTab = new Map(); const pendingDialogsByFrame = new Map(); const autoAttachByTab = new Map(); @@ -650,8 +651,223 @@ return objectId ? state.objects.get(objectId) : state.nodes.get(id); }; const editableTarget = (target) => target?.closest?.('input,textarea,select,button,a[href],[contenteditable="true"],[tabindex]') ?? target; + const deepestElementFromPoint = (x, y) => { + let target = document.elementFromPoint(x, y) ?? document.body ?? document.documentElement; + while (target?.shadowRoot?.elementFromPoint != null) { + const inner = target.shadowRoot.elementFromPoint(x, y); + if (inner == null || inner === target) break; + target = inner; + } + return target; + }; + const scrollAtPoint = (x, y, deltaX, deltaY) => { + const hitTarget = deepestElementFromPoint(x, y); + let scrollTarget = hitTarget instanceof Element ? hitTarget : hitTarget?.parentElement; + let remainingX = deltaX; + let remainingY = deltaY; + let lastScrolled = null; + while (scrollTarget instanceof Element) { + const style = getComputedStyle(scrollTarget); + const scrollableX = /^(auto|scroll|overlay)$/.test(style.overflowX || style.overflow) + && scrollTarget.scrollWidth > scrollTarget.clientWidth; + const scrollableY = /^(auto|scroll|overlay)$/.test(style.overflowY || style.overflow) + && scrollTarget.scrollHeight > scrollTarget.clientHeight; + const canConsumeX = scrollableX && (remainingX < 0 ? scrollTarget.scrollLeft > 0 : remainingX > 0 && scrollTarget.scrollLeft < scrollTarget.scrollWidth - scrollTarget.clientWidth); + const canConsumeY = scrollableY && (remainingY < 0 ? scrollTarget.scrollTop > 0 : remainingY > 0 && scrollTarget.scrollTop < scrollTarget.scrollHeight - scrollTarget.clientHeight); + if (canConsumeX || canConsumeY) { + scrollTarget.scrollBy({ left: canConsumeX ? remainingX : 0, top: canConsumeY ? remainingY : 0, behavior: "instant" }); + if (canConsumeX) remainingX = 0; + if (canConsumeY) remainingY = 0; + lastScrolled = scrollTarget; + if (remainingX === 0 && remainingY === 0) return lastScrolled; + } + scrollTarget = scrollTarget.parentElement ?? scrollTarget.getRootNode?.()?.host ?? null; + } + if (remainingX !== 0 || remainingY !== 0) { + window.scrollBy({ left: remainingX, top: remainingY, behavior: "instant" }); + return window; + } + return lastScrolled; + }; + + const serializeDomNode = (node, currentDepth, maxDepth, frameOwners) => { + const id = nodeId(node); + const serialized = { + nodeId: id, + backendNodeId: id, + nodeType: node.nodeType, + nodeName: node.nodeName, + localName: node.localName ?? "", + nodeValue: node.nodeValue ?? "", + childNodeCount: node.childNodes?.length ?? 0, + attributes: node.nodeType === Node.ELEMENT_NODE + ? [...node.attributes].flatMap((attribute) => [attribute.name, attribute.value]) + : [], + }; + if (currentDepth < maxDepth || maxDepth < 0) { + serialized.children = [...(node.childNodes ?? [])] + .map((child) => serializeDomNode(child, currentDepth + 1, maxDepth, frameOwners)); + } + if (node === document) { + serialized.documentURL = location.href; + serialized.baseURL = document.baseURI; + } + if (node instanceof HTMLIFrameElement || node instanceof HTMLFrameElement) { + const ownerIndex = frameOwners.indexOf(node); + const source = node.hasAttribute("srcdoc") ? "about:srcdoc" : (node.getAttribute("src") || "about:blank"); + let resolvedFrameUrl = ""; + try { resolvedFrameUrl = new URL(source, document.baseURI).href; } catch {} + serialized.__frameOwnerIndex = ownerIndex; + serialized.__sameUrlOwnerIndex = frameOwners.slice(0, ownerIndex).filter((element) => { + const candidate = element.hasAttribute("srcdoc") ? "about:srcdoc" : (element.getAttribute("src") || "about:blank"); + try { return new URL(candidate, document.baseURI).href === resolvedFrameUrl; } catch { return false; } + }).length; + serialized.__resolvedFrameUrl = resolvedFrameUrl; + } + return serialized; + }; + + const remoteNode = (node, objectGroup = null) => { + if (!(node instanceof Node)) throw new Error("DOM node not found"); + const objectId = `firefox-object-${state.nextObjectId++}`; + state.objects.set(objectId, node); + if (objectGroup) state.objectGroups.set(objectId, objectGroup); + return { + type: "object", + subtype: "node", + className: node.constructor?.name ?? "Node", + description: node.localName || node.nodeName || "Node", + objectId, + }; + }; switch (operation) { + case "installPlaywrightHelper": { + const helperBrand = "codex-firefox-playwright-helper-v1"; + class FirefoxPlaywrightInjected { + constructor(targetWindow) { + this.window = targetWindow; + Object.defineProperty(this, "__codexFirefoxPlaywrightHelperBrand", { + value: helperBrand, + enumerable: true, + }); + } + parseSelector(selector) { + const parts = String(selector).split(" >> ").map((source) => { + const engine = /^([a-z][\w-]*(?::[\w-]+)?)=([\s\S]*)$/iu.exec(source); + if (engine == null) return { name: "css", body: source, source }; + return { name: engine[1], body: engine[2], source }; + }); + return { parts }; + } + querySelectorAll(parsedSelector, root) { + let matches = [root]; + const deepQuery = (scope, selector) => { + const found = [...(scope.querySelectorAll?.(selector) ?? [])]; + for (const element of [...(scope.querySelectorAll?.("*") ?? [])]) { + if (element.shadowRoot) found.push(...deepQuery(element.shadowRoot, selector)); + } + return found; + }; + const textBody = (body) => { + const source = String(body); + const flagged = /^([\s\S]*)([is])$/u.exec(source); + let flag = null; + let value = source; + if (flagged != null) { + try { + value = JSON.parse(flagged[1]); + flag = flagged[2]; + } catch {} + } + if (flag == null) { + try { value = JSON.parse(source); } catch {} + } + return { value, caseSensitive: flag === "s" }; + }; + const normalizeText = (value) => String(value).replace(/\s+/gu, " ").trim(); + for (const part of parsedSelector.parts ?? []) { + if (part.name === "nth") { + let index = Number(part.body); + if (index === -1) index = matches.length - 1; + matches = matches.slice(index, index + 1); + continue; + } + const next = []; + for (const scope of matches) { + if (part.name === "css") { + next.push(...deepQuery(scope, part.body)); + } else if (part.name === "internal:label") { + const labelMatcher = textBody(part.body); + const expected = normalizeText(labelMatcher.value); + next.push(...deepQuery(scope, "input,textarea,select,button,[aria-label]").filter((element) => { + const labels = [ + element.getAttribute?.("aria-label"), + element.labels?.[0]?.textContent, + element.getAttribute?.("placeholder"), + ].filter(Boolean).map(normalizeText); + return labels.some((label) => labelMatcher.caseSensitive + ? label === expected + : label.toLowerCase().includes(expected.toLowerCase())); + })); + } else if (part.name === "internal:control" && part.body === "enter-frame") { + continue; + } else { + throw new Error(`Unsupported Firefox Playwright selector engine: ${part.name}`); + } + } + matches = next; + } + return matches.filter((element, index, values) => values.indexOf(element) === index); + } + checkDeprecatedSelectorUsage() {} + strictModeViolationError(_selector, matches) { + return new Error(`strict mode violation: selector resolved to ${matches.length} elements`); + } + elementState(element, stateName) { + if (!element?.isConnected && element?.ownerDocument !== document) { + return { matches: false, received: "error:notconnected" }; + } + if (stateName === "visible" || stateName === "hidden") { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect?.(); + const visible = style.display !== "none" && style.visibility !== "hidden" + && Number(style.opacity ?? 1) !== 0 && (rect == null || rect.width > 0 || rect.height > 0); + return { matches: stateName === "visible" ? visible : !visible, received: visible ? "visible" : "hidden" }; + } + if (stateName === "enabled" || stateName === "disabled") { + const disabled = element.matches?.(":disabled") === true || element.disabled === true; + return { matches: stateName === "disabled" ? disabled : !disabled, received: disabled ? "disabled" : "enabled" }; + } + if (stateName === "editable") { + const editable = !element.readOnly && !element.disabled + && (element.matches?.("input,textarea,select,[contenteditable=true]") === true); + return { matches: editable, received: editable ? "editable" : "noteditable" }; + } + if (stateName === "checked") { + return { matches: element.checked === true, received: element.checked === true ? "checked" : "unchecked" }; + } + return { matches: true, received: stateName }; + } + } + globalThis.__codexPlaywrightInjected = new FirefoxPlaywrightInjected(globalThis); + return {}; + } + case "getDocument": { + const depth = Number.isInteger(payload.depth) ? payload.depth : 2; + const frameOwners = [...document.querySelectorAll("iframe,frame")]; + return { root: serializeDomNode(document, 0, depth, frameOwners) }; + } + case "querySelector": { + const root = state.nodes.get(payload.nodeId) ?? document; + return { nodeId: nodeId(root.querySelector?.(payload.selector) ?? null) }; + } + case "querySelectorAll": { + const root = state.nodes.get(payload.nodeId) ?? document; + return { nodeIds: [...(root.querySelectorAll?.(payload.selector) ?? [])].map(nodeId) }; + } + case "resolveNode": + return { object: remoteNode(nodeFromPayload(), payload.objectGroup) }; case "accessibilityTree": { const root = payload.rootBackendNodeId == null ? document.documentElement @@ -809,7 +1025,7 @@ const y = Number(event.y ?? state.pointer.y); state.pointer.x = x; state.pointer.y = y; - const target = document.elementFromPoint(x, y) ?? document.body ?? document.documentElement; + const target = deepestElementFromPoint(x, y); const interactive = editableTarget(target); const fileInput = interactive instanceof HTMLInputElement && interactive.type === "file" ? interactive : null; state.pointer.element = target; @@ -818,8 +1034,10 @@ const buttons = Number(event.buttons ?? (event.type === "mousePressed" ? 1 << button : 0)); const common = { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, screenX: x, screenY: y, button, buttons, detail: event.clickCount ?? 1 }; if (event.type === "mouseWheel") { - target.dispatchEvent(new WheelEvent("wheel", { ...common, deltaX: event.deltaX ?? 0, deltaY: event.deltaY ?? 0, deltaMode: WheelEvent.DOM_DELTA_PIXEL })); - window.scrollBy({ left: event.deltaX ?? 0, top: event.deltaY ?? 0, behavior: "instant" }); + const deltaX = Number(event.deltaX ?? 0); + const deltaY = Number(event.deltaY ?? 0); + const wheel = new WheelEvent("wheel", { ...common, deltaX, deltaY, deltaMode: WheelEvent.DOM_DELTA_PIXEL }); + if (target.dispatchEvent(wheel)) scrollAtPoint(x, y, deltaX, deltaY); } else if (event.type === "mouseMoved") { target.dispatchEvent(new PointerEvent("pointermove", { ...common, pointerId: 1, pointerType: "mouse", isPrimary: true })); target.dispatchEvent(new MouseEvent("mousemove", common)); @@ -866,9 +1084,10 @@ if ((event.clickCount ?? 1) >= 2) interactive.dispatchEvent(new MouseEvent("dblclick", common)); } } - return event.type === "mouseReleased" && fileInput + const result = event.type === "mouseReleased" && fileInput ? { fileChooser: { backendNodeId: nodeId(fileInput), mode: fileInput.multiple ? "selectMultiple" : "selectSingle" } } : {}; + return result; } case "dispatchKeyboard": { const event = payload.event ?? {}; @@ -901,6 +1120,24 @@ const to = start === end ? Math.min(target.value.length, end + 1) : end; target.setRangeText("", start, to, "start"); target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentForward" })); + } else if (target instanceof HTMLSelectElement && !target.multiple && (key === "ArrowDown" || key === "ArrowUp")) { + const direction = key === "ArrowDown" ? 1 : -1; + let nextIndex = target.selectedIndex; + do { + nextIndex += direction; + } while ( + nextIndex >= 0 + && nextIndex < target.options.length + && ( + target.options[nextIndex].disabled + || (target.options[nextIndex].parentElement?.tagName === "OPTGROUP" && target.options[nextIndex].parentElement.disabled) + ) + ); + if (nextIndex >= 0 && nextIndex < target.options.length && nextIndex !== target.selectedIndex) { + target.selectedIndex = nextIndex; + target.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + target.dispatchEvent(new Event("change", { bubbles: true })); + } } else if (key === "Enter") { if (target instanceof HTMLTextAreaElement) { const start = target.selectionStart ?? target.value.length; @@ -1297,10 +1534,13 @@ } return {}; } - case "synthesizeScroll": - window.scrollBy({ left: -(Number(payload.xDistance) || 0), top: -(Number(payload.yDistance) || 0), behavior: "instant" }); - window.dispatchEvent(new Event("scroll", { bubbles: true })); + case "synthesizeScroll": { + const x = Number(payload.x ?? state.pointer.x); + const y = Number(payload.y ?? state.pointer.y); + const scrolled = scrollAtPoint(x, y, -(Number(payload.xDistance) || 0), -(Number(payload.yDistance) || 0)); + if (scrolled === window) window.dispatchEvent(new Event("scroll", { bubbles: true })); return {}; + } default: throw new Error(`Unsupported CSP-safe Firefox page operation: ${operation}`); } @@ -1677,7 +1917,6 @@ const returnByValue = params.returnByValue === true; const awaitPromise = params.awaitPromise === true; const objectGroup = typeof params.objectGroup === "string" ? params.objectGroup : null; - const browserUseBindingName = browserUseBindingNameFromExpression(expression); if (browserUseBindingName != null) { const installed = bindingNamesByTab.get(tabId)?.has(browserUseBindingName) === true; @@ -1721,17 +1960,23 @@ expression.includes("new PlaywrightInjected.InjectedScript") && expression.includes("__codexPlaywrightInjected") ) { - // The strict-CSP snapshot path below does not need Playwright's dynamically - // generated helper bundle, but the Browser Use client expects its one-time - // installation call to succeed before issuing the snapshot request. + await executeCspSafePageOperation(tabId, "installPlaywrightHelper", {}, { frameId }); return { result: byValueRemote(undefined) }; } if (expression.includes("incrementalAriaSnapshot") && expression.includes("iframeRefs")) { + await executeCspSafePageOperation(tabId, "installPlaywrightHelper", {}, { frameId }); const value = await executeCspSafePageOperation(tabId, "playwrightDomSnapshot", {}, { frameId }); return { result: byValueRemote(value) }; } + const evaluationFrameKey = frameKey(tabId, frameId); + if (expression.includes("__codexPlaywrightInjected") && dynamicEvalBlockedFrames.has(evaluationFrameKey)) { + throw new Error( + "The Firefox bridge cannot execute this Playwright helper expression on a strict-CSP page. Use a supported Browser Use locator or DOM operation instead.", + ); + } + if ( expression.includes("__browserUseVisibleDomState") && expression.includes("interactiveRoleNames") @@ -1782,6 +2027,7 @@ return await executeUserScript(tabId, code, { frameId }); } catch (error) { if (/\beval\b.*(?:content security policy|csp|unsafe-eval)|(?:content security policy|csp|unsafe-eval).*\beval\b/iu.test(error?.message ?? String(error))) { + dynamicEvalBlockedFrames.add(evaluationFrameKey); throw new Error( "The Firefox bridge is connected, but this dynamic Runtime.evaluate script is not supported on a strict-CSP page. Use DOM CUA, CUA, or the built-in DOM snapshot instead.", ); @@ -2332,52 +2578,7 @@ async function getDocument(tabId, params, frameId = 0) { const depth = Number.isInteger(params.depth) ? params.depth : 2; - const result = await executeUserScript( - tabId, - operationScript(` - const __maxDepth = ${JSON.stringify(depth)}; - const __frameOwners = [...document.querySelectorAll("iframe,frame")]; - const __ownerUrl = (element) => { - try { - const source = element.hasAttribute("srcdoc") ? "about:srcdoc" : (element.getAttribute("src") || "about:blank"); - return new URL(source, document.baseURI).href; - } catch { return ""; } - }; - const __serialize = (node, currentDepth) => { - const nodeId = __nodeId(node); - const attributes = node.nodeType === Node.ELEMENT_NODE - ? [...node.attributes].flatMap((attribute) => [attribute.name, attribute.value]) - : []; - const serialized = { - nodeId, - backendNodeId: nodeId, - nodeType: node.nodeType, - nodeName: node.nodeName, - localName: node.localName ?? "", - nodeValue: node.nodeValue ?? "", - childNodeCount: node.childNodes?.length ?? 0, - attributes, - }; - if (currentDepth < __maxDepth || __maxDepth < 0) { - serialized.children = [...(node.childNodes ?? [])].map((child) => __serialize(child, currentDepth + 1)); - } - if (node === document) { - serialized.documentURL = location.href; - serialized.baseURL = document.baseURI; - } - if (node instanceof HTMLIFrameElement || node instanceof HTMLFrameElement) { - const ownerIndex = __frameOwners.indexOf(node); - const ownerUrl = __ownerUrl(node); - serialized.__frameOwnerIndex = ownerIndex; - serialized.__sameUrlOwnerIndex = __frameOwners.slice(0, ownerIndex).filter((element) => __ownerUrl(element) === ownerUrl).length; - serialized.__resolvedFrameUrl = ownerUrl; - } - return serialized; - }; - return { root: __serialize(document, 0) }; - `), - { frameId }, - ); + const result = await executeCspSafePageOperation(tabId, "getDocument", { depth }, { frameId }); const frames = await firefox.webNavigation.getAllFrames({ tabId }).catch(() => []); const visit = (node) => { if (node == null) return; @@ -2396,15 +2597,7 @@ } async function querySelector(tabId, params, frameId = 0) { - return executeUserScript( - tabId, - operationScript(` - const root = __state.nodes.get(${JSON.stringify(params.nodeId)}) ?? document; - const node = root.querySelector?.(${JSON.stringify(params.selector)}) ?? null; - return { nodeId: __nodeId(node) }; - `), - { frameId }, - ); + return executeCspSafePageOperation(tabId, "querySelector", params, { frameId }); } function setFrameOwnerFrameId(tabId, parentFrameId, node, frames) { @@ -2443,16 +2636,7 @@ } async function resolveNode(tabId, params, frameId = 0) { - return executeUserScript( - tabId, - operationScript(` - const id = ${JSON.stringify(params.nodeId ?? params.backendNodeId ?? 0)}; - const node = __state.nodes.get(id); - if (!(node instanceof Node)) throw new Error("DOM node not found"); - return { object: __remote(node, false, ${JSON.stringify(params.objectGroup ?? null)}) }; - `), - { frameId }, - ); + return executeCspSafePageOperation(tabId, "resolveNode", params, { frameId }); } async function requestNode(tabId, params, frameId = 0) { @@ -2720,6 +2904,9 @@ } async function synthesizeScroll(tabId, params, frameId = 0) { + const routed = await routePointToFrame(tabId, frameId, Number(params.x) || 0, Number(params.y) || 0); + frameId = routed.frameId; + params = { ...params, x: routed.x, y: routed.y }; return executeCspSafePageOperation(tabId, "synthesizeScroll", params, { frameId }); } @@ -3489,7 +3676,7 @@ case "DOM.querySelector": return querySelector(tabId, params, frameId); case "DOM.querySelectorAll": - return executeUserScript(tabId, operationScript(`const root=__state.nodes.get(${JSON.stringify(params.nodeId)})??document; return {nodeIds:[...(root.querySelectorAll?.(${JSON.stringify(params.selector)})??[])].map(__nodeId)};`), { frameId }); + return executeCspSafePageOperation(tabId, "querySelectorAll", params, { frameId }); case "DOM.getAttributes": return executeUserScript(tabId, operationScript(`const node=__state.nodes.get(${JSON.stringify(params.nodeId)}); return {attributes:node instanceof Element?[...node.attributes].flatMap(attribute=>[attribute.name,attribute.value]):[]};`), { frameId }); case "DOM.setAttributeValue": @@ -3890,6 +4077,9 @@ networkConditionsByTab.delete(tabId); userAgentByTab.delete(tabId); bypassCspTabs.delete(tabId); + for (const key of dynamicEvalBlockedFrames) { + if (key.startsWith(`${tabId}:`)) dynamicEvalBlockedFrames.delete(key); + } for (const [requestId, pending] of pendingInterceptions) { if (pending.tabId === tabId) settleInterception(requestId, {}); } @@ -4150,6 +4340,9 @@ networkConditionsByTab.delete(tabId); userAgentByTab.delete(tabId); bypassCspTabs.delete(tabId); + for (const key of dynamicEvalBlockedFrames) { + if (key.startsWith(`${tabId}:`)) dynamicEvalBlockedFrames.delete(key); + } emulationStateByTab.delete(tabId); for (const [requestId, pending] of pendingInterceptions) { if (pending.tabId === tabId) settleInterception(requestId, {}); @@ -4181,6 +4374,7 @@ firefox.webNavigation.onCommitted.addListener((details) => { const { tabId, frameId } = details; if (!debuggerAttachedTabs.has(tabId)) return; + dynamicEvalBlockedFrames.delete(frameKey(tabId, frameId)); const sessionId = frameId === 0 ? null : (sessionIdByFrame.get(frameKey(tabId, frameId)) ?? null); emitDebuggerEvent(tabId, "Runtime.executionContextDestroyed", { executionContextId: executionContextIdForFrame(frameId), diff --git a/tests/test-firefox-compat.mjs b/tests/test-firefox-compat.mjs index 793c9e3..e8d79d3 100644 --- a/tests/test-firefox-compat.mjs +++ b/tests/test-firefox-compat.mjs @@ -39,6 +39,7 @@ let allWebsiteAccessGranted = true; let captureVisibleTabCalls = []; let targetTabActive = true; let strictCspEnabled = false; +let strictCspDynamicEvaluationAttempts = 0; const tabUpdateCalls = []; const nativePostedMessages = []; const nativePort = { @@ -49,7 +50,14 @@ const nativePort = { }; class StrictCspEvent { - constructor(type, init = {}) { this.type = type; Object.assign(this, init); } + constructor(type, init = {}) { + this.type = type; + this.defaultPrevented = false; + Object.assign(this, init); + } + preventDefault() { + if (this.cancelable) this.defaultPrevented = true; + } } class StrictCspElement {} class StrictCspHtmlElement extends StrictCspElement { @@ -60,12 +68,22 @@ class StrictCspHtmlElement extends StrictCspElement { this.childNodes = []; this.children = []; this.events = []; + this.eventListeners = new Map(); this.isConnected = true; this.isContentEditable = false; this.shadowRoot = null; } closest(selector) { return selector.includes("input") ? this : null; } - dispatchEvent(event) { this.events.push(event); return true; } + addEventListener(type, listener) { + const listeners = this.eventListeners.get(type) ?? []; + listeners.push(listener); + this.eventListeners.set(type, listeners); + } + dispatchEvent(event) { + this.events.push(event); + for (const listener of [...(this.eventListeners.get(event.type) ?? [])]) listener.call(this, event); + return !event.defaultPrevented; + } focus() {} getAttribute(name) { return this.attributeValues.get(name) ?? null; } hasAttribute(name) { return this.attributeValues.has(name); } @@ -93,15 +111,83 @@ class StrictCspInputElement extends StrictCspHtmlElement { } } class StrictCspTextAreaElement extends StrictCspInputElement {} +class StrictCspSelectElement extends StrictCspHtmlElement { + constructor(values = ["Alpha", "Beta", "Gamma"], { multiple = false } = {}) { + super(); + this.disabled = false; + this.localName = "select"; + this.multiple = multiple; + this.nodeName = "SELECT"; + this.options = values.map((value) => ({ disabled: false, label: value, selected: false, value })); + this.setSelectedIndices([0]); + this.tagName = "SELECT"; + } + get selectedIndex() { return this.options.findIndex((option) => option.selected); } + set selectedIndex(index) { + for (const [optionIndex, option] of this.options.entries()) option.selected = optionIndex === index; + } + setSelectedIndices(indices) { + const selected = new Set(indices); + for (const [optionIndex, option] of this.options.entries()) option.selected = selected.has(optionIndex); + } + get value() { return this.options[this.selectedIndex]?.value ?? ""; } +} +class StrictCspScrollElement extends StrictCspHtmlElement { + constructor({ clientHeight = 144, clientWidth = 722, scrollHeight = 484, scrollWidth = 722 } = {}) { + super(); + this.clientHeight = clientHeight; + this.clientWidth = clientWidth; + this.parentElement = null; + this.scrollHeight = scrollHeight; + this.scrollLeft = 0; + this.scrollTop = 0; + this.scrollWidth = scrollWidth; + this.tagName = "DIV"; + } + scrollBy({ left = 0, top = 0 }) { + this.scrollLeft = Math.max(0, Math.min(this.scrollWidth - this.clientWidth, this.scrollLeft + left)); + this.scrollTop = Math.max(0, Math.min(this.scrollHeight - this.clientHeight, this.scrollTop + top)); + } +} +class StrictCspButtonElement extends StrictCspHtmlElement { + constructor() { + super(); + this.clickCount = 0; + this.tagName = "BUTTON"; + } + click() { this.clickCount += 1; } +} class StrictCspFrameElement extends StrictCspHtmlElement {} const strictCspInput = new StrictCspInputElement(); +const controlledUploadInput = new StrictCspInputElement(); +controlledUploadInput.type = "file"; +controlledUploadInput.attributeValues.set("aria-label", "Controlled upload"); +const strictCspSelect = new StrictCspSelectElement(); +const strictCspScroll = new StrictCspScrollElement(); +const strictCspShadowButton = new StrictCspButtonElement(); +const strictCspShadowHost = new StrictCspHtmlElement(); +strictCspShadowHost.shadowRoot = { elementFromPoint: () => strictCspShadowButton }; +let strictCspHitTarget = strictCspInput; +let strictCspFrameHit = null; +const strictCspChildFrameScrolls = []; +const strictCspPageScrolls = []; +const strictCspControlCandidates = [controlledUploadInput]; const strictCspDocument = { activeElement: strictCspInput, body: strictCspInput, + childNodes: [strictCspInput, controlledUploadInput], documentElement: strictCspInput, - elementFromPoint: () => strictCspInput, + elementFromPoint: () => strictCspHitTarget, hasFocus: () => true, - querySelectorAll: () => [], + querySelector(selector) { + return selector === 'input[type="file"]' ? controlledUploadInput : null; + }, + querySelectorAll(selector) { + if (selector === 'input[type="file"]' || selector === "input,textarea,select,button,[aria-label]") { + return selector === 'input[type="file"]' ? [controlledUploadInput] : strictCspControlCandidates; + } + return []; + }, }; const strictCspPage = { document: strictCspDocument, @@ -109,26 +195,39 @@ const strictCspPage = { Element: StrictCspElement, HTMLElement: StrictCspHtmlElement, HTMLInputElement: StrictCspInputElement, + HTMLSelectElement: StrictCspSelectElement, HTMLTextAreaElement: StrictCspTextAreaElement, HTMLIFrameElement: StrictCspFrameElement, HTMLFrameElement: StrictCspFrameElement, InputEvent: StrictCspEvent, + Event: StrictCspEvent, KeyboardEvent: StrictCspEvent, MouseEvent: StrictCspEvent, PointerEvent: StrictCspEvent, WheelEvent: StrictCspEvent, ClipboardEvent: undefined, DataTransfer: undefined, - getComputedStyle: () => ({ display: "block", opacity: "1", pointerEvents: "auto", visibility: "visible" }), + getComputedStyle: (element) => ({ + display: "block", + opacity: "1", + overflow: element instanceof StrictCspScrollElement ? "auto" : "visible", + overflowX: element instanceof StrictCspScrollElement ? "auto" : "visible", + overflowY: element instanceof StrictCspScrollElement ? "auto" : "visible", + pointerEvents: "auto", + visibility: "visible", + }), innerHeight: 800, innerWidth: 1200, Map, Set, WeakMap, + dispatchEvent: () => true, + scrollBy: (details) => strictCspPageScrolls.push(details), }; strictCspPage.window = strictCspPage; strictCspDocument.defaultView = strictCspPage; strictCspInput.ownerDocument = strictCspDocument; +controlledUploadInput.ownerDocument = strictCspDocument; const strictCspPageContext = vm.createContext(strictCspPage, { codeGeneration: { strings: false, wasm: false }, }); @@ -212,8 +311,13 @@ const browser = { value = target.frameIds?.[0] === 7 ? { focused: true, meaningful: true, frameOwner: false } : { focused: false, meaningful: false, frameOwner: false }; + } else if (args[0] === "hitTestFrame") { + value = target.frameIds?.[0] === 0 ? strictCspFrameHit : null; + } else if (target.frameIds?.[0] === 7 && args[0] === "synthesizeScroll") { + strictCspChildFrameScrolls.push(args[1]); + value = {}; } else if ([ - "dispatchKeyboard", "dispatchMouse", "playwrightDomSnapshot", "visibleDomPoint", "visibleDomSnapshot", + "dispatchKeyboard", "dispatchMouse", "installPlaywrightHelper", "synthesizeScroll", "playwrightDomSnapshot", "visibleDomPoint", "visibleDomSnapshot", "virtualClipboard", "virtualClipboardCommitCut", ].includes(args[0])) { value = executeInStrictCspPage(func, args); @@ -221,6 +325,54 @@ const browser = { value = { node: { nodeId: 3, backendNodeId: 3, nodeType: 1, nodeName: "IFRAME", localName: "iframe", nodeValue: "", childNodeCount: 0, attributes: ["src", "https://child.test/"], __frameOwnerIndex: 0, __sameUrlOwnerIndex: 0, __resolvedFrameUrl: "https://child.test/" } }; } else if (args[0] === "describeNode" && args[1].nodeId === 4) { value = { node: { nodeId: 4, backendNodeId: 4, nodeType: 1, nodeName: "DIV", localName: "div", nodeValue: "", childNodeCount: 0, attributes: [] } }; + } else if (args[0] === "getDocument") { + value = { + root: { + nodeId: 1, + backendNodeId: 1, + nodeType: 9, + nodeName: "#document", + localName: "", + nodeValue: "", + childNodeCount: 1, + attributes: [], + children: [{ + nodeId: 2, + backendNodeId: 2, + nodeType: 1, + nodeName: "INPUT", + localName: "input", + nodeValue: "", + childNodeCount: 0, + attributes: ["type", "file", "aria-label", "Controlled upload"], + }], + }, + }; + } else if (args[0] === "querySelector" && args[1].selector === 'input[type="file"]') { + value = { nodeId: 2 }; + } else if (args[0] === "describeNode" && args[1].nodeId === 2) { + value = { + node: { + nodeId: 2, + backendNodeId: 2, + nodeType: 1, + nodeName: "INPUT", + localName: "input", + nodeValue: "", + childNodeCount: 0, + attributes: ["type", "file", "aria-label", "Controlled upload"], + }, + }; + } else if (args[0] === "resolveNode" && args[1].nodeId === 2) { + value = { + object: { + type: "object", + subtype: "node", + className: "HTMLInputElement", + description: "input", + objectId: "firefox-object-2", + }, + }; } return [{ frameId: target.frameIds?.[0] ?? 0, result: value }]; } @@ -233,6 +385,7 @@ const browser = { } }]; } if (strictCspEnabled) { + strictCspDynamicEvaluationAttempts += 1; return [{ frameId: target.frameIds?.[0] ?? 0, error: { message: "call to eval() blocked by Content Security Policy" } }]; } const sourceText = args[0]; @@ -473,6 +626,17 @@ await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { }); assert.equal(JSON.stringify(executedTargets.at(-1).frameIds), "[7]", "Focused cross-origin clipboard evaluation was not tunneled into the child frame."); +const unrecognizedPlaywrightHelperExpression = "globalThis.__codexPlaywrightInjected.unrecognizedHelperCall()"; +const nonCspUnrecognizedHelperEvaluation = await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { + expression: unrecognizedPlaywrightHelperExpression, + returnByValue: true, +}); +assert.equal( + nonCspUnrecognizedHelperEvaluation.result.value, + "child-evaluation", + "Outside strict CSP, an unrecognized Playwright helper expression must preserve normal dynamic evaluation.", +); + const browserUseBindingName = "__browserUseClipboard_strict_csp_test"; strictCspEnabled = true; const strictCspOperationStart = cspSafeOperations.length; @@ -522,10 +686,107 @@ assert.equal(cspSafeOperations.at(-1).target.frameIds[0], 7, "Strict-CSP typing await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchKeyEvent", { type: "char", key: "!", text: "!" }); assert.equal(strictCspInput.value, `${typedText}!`, "Input.dispatchKeyEvent type=char must insert its text."); +strictCspDocument.activeElement = strictCspSelect; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowDown", code: "ArrowDown" }); +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowDown", code: "ArrowDown" }); +assert.equal(strictCspSelect.value, "Gamma", "ArrowDown must change the focused Firefox select option."); +assert.deepEqual( + strictCspSelect.events.filter(({ type }) => type === "input" || type === "change").map(({ type }) => type), + ["input", "change", "input", "change"], + "Keyboard selection must notify page code through input and change events.", +); +strictCspDocument.activeElement = strictCspInput; +strictCspHitTarget = strictCspScroll; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchMouseEvent", { type: "mouseWheel", x: 20, y: 30, deltaX: 0, deltaY: 420 }); +assert.equal(strictCspScroll.scrollTop, 340, "A wheel over a scrollable element must move that element to its scroll boundary."); +assert.equal(strictCspPageScrolls.length, 0, "Nested scrolling must not move the page when the inner element can consume the wheel delta."); +strictCspScroll.scrollTop = 0; +strictCspPageScrolls.length = 0; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.synthesizeScrollGesture", { x: 20, y: 30, xDistance: 0, yDistance: -420 }); +assert.equal(strictCspScroll.scrollTop, 340, "A synthesized gesture over a scrollable element must move that element to its scroll boundary."); +assert.equal(strictCspPageScrolls.length, 0, "A nested synthesized gesture must not move the page when the inner element can consume it."); + +strictCspScroll.scrollTop = 0; +strictCspScroll.addEventListener("wheel", (event) => event.preventDefault()); +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchMouseEvent", { type: "mouseWheel", x: 20, y: 30, deltaX: 0, deltaY: 120 }); +assert.equal( + strictCspScroll.scrollTop, + 0, + "Canceling a synthetic wheel event must prevent the bridge from performing its default scroll action.", +); + +const diagonalOuterScroll = new StrictCspScrollElement({ clientHeight: 100, clientWidth: 100, scrollHeight: 100, scrollWidth: 400 }); +const diagonalInnerScroll = new StrictCspScrollElement({ clientHeight: 100, clientWidth: 100, scrollHeight: 400, scrollWidth: 100 }); +diagonalInnerScroll.parentElement = diagonalOuterScroll; +strictCspHitTarget = diagonalInnerScroll; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchMouseEvent", { type: "mouseWheel", x: 20, y: 30, deltaX: 120, deltaY: 160 }); +assert.equal(diagonalInnerScroll.scrollTop, 160, "The first vertically eligible scroller must consume the vertical axis."); +assert.equal(diagonalOuterScroll.scrollLeft, 120, "An unconsumed horizontal axis must continue to the next eligible ancestor scroller."); + +const multiSelect = new StrictCspSelectElement(["Alpha", "Beta", "Gamma"], { multiple: true }); +multiSelect.setSelectedIndices([0, 2]); +strictCspDocument.activeElement = multiSelect; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowDown", code: "ArrowDown" }); +assert.deepEqual( + multiSelect.options.map((option) => option.selected), + [true, false, true], + "An unmodified ArrowDown in a multi-select must not collapse its selected-option set.", +); +assert.deepEqual( + multiSelect.events.filter(({ type }) => type === "input" || type === "change").map(({ type }) => type), + [], + "An unmodified ArrowDown in a multi-select must not emit destructive input or change events.", +); + +const optgroupSelect = new StrictCspSelectElement(["Allowed", "Disabled group option", "Allowed after group"]); +optgroupSelect.options[1].parentElement = { disabled: true, tagName: "OPTGROUP" }; +strictCspDocument.activeElement = optgroupSelect; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowDown", code: "ArrowDown" }); +assert.equal( + optgroupSelect.selectedIndex, + 2, + "Keyboard select navigation must skip an option disabled through its optgroup.", +); + +strictCspChildFrameScrolls.length = 0; +strictCspFrameHit = { left: 10, ownerIndex: 0, resolvedFrameUrl: "https://child.test/", sameUrlOwnerIndex: 0, top: 10 }; +const childScrollOperationStart = cspSafeOperations.length; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.synthesizeScrollGesture", { x: 30, y: 40, xDistance: 0, yDistance: -120 }); +assert.equal( + cspSafeOperations.slice(childScrollOperationStart).find(({ operation }) => operation === "synthesizeScroll")?.target.frameIds?.[0], + 7, + "A synthesized scroll whose hit target is inside a child frame must be routed to that child frame.", +); +assert.equal(strictCspChildFrameScrolls.length, 1, "The child frame must receive the synthesized scroll operation."); +assert.equal( + strictCspChildFrameScrolls.at(-1).x, + 20, + "A child-frame synthesized scroll must translate its horizontal coordinate from the parent viewport.", +); +assert.equal( + strictCspChildFrameScrolls.at(-1).y, + 30, + "A child-frame synthesized scroll must translate its vertical coordinate from the parent viewport.", +); +strictCspFrameHit = null; +strictCspDocument.activeElement = strictCspInput; +strictCspHitTarget = strictCspScroll; + +strictCspHitTarget = strictCspShadowHost; +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchMouseEvent", { type: "mousePressed", button: "left", x: 20, y: 30 }); +await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchMouseEvent", { type: "mouseReleased", button: "left", x: 20, y: 30 }); +assert.equal(strictCspShadowButton.clickCount, 1, "A pointer click must activate the deepest element inside an open shadow root."); +strictCspHitTarget = strictCspInput; await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchMouseEvent", { type: "mouseMoved", x: 10, y: 20 }); const strictMouseMove = strictCspInput.events.filter(({ type }) => type === "mousemove").at(-1); assert.equal(strictMouseMove.buttons, 0, "A plain mouse move must not imply that the left button is pressed."); +const untrustedPlaywrightHelper = { + __codexFirefoxPlaywrightHelperBrand: "codex-firefox-playwright-helper-v1", + parseSelector() { throw new Error("Page-controlled helper must never be trusted."); }, + querySelectorAll() { return [strictCspInput]; }, +}; +strictCspPage.__codexPlaywrightInjected = untrustedPlaywrightHelper; const playwrightInstall = await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { expression: `(() => { if (!window.__codexPlaywrightInjected) { @@ -536,6 +797,161 @@ const playwrightInstall = await compat.debugger.sendCommand({ tabId: 1 }, "Runti returnByValue: false, }); assert.equal(playwrightInstall.result.type, "undefined", "The optional Playwright helper install must not use eval under strict CSP."); +const trustedPlaywrightHelper = strictCspPage.__codexPlaywrightInjected; +assert.notStrictEqual(trustedPlaywrightHelper, untrustedPlaywrightHelper, "The bridge must replace an untrusted truthy page-defined Playwright helper global."); +assert.equal( + trustedPlaywrightHelper.__codexFirefoxPlaywrightHelperBrand, + "codex-firefox-playwright-helper-v1", + "The installed Firefox Playwright helper must expose its stable identifier.", +); +const cssMatches = trustedPlaywrightHelper.querySelectorAll( + trustedPlaywrightHelper.parseSelector('input[type="file"]'), + strictCspDocument, +); +assert.equal(cssMatches.length, 1, "The branded helper must support its advertised CSS selector subset."); +assert.strictEqual(cssMatches[0], controlledUploadInput); +const labelMatches = trustedPlaywrightHelper.querySelectorAll( + trustedPlaywrightHelper.parseSelector('internal:label="Controlled upload"i'), + strictCspDocument, +); +assert.equal(labelMatches.length, 1, "The branded helper must support its advertised internal:label selector subset."); +assert.strictEqual(labelMatches[0], controlledUploadInput); +await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { + expression: `(() => { + if (!window.__codexPlaywrightInjected) { + window.__codexPlaywrightInjected = new PlaywrightInjected.InjectedScript(window, {}); + } + })()`, + awaitPromise: true, + returnByValue: false, +}); +const reinstalledPlaywrightHelper = strictCspPage.__codexPlaywrightInjected; +assert.notStrictEqual( + reinstalledPlaywrightHelper, + trustedPlaywrightHelper, + "Each install must replace the page global with a fresh valid helper because the public brand is forgeable in the MAIN world.", +); +const reinstalledCssMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('input[type="file"]'), + strictCspDocument, +); +assert.equal(reinstalledCssMatches.length, 1, "A reinstalled helper must retain the advertised CSS selector subset."); +assert.strictEqual(reinstalledCssMatches[0], controlledUploadInput); +const reinstalledLabelMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="Controlled upload"i'), + strictCspDocument, +); +assert.equal(reinstalledLabelMatches.length, 1, "A reinstalled helper must retain the advertised internal:label selector subset."); +assert.strictEqual(reinstalledLabelMatches[0], controlledUploadInput); + +const caseSensitiveLabelInput = new StrictCspInputElement(); +caseSensitiveLabelInput.attributeValues.set("aria-label", "Case Sensitive Label"); +strictCspControlCandidates.push(caseSensitiveLabelInput); +const caseInsensitiveLabelMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="case sensitive label"i'), + strictCspDocument, +); +assert.equal( + caseInsensitiveLabelMatches.length, + 1, + "internal:label with the i flag must compare labels case-insensitively.", +); +assert.strictEqual(caseInsensitiveLabelMatches[0], caseSensitiveLabelInput); +const caseSensitiveLabelMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="case sensitive label"s'), + strictCspDocument, +); +assert.equal( + caseSensitiveLabelMatches.length, + 0, + "internal:label with the s flag must preserve the selector's case sensitivity.", +); +strictCspControlCandidates.pop(); + +const whitespaceLabelInput = new StrictCspInputElement(); +whitespaceLabelInput.attributeValues.set("aria-label", " Primary Email Address "); +strictCspControlCandidates.push(whitespaceLabelInput); +const normalizedSubstringMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="email"i'), + strictCspDocument, +); +assert.equal( + normalizedSubstringMatches.length, + 1, + "internal:label with i must match a normalized-whitespace, case-insensitive substring.", +); +assert.strictEqual(normalizedSubstringMatches[0], whitespaceLabelInput); +const normalizedSensitiveExactMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="Primary Email Address"s'), + strictCspDocument, +); +assert.equal( + normalizedSensitiveExactMatches.length, + 1, + "internal:label with s must use normalized-whitespace exact matching.", +); +assert.strictEqual(normalizedSensitiveExactMatches[0], whitespaceLabelInput); +const normalizedSensitiveSubstringMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="email"s'), + strictCspDocument, +); +assert.equal( + normalizedSensitiveSubstringMatches.length, + 0, + "internal:label with s must not change exact matching into substring matching.", +); +const wrongCaseSensitiveExactMatches = reinstalledPlaywrightHelper.querySelectorAll( + reinstalledPlaywrightHelper.parseSelector('internal:label="primary email address"s'), + strictCspDocument, +); +assert.equal( + wrongCaseSensitiveExactMatches.length, + 0, + "internal:label with s must preserve case sensitivity after normalizing whitespace.", +); +strictCspControlCandidates.pop(); + +const dynamicEvaluationAttemptsBeforeHelperFallback = strictCspDynamicEvaluationAttempts; +await assert.rejects( + compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { + expression: unrecognizedPlaywrightHelperExpression, + returnByValue: true, + }), + /strict-CSP/u, + "The first unsupported Playwright helper evaluation must report the strict-CSP compatibility limit.", +); +assert.equal( + strictCspDynamicEvaluationAttempts, + dynamicEvaluationAttemptsBeforeHelperFallback + 1, + "The first unsupported Playwright helper evaluation may make one authoritative dynamic-evaluation attempt to detect strict CSP.", +); +await assert.rejects( + compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { + expression: unrecognizedPlaywrightHelperExpression, + returnByValue: true, + }), + /strict-CSP/u, + "A repeated unsupported Playwright helper evaluation must use the cached strict-CSP result.", +); +assert.equal( + strictCspDynamicEvaluationAttempts, + dynamicEvaluationAttemptsBeforeHelperFallback + 1, + "A cached strict-CSP result must prevent repeated CSP-blocked dynamic evaluations in the same frame.", +); + +// This covers direct debugger DOM-domain resolution only. It does not exercise +// the installed client's live Playwright locator pipeline. +const locatorDocument = await compat.debugger.sendCommand({ tabId: 1 }, "DOM.getDocument", { depth: 2 }); +assert.equal(locatorDocument.root.nodeId, 1, "Direct DOM.getDocument must expose a top-level document root."); +const locatorMatch = await compat.debugger.sendCommand({ tabId: 1 }, "DOM.querySelector", { + nodeId: locatorDocument.root.nodeId, + selector: 'input[type="file"]', +}); +assert.notEqual(locatorMatch.nodeId, 0, "Direct DOM.querySelector must resolve the controlled file input."); +const locatorNode = await compat.debugger.sendCommand({ tabId: 1 }, "DOM.describeNode", { nodeId: locatorMatch.nodeId }); +assert.deepEqual(locatorNode.node.attributes, ["type", "file", "aria-label", "Controlled upload"]); +const locatorObject = await compat.debugger.sendCommand({ tabId: 1 }, "DOM.resolveNode", { nodeId: locatorMatch.nodeId }); +assert.equal(locatorObject.object.subtype, "node", "The directly resolved DOM node must remain usable through the runtime object bridge."); const playwrightSnapshot = await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { expression: `(() => { @@ -616,7 +1032,55 @@ await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { }); await compat.debugger.sendCommand({ tabId: 1 }, "Page.removeScriptToEvaluateOnNewDocument", { identifier: clipboardInit.identifier }); await compat.debugger.sendCommand({ tabId: 1 }, "Runtime.removeBinding", { name: browserUseBindingName }); +const childFrameDebuggee = { tabId: 1, sessionId: "firefox-session-1-7" }; +const childFrameCspAttemptStart = strictCspDynamicEvaluationAttempts; +await assert.rejects( + compat.debugger.sendCommand(childFrameDebuggee, "Runtime.evaluate", { + expression: unrecognizedPlaywrightHelperExpression, + returnByValue: true, + }), + /strict-CSP/u, + "The child frame must record its own strict-CSP helper evaluation result.", +); +assert.equal( + strictCspDynamicEvaluationAttempts, + childFrameCspAttemptStart + 1, + "The first child-frame helper evaluation must make one strict-CSP detection attempt.", +); +browser.webNavigation.onCommitted.emit({ + tabId: 1, + frameId: 7, + parentFrameId: 0, + url: "https://child-next.test/", +}); +await new Promise((resolve) => setTimeout(resolve, 0)); strictCspEnabled = false; +await assert.rejects( + compat.debugger.sendCommand({ tabId: 1 }, "Runtime.evaluate", { + expression: unrecognizedPlaywrightHelperExpression, + returnByValue: true, + }), + /strict-CSP/u, + "A child-frame navigation must not clear the cached strict-CSP result for a different frame.", +); +const childFrameAfterNavigation = await compat.debugger.sendCommand( + childFrameDebuggee, + "Runtime.evaluate", + { + expression: unrecognizedPlaywrightHelperExpression, + returnByValue: true, + }, +); +assert.equal( + childFrameAfterNavigation.result.value, + "child-evaluation", + "A committed child-frame navigation must clear that frame's strict-CSP cache for the new document.", +); +assert.equal( + strictCspDynamicEvaluationAttempts, + childFrameCspAttemptStart + 1, + "The child frame's fresh non-CSP document must dynamically evaluate without another CSP detection attempt.", +); await compat.debugger.sendCommand({ tabId: 1 }, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "Control", code: "ControlLeft", text: "" }); assert.equal(cspSafeOperations.at(-1).operation, "dispatchKeyboard", "Keyboard input must use the CSP-safe page-operation path.");