Files
magpie/internal/gui/tests/plugin-checkin.test.cjs
yetone 87487c5ef0 plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it.
2026-10-06 18:40:45 +08:00

155 lines
9.9 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// Lemon on Discord: "provider 每日签到的方法能放到插件里面去做吗". A plugin
// whose auth hook has checkin presses its vendor's daily check-in itself, and
// magpie shows it as it shows its own: the account's Usage card has the row
// (checkinBy "plugin:<provider>"), with Auto check-in posting
// /api/settings/plugin-checkin {provider, on} and Check in now posting
// /api/usage/plugin-checkin {provider}; a captcha the vendor asks for is said
// as such, never as a failure, as magpie never solves one. Settings' check-in
// tabs gain Plugins while such a plugin is signed in, a row a provider with
// Off/On. English, Chinese, Japanese and German, the window and the panel's
// 440px; the API is faked here.
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 today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
const quotas = () => [
{ provider: "plugin:fakeco", name: "FakeCo", plan: "Pro", user: "ann", windows: [{ name: "Credits", used: 12 }],
checkins: true, checkinBy: "plugin:fakeco", checkin: { user: "ann", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "claimed", credit: 50, streak: 3 } },
{ provider: "plugin:fakeco", name: "FakeCo", plan: "Free", user: "bob", windows: [{ name: "Credits", used: 2 }],
checkins: true, checkinBy: "plugin:fakeco", checkin: { user: "bob", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "captcha", msg: "slide the puzzle" } },
{ provider: "codex", name: "Codex", icon: "openai", plan: "Plus", windows: [{ name: "5 hours", used: 20 }] },
];
function serve(lang, asked) {
const settings = { theme: "light", lang, quotaLeft: false, currency: "usd",
checkinPlugins: [{ id: "fakeco", name: "FakeCo", on: false, checkins: [
{ user: "ann", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "claimed", credit: 50, streak: 3 },
{ user: "bob", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "captcha" },
] }] };
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({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings/plugin-checkin") {
const b = route.request().postDataJSON();
asked.push(["set", b]);
settings.checkinPlugins = settings.checkinPlugins.map((p) => (p.id === b.provider ? { ...p, on: b.on } : p));
return json(settings);
}
if (url.pathname === "/api/usage/plugin-checkin") {
asked.push(["now", route.request().postDataJSON()]);
return json([{ user: "bob", by: "plugin:fakeco", day: today, outcome: "captcha" }]);
}
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/usage/quotas") return json(quotas());
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
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)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const words = {
en: { done: "Checked in today +50 · 3-day streak", captcha: "Asks for a captcha; check in in its own app · slide the puzzle", auto: "Auto check-in", tab: "Plugins", sub: /FakeCo plugin check each signed-in account.*bob is asked for a captcha/ },
zh: { done: "今日已签到 +50 · 连续 3 天", captcha: "需要验证码,请在它自己的 App 里签到 · slide the puzzle", auto: "自动签到", tab: "插件", sub: /FakeCo 插件.*bob 需要验证码/ },
ja: { captcha: "CAPTCHA が必要です。公式アプリでチェックインしてください · slide the puzzle", tab: "プラグイン", sub: /FakeCo プラグイン.*bob は CAPTCHA が必要です/ },
de: { captcha: "Verlangt ein Captcha; in der eigenen App einchecken · slide the puzzle", tab: "Erweiterungen", sub: /FakeCo-Plugin.*bob soll ein Captcha lösen/ },
};
const scrolls = (page) => page.evaluate(() => [scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join(","));
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh", "ja", "de"]) {
const w = words[lang];
test(`${engine} ${lang}: a plugin's own check-in on its Usage card`, 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: 900, height: 900 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], asked = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, asked));
await page.goto("http://magpie.test/?view=usage");
const card = page.locator(".subscription-card", { hasText: "FakeCo" });
await card.locator(".wb-checkin").first().waitFor();
const rows = card.locator(".wb-checkin");
assert.equal(await rows.count(), 2, "one line an account");
const says = (await rows.locator(".ci-say").allInnerTexts()).map((s) => s.trim());
if (w.done) assert.equal(says[0], w.done);
assert.equal(says[1], w.captcha, "a captcha is said as such");
assert.notEqual(await rows.nth(1).getAttribute("data-state"), "bad", "a captcha isn't a failure");
assert.match(await rows.first().locator(".ci-say").getAttribute("title"), /FakeCo/);
assert.equal(await page.locator(".subscription-card", { hasText: "Codex" }).locator(".wb-checkin").count(), 0);
const auto = card.locator(".ci-auto");
assert.equal(await auto.count(), 1);
if (w.auto) assert.equal((await auto.innerText()).trim(), w.auto);
assert.match(await auto.getAttribute("title"), /FakeCo/);
assert.equal(await auto.getAttribute("aria-pressed"), "false");
const y = await scrolls(page);
await auto.click();
await page.waitForFunction(() => document.querySelector(".ci-auto")?.getAttribute("aria-pressed") === "true");
assert.deepEqual(asked, [["set", { provider: "fakeco", on: true }]]);
assert.equal(await scrolls(page), y, "the toggle moved the page");
assert.deepEqual(errors, []);
});
test(`${engine} ${lang}: Settings has a Plugins check-in tab at 440px`, 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: 440, height: 900 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], asked = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, asked));
await page.goto("http://magpie.test/?view=settings&tab=usage");
const tab = page.locator("#warmTab-plugins");
await tab.waitFor();
assert.equal(await tab.isHidden(), false);
assert.equal((await tab.textContent()).trim(), w.tab);
assert.equal(await page.locator("#warmTab-qoder").isHidden(), true, "Qoder's tab with no Qoder account");
// at 440x900 the tabs are below the fold: the reader scrolls to them
await page.mouse.move(220, 400);
for (let i = 0; i < 10; i++) {
const r = await tab.boundingBox(), foot = await page.locator("footer.foot").boundingBox();
if (r.y + r.height < foot.y - 8) break;
await page.mouse.wheel(0, 200);
await page.waitForTimeout(100);
}
await tab.click();
const row = page.locator('#pluginCheckinList .row[data-provider="fakeco"]');
await row.waitFor();
assert.match(await row.locator(".name").innerText(), /FakeCo/);
assert.match((await row.locator(".sub").innerText()).replace(/\s+/g, " "), w.sub);
const box = await row.boundingBox();
assert(box.width > 0 && box.x + box.width <= 440, "the row fits at 440px");
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "no sideways scroll");
if (process.env.ARTIFACT_DIR) await row.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-checkin-${engine}-${lang}.png`) });
// and on to its switch, under the foot in a long language
const on = row.locator(".segs button").nth(1);
for (let i = 0; i < 10; i++) {
const r = await on.boundingBox(), foot = await page.locator("footer.foot").boundingBox();
if (r.y + r.height < foot.y - 8) break;
await page.mouse.wheel(0, 200);
await page.waitForTimeout(100);
}
const saved = page.waitForRequest((r) => new URL(r.url()).pathname === "/api/settings/plugin-checkin");
await on.click();
await saved;
assert.deepEqual(asked, [["set", { provider: "fakeco", on: true }]]);
assert.equal(await page.locator("select").count(), 0);
assert.deepEqual(errors, []);
});
}
}