diff --git a/macos/scripts/injector.mjs b/macos/scripts/injector.mjs index aa3679d6..9a3ce4ce 100644 --- a/macos/scripts/injector.mjs +++ b/macos/scripts/injector.mjs @@ -1070,16 +1070,31 @@ export async function inspectNativeWindow(session) { } } -async function verifySession(session, expectedThemeId = null, expectedRevision = null) { +export async function verifySession(session, expectedThemeId = null, expectedRevision = null) { const renderer = await session.evaluate(`(() => { const box = (node) => { if (!node) return null; const r = node.getBoundingClientRect(); const style = getComputedStyle(node); + const opacity = Number.parseFloat(style.opacity); + const right = Number.isFinite(r.right) ? r.right : r.x + r.width; + const bottom = Number.isFinite(r.bottom) ? r.bottom : r.y + r.height; + let cssVisible = r.width > 0 && r.height > 0 && style.display !== 'none' && + style.visibility !== 'hidden' && style.visibility !== 'collapse' && + style.contentVisibility !== 'hidden' && (!Number.isFinite(opacity) || opacity > 0); + try { + if (typeof node.checkVisibility === 'function') { + cssVisible = cssVisible && node.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + }); + } + } catch {} + const intersectsViewport = right > 0 && bottom > 0 && r.x < innerWidth && r.y < innerHeight; return { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height), - visible: r.width > 0 && r.height > 0 && style.display !== 'none' && style.visibility !== 'hidden', + visible: Boolean(node.isConnected !== false && cssVisible && intersectsViewport), }; }; const homeIndicator = document.querySelector(${selectorLiteral("home-icon")}); @@ -1179,14 +1194,31 @@ async function verifySession(session, expectedThemeId = null, expectedRevision = }); } -async function waitForVerifiedSession(session, timeoutMs, expectedThemeId = null, expectedRevision = null) { +export async function waitForVerifiedSession( + session, + timeoutMs, + expectedThemeId = null, + expectedRevision = null, + retryDelayMs = 500, +) { const deadline = Date.now() + timeoutMs; + const retryDelay = Number.isFinite(retryDelayMs) && retryDelayMs >= 0 ? retryDelayMs : 500; let lastResult; + let lastError; while (Date.now() < deadline) { - lastResult = await verifySession(session, expectedThemeId, expectedRevision); - if (lastResult.pass) return lastResult; - await new Promise((resolve) => setTimeout(resolve, 500)); + try { + lastResult = await verifySession(session, expectedThemeId, expectedRevision); + lastError = null; + if (lastResult.pass) return lastResult; + } catch (error) { + // Renderer navigations can invalidate Runtime.evaluate while Codex is + // swapping documents. Treat that as a transient sample until the same + // bounded verification deadline expires, matching the Windows injector. + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, retryDelay)); } + if (!lastResult && lastError) throw lastError; return lastResult; } diff --git a/macos/tests/renderer-verification.test.mjs b/macos/tests/renderer-verification.test.mjs new file mode 100644 index 00000000..8a13fa69 --- /dev/null +++ b/macos/tests/renderer-verification.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { verifySession, waitForVerifiedSession } from "../scripts/injector.mjs"; + +const selectors = { + shell: "main.main-surface", + sidebar: "aside.app-shell-left-panel", + composer: ".composer-surface-chrome", + home: '[role="main"]:has([data-testid="home-icon"])', + homeIcon: '[data-testid="home-icon"]', + gameSource: '[data-feature="game-source"]', + suggestions: ".group\\/home-suggestions", + settings: 'input[name="appearance-theme"]', + themePreview: '[data-testid="theme-preview"]', +}; + +function makeRect(width = 800, height = 600, x = 0, y = 0) { + return { x, y, width, height, right: x + width, bottom: y + height }; +} + +function makeElement({ + rect = makeRect(), + style = {}, + checkVisibility = true, + isConnected = true, +} = {}) { + return { + isConnected, + classList: [], + childNodes: [], + textContent: "", + _style: { + display: "block", + visibility: "visible", + contentVisibility: "visible", + opacity: "1", + color: "rgb(0, 0, 0)", + ...style, + }, + getBoundingClientRect: () => rect, + checkVisibility: () => checkVisibility, + closest: () => null, + querySelector: () => null, + querySelectorAll: () => [], + }; +} + +function makeDomFixture({ + scope = { level: "L1", baseState: "thread" }, + shell = makeElement(), + sidebar = makeElement(), + composer = makeElement(), + settings = null, + visibilityState = "visible", + viewportWidth = 1280, + viewportHeight = 800, +} = {}) { + const styleNode = {}; + const documentElement = { + scrollWidth: viewportWidth, + clientWidth: viewportWidth, + scrollHeight: viewportHeight, + clientHeight: viewportHeight, + getAttribute: (name) => name === "data-dream-skin" ? "active" : null, + }; + const document = { + documentElement, + adoptedStyleSheets: [], + visibilityState, + querySelector(selector) { + if (selector === selectors.shell) return shell; + if (selector === selectors.sidebar) return sidebar; + if (selector === selectors.composer) return composer; + if (selector === selectors.settings || selector === selectors.themePreview) return settings; + if (selector === selectors.home || selector === selectors.homeIcon || + selector === selectors.gameSource || selector === selectors.suggestions) return null; + return null; + }, + querySelectorAll: () => [], + getElementById: (id) => id === "codex-dream-skin-style" ? styleNode : null, + }; + const window = { + __CODEX_DREAM_SKIN_STATE__: { + version: "1.5.6", + themeId: "fixture-theme", + revision: "fixture-revision", + styleMode: "style", + styleNode, + scope, + }, + }; + return { + document, + window, + innerWidth: viewportWidth, + innerHeight: viewportHeight, + getComputedStyle: (node) => node?._style ?? {}, + }; +} + +function makeSession({ + dom = makeDomFixture(), + evaluateErrors = [], + nativeResponse = { + windowId: 41, + bounds: { width: 1280, height: 800, windowState: "normal" }, + }, +} = {}) { + let evaluateCount = 0; + return { + target: { id: "page-main" }, + get evaluateCount() { return evaluateCount; }, + async evaluate(expression) { + const error = evaluateErrors[evaluateCount]; + evaluateCount += 1; + if (error) throw error; + return vm.runInNewContext(expression, dom); + }, + async send(method, params) { + assert.equal(method, "Browser.getWindowForTarget"); + assert.deepEqual(params, { targetId: "page-main" }); + return nativeResponse; + }, + }; +} + +async function verify(overrides = {}) { + return verifySession( + makeSession(overrides), + "fixture-theme", + "fixture-revision", + ); +} + +test("visible L1 renderer passes exact macOS verification", async () => { + const result = await verify(); + assert.equal(result.pass, true); + assert.equal(result.shell.visible, true); + assert.equal(result.sidebar.visible, true); +}); + +test("CSS-hidden, detached, and offscreen anchors cannot satisfy L1", async () => { + const cases = [ + ["display none", makeElement({ style: { display: "none" } })], + ["visibility hidden", makeElement({ style: { visibility: "hidden" } })], + ["visibility collapse", makeElement({ style: { visibility: "collapse" } })], + ["content-visibility hidden", makeElement({ style: { contentVisibility: "hidden" } })], + ["opacity zero", makeElement({ style: { opacity: "0" } })], + ["checkVisibility false", makeElement({ checkVisibility: false })], + ["detached", makeElement({ isConnected: false })], + ["offscreen right", makeElement({ rect: makeRect(200, 200, 1280, 20) })], + ["offscreen left", makeElement({ rect: makeRect(200, 200, -200, 20) })], + ["offscreen below", makeElement({ rect: makeRect(200, 200, 20, 800) })], + ]; + + for (const [label, shell] of cases) { + const result = await verify({ dom: makeDomFixture({ shell }) }); + assert.equal(result.pass, false, label); + assert.equal(result.checks.structurePass, false, label); + assert.equal(result.shell.visible, false, label); + } +}); + +test("transient Runtime.evaluate failures are retried inside the bounded deadline", async () => { + const session = makeSession({ + evaluateErrors: [new Error("Execution context was destroyed during navigation")], + }); + const result = await waitForVerifiedSession( + session, + 100, + "fixture-theme", + "fixture-revision", + 1, + ); + assert.equal(result.pass, true); + assert.equal(session.evaluateCount, 2); +}); + +test("verification rethrows the last transient error when no sample succeeds", async () => { + const session = makeSession(); + session.evaluate = async () => { + throw new Error("Execution context stayed unavailable"); + }; + await assert.rejects( + waitForVerifiedSession(session, 15, "fixture-theme", "fixture-revision", 1), + /Execution context stayed unavailable/, + ); +});