Files
magpie/internal/gui/tests/account-proxy.test.cjs
T
yetone 3dc8f00f06 providers: each account of a subscription holding several can go through its own proxy — the provider's, none, or its own address (Discord, gakki: 是否可以为不同的codex账号设置不用的代理,现在是同一个供应商下使用相同的代理) — the per-provider proxy (#237) was one for all of Codex's signed-in accounts. A subscription's entry in providers.json now keeps accountProxies, by the account's name in lower case, each checked by settings.CheckProxy on save (ftp:// refused), addresses trimmed, accounts following the provider's proxy left out. Provider.ProxyChoice picks an account's own proxy, else the provider's, else the global one, so everything that already went through Provider.Via follows the account: gateway turns and fallback across accounts, usage and quota, token refresh, Codex's warm-up, reset and saved accounts' model lists, Claude's warm-up and the *_PROXY of Claude Code runs (ViaLogin / ProxyOfLogin), and Codex's own sign-in relayed as it came (ViaSignedIn). Extra accounts of Copilot, ZCode, Kiro and the like, made afresh without the provider's settings, now read them from providers.json, so they honour the provider's proxy too. A save without accountProxies keeps them; {} clears them. The provider editor, for a subscription with two or more accounts, lists each under the Proxy row: Provider's proxy | Direct | Custom, the address field's room kept so a pick moves nothing; Custom with no address is refused before anything is posted, its field focused without scrolling. en and zh together; no border stripes. Tested with internal/gateway/account_proxy_test.go in a sandbox HOME against fake proxies and a fake ChatGPT backend: three Codex accounts (own proxy, direct, following the provider) each reach the backend their own way in one turn and for usage; clearing an account's proxy sends it through the provider's, clearing the provider's through the global one, direct staying direct; ftp:// refused; TestClaudeAccountProxyEnv covers each Claude account's HTTPS_PROXY. Both fail with the per-account lookup off. account-proxy.test.cjs, Chromium and WebKit, en and zh (new), provider-proxy, click-scroll and old-webkit pass. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real proxy, real accounts or the real Wails window; Grok's per-account usage and Kiro's usage on a key still use the provider's proxy.
2026-09-30 04:52:03 +08:00

177 lines
11 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// Each account of a subscription with several has a proxy of its own
// (gakki: 是否可以为不同的codex账号设置不同的代理): Codex's editor has, under
// its Proxy row, a line per account — Provider's proxy | Direct | Custom —
// opening on what each has; Save posts accountProxies with each account
// that doesn't follow Codex's, and Custom with no address is refused
// before anything is posted. A pick moves nothing and the page stays where
// it is. A subscription with one account has no such lines. In English
// and Chinese.
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 codex = {
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "",
models: [{ id: "gpt-6", name: "GPT-6", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
account: {
agent: "codex", agentName: "Codex", user: "me@example.com", plan: "PLUS",
logins: [
{ user: "spare@example.com", plan: "PLUS", on: true },
{ user: "me@example.com", plan: "PLUS", active: true, on: true },
{ user: "Third@Example.com", plan: "PRO", on: true },
],
},
proxy: "http://10.0.0.2:7890",
accountProxies: { "me@example.com": "socks5://10.0.0.5:1080", "spare@example.com": "direct" },
};
const claude = {
id: "claude", name: "Claude", icon: "claude-color", chat: "", responses: "", anthropic: "", catalog: "",
models: [{ id: "claude-opus-5", name: "Claude Opus 5", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
account: { agent: "claude", agentName: "Claude Code", user: "solo@example.com", plan: "MAX", logins: [{ user: "solo@example.com", plan: "MAX", active: true, on: true }] },
proxy: "",
};
function serve(lang, posts) {
const providers = { providers: [codex, claude], presets: [], excluded: [], gateway: { running: true, window: true } };
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
return async (route) => {
const url = new URL(route.request().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);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname.startsWith("/api/provider/") && route.request().method() === "POST") {
posts.push({ action: url.pathname.slice("/api/provider/".length), body: route.request().postDataJSON() });
return json(providers);
}
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 });
};
}
const words = {
en: { prov: "Provider's proxy", direct: "Direct", custom: "Custom", save: "Save", missing: "Proxy of Third@Example.com: type its address, like http://127.0.0.1:7890" },
zh: { prov: "供应商代理", direct: "直连", custom: "自定义", save: "保存", missing: "Third@Example.com 的代理:请填写地址,例如 http://127.0.0.1:7890" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
const open = async (t, name, wait = ".editor .proxy-mode") => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${name}-account-proxy.png`) });
}
await browser.close();
});
const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const posts = [];
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: name }).first().click();
await page.locator(wait).first().waitFor();
return { page, errors, posts };
};
const line = (page, user) => page.locator(`.editor .acct-proxy[data-user="${user}"]`);
const mode = (page, user) => line(page, user).locator(".proxy-mode .opt.on").textContent();
const addr = (page, user) => line(page, user).locator(".proxy-url");
// what the page and the dialog's scrollers have scrolled
const scrolled = (page) => page.evaluate(() => [window.scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop > 0).map((e) => `${e.className}:${e.scrollTop}`)].join(" "));
// a click on one of an account's options: it, the lines and the Save
// button left where they were, nothing scrolled
const pick = async (page, user, name) => {
const b = line(page, user).locator(".proxy-mode .opt", { hasText: name });
await b.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
const where = () => page.evaluate(() => [...document.querySelectorAll(".editor .acct-proxy, .editor .bar")].map((e) => Math.round(e.getBoundingClientRect().top)));
const before = await b.evaluate((e) => e.getBoundingClientRect().top), lines = await where(), sc = await scrolled(page);
await b.click();
await page.waitForTimeout(250);
const after = await b.evaluate((e) => e.getBoundingClientRect().top);
assert(Math.abs(after - before) <= 1, `${user} ${name} moved from ${before} to ${after}`);
assert.deepEqual(await where(), lines, `a pick of ${name} moved the lines`);
assert.equal(await scrolled(page), sc, `a pick of ${name} scrolled`);
};
const save = async (page, posts) => {
const n = posts.length;
await page.locator(".editor .bar").getByRole("button", { name: w.save, exact: true }).click();
for (let i = 0; i < 50 && posts.length === n; i++) await page.waitForTimeout(50);
return posts.at(-1);
};
test(`${engine} ${lang}: each Codex account's proxy opens as it is and is saved`, async (t) => {
const { page, errors, posts } = await open(t, "Codex", ".editor .acct-proxy");
// the account in use first, then the others as they are listed
assert.deepEqual(await page.locator(".editor .acct-proxy").evaluateAll((ls) => ls.map((l) => l.dataset.user)), ["me@example.com", "spare@example.com", "Third@Example.com"]);
assert.equal(await mode(page, "me@example.com"), w.custom);
assert.equal(await addr(page, "me@example.com").inputValue(), "socks5://10.0.0.5:1080");
assert(await addr(page, "me@example.com").isVisible());
assert.equal(await mode(page, "spare@example.com"), w.direct);
assert(await addr(page, "spare@example.com").isHidden());
assert.equal(await mode(page, "Third@Example.com"), w.prov, "an account with none follows Codex's");
assert(await addr(page, "Third@Example.com").isHidden());
assert.equal(await page.locator(".editor .proxy-pick .proxy-url").inputValue(), "http://10.0.0.2:7890", "Codex's own is kept above");
// saved untouched, it posts what it has
let saved = await save(page, posts);
assert.equal(saved.action, "save");
assert.equal(saved.body.id, "codex");
assert.equal(saved.body.proxy, "http://10.0.0.2:7890");
assert.deepEqual(saved.body.accountProxies, { "me@example.com": "socks5://10.0.0.5:1080", "spare@example.com": "direct" });
// Custom for the third with nothing typed is refused, nothing posted
await page.locator(".row.provider", { hasText: "Codex" }).first().click();
await line(page, "Third@Example.com").waitFor();
await pick(page, "Third@Example.com", w.custom);
assert(await addr(page, "Third@Example.com").isVisible());
const n = posts.length;
await page.locator(".editor .bar").getByRole("button", { name: w.save, exact: true }).click();
await page.locator(".editor .editor-error").waitFor();
assert.equal(await page.locator(".editor .editor-error").textContent(), w.missing);
assert.equal(posts.length, n);
assert(await addr(page, "Third@Example.com").evaluate((e) => e === document.activeElement), "its address is focused");
// typed, the spare back to Codex's, the first direct
await addr(page, "Third@Example.com").fill(" http://10.0.0.7:3128 ");
await pick(page, "spare@example.com", w.prov);
await pick(page, "me@example.com", w.direct);
assert(await addr(page, "me@example.com").isHidden());
saved = await save(page, posts);
assert.deepEqual(saved.body.accountProxies, { "me@example.com": "direct", "third@example.com": "http://10.0.0.7:3128" });
assert.deepEqual(saved.body.models, ["gpt-6"], "its picks go with it");
const border = await page.evaluate(() => [...document.querySelectorAll(".acct-proxies, .acct-proxies *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1));
assert.deepEqual(border, [], "no border stripes");
const missing = await page.evaluate(() => [
"Provider's proxy",
"Each account can go through a proxy of its own; Provider's proxy is the one above",
"Proxy of {user}: type its address, like http://127.0.0.1:7890",
].filter((k) => !I18N.zh[k]));
assert.deepEqual(missing, [], "every string has its Chinese");
assert.deepEqual(errors, []);
});
test(`${engine} ${lang}: a subscription with one account has no per-account lines`, async (t) => {
const { page, errors, posts } = await open(t, "Claude");
assert.equal(await page.locator(".editor .acct-proxies").count(), 0);
const saved = await save(page, posts);
assert.equal(saved.body.id, "claude");
assert.deepEqual(saved.body.accountProxies, {});
assert.deepEqual(errors, []);
});
}
}