Skip to content
Merged
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
2 changes: 1 addition & 1 deletion launcher/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
20 changes: 17 additions & 3 deletions src/adapters/chatgpt-web/browser-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]);
Expand All @@ -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"),
Expand Down
14 changes: 11 additions & 3 deletions src/chatgpt-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = "3.0.7";
export const VERSION = "3.0.8";
90 changes: 83 additions & 7 deletions tests/browser-worker-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(() => {});
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<void>,
): 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 [
Expand Down Expand Up @@ -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; },
Expand Down Expand Up @@ -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;
},
Expand Down
53 changes: 53 additions & 0 deletions tests/chatgpt-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});