Files
magpie/internal/gui/tests/zcode-site.test.cjs
T
yetone 8117d6f4a9 zcode: a seat on a team's GLM Coding Plan (团队套餐) is signed in, routed and metered as ZCode does it, on BigModel (智谱) as well as Z.ai, and a Zhipu key on a team plan shows its 5-hour and weekly windows and resets (#236 EZN7L2C3: a BigModel team GLM Coding Plan, the sign-in refused it and the usage was never read) — magpie's ZCode sign-in only went to Z.ai and only looked for the account's own coding-plan key, so a member of a team's plan, whose seat lives in a team project, was refused, and a key on a team plan read nothing from /api/monitor/usage/quota/limit. Read from ZCode.app 3.14.3 (host/index.js and config/provider/zcode-builtin.json): the team rules account:{zai|bigmodel}-team-coding-plan use the individual plans' base URLs with no extra inference headers; getCustomerInfo lists the projects, projectType "2" being a team's (isBigModelTeamCodingPlanProject); fetchTeamCodingPlanEntitlement asks /api/biz/team/subscribe/product/querySubscribeDetail (EFFECTIVE + memberGrantStatus VALID usable, UNASSIGNED no seat, EXPIRED run out); ensureBigModelTeamPlanProjectApiKeyWithStatus / copyBigModelTeamPlanProjectApiKeySecret find or create "zcode-team-api-key" (keyType 2) in the team project and copy its secret; createBigModelBizHeaders sends the access token bare in Authorization with bigmodel-organization / bigmodel-project; resolveAccountTeamPlanRuntimeApiKey takes the stored oauth:{family}:access_token (skipping BigModel's when it equals zcodejwttoken); buildQuotaLimitUrl adds type=2 and createBigModelUsageHeaders sends the key with the org/project headers; ZCode keeps the chosen team in ~/.zcode/v2/setting.json providerFamilyConnectionSelections (legacy modelProviderFamilySelectedKeys "team-plan:builtin:…", migrateLegacyAccountConnectionSettings); pollPendingOAuth/startOAuthWithPolling init with {provider:"zai"|"bigmodel"}, BigModel's redirect param being redirect, its token the poll's bigmodel.access_token. Now: the sign-in first asks where the account is — Z.ai or BigModel (智谱) (POST /api/signin takes a site, StartSignInAt, "zcode:bigmodel" too) — and on its site tries the account's own plan, then a team project with a usable seat (a new zcode_team.go: zcodeTeamSignIn keeps the team key, org, project and the biz token, the plan named by its product, "GLM Coding Team" otherwise), then the Start Plan; a team seat not yet granted or a team plan expired is refused saying so ("…has no seat on it yet — ask the team's admin…"), and an account with none of them names both plans and the site's subscribe page. ZCode's own team login is read from its credentials and setting.json (zcodeOwnTeam), its key minted lazily and kept (zcodeTeamKeyOf, retried after a minute, "sign in to ZCode again" when refused). Team requests go to the family's Anthropic base signed with the team key, no ZCode headers, never the Start Plan. Team usage asks bigmodel.cn (or api.z.ai) /api/monitor/usage/quota/limit?type=2 with the key and Authorization, Bigmodel-Organization, Bigmodel-Project, Set-Language, Accept-Language: CREDIT_LIMIT windows, unit 3 "5 hours" and unit 6 weekly, "current / total" when there is no remaining; /api/biz/customer-package-reset/list?targetType=TEAM gives the resets, fiveHourResets and weekResets counted where available is true, the soonest expireTime as until; the plan's end from querySubscribeDetail, Renew off. ResetCredits gains byWindow/fiveHour/weekly and Words(), "2 five-hour resets · 1 weekly reset" in the CLI, TUI and GUI (en and zh), with no "Use a reset" button for them since they are spent on the vendor's page. The Zhipu GLM key provider (planquota.go) falls back to the team query when the personal one fails or has no windows, adding the team headers when a ZCode team login holds that key. Personal accounts, Coding Plan and Start Plan, are routed, signed and metered as before. Tested with a new internal/provider/zcode_team_test.go against a fake bigmodel.cn/Z.ai in a sandbox HOME, no real tokens: a BigModel team sign-in through StartSignIn("zcode:bigmodel") is added (redirect param, plan "GLM Coding Team Pro", key made with keyType 2, org/project kept), its requests at the BigModel base signed with the team key and no ZCode headers, usage 5 hours 42% "420 / 1000" with a 5h span and weekly 10%, the quota headers sent, resets 2 five-hour and 1 weekly until 2026-10-05, plan until 2026-12-31; an unassigned seat is refused with no key made; ZCode's own team login from credentials + setting.json is found and metered while an individual plan stays as it was; the key provider's team fallback. TestPlanQuotas gains the type=2 case; the zcode Start Plan and account tests pass. A new internal/gui/tests/zcode-site.test.cjs, Chromium and WebKit, en and zh: starting ZCode's sign-in asks the site and posts nothing, BigModel posts {agent:"zcode",site:"bigmodel"} and keeps it, and the by-window resets read in both languages; signin-callback, click-scroll and login-import pass. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass (TestUserPath timed out under load and passes alone). Not tried against a real account: BigModel's OAuth sign-in and poll shape, the Set-Language values, customer-package-reset's field formats, querySubscribeDetail on Z.ai, type=2 for a pasted key with no org/project, and the time formats are from ZCode's code, not seen on the wire; not tried in the real Wails window.
2026-09-30 04:15:06 +08:00

80 lines
4.7 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// ZCode's sign-in asks where the account is (Z.ai or BigModel/智谱) and posts
// that site; a GLM team plan's resets are counted by window with no "Use" button.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const playwright = require("playwright");
const engines = (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"]);
const assets = path.resolve(__dirname, "../assets");
for (const engine of engines) for (const lang of ["en", "zh"]) {
test(`zcode site chooser and team resets (${engine}, ${lang})`, async (t) => {
const browser = await playwright[engine].launch();
t.after(() => browser.close());
const page = await browser.newPage({ viewport: { width: 420, height: 640 } });
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const posted = [];
await page.route("**/*", 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:"dark",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({ agents: [], profiles: [], settings: { lang, theme: "dark" } });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: {} });
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/signin") {
posted.push(route.request().postDataJSON());
return json({ id: "zflow", agent: "zcode", state: "waiting", url: "http://vendor.test/oauth" });
}
if (url.pathname === "/api/signin/zflow") return new Promise(() => {}); // stays waiting
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)];
return route.fulfill({ body: await fs.readFile(file), contentType });
});
await page.goto("http://magpie.test/");
await page.waitForFunction((l) => state.settings.lang === l, lang);
// starting ZCode's sign-in asks the site first, posting nothing
await page.evaluate(() => {
window.renderProviders = () => {
const host = document.getElementById("zsite") || Object.assign(document.createElement("div"), { id: "zsite" });
host.style.cssText = "position:fixed;inset:0 auto auto 0;width:100%;z-index:1000;";
host.replaceChildren(signing ? renderSigning(subOf("zcode")) : "");
document.body.append(host);
};
startSignIn("zcode", true);
});
await page.waitForFunction(() => signing?.state === "site");
assert.equal(posted.length, 0);
const title = lang === "zh" ? "你的" : "Where is your";
assert.match(await page.locator("#zsite").innerText(), new RegExp(title));
const sites = await page.locator("#zsite button[data-site]").evaluateAll((bs) => bs.map((b) => [b.dataset.site, b.textContent, b.title]));
assert.deepEqual(sites.map((s) => s[0]), ["zai", "bigmodel"]);
assert.equal(sites[1][1], lang === "zh" ? "智谱 BigModel" : "BigModel (智谱)");
assert.equal(sites[1][2], "bigmodel.cn");
// BigModel posts its site and keeps it for "Try again"
await page.locator('#zsite button[data-site="bigmodel"]').click();
await page.waitForFunction(() => signing?.state === "waiting");
assert.deepEqual(posted, [{ agent: "zcode", site: "bigmodel" }]);
assert.equal(await page.evaluate(() => signing.site), "bigmodel");
// a team's resets are counted by window and not spent here
const words = await page.evaluate(() => {
const w = resetsWords({ byWindow: true, fiveHour: 2, weekly: 1, count: 3 });
return [w.querySelector(".resets-n").textContent, w.title];
});
assert.equal(words[0], lang === "zh" ? "↺ 2 次 5 小时重置 · 1 次每周重置" : "↺ 2 five-hour resets · 1 weekly reset");
assert.ok(words[1].length > 0);
const one = await page.evaluate(() => resetsWords({ byWindow: true, fiveHour: 1, count: 1 }).querySelector(".resets-n").textContent);
assert.equal(one, lang === "zh" ? "↺ 1 次 5 小时重置" : "↺ 1 five-hour reset");
assert.deepEqual(errors, []);
});
}