Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions macos/scripts/injector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")});
Expand Down Expand Up @@ -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;
}

Expand Down
189 changes: 189 additions & 0 deletions macos/tests/renderer-verification.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
Loading