Files
magpie/internal/gui/tests/plugin-market.test.cjs
2cc663d0a2 gui: protect unsaved edits and confirm destructive actions (#820)
* gui: edited forms ask before they are discarded, removals ask before they take effect, and the header keeps its first layout — Cancel, Escape, the backdrop and a page change could throw away a provider's draft, and several account, key, plugin and library removals took effect at once. Modified provider, settings, library and routing forms now ask before discarding; unmodified forms still close without asking, and refresh or closing the browser tab uses its leave confirmation. Removal and discard confirmations are in-app dialogs over the intact editor, centered, with Cancel focused first, Tab kept inside, Escape canceling and focus returned afterwards; destructive buttons have readable foregrounds in light and dark themes. Text and connection settings have Save/Cancel rather than saving on blur; simple switches still save at once. Editors associate labels and help with fields, make the background inert while open and keep focus through redraws; navigation keeps browser back/forward history and its current-page state, errors remain until dismissed or replaced, and focus and validation can scroll into view. Mobile settings tabs have larger targets, fields use 16px text and page zoom stays available. layout.js applies mode and platform before the header paints, theme.js applies the saved text size before styles, and mode-specific window controls stay hidden while app.js downloads. zh/ja/de strings added. routing-shape's global .rt-day selector selected the hidden By session button on WebKit; its history click now stays in .rt-days. ux-safety covers unchanged and modified closes, canceled and confirmed removals, Escape, backdrop, modal focus, light/dark centering and button contrast, explicit saves, browser history and sync-form discard. Rebased onto upstream/main 2f183c4c. Chromium and WebKit each pass 31 related browser tests in en and zh; gui-ja and gui-de pass all 6 browser and translation-parity tests. go vet -tags nogui ./..., go build -tags nogui, go test -tags nogui ./internal/gui, JavaScript syntax checks and git diff --check pass.

Co-authored-by: XYenon <register@xyenon.bid>

* gui: edited providers, routing groups and library forms ask before their drafts are discarded, and only immediate removals ask before taking effect — Settings keep saving on Enter and blur, switches save at once, and the reader's scroll position is restored after an unasked-for scroll. Header rows, fallback models, group members, patterns and rules stay ordinary reversible draft edits. The confirmation dialog keeps its own readable destructive colors; the window's text selection, muted and faint colors, timed status messages, replaceState navigation and iOS viewport policy stay as before. The keyboard scroll assertion is restored, and click-scroll's date buttons are scoped to .rt-days .rt-day so WebKit cannot pick a hidden request-grouping button. The safety tests cover dirty editor closes and page changes, immediate provider deletion, labels, modal focus, Escape and backdrop cancellation, with the withdrawn settings, history and persistent-error assertions removed. First-paint layout and the saved theme/text size remain early.

Co-authored-by: XYenon <register@xyenon.bid>

* gui: old WebKit gets its layout before the header paints, and browser tests answer the new discard and removal questions — compat.js runs before layout.js, whose viewport setup uses ES5; the confirmation uses vh rather than Safari 15.4 dynamic viewport units. Opening a provider from its address keeps the editor pending until its page loads, and fields keep their own nested labels when Names & levels is reopened. An empty model preference map is compared as Save sends it, so opening model settings without changing anything closes without a question. Removal tests assert nothing is sent before confirmation; draft tests answer Discard and retain their Save and Cancel assertions. Plugin migration uses the reader’s wheel rather than a scripted scroll, keeping every scroll assertion. Targeted WebKit regressions pass 40/40, Chromium regressions pass 41/41, and the model-setting and editor-safety checks pass in both engines. go vet -tags nogui ./..., go build -tags nogui, JavaScript syntax checks and git diff --check pass.

Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847
Co-authored-by: XYenon <register@xyenon.bid>

* gui tests: the upstream model list keeps folding with the new discard question — models-fold answers Discard only after picking another model; the short list and an unchanged folded list still close without asking. ux-safety waits for its Relay row rather than matching loading placeholders. Rebased onto upstream/main c353f7bd, including the new model-list expansion. models-fold passes in WebKit and Chromium, as do the English and Chinese editor-safety checks; the related model drafts and first-paint phone tests pass on WebKit.

Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847
Co-authored-by: XYenon <register@xyenon.bid>

* gui: an editor redraw keeps its removal confirmation and menus interactive — Only a fresh modal makes the existing background inert, and closing restores exactly the elements it changed. A redraw no longer disables a confirmation or popup appended over the editor; elements already inert and overlays added later retain their own state. The original background is retained if a new editor interrupts the closing animation. ux-safety redraws Relay twice under its Remove confirmation, clicks Cancel, and verifies no deletion was sent; it also picks a menu item after an editor redraw and checks background restoration. The regression reproduced on WebKit before the fix. All 8 English and Chinese safety checks pass on WebKit and Chromium. The related WebKit removal, draft, routing and phone checks pass, with account-arrange passing separately after a detached-element race in the concurrent run. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, go build -tags nogui, JavaScript syntax and git diff --check pass.

Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847
Co-authored-by: XYenon <register@xyenon.bid>

* gui tests: editor safety uses the page's boot preferences, not its settings — gateway in boot.js is now the gateway-mode flag, whereas settings.gateway is the gateway's URL. Spreading settings into the fixture boot data mistook the URL for gateway mode and redirected a dirty editor's attempted page change back to Providers. The fixture now emits the same language, theme, text size and web fields as the server. The 8 English and Chinese editor-safety tests pass on Chromium and WebKit; gateway-mode, plugin middleware and plugin-market pass on WebKit, and phone-web passes separately after a scroll race under concurrent load. Rebased onto upstream/main 2f135ed4 with gateway-mode navigation and initialization retained. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, JavaScript syntax and git diff --check pass.

Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847
Co-authored-by: XYenon <register@xyenon.bid>

* gui: upstream's new removals and fields follow the editor-safety rules too — Audited GUI changes from the original PR base 2f183c4c through upstream/main 9debc583, including earlier rebases. Bulk group removal uses the in-app consequence dialog rather than a second click, and clearing middleware options asks before deleting saved options. Cancel sends nothing and keeps the selection or editor. Custom group context, Settings' compact threshold, the inline key-weight input and middleware JSON have accessible names; the JSON note/error and group context hint are linked, and invalid input is exposed. The copy-out-of-date skill action is a keyboard button supporting Enter and Space. A skill's immediately saved per-agent transfer choices update the modal baseline, so Done, Escape and navigation no longer ask to discard already saved changes. Settings save behavior, draft-only removals, scroll guard, viewport and themes stay intact. Chinese, Japanese and German consequence strings added; obsolete bulk-removal armed styles and strings removed. The six affected browser files pass across Chromium and WebKit in English and Chinese (54 tests after scoping compact-at to its provider rather than skeletons). Additional provider price, key import/weight, group member/first-token, caller-key, purpose and gateway-mode regressions pass on WebKit; key-weight also passes in Chromium, with a concurrent status-message race passing separately. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, go build -tags nogui, JavaScript syntax and git diff --check pass. The Chinese bulk confirmation was rendered and inspected on WebKit.

Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847
Co-authored-by: XYenon <register@xyenon.bid>

* gui: new upstream key removals and nested group jumps keep the editor-safety rules — Bulk deletion of failing, off and matching keys uses the app's consequence dialog, with Cancel and Escape sending nothing and a redraw leaving its buttons interactive. The shared group-editor entry asks before replacing a dirty draft, and nested navigation waits for acceptance before upstream's explicit scroll. Codex's Sign out uses its existing confirmation once. Group and key filters have accessible names. A focused model-price input is included in leave protection before its change/blur parser runs, and reverting the raw value is clean. Settings saving, scroll protection, draft-only removal, viewport and themes retain their upstream behavior. The affected flows pass in Chromium and WebKit, English and Chinese; Names & levels also passes in Japanese and German. The price fixture focuses before filling because the previous blur normalizes sibling fields, a race reproduced on upstream. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, go build -tags nogui, JavaScript syntax and git diff --check pass. The Chinese bulk-key dialog was rendered and inspected on WebKit.

Co-authored-by: XYenon <register@xyenon.bid>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847

* gui tests: Codex's newly removable active account waits for the consequence question (#820) — The latest upstream multiaccount Remove path uses accountAction's immediate-removal dialog. Its test now verifies that the first click sends nothing, Cancel keeps the account, and accepting sends exactly one login/forget request. The original account, status and translation assertions remain. Rebased onto upstream/main 8d3cf5f8; the final Codex, nested-group, bulk-key, focused-price and editor-safety tests pass 48/48 across Chromium and WebKit in English and Chinese. Upstream's new quota, skill-group, hit-rate and translation checks pass; search-first's detached-element failure reproduces on isolated upstream. The complete WebKit suite ran 1413 tests before this final upstream delta, with 1289 passes and 124 failures; failing files were compared with upstream or rerun separately. Syntax and diff checks, go vet and the nogui build pass.

Co-authored-by: XYenon <register@xyenon.bid>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847

* gui: the Sessions agent strip reports which reused button is selected (#820, #929) — Rebased onto upstream/main 1330b966, retaining the strip's own scrolling, visible selected agent and preserved buttons. Each redraw updates aria-pressed for the selected agent and clears it for the previous selection. The regression checks All initially, the unselected alternatives, then Zed selected with All cleared; on unmodified upstream it fails with null instead of true. Both strip files pass 8/8 across Chromium and WebKit in English and Chinese, including narrow and wide layout and scroll assertions. The latest ChatGPT API, Codex and editor-safety browser checks pass 32/32. Final GUI, Agent, Provider and Gateway Go checks, go vet -tags nogui ./..., Linux nogui and Windows builds, syntax and diff checks pass. Linux native GUI build is blocked by the orb's GTK 4.8 lacking APIs used by Wails beta.24. The complete WebKit run finished with 1289 passes and 124 failures; all failing files were checked against upstream or rerun separately, with details retained in the review artifacts. The full Go suite's sole sessions rewrite failure also reproduces on upstream.

Co-authored-by: XYenon <register@xyenon.bid>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847

* gui: closing editors immediately give clicks back to the page (#820, #944) — Restore the background when the close animation starts and protect it again when an editor reopens. Both New group buttons and the model entry ask before replacing a dirty routing draft; creating a group from a provider waits for navigation to be accepted. The GUI shell reference describes these transitions.

The real mouse click during closing fails without the change in all four engine/language combinations, with null instead of received. The unguarded New group and model entries fail waiting for the discard question; the old model navigation creates a routing draft even after Cancel. ux-safety, provider-remove, click-scroll and group-new-head pass 50/50 in Chromium and WebKit, English and Chinese. Closing/reopening also passes five additional rounds, 20/20. After the final upstream translation and TUI additions, i18n-keys, Japanese/German, ux-safety and group-new-head pass 23/23. Ten Chromium update-wait runs have no no-click-scroll failure; two fail on the Restarting status assertion, also reproduced on upstream.

The complete WebKit run finished with 1,307 passes and 141 failures across 1,448 tests, with no skips or cancellations. It is not green: failures in 63 of its 70 failing files also occur on upstream; the other seven files pass separate PR reruns. The complete suite preceded the final upstream translation deduplication, whose resolved keys and values are unchanged, and the TUI/docs additions. go vet and the Linux GUI build with the Makefile's gtk3 tag, Linux nogui and Windows builds, GUI/TUI Go tests, syntax and diff checks pass. Full nogui Go tests are not green: the sessions same-size rewrite check and the final run's WebDAV slow-transfer stall both reproduce on upstream with -race -count=20 under an isolated HOME with pinned caches.

Co-authored-by: XYenon <register@xyenon.bid>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-10-06 06:57:49 +08:00

236 lines
16 KiB
JavaScript

// Run with Node's test runner and Playwright on the module path; see README.md.
// The Plugins tab: Discover lists the plugins magpie suggests in two
// sections; a card installs its plugin, then offers its sign-in, which
// opens in the Providers add sheet. A search filters at once and adds what
// npm has. A card opens the plugin's page with its README — no pictures,
// links opened outside. Installed shows why one didn't load, updates one
// and removes one. The providers list's button and the add sheet lead
// here. English and Chinese; 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 listings = [
{ package: "@magpie-community/opencode-zed-auth", name: "Zed", icon: "zed", providers: ["zed"], community: true, replaces: "zed",
summary: { en: "Claude, GPT, Gemini and Grok hosted by Zed, on your Zed plan.", zh: "用 Zed 订阅使用 Zed 托管的 Claude、GPT、Gemini 和 Grok。" },
npm: { version: "0.1.0", weekly: 120, license: "MIT", repository: "https://github.com/magpie-community/plugins" } },
{ package: "@magpie-community/opencode-qoder-auth", name: "Qoder", icon: "qoder", providers: ["qoder"], community: true,
summary: { en: "Your Qoder subscription.", zh: "Qoder 订阅。" }, npm: { weekly: 0 } },
{ package: "opencode-copilot-auth", name: "GitHub Copilot", icon: "githubcopilot", providers: ["github-copilot"],
summary: { en: "Your GitHub Copilot plan.", zh: "你的 GitHub Copilot 套餐。" },
npm: { version: "0.0.9", weekly: 48213, publisher: "thdxr", license: "MIT", repository: "https://github.com/sst/opencode-copilot-auth" } },
{ package: "opencode-gemini-auth", name: "Gemini", icon: "gemini-color", providers: ["google"],
summary: { en: "Gemini with a Google account.", zh: "用 Google 账号使用 Gemini。" },
npm: { version: "1.4.0", weekly: 9100, publisher: "jenslys" } },
];
const README = [
"# opencode-copilot-auth",
"![badge](https://img.shields.io/npm/v/x.svg) Copilot for **OpenCode**.",
"**Curated by [Ann](https://example.test/ann)**",
"[![npm version](https://img.shields.io/npm/v/x.svg)](https://www.npmjs.com/package/x)",
"[![Tests](https://github.com/x/y/badge.svg)](https://github.com/x/y/actions)",
"[Jump](#-install) · <a href=\"https://example.test/html\"><b>HTML link</b></a>",
...Array.from({ length: 40 }, (_, i) => "\nFiller paragraph " + i + " so the page scrolls."),
"",
"## 🚀 Install",
"```sh",
"opencode auth login",
"```",
"- one `item`",
"- see [the docs](https://example.test/docs)",
"<img src=\"https://evil.test/x.png\"> <script>window.pwned = 1</script>",
].join("\n");
function server(lang, asked) {
const installed = [{ spec: "opencode-broken", error: "Cannot find module 'x'", providers: [], version: "1.0.0" }];
const subs = [];
const market = () => ({ listings, state: { bun: true, bunVersion: "1.3.0", plugins: installed } });
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
const body = () => route.request().postDataJSON();
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
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: "light" } });
if (url.pathname === "/api/providers") {
return json({ providers: [{ id: "openai", name: "OpenAI", icon: "openai", preset: "openai", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }],
presets: [], excluded: [], gateway: { running: true, window: true }, plugins: subs });
}
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
// the page asks for the market in parts (#488)
if (url.pathname === "/api/plugins/market" || url.pathname === "/api/plugins" || url.pathname === "/api/plugins/listings") { const m = market(); return json(url.pathname === "/api/plugins" ? m.state : url.pathname === "/api/plugins/listings" ? { listings: m.listings } : m); }
if (url.pathname === "/api/plugins/search") {
asked.push(["search", url.searchParams.get("q")]);
return json({ hits: [
{ package: "opencode-copilot-auth", version: "0.0.9" },
{ package: "opencode-copilot-proxy-auth", version: "2.1.0", description: "Copilot through a proxy", publisher: "someone" },
] });
}
if (url.pathname === "/api/plugins/page") { asked.push(["page", url.searchParams.get("name")]); return json({ readme: README, updated: "2026-09-01T00:00:00Z" }); }
if (url.pathname === "/api/plugins/add") {
const b = body();
asked.push(["add", b]);
await new Promise((r) => setTimeout(r, 150));
installed.push({ spec: b.spec, providers: ["GitHub Copilot"], version: "0.0.7", latest: "0.0.9" });
subs.push({ id: "github-copilot-plugin", pid: "github-copilot", name: "GitHub Copilot", icon: "githubcopilot", spec: b.spec, signedIn: false, models: 34,
methods: [{ type: "oauth", label: "Login with GitHub" }] });
return json({});
}
if (url.pathname === "/api/plugins/upgrade") { asked.push(["upgrade", body()]); installed.find((e) => e.spec === body().spec).version = "0.0.9"; return json({}); }
if (url.pathname === "/api/plugins/remove") { asked.push(["remove", body()]); installed.splice(installed.findIndex((e) => e.spec === body().spec), 1); return json({}); }
if (url.pathname === "/api/open") { asked.push(["open", body()]); return route.fulfill({ status: 204 }); }
if (url.pathname === "/api/plugin-signin") { asked.push(["signin", body()]); return json({ id: "p1", agent: "github-copilot-plugin", state: "waiting", url: "https://github.test/login/device" }); }
if (url.pathname === "/api/signin/p1") return json({ id: "p1", agent: "github-copilot-plugin", state: "waiting", url: "https://github.test/login/device" });
if (url.pathname.startsWith("/api/")) return json({});
if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") { asked.push(["fetched", url.href]); return route.fulfill({ status: 404, body: "" }); }
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 L = {
en: { tab: "Plugins", ours: "Subscriptions", install: "Install", soon: "Coming soon", signIn: "Sign in", onNpm: "On npm",
installed: "Installed", failed: /Didn't load: Cannot find module/, update: "Update", remove: "Remove", moreTile: "More in Plugins",
lookFor: "look for a plugin", week: "48k/week", readme: "Install", signing: "GitHub Copilot" },
zh: { tab: "插件", ours: "订阅", install: "安装", soon: "即将上线", signIn: "登录", onNpm: "npm 上的插件",
installed: "已安装", failed: /没有加载成功:Cannot find module/, update: "更新", remove: "移除", moreTile: "插件中还有更多",
lookFor: "找找插件", week: "48k/周", readme: "Install", signing: "GitHub Copilot" },
};
const shot = async (loc, name) => {
if (!process.env.ARTIFACT_DIR) return;
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await loc.screenshot({ path: path.join(process.env.ARTIFACT_DIR, name + ".png") });
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": the Plugins tab", async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh"]) {
await t.test(lang, async () => {
const w = L[lang];
const page = await (await browser.newContext({ viewport: { width: 980, height: 820 } })).newPage();
page.setDefaultTimeout(5000);
const errors = [], asked = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, asked));
// the providers list has no way of its own there: the add sheet's
// Subscriptions has it, as its last row
await page.goto("http://magpie.test/?view=providers");
await page.locator("#addProvider").waitFor();
assert.equal(await page.locator("#view-providers .after-list .more-subs").count(), 0);
await page.locator("#addProvider").click();
const row = page.locator("#addSheet .tile.more-plugins");
await row.waitFor();
await shot(page.locator("#addSheet"), `plugins-row-${engine}-${lang}`);
await row.click();
const view = page.locator("#view-plugins");
await view.waitFor({ state: "visible" });
assert.equal(await page.locator('#nav button[data-view="plugins"]').getAttribute("class"), "on");
assert.match(page.url(), /view=plugins/);
// Discover: magpie's community's plugins alone, others' only found by a search
await view.locator(".pm-sechead h3", { hasText: w.ours }).waitFor();
assert.equal(await view.locator(".pm-sec").count(), 1);
assert.equal(await view.locator('.pm-card[data-pkg="opencode-copilot-auth"]').count(), 0);
assert.doesNotMatch(await view.innerText(), /OpenCode 社区|OpenCode's community/);
assert.equal(await view.locator('.pm-card[data-pkg="@magpie-community/opencode-qoder-auth"] .pm-act').innerText(), w.soon);
assert.ok(await view.locator('.pm-card[data-pkg="@magpie-community/opencode-qoder-auth"] .pm-act').isDisabled());
await shot(view, `plugins-discover-${engine}-${lang}`);
await view.locator(".pm-find input").fill("copilot");
const copilot = view.locator('.pm-card[data-pkg="opencode-copilot-auth"]');
await copilot.locator(".pm-dl", { hasText: w.week }).waitFor();
assert.equal((await copilot.locator(".pm-by").innerText()).trim(), "thdxr");
// a card's page: facts, links opened outside, the README without pictures
await copilot.locator(".pm-sum").click();
const dlg = page.locator("#modal .pm-detail");
await dlg.locator(".pm-md h4", { hasText: w.readme }).waitFor();
assert.equal(await dlg.locator(".pm-md img, .pm-md script").count(), 0);
assert.equal(await page.evaluate(() => window.pwned), undefined);
assert.doesNotMatch(await dlg.locator(".pm-md").innerText(), /pwned/);
assert.equal(await dlg.locator(".pm-md pre code").innerText(), "opencode auth login");
await shot(page.locator("#modal .dialog"), `plugins-page-${engine}-${lang}`);
// markdown in bold is still markdown; a badge (a picture in a link) goes whole; nothing shows as source
assert.equal((await dlg.locator(".pm-md strong a").innerText()).trim(), "Ann");
assert.doesNotMatch(await dlg.locator(".pm-md").innerText(), /!\[|\]\(|npm version|Tests|<\/?[ab]\b/);
assert.equal((await dlg.locator(".pm-md a", { hasText: "HTML link" }).innerText()).trim(), "HTML link");
// a link to a heading goes to it, in the page, not out
const body = dlg.locator(".ebody");
assert.equal(await body.evaluate((b) => b.scrollTop), 0);
await dlg.locator(".pm-md a", { hasText: "Jump" }).click();
await page.waitForFunction(() => {
const b = document.querySelector("#modal .pm-detail .ebody"), h = b.querySelector("h4[data-slug='-install']");
const r = h?.getBoundingClientRect(), v = b.getBoundingClientRect();
return h && b.scrollTop > 0 && r.top >= v.top - 1 && r.bottom <= v.bottom + 1;
});
assert(!asked.some(([k]) => k === "open"), "a heading's link isn't opened outside");
await dlg.locator(".pm-md a", { hasText: "the docs" }).click();
assert.deepEqual(asked.find(([k]) => k === "open")[1], { url: "https://example.test/docs" });
// install from the page: the button becomes Sign in, which opens the add sheet's sign-in
await dlg.locator(".pm-act", { hasText: w.install }).click();
await dlg.locator(".pm-act.go", { hasText: w.signIn }).waitFor();
assert.deepEqual(asked.find(([k]) => k === "add")[1], { spec: "opencode-copilot-auth" });
await dlg.locator(".pm-act.go").click();
await page.locator("#view-providers").waitFor({ state: "visible" });
await page.waitForFunction(() => document.querySelector("#modal").hidden);
await page.locator("#addSheet .signing").waitFor();
assert.match(await page.locator("#addSheet .signing").innerText(), new RegExp(w.signing));
// the add sheet's row to Plugins, and its way when a search finds nothing
const tile = page.locator("#addSheet .tile.more-plugins");
await page.locator("#addSheet .signing button.text:not(.primary)").last().click().catch(() => {});
await tile.waitFor();
assert.equal((await tile.innerText()).trim(), w.moreTile);
await page.locator("#addSheet input.find").fill("fakeco");
await page.locator("#addSheet .none button", { hasText: w.lookFor }).click();
await view.waitFor({ state: "visible" });
assert.equal(await view.locator(".pm-find input").inputValue(), "fakeco");
// a search: the list filtered at once, and what npm has
const q = view.locator(".pm-find input");
await q.fill("copilot");
await view.locator(".pm-sechead h3", { hasText: w.onNpm }).waitFor();
await view.locator('.pm-card[data-pkg="opencode-copilot-proxy-auth"]').waitFor();
assert.equal(await view.locator('.pm-card[data-pkg="opencode-copilot-auth"]').count(), 1, "a listed plugin isn't shown twice");
assert.ok(asked.some(([k, v]) => k === "search" && v === "copilot"));
assert.equal(await q.evaluate((e) => e === document.activeElement), true, "typing keeps the field");
await shot(view, `plugins-search-${engine}-${lang}`);
await q.fill("");
// Installed: why one didn't load, an update, a removal
await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
const rows = view.locator(".pm-row");
await rows.first().waitFor();
assert.equal(await rows.count(), 2);
await view.locator(".pm-row .sub.bad", { hasText: w.failed }).waitFor();
const cp = rows.filter({ hasText: "GitHub Copilot" });
await cp.locator(".pm-chip.up").waitFor();
await shot(view, `plugins-installed-${engine}-${lang}`);
const top = await view.evaluate((e) => e.scrollTop);
await cp.locator("button", { hasText: new RegExp("^" + w.update + "$") }).click();
await cp.locator(".pm-chip.up").waitFor({ state: "detached" });
assert.deepEqual(asked.find(([k]) => k === "upgrade")[1], { spec: "opencode-copilot-auth" });
await rows.filter({ hasText: "opencode-broken" }).locator("button", { hasText: w.remove }).click();
assert.deepEqual(asked.filter(([k]) => k === "remove"), [], "Remove waits for confirmation");
await page.locator("dialog.action-confirm[open] button").last().click();
await page.waitForFunction(() => document.querySelectorAll("#view-plugins .pm-row").length === 1);
assert.equal(await view.evaluate((e) => e.scrollTop), top, "a click doesn't move the page");
assert.deepEqual(asked.filter(([k]) => k === "fetched"), [], "nothing fetched from elsewhere");
assert.deepEqual(errors, []);
});
}
});
}