253 lines
14 KiB
JavaScript
253 lines
14 KiB
JavaScript
// Run with Node's test runner and Playwright on the module path; see README.md.
|
|
// A click never moves the page: what was clicked stays where it is on the
|
|
// screen, and code scrolls a view only with the reader's click in hand.
|
|
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 now = new Date();
|
|
const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-");
|
|
const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString();
|
|
const who = { id: "fixture-key", provider: "fixture", name: "Fixture", who: "key-1", kind: "key", model: "model-a", routing: "", used: 0 };
|
|
const routes = Array.from({ length: 12 }, (_, i) => ({
|
|
id: 100 - i, seq: 100 - i, time: at(i), agent: "fixture", model: "model-a", provider: "fixture",
|
|
order: [who], tries: [{ id: who.id, model: "model-a", start: at(i), done: true, status: 200, ms: 1200 }],
|
|
done: true, status: 200, ms: 1200, tokens: 3000,
|
|
}));
|
|
const state = { agents: [{ id: "fixture", name: "Fixture", path: "/test/config.toml", fields: [] }], profiles: [], settings: { lang: "en", theme: "light" } };
|
|
|
|
async function serve(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:"en",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/gateway/trace") {
|
|
// live keeps none: the gateway started a moment ago; the day has them
|
|
if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
|
|
return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
|
|
}
|
|
if (url.pathname === "/api/gateway/history") {
|
|
const d = url.searchParams.get("day");
|
|
return json({ cut: false, days: [{ day, requests: routes.length }], routes: d ? routes : [] });
|
|
}
|
|
if (url.pathname === "/api/groups") return json({ groups: [] });
|
|
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
|
|
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 view = "#view-routing";
|
|
const top = (page, sel) => page.locator(sel).evaluate((e) => e.getBoundingClientRect().top);
|
|
const settle = (page) => page.waitForTimeout(900);
|
|
|
|
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
|
test(engine + ": a click never moves the page", 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 context = await browser.newContext({ viewport: { width: 1100, height: 640 }, reducedMotion: "reduce" });
|
|
const page = await context.newPage();
|
|
// The request/session grouping buttons also use rt-day. Wait for and
|
|
// click only the date bar, including while its history is loading.
|
|
const dayButtons = page.locator(".rt-days .rt-day");
|
|
page.setDefaultTimeout(5000);
|
|
const errors = [];
|
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
await page.route("**/*", serve);
|
|
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 + "-click.png") });
|
|
}
|
|
await browser.close();
|
|
});
|
|
async function reset() {
|
|
await page.goto("http://magpie.test/?view=routing");
|
|
await dayButtons.nth(1).waitFor();
|
|
}
|
|
// the reader scrolls the view till sel is y under its top, or less
|
|
async function scrollTo(sel, y = 300) {
|
|
const box = await page.locator(view).boundingBox();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + 40);
|
|
for (let i = 0; i < 150 && (await top(page, sel)) - box.y > y; i++) {
|
|
// An unchanged frame need not mean the end: WebKit can still be
|
|
// applying the previous wheel event. Check the scroll boundary.
|
|
if (await page.locator(view).evaluate((v) => v.scrollTop + v.clientHeight >= v.scrollHeight - 1)) break;
|
|
await page.mouse.wheel(0, 20);
|
|
await page.waitForTimeout(20);
|
|
}
|
|
await page.waitForTimeout(300);
|
|
assert((await top(page, sel)) > box.y, "what's clicked must be in sight, or the click itself scrolls to it");
|
|
assert(await page.locator(view).evaluate((v) => v.scrollTop > 0), "the view must have scrolled");
|
|
}
|
|
|
|
await t.test("date controls stay separate from request grouping controls", async () => {
|
|
await reset();
|
|
assert.equal(await page.locator(".rt-group-by .rt-day").count(), 2, "the grouping controls share the date-button class");
|
|
assert.equal(await dayButtons.count(), 2, "only Live and the fixture's history day should be selected");
|
|
assert.match(await dayButtons.first().innerText(), /Live/);
|
|
await dayButtons.nth(1).click();
|
|
await page.locator(".rt-req").first().waitFor();
|
|
assert.equal(await dayButtons.nth(1).getAttribute("aria-pressed"), "true");
|
|
assert.equal(await page.locator(".rt-group-by .rt-day").first().getAttribute("aria-pressed"), "true", "picking a day preserves By request");
|
|
});
|
|
|
|
await t.test("Live and a day, picked in turn, stay under the pointer", async () => {
|
|
await reset();
|
|
await dayButtons.nth(1).click(); // the day's requests, the list full
|
|
await settle(page);
|
|
// down to the list's end: Live, with none, leaves the page shorter
|
|
await scrollTo(".rt-days", 0);
|
|
assert(await page.locator(view).evaluate((v) => v.scrollTop + v.clientHeight >= v.scrollHeight - 2), "the view must be at its end");
|
|
for (const i of [0, 1, 0, 1]) {
|
|
const chip = dayButtons.nth(i), was = await top(page, ".rt-days");
|
|
await chip.click();
|
|
await settle(page);
|
|
assert.equal(await dayButtons.nth(i).getAttribute("aria-pressed"), "true");
|
|
const is = await top(page, ".rt-days");
|
|
assert(Math.abs(is - was) <= 1, `picking ${i ? "the day" : "Live"} moved the page ${Math.round(is - was)}px`);
|
|
}
|
|
});
|
|
|
|
await t.test("a request picked in the list, and Replay them all, stay under the pointer", async () => {
|
|
await reset();
|
|
await dayButtons.nth(1).click();
|
|
await settle(page);
|
|
for (const i of [3, 7, 5]) {
|
|
const row = () => page.locator(".rt-req").nth(i);
|
|
await scrollTo(`.rt-req >> nth=${i}`);
|
|
// the list scrolls on its own: the reader brings the row into it
|
|
// (the click would, before it is measured)
|
|
const list = await page.locator(".rt-reqs").boundingBox();
|
|
await page.mouse.move(list.x + list.width / 2, list.y + 20);
|
|
for (let k = 0; k < 40; k++) {
|
|
const b = await row().boundingBox();
|
|
if (b.y + b.height <= list.y + list.height && b.y >= list.y) break;
|
|
await page.mouse.wheel(0, b.y < list.y ? -20 : 20);
|
|
await page.waitForTimeout(20);
|
|
}
|
|
await page.waitForTimeout(300);
|
|
const was = await row().evaluate((e) => e.getBoundingClientRect().top);
|
|
await row().click();
|
|
await settle(page);
|
|
assert.equal(await row().getAttribute("aria-pressed"), "true");
|
|
const is = await row().evaluate((e) => e.getBoundingClientRect().top);
|
|
assert(Math.abs(is - was) <= 1, `picking request ${i} moved the page ${Math.round(is - was)}px`);
|
|
}
|
|
const reqs = ".rt-req >> nth=0";
|
|
const was = await top(page, reqs);
|
|
await page.getByRole("button", { name: "Replay them all" }).click();
|
|
await settle(page);
|
|
const is = await top(page, reqs);
|
|
assert(Math.abs(is - was) <= 1, `Replay them all moved the list ${Math.round(is - was)}px`);
|
|
});
|
|
|
|
await t.test("code can't take the page from a click", async () => {
|
|
await reset();
|
|
await page.locator(view).evaluate((v) => {
|
|
const s = document.createElement("div");
|
|
s.style.cssText = "flex:none;height:1600px";
|
|
const mk = (id, fn) => { const b = document.createElement("button"); b.id = id; b.textContent = id; b.style.flex = "none"; b.onclick = fn; return b; };
|
|
const above = document.createElement("div");
|
|
above.style.flex = "none";
|
|
v.prepend(above);
|
|
v.append(s,
|
|
// sets scrollTop outright
|
|
mk("jump", () => { v.scrollTop = 0; }),
|
|
// the page above it grows when a load comes in
|
|
mk("grow", () => setTimeout(() => { above.style.height = "400px"; }, 300)),
|
|
// redraws itself, and everything after it
|
|
mk("redraw", (e) => { const b = e.currentTarget, c = b.cloneNode(true); c.onclick = b.onclick; above.style.height = "120px"; b.replaceWith(c); }),
|
|
// says it's on purpose, but with no click in hand
|
|
mk("sneak", () => setTimeout(() => { scrollOnPurpose(); v.scrollTop = 0; }, 50)),
|
|
// scrollIntoView from a helper a click only shares
|
|
mk("helper", () => setTimeout(() => document.querySelector("#rt").scrollIntoView(), 50)),
|
|
document.createElement("div"));
|
|
v.lastChild.style.cssText = "flex:none;height:600px";
|
|
});
|
|
for (const id of ["jump", "grow", "redraw", "sneak", "helper"]) {
|
|
await scrollTo("#" + id);
|
|
const was = await top(page, "#" + id);
|
|
await page.locator("#" + id).click();
|
|
await settle(page);
|
|
const is = await top(page, "#" + id);
|
|
assert(Math.abs(is - was) <= 1, `${id} moved the page ${Math.round(is - was)}px`);
|
|
}
|
|
});
|
|
|
|
await t.test("a control under what it unrolls goes down with it", async () => {
|
|
await reset();
|
|
await page.locator(view).evaluate((v) => {
|
|
const s = document.createElement("div");
|
|
s.style.cssText = "flex:none;height:1600px";
|
|
const part = document.createElement("div");
|
|
part.id = "part"; part.style.flex = "none";
|
|
const head = document.createElement("div");
|
|
head.id = "head"; head.textContent = "head";
|
|
const fold = document.createElement("div");
|
|
const b = document.createElement("button");
|
|
b.id = "more"; b.textContent = "more"; b.dataset.unrolls = "";
|
|
b.onclick = () => { fold.style.height = "300px"; };
|
|
part.append(head, fold, b);
|
|
v.append(s, part, Object.assign(document.createElement("div"), { style: "flex:none;height:600px" }));
|
|
});
|
|
await scrollTo("#more");
|
|
const head = await top(page, "#head"), more = await top(page, "#more");
|
|
await page.locator("#more").click();
|
|
await settle(page);
|
|
assert(Math.abs((await top(page, "#head")) - head) <= 1, "what's above the fold must stay put");
|
|
assert(Math.abs((await top(page, "#more")) - more - 300) <= 1, "the control must go down with the rows");
|
|
});
|
|
|
|
await t.test("a click that asks to go somewhere does", async () => {
|
|
await reset();
|
|
await page.locator(view).evaluate((v) => {
|
|
const s = document.createElement("div");
|
|
s.style.cssText = "flex:none;height:1600px";
|
|
const b = document.createElement("button");
|
|
b.id = "go"; b.textContent = "go"; b.style.flex = "none";
|
|
b.onclick = (e) => { if (scrollOnPurpose(e)) v.scrollTop = 0; };
|
|
v.append(s, b);
|
|
});
|
|
await scrollTo("#go");
|
|
await page.locator("#go").click();
|
|
await settle(page);
|
|
assert.equal(await page.locator(view).evaluate((v) => v.scrollTop), 0);
|
|
});
|
|
|
|
await t.test("the room kept for a click goes as the reader scrolls back", async () => {
|
|
await reset();
|
|
await dayButtons.nth(1).click();
|
|
await settle(page);
|
|
await scrollTo(".rt-days", 0);
|
|
await dayButtons.nth(0).click(); // Live: none, the page shorter
|
|
await settle(page);
|
|
assert(await page.locator(view).evaluate((v) => !!v.querySelector(":scope > .view-room")), "room must be kept");
|
|
const box = await page.locator(view).boundingBox();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + 60);
|
|
for (let i = 0; i < 30; i++) { await page.mouse.wheel(0, -40); await page.waitForTimeout(20); }
|
|
await page.waitForTimeout(300);
|
|
assert.equal(await page.locator(view).evaluate((v) => v.querySelector(":scope > .view-room")), null, "the room must go");
|
|
});
|
|
|
|
await t.test("the reader's wheel still scrolls after a click", async () => {
|
|
await reset();
|
|
await page.locator(view).evaluate((v) => { const s = document.createElement("div"); s.style.cssText = "flex:none;height:1600px"; v.append(s); });
|
|
await dayButtons.nth(1).click();
|
|
const was = await page.locator(view).evaluate((v) => v.scrollTop);
|
|
const box = await page.locator(view).boundingBox();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + 60);
|
|
await page.mouse.wheel(0, 300);
|
|
await page.waitForTimeout(400);
|
|
assert((await page.locator(view).evaluate((v) => v.scrollTop)) > was + 100, "the wheel must scroll the view");
|
|
});
|
|
|
|
assert.deepEqual(errors, [], "page runtime errors");
|
|
});
|
|
}
|