Files
magpie/internal/gui/tests/agent-failover.test.cjs
yetone 0b5b10da2a agent, gateway, gui, tui: a Codex that goes through magpie only to fail over to the user's other ChatGPT accounts says so on the Agents row and keeps to its own models (#1385, shenghsi)
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.
2026-10-09 14:10:13 +08:00

109 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}`);
});
}
}
}