diff --git a/launcher/package.json b/launcher/package.json index b678025b9..6ca17d1bd 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "3.0.7", + "version": "3.0.8", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/package.json b/package.json index 160e5090c..567ba8401 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "3.0.7", + "version": "3.0.8", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index e86558bc7..3bd35acb8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-miuuyy/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-3.0.7}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-3.0.8}" BIN_DIR="${CODEX_CHATGPT_WEB_BIN_DIR:-$HOME/.local/bin}" LIB_DIR="${CODEX_CHATGPT_WEB_LIB_DIR:-$HOME/.local/lib/codex-chatgpt-web}" DOC_DIR="${CODEX_CHATGPT_WEB_DOC_DIR:-$HOME/.local/share/doc/codex-chatgpt-web}" diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 414e90cc8..f886d0f16 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -1296,13 +1296,19 @@ export class ChatGptBrowserWorker { await captureDiagnostic?.("effort-menu-open-requested"); const effortChoices = effortMenu.locator(CHATGPT_EFFORT_ITEM_SELECTOR); const effortChoice = effortChoices.nth(uiEffortIndex); - const effortSlider = page.locator(CHATGPT_EFFORT_SLIDER_SELECTOR).filter({ visible: true }).last(); + // The launcher deliberately leases a background WebContentsView with a 0x0 viewport. + // Current ChatGPT still attaches the authoritative ARIA slider inside the exact opened + // picker, but Playwright classifies it as not visible because the portal is clipped by that + // viewport. Scope the slider to the proven menu and wait for attachment; its bounded ARIA + // range is the selection authority, not screen geometry. + const effortSliders = effortMenu.locator(CHATGPT_EFFORT_SLIDER_SELECTOR); + const effortSlider = effortSliders.last(); const waitAbort = new AbortController(); let ready: "effort" | "slider" | "rate-limit" | "session-expired"; try { ready = await Promise.race([ + effortSlider.waitFor({ state: "attached", timeout: 70_000, signal: waitAbort.signal }).then(() => "slider" as const), effortChoice.waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }).then(() => "effort" as const), - effortSlider.waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }).then(() => "slider" as const), chatGptRateLimitDialog(page).waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }).then(() => "rate-limit" as const), chatGptExpiredSessionAlert(page).waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }).then(() => "session-expired" as const), ]); @@ -1315,13 +1321,21 @@ export class ChatGptBrowserWorker { await throwIfChatGptSessionFailureAlert(page); throw new ChatGptWebAdapterError( `ChatGPT effort menu did not expose item index ${uiEffortIndex}` - + `; item count: ${await effortChoices.count().catch(() => 0)}`, + + `; item count: ${await effortChoices.count().catch(() => 0)}` + + `; slider count: ${await effortSliders.count().catch(() => 0)}`, { status: 502, errorType: "server_error", code: "upstream_server_error", retryable: false }, ); } finally { waitAbort.abort(); } if (ready === "slider") { + const sliderCount = await effortSliders.count(); + if (sliderCount !== 1) { + throw new ChatGptWebAdapterError( + `ChatGPT effort menu exposed ${sliderCount} semantic sliders; refusing ambiguous selection`, + { status: 502, errorType: "server_error", code: "upstream_server_error", retryable: false }, + ); + } let sliderState = parseChatGptEffortSliderState( await effortSlider.getAttribute("aria-valuemin"), await effortSlider.getAttribute("aria-valuemax"), diff --git a/src/chatgpt-session.ts b/src/chatgpt-session.ts index b8fc46a20..b142cbb82 100644 --- a/src/chatgpt-session.ts +++ b/src/chatgpt-session.ts @@ -145,18 +145,26 @@ export async function detectChatGptAccountCapabilities( if (!menuVisible && menuExpanded !== "true") await effortButton.press("Enter"); try { const efforts = menu.locator(CHATGPT_EFFORT_ITEM_SELECTOR); - const slider = page.locator(CHATGPT_EFFORT_SLIDER_SELECTOR).filter({ visible: true }).last(); + // A launcher-owned background WebContentsView can be 0x0 while the exact opened picker still + // contains one attached semantic slider. Capability proof therefore uses menu containment and + // ARIA state rather than viewport visibility. + const sliders = menu.locator(CHATGPT_EFFORT_SLIDER_SELECTOR); + const slider = sliders.last(); const waitAbort = new AbortController(); try { const ready = await Promise.race([ + slider.waitFor({ state: "attached", timeout: 70_000, signal: waitAbort.signal }) + .then(() => "slider" as const), efforts.first().waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }) .then(() => "items" as const), - slider.waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }) - .then(() => "slider" as const), ]); if (ready === "items") { return { solAvailable: true, proAvailable: await efforts.count() >= 5 }; } + const sliderCount = await sliders.count(); + if (sliderCount !== 1) { + throw new Error(`ChatGPT effort menu exposed ${sliderCount} semantic sliders`); + } const state = parseChatGptEffortSliderState( await slider.getAttribute("aria-valuemin"), await slider.getAttribute("aria-valuemax"), diff --git a/src/version.ts b/src/version.ts index 23cc2f234..f6521048f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "3.0.7"; +export const VERSION = "3.0.8"; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 40a2be52f..bcab09d0b 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -936,11 +936,84 @@ test("effort selection uses structural menu and slider indices instead of locali expect(workerSource).toContain('getAttribute("aria-expanded")'); expect(workerSource).toContain('getAttribute("aria-valuenow")'); expect(workerSource).toContain("sliderControl.press(key)"); + expect(workerSource).toContain("effortMenu.locator(CHATGPT_EFFORT_SLIDER_SELECTOR)"); + expect(workerSource).toContain('effortSlider.waitFor({ state: "attached"'); + expect(workerSource).not.toContain('page.locator(CHATGPT_EFFORT_SLIDER_SELECTOR).filter({ visible: true })'); expect(workerSource).not.toContain("currentLabel === targetLabel"); expect(workerSource).not.toContain("chatGptEffortLabelsMatch"); expect(workerSource).not.toMatch(/getByRole\("button", \{\s*name: "(?:Instant|Medium|High|Extra High|Pro)"/); }); +test("effort selection accepts one attached semantic slider in a zero-viewport picker", async () => { + const never = new Promise(() => {}); + const checkpoints: string[] = []; + const pressed: string[] = []; + const hidden = { + filter() { return this; }, + last() { return this; }, + isVisible: async () => false, + waitFor: async () => await never, + }; + const effortControl = { + filter() { return this; }, + last() { return this; }, + count: async () => 1, + waitFor: async (options: { state: string }) => { expect(options.state).toBe("visible"); }, + getAttribute: async (name: string) => name === "aria-expanded" ? "true" : null, + }; + const composerForm = { locator: () => effortControl }; + const composer = { locator: () => composerForm }; + const effortChoice = { waitFor: async () => await never }; + const effortChoices = { nth: () => effortChoice, count: async () => 2 }; + const sliderControl = { press: async (key: string) => { pressed.push(key); } }; + const effortSlider = { + waitFor: async (options: { state: string }) => { expect(options.state).toBe("attached"); }, + getAttribute: async (name: string) => ({ + "aria-valuemin": "0", + "aria-valuemax": "4", + "aria-valuenow": "4", + }[name] ?? null), + locator: () => sliderControl, + }; + const effortSliders = { last: () => effortSlider, count: async () => 1 }; + const effortMenu = { + last() { return this; }, + isVisible: async () => true, + locator: (selector: string) => selector.includes("data-model-reasoning-effort-slider") + ? effortSliders + : effortChoices, + }; + const page = { + locator: (selector: string) => { + if (selector.includes("composer-intelligence-picker-content")) return effortMenu; + return hidden; + }, + keyboard: { press: async (key: string) => { pressed.push(key); } }, + }; + const selectModelAndEffort = (ChatGptBrowserWorker.prototype as unknown as { + selectModelAndEffort( + page: unknown, + modelId: string, + reasoning: string, + capabilities: { localToolsEnabled: boolean; solAvailable: boolean; proAvailable: boolean }, + captureDiagnostic: (checkpoint: string) => Promise, + ): Promise<{ displayLabel: string; uiEffortIndex: number | null }>; + }).selectModelAndEffort; + + const mode = await selectModelAndEffort.call({ + activeComposer: async () => composer, + }, page, CHATGPT_WEB_MODEL_ID, "max", { + localToolsEnabled: true, + solAvailable: true, + proAvailable: true, + }, async checkpoint => { checkpoints.push(checkpoint); }); + + expect(mode).toMatchObject({ displayLabel: "Pro", uiEffortIndex: 4 }); + expect(checkpoints).toContain("effort-slider-visible"); + expect(checkpoints).toContain("effort-selected"); + expect(pressed).toEqual(["Escape"]); +}); + test("effort slider ARIA state fails closed on malformed and unsupported ranges", () => { expect(parseChatGptEffortSliderState("0", "4", "3")).toEqual({ min: 0, max: 4, value: 3 }); for (const attributes of [ @@ -1272,15 +1345,19 @@ test("effort menu waiting stops when ChatGPT reports an expired session", async const composer = { locator: () => composerForm }; const effortChoice = { waitFor: async () => await neverVisible }; const effortChoices = { nth: () => effortChoice, count: async () => 3 }; + const effortSlider = { + waitFor: async () => await neverVisible, + }; + const effortSliders = { + last: () => effortSlider, + count: async () => 0, + }; const effortMenu = { last() { return this; }, isVisible: async () => true, - locator: () => effortChoices, - }; - const effortSlider = { - filter() { return this; }, - last() { return this; }, - waitFor: async () => await neverVisible, + locator: (selector: string) => selector.includes("data-model-reasoning-effort-slider") + ? effortSliders + : effortChoices, }; const sessionAlert = { filter() { return this; }, @@ -1309,7 +1386,6 @@ test("effort menu waiting stops when ChatGPT reports an expired session", async locator: (selector: string) => { if (selector.includes('[role="alert"]')) return sessionAlert; if (selector.includes('[role="menu"]') || selector.includes("composer-intelligence-picker-content")) return effortMenu; - if (selector.includes("data-model-reasoning-effort-slider")) return effortSlider; if (selector.includes('[role="dialog"]')) return hiddenDialog; return effortMenu; }, diff --git a/tests/chatgpt-session.test.ts b/tests/chatgpt-session.test.ts index 0a113b7e6..b77a9b812 100644 --- a/tests/chatgpt-session.test.ts +++ b/tests/chatgpt-session.test.ts @@ -99,3 +99,56 @@ test("a transient effort control does not turn a Luna-only account into Sol", as })).resolves.toEqual({ solAvailable: false, proAvailable: false }); expect(visibilityReads).toBe(2); }); + +test("capability detection accepts an attached clipped slider inside the exact picker", async () => { + const pressed: string[] = []; + const effortButton = { + last() { return this; }, + isVisible: async () => true, + getAttribute: async (name: string) => name === "aria-expanded" ? "true" : null, + }; + const composerForm = { + count: async () => 1, + locator: () => effortButton, + }; + const composers = { + filter() { return this; }, + last() { return this; }, + count: async () => 1, + locator: () => composerForm, + }; + const efforts = { + first() { return this; }, + waitFor: async () => {}, + count: async () => 2, + }; + const slider = { + waitFor: async (options: { state: string }) => { expect(options.state).toBe("attached"); }, + getAttribute: async (name: string) => ({ + "aria-valuemin": "0", + "aria-valuemax": "4", + "aria-valuenow": "4", + }[name] ?? null), + }; + const sliders = { last: () => slider, count: async () => 1 }; + const menu = { + last() { return this; }, + isVisible: async () => true, + locator: (selector: string) => selector.includes("data-model-reasoning-effort-slider") + ? sliders + : efforts, + }; + const page = { + locator: (selector: string) => selector.includes("composer-intelligence-picker-content") + ? menu + : composers, + keyboard: { press: async (key: string) => { pressed.push(key); } }, + evaluate: async () => true, + }; + + await expect(detectChatGptAccountCapabilities(page as never, { + selectorTimeoutMs: 100, + stableAbsenceMs: 0, + })).resolves.toEqual({ solAvailable: true, proAvailable: true }); + expect(pressed).toEqual(["Escape"]); +});