With several ChatGPT accounts on, failover writes openai_base_url into ~/.codex/config.toml so a turn the signed-in account can't pay for goes on to the next account. With Codex's switch off, the row said only "not connected", but every request still went through the gateway, and the gateway's /backend-api/codex/models answer added all of magpie's models (62 for the reporter). - agent: Agent.FailingOver means not wired, while the stash's codex.failover (or a CC Switch mirror's codex.mirror_failover) base URL points at magpie. FailoverSaid is the line the CLI list and the TUI show. CodexOwnOnly is true for this machine's Codex when it is failing over and no WSL Codex is connected, since a WSL Codex shares the gateway path. Sync ages a models_cache.json that isn't tagged with the own-only list. - gateway: codexModels leaves out provider.CodexListed when gateway.CodexOwnOnly (set by main to agent.CodexOwnOnly) says so. The ETag and X-Models-Etag carry provider.CodexOwnListTag. Neither tag contains the other, so Codex fetches the list again when it is connected or disconnected. - gui: /api/state carries failover. The row says "Not connected · goes through magpie only to fail over to your other ChatGPT accounts". Its title says what turns it off. A "ChatGPT accounts" button opens Providers › Codex. Strings are in zh, zh-TW, ja and de. openProvider no longer draws the Providers list before it has loaded. It was only safe once the tab had been opened, and this is its first caller from another page. - docs: agent-wiring and gateway-routing describe the failover-only Codex and list the new tests. A user's hand-written openai_base_url (#856) isn't failover's, so it stays a plain not-connected row. Verified: - TestCodexFailoverOnlyKeepsOwnModels (agent) builds the reporter's state in a temp HOME: Codex signed in, two more ChatGPT logins on, Sync, so the stash has codex.failover=1 and no joined/beside. It checks that the row is not wired and is FailingOver, that FailoverSaid names Providers › Codex, and that the real gateway handler's /backend-api/codex/models lists only the backend's gpt-5.5. It also checks that the cached full list is aged, and that after Join magpie's fake/m1 is back. - TestCodexModelListOwnOnlyForFailover (gateway) checks the list and both ETags with the hook on and off. - agent-failover.test.cjs checks the row text, title and button in en, zh, zh-TW, ja and de, at 980px and 420px, in Chromium and WebKit (20 pass). It also checks that the button opens Codex's editor, that the page doesn't scroll sideways, and that no click moves it. - Broken by hand, each test goes red: - codexModels ignoring the hook fails both Go tests. - failingOver returning false fails the agent test. - dropping the Sync cache case fails the agent test. - removing the GUI branch fails the GUI test. - drawing the list before it has loaded (the old openProvider) fails the GUI test. - go test -tags nogui ./internal/agent ./internal/gateway ./internal/provider ./internal/gui ./internal/tui ./internal/codexcat . in a temp HOME: all ok. - node --test agent-*.test.cjs (254 pass) and i18n-keys, gui-ja, gui-de, click-scroll: all pass. - gofmt -l is clean. - go vet -tags nogui on darwin, linux and windows: clean. - go build, and GOOS=windows go build: ok.
109 lines
7.1 KiB
JavaScript
109 lines
7.1 KiB
JavaScript
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||
// #1385 (shenghsi): Codex isn't connected, yet magpie set openai_base_url for
|
||
// it so a request its ChatGPT account has no allowance for goes on to the
|
||
// user's other ChatGPT accounts on in magpie. The row says it goes through
|
||
// magpie for that alone, its title says what turns it off, and a button
|
||
// opens Providers › Codex, where those accounts are switched off. A Codex
|
||
// not failing over says what it said before. In every language, at a narrow
|
||
// width too, in Chromium and WebKit; no click moves the page.
|
||
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs/promises");
|
||
const path = require("node:path");
|
||
const { test } = require("node:test");
|
||
const { chromium, webkit } = require("playwright");
|
||
|
||
const assets = path.resolve(__dirname, "../assets");
|
||
const options = [{ value: "gpt-5.5", label: "GPT-5.5" }, { value: "relay/m1", label: "m1", ref: "relay/m1", note: "Relay · via magpie" }];
|
||
const state = () => ({
|
||
agents: [
|
||
{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", failover: true, fields: [{ key: "model", label: "model", value: "gpt-5.5", options }] },
|
||
{ id: "codex@wsl:Ubuntu-24.04", name: "Codex", icon: "generic", path: "/fixture/wsl-codex", fields: [{ key: "model", label: "model", value: "gpt-5.5", options }] },
|
||
],
|
||
profiles: [],
|
||
});
|
||
|
||
function server(lang) {
|
||
return async (route) => {
|
||
const req = route.request(), url = new URL(req.url());
|
||
const json = (data) => route.fulfill({ json: data });
|
||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||
if (url.pathname === "/api/state") return json({ ...state(), settings: { lang, theme: "light" } });
|
||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
|
||
if (url.pathname === "/api/providers") return json({ providers: [codexProvider], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||
if (url.pathname.startsWith("/api/")) return json({});
|
||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||
};
|
||
}
|
||
|
||
// three ChatGPT accounts on, as the reporter's
|
||
const codexProvider = {
|
||
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "", routing: "smart",
|
||
models: [{ id: "gpt-5.5", name: "GPT-5.5", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
|
||
account: {
|
||
agent: "codex", agentName: "Codex", user: "me@example.com", plan: "PLUS",
|
||
logins: [
|
||
{ user: "me@example.com", plan: "PLUS", active: true, on: true },
|
||
{ user: "spare1@example.com", plan: "PLUS", on: true },
|
||
{ user: "spare2@example.com", plan: "PRO", on: true },
|
||
],
|
||
},
|
||
};
|
||
|
||
const said = "Not connected · goes through magpie only to fail over to your other ChatGPT accounts";
|
||
const title = "magpie set openai_base_url in {agent}'s config.toml, so a request the ChatGPT account {agent} is signed in to has no allowance for goes on to your other ChatGPT accounts on in magpie. {agent} keeps to its own models. To stop it, switch those accounts off under Providers › Codex; connecting {agent} adds magpie's models.";
|
||
const button = "ChatGPT accounts";
|
||
const plain = "Not connected · {agent} uses its own settings";
|
||
const row = (id) => `.row.agent[data-id="${id}"]`;
|
||
|
||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||
for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) {
|
||
for (const width of [980, 420]) {
|
||
test(`${engine} ${lang} ${width}px: a Codex there for failover alone says so and how to stop it`, async (t) => {
|
||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||
t.after(() => browser.close());
|
||
const page = await (await browser.newContext({ viewport: { width, height: 640 }, reducedMotion: "reduce" })).newPage();
|
||
page.setDefaultTimeout(5000);
|
||
const errors = [], dialogs = [];
|
||
page.on("pageerror", (e) => errors.push(e.message));
|
||
page.on("dialog", (d) => { dialogs.push(d.message()); d.dismiss(); });
|
||
await page.route("**/*", server(lang));
|
||
await page.goto("http://magpie.test/?view=agents");
|
||
await page.locator(`${row("codex")} .ag-st`).waitFor();
|
||
const tr = (k) => page.evaluate(([k, lang]) => (lang === "en" ? k : I18N[lang]?.[k]), [k, lang]);
|
||
for (const k of [said, title, button]) assert.ok(await tr(k), `${lang} has ${k.slice(0, 40)}`);
|
||
const want = (await tr(said));
|
||
const wantTitle = (await tr(title)).replaceAll("{agent}", "Codex");
|
||
const st = page.locator(`${row("codex")} .ag-st`);
|
||
assert.ok((await st.textContent()).startsWith(want), await st.textContent());
|
||
const go = st.locator("button.ag-acc");
|
||
assert.equal(await go.textContent(), await tr(button));
|
||
assert.equal(await go.getAttribute("title"), wantTitle);
|
||
// the twin not failing over says what it always did
|
||
assert.equal(await page.locator(`${row("codex@wsl:Ubuntu-24.04")} .ag-st`).textContent(),
|
||
(await tr(plain)).replace("{agent}", "Codex"));
|
||
assert.equal(await page.locator(`${row("codex@wsl:Ubuntu-24.04")} .ag-acc`).count(), 0);
|
||
const wide = await page.evaluate(() => document.scrollingElement.scrollWidth - document.scrollingElement.clientWidth);
|
||
assert.ok(wide <= 0, `the page scrolls sideways by ${wide}px`);
|
||
const box = await go.boundingBox();
|
||
assert.ok(box && box.x >= 0 && box.x + box.width <= width, `button off screen: ${JSON.stringify(box)}`);
|
||
const top = await page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]);
|
||
await go.click();
|
||
await page.waitForFunction(() => new URL(location.href).searchParams.get("view") === "providers");
|
||
await page.waitForFunction(() => new URL(location.href).searchParams.get("edit") === "codex");
|
||
// its editor, with the accounts to switch off, is what opens
|
||
await page.waitForFunction(() => !document.querySelector("#modal").hidden && document.querySelector("#modal").innerText.startsWith("Codex"));
|
||
assert.ok(await page.locator(".row.provider.selected[data-id=codex]").count(), "Codex is the one open");
|
||
assert.deepEqual(dialogs, []);
|
||
assert.deepEqual(errors, []);
|
||
assert.ok(top.every((v) => v === 0 || v === undefined), `page was scrolled ${top}`);
|
||
});
|
||
}
|
||
}
|
||
}
|