diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index bf449b62..674aba51 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -14408,14 +14408,17 @@ new ResizeObserver(() => { ledHScroll(); }).observe($("#ledWrap")); // a table that grows past the window when its width didn't change (a font -// arriving, a redraw while out of sight) leaves its columns out then too +// arriving, a redraw while out of sight) leaves its columns out then too; +// and its scrollbar goes once a table left without them has come to fit, +// rather than staying under a table with nothing to scroll let ledGrowFrame = 0; const ledGrow = new ResizeObserver(() => { if (ledGrowFrame) return; ledGrowFrame = requestAnimationFrame(() => { ledGrowFrame = 0; const wrap = $("#ledWrap"), table = wrap.querySelector("table.led"); - if (table && !table.classList.contains("tight") && wrap.clientWidth && wrap.scrollWidth > wrap.clientWidth + 1) { ledFit(); ledHScroll(); } + if (table && !table.classList.contains("tight") && wrap.clientWidth && wrap.scrollWidth > wrap.clientWidth + 1) ledFit(); + ledHScroll(); }); }); new ResizeObserver(() => { diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 00f20f93..46de3485 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -2242,14 +2242,14 @@ // stale: it ends, the stage is drawn as it is now, and only the requests // still under way fly (#302). const LIVE = 4; + const underWay = () => pinned || day ? [] : [...src().values()].filter((r) => !r.done && matchesPurpose(r)).sort((a, b) => a.id - b.id).slice(-LIVE); function resume() { stopPlays(); if (!loaded) return; if (!pinned && !rp) cur = newest(); if (cur) sync(true); else empty(); - const live = pinned || day ? [] : [...src().values()].filter((r) => !r.done && matchesPurpose(r)).sort((a, b) => a.id - b.id).slice(-LIVE); - for (const r of live) play(r.id); + for (const r of underWay()) play(r.id); renderAll(); } let seen = false, lastFrame = 0, ticking = 0; @@ -2381,6 +2381,9 @@ if (wanted) { wanted = 0; params.delete("req"); history.replaceState(null, "", params.size ? "?" + params : location.pathname); } if (asked && asked.id !== newest().id) pinned = asked; if (r) { cur = r; sync(true); say(affWhy(r, true) || ruleWhy(r, true) || firstWhy(r)); renderAll(); } else empty(); + // the page's first frame came before the trace did, and found + // nothing to fly: the requests under way fly now, not never + if (seen && shown()) for (const u of underWay()) if (!playing.has(u.id)) play(u.id); } else { if (cur && routes.has(cur.id) && !rp) cur = routes.get(cur.id); if (pinned && routes.has(pinned.id)) pinned = routes.get(pinned.id); diff --git a/internal/gui/tests/by-agent.test.cjs b/internal/gui/tests/by-agent.test.cjs index d774ddf5..e03e8b13 100644 --- a/internal/gui/tests/by-agent.test.cjs +++ b/internal/gui/tests/by-agent.test.cjs @@ -102,7 +102,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; const row = (sheet, id) => sheet.locator(`.lib-byagent-row[data-agent="${id}"]`); diff --git a/internal/gui/tests/every-skill.test.cjs b/internal/gui/tests/every-skill.test.cjs index 570014ee..a60e381f 100644 --- a/internal/gui/tests/every-skill.test.cjs +++ b/internal/gui/tests/every-skill.test.cjs @@ -79,7 +79,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; diff --git a/internal/gui/tests/import-all-skills.test.cjs b/internal/gui/tests/import-all-skills.test.cjs index ca77a12d..e11cd459 100644 --- a/internal/gui/tests/import-all-skills.test.cjs +++ b/internal/gui/tests/import-all-skills.test.cjs @@ -71,7 +71,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts, fail)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; diff --git a/internal/gui/tests/ledger-hscroll.test.cjs b/internal/gui/tests/ledger-hscroll.test.cjs index 7e2ac06c..63527beb 100644 --- a/internal/gui/tests/ledger-hscroll.test.cjs +++ b/internal/gui/tests/ledger-hscroll.test.cjs @@ -51,7 +51,7 @@ const L = { for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { test(engine + ": the Requests table's scrollbar and a row's status", async (t) => { assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); - const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium", ignoreDefaultArgs: ["--hide-scrollbars"] })); t.after(() => browser.close()); for (const lang of ["en", "zh"]) { const w = L[lang]; diff --git a/internal/gui/tests/library-all.test.cjs b/internal/gui/tests/library-all.test.cjs index db72f0f1..191b99ce 100644 --- a/internal/gui/tests/library-all.test.cjs +++ b/internal/gui/tests/library-all.test.cjs @@ -105,7 +105,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("http://magpie.test/**", server(lang, posts, fail, fixture)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; const row = (page, name) => page.locator("#view-library .lib-row").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name + "$") }) }); diff --git a/internal/gui/tests/library-items.test.cjs b/internal/gui/tests/library-items.test.cjs index c827b7d8..036d3bca 100644 --- a/internal/gui/tests/library-items.test.cjs +++ b/internal/gui/tests/library-items.test.cjs @@ -97,7 +97,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("http://magpie.test/**", server(lang, theme, posts, { flaky })); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; // every row inside the list and its own box: the label on one line, the @@ -152,7 +152,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator("#view-library .lib-tabs .opt").first().click(); await card.waitFor(); await page.locator("#view-library .lib-tabs .opt").nth(2).click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); const again = card.getByRole("button", { name: words[lang].again }); const before = await page.evaluate(() => document.querySelector("#view-library").scrollTop); diff --git a/internal/gui/tests/library-mcp-health.test.cjs b/internal/gui/tests/library-mcp-health.test.cjs index b59331e9..765a7778 100644 --- a/internal/gui/tests/library-mcp-health.test.cjs +++ b/internal/gui/tests/library-mcp-health.test.cjs @@ -105,7 +105,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("http://magpie.test/**", server(lang, checks)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); const status = (name) => page.locator(`#view-library .lib-health[data-server="${name}"]`); // every server asked for at once, each showing it's being checked diff --git a/internal/gui/tests/library-own-folder.test.cjs b/internal/gui/tests/library-own-folder.test.cjs index a7c20410..efccc7ee 100644 --- a/internal/gui/tests/library-own-folder.test.cjs +++ b/internal/gui/tests/library-own-folder.test.cjs @@ -104,7 +104,10 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("http://magpie.test/**", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); + // the page's entrance played out: a button still sliding in isn't + // stable, and Playwright scrolls the view to click it + await page.waitForFunction(() => !document.querySelector("#view-library").getAnimations({ subtree: true }).some((a) => a.playState === "running" && isFinite(a.effect.getComputedTiming().endTime))); return page; }; const row = (page, name) => page.locator("#view-library .lib-row").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name) }) }); diff --git a/internal/gui/tests/list-sort.test.cjs b/internal/gui/tests/list-sort.test.cjs index be679a82..8e95d8a8 100644 --- a/internal/gui/tests/list-sort.test.cjs +++ b/internal/gui/tests/list-sort.test.cjs @@ -98,7 +98,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const openLib = async () => { await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); }; await openLib(); assert.deepEqual(await serverNames(), ["Alpha", "beta", "gamma"]); @@ -124,7 +124,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await openLib(); assert.deepEqual(await skillNames(), ["pear", "mango", "kiwi", "cherry", "apple"]); await page.locator("#view-library .lib-tabs .opt").nth(1).click(); - await page.locator("#view-library .lib-list .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-list .lib-row").first().waitFor(); assert.deepEqual(await serverNames(), ["gamma", "beta", "Alpha"]); await page.locator("#view-library .lib-tabs .opt").nth(2).click(); await pick("libSkills", 0); diff --git a/internal/gui/tests/menu-scroll.test.cjs b/internal/gui/tests/menu-scroll.test.cjs index f8780338..621cc239 100644 --- a/internal/gui/tests/menu-scroll.test.cjs +++ b/internal/gui/tests/menu-scroll.test.cjs @@ -17,9 +17,9 @@ const sessions = usage.map((u, i) => ({ last: date + "T08:00:00Z", start: date + "T07:00:00Z", models: [u], unpriced: 0, })); const state = { - agents: [{ id: "fixture", name: "Fixture", path: "/test/config.toml", fields: [{ + agents: [{ id: "fixture", name: "Fixture", path: "/test/config.toml", wired: true, fields: [{ key: "model", label: "model", value: "model-01", - options: usage.map(u => ({ value: u.model, label: u.model })), + options: usage.map(u => ({ value: u.model, label: u.model, ref: u.model })), }] }], profiles: [], settings: { lang: "en", theme: "light" }, }; @@ -38,6 +38,9 @@ async function serve(route) { if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false }); if (url.pathname === "/api/groups") return json({ groups: [] }); if (url.pathname === "/api/gateway/trace") return json({ mine: false, now: date, totals: { requests: 0, rerouted: 0, errors: 0 } }); + if (url.pathname === "/api/plugins/updates") return json({ waiting: [] }); + if (url.pathname === "/api/usage/quotas" || url.pathname === "/api/usage/quotas/history" || url.pathname === "/api/agents/install") return json([]); + if (url.pathname === "/api/whatsnew") return json({}); assert(!url.pathname.startsWith("/api/"), "Unexpected API: " + url.pathname); 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)]; diff --git a/internal/gui/tests/own-skill.test.cjs b/internal/gui/tests/own-skill.test.cjs index 78fa4a84..d6febb56 100644 --- a/internal/gui/tests/own-skill.test.cjs +++ b/internal/gui/tests/own-skill.test.cjs @@ -98,7 +98,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; diff --git a/internal/gui/tests/remove-all-skills.test.cjs b/internal/gui/tests/remove-all-skills.test.cjs index 7c492339..2d1b41ef 100644 --- a/internal/gui/tests/remove-all-skills.test.cjs +++ b/internal/gui/tests/remove-all-skills.test.cjs @@ -79,7 +79,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; diff --git a/internal/gui/tests/routing-sessions.test.cjs b/internal/gui/tests/routing-sessions.test.cjs index 3693d8d3..68f1db5f 100644 --- a/internal/gui/tests/routing-sessions.test.cjs +++ b/internal/gui/tests/routing-sessions.test.cjs @@ -7,8 +7,15 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const now = new Date(); const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-"); +// a time as the gateway writes it: local, with its offset, so its date is +// the local day its history keeps it under (a UTC time is a day off for +// the hours between the two midnights) +const stamp = (d) => { + const o = -d.getTimezoneOffset(), p = (n) => String(Math.floor(Math.abs(n))).padStart(2, "0"); + return new Date(d.getTime() + o * 60e3).toISOString().slice(0, 19) + (o < 0 ? "-" : "+") + p(o / 60) + ":" + p(o % 60); +}; function req(id, agent, session, cost, priced = true) { - const time = new Date(now.getTime() - (10 - id) * 60e3).toISOString(); + const time = stamp(new Date(now.getTime() - (10 - id) * 60e3)); const seat = { id: "relay", provider: "relay", name: "Relay", who: "Test account", kind: "account", model: id === 1 ? "model-a" : "model-b" }; return { id, seq: id, time, agent, session, model: `relay/${seat.model}`, provider: "relay", cost, priced, order: [seat], tries: [{ id: seat.id, model: seat.model, start: time, done: true, status: 200, ms: 4500, effort: "medium" }], @@ -424,8 +431,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" }); page.setDefaultTimeout(10000); const errors = [], titleRequests = []; - const fixture = Array.from({ length: 2000 }, (_, i) => ({ ...req(i + 1, mixed && i < 800 ? "claude" : "codex", `chat-${i + 1}`, 0.01), time: now.toISOString() })); - const extra = { ...req(2001, "codex", "opened-chat", 0.01), time: now.toISOString() }; + const fixture = Array.from({ length: 2000 }, (_, i) => ({ ...req(i + 1, mixed && i < 800 ? "claude" : "codex", `chat-${i + 1}`, 0.01), time: stamp(now) })); + const extra = { ...req(2001, "codex", "opened-chat", 0.01), time: stamp(now) }; const feed = { titleRequests, route: extra, names: ({ ids }) => ({ names: Object.fromEntries(ids.map((id) => [id, `Applied ${id}`])) }) }; page.on("pageerror", (e) => errors.push(e.message)); await page.route("**/*", serve(lang, feed, fixture)); diff --git a/internal/gui/tests/sessions-overview.test.cjs b/internal/gui/tests/sessions-overview.test.cjs index cd020f70..8112ce08 100644 --- a/internal/gui/tests/sessions-overview.test.cjs +++ b/internal/gui/tests/sessions-overview.test.cjs @@ -250,9 +250,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const y = (await bar.boundingBox()).y; await bar.click(); await page.waitForFunction(() => document.querySelector("#sessProjects .sess-bar.on")); + // the bars are drawn again once the folder's overview is in: the bar + // is measured where that left it, not as it is taken off for it + await page.waitForFunction(() => document.querySelector("#sessStats .kpi b")?.textContent === "7"); assert.equal((await page.locator("#sessProjects .sess-bar.on").boundingBox()).y, y); assert.match(await page.locator("#sessFolder").innerText(), /beta/); - await page.waitForFunction(() => document.querySelector("#sessStats .kpi b")?.textContent === "7"); assert(seen.some((s) => s.includes("cwd=%2Fwork%2Fbeta")), seen.join("\n")); assert.equal(await page.locator("#sessProjects .sess-bar").count(), 2); await page.locator("#sessProjects .sess-bar.on").click(); diff --git a/internal/gui/tests/shared-skills.test.cjs b/internal/gui/tests/shared-skills.test.cjs index ce56b473..6c5ec43c 100644 --- a/internal/gui/tests/shared-skills.test.cjs +++ b/internal/gui/tests/shared-skills.test.cjs @@ -65,7 +65,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; const row = (page, name) => page.locator("#view-library .lib-row").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name + "$") }) }); diff --git a/internal/gui/tests/skill-how.test.cjs b/internal/gui/tests/skill-how.test.cjs index 62210b86..25b2c6c2 100644 --- a/internal/gui/tests/skill-how.test.cjs +++ b/internal/gui/tests/skill-how.test.cjs @@ -108,7 +108,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, posts)); await page.goto("http://magpie.test/"); await page.locator('button[data-view="library"]').click(); - await page.locator("#view-library .lib-row").first().waitFor(); + await page.locator("#view-library .lib-body:not(.lib-skel) .lib-row").first().waitFor(); return page; }; diff --git a/internal/gui/tests/view-scrollbar.test.cjs b/internal/gui/tests/view-scrollbar.test.cjs index 81d4db41..1aee868b 100644 --- a/internal/gui/tests/view-scrollbar.test.cjs +++ b/internal/gui/tests/view-scrollbar.test.cjs @@ -39,7 +39,7 @@ function serve() { for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { test(`${engine}: a long page shows a scrollbar, on the Mac too`, async (t) => { - const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium", ignoreDefaultArgs: ["--hide-scrollbars"] })); const page = await (await browser.newContext({ viewport: { width: 900, height: 500 }, reducedMotion: "reduce" })).newPage(); t.after(() => browser.close()); page.setDefaultTimeout(5000);