Public: anyone can read this; the crew can change it.
A browser test loads Home and finds no button busy or spinning at rest #3075
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3067-a-browser-test-loads-home-and-finds-no-b → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +229
−19
test/helpers/chrome.ts +108 −16
| @@ -104,14 +104,17 @@ async function readDebuggerUrl( | ||
| 104 | 104 | |
| 105 | 105 | /** |
| 106 | 106 | * Load `url` in headless Chromium at a `width`×`height` viewport, carrying |
| 107 | * one cookie (a session), and report how wide the document ended up. | |
| 107 | * cookies and the given colour scheme, and return what `expression` (a JS | |
| 108 | * expression that yields a JSON string) says once the page has loaded. | |
| 108 | 109 | */ |
| 109 | export async function measurePageWidth( | |
| 110 | async function evaluateLoaded( | |
| 110 | 111 | binary: string, |
| 111 | 112 | url: string, |
| 112 | 113 | viewport: { width: number; height: number }, |
| 113 | cookie: { name: string; value: string }, | |
| 114 | ): Promise<PageWidth> { | |
| 114 | cookies: { name: string; value: string }[], | |
| 115 | expression: string, | |
| 116 | scheme: "light" | "dark" = "light", | |
| 117 | ): Promise<string> { | |
| 115 | 118 | const profile = mkdtempSync(join(tmpdir(), "forge-phone-")); |
| 116 | 119 | const proc = Bun.spawn( |
| 117 | 120 | [ |
| @@ -157,16 +160,39 @@ export async function measurePageWidth( | ||
| 157 | 160 | deviceScaleFactor: 1, |
| 158 | 161 | mobile: true, |
| 159 | 162 | }); |
| 160 | await cdp.send("Network.setCookie", { | |
| 161 | name: cookie.name, | |
| 162 | value: cookie.value, | |
| 163 | url, | |
| 163 | await cdp.send("Emulation.setEmulatedMedia", { | |
| 164 | features: [{ name: "prefers-color-scheme", value: scheme }], | |
| 164 | 165 | }); |
| 166 | for (const cookie of cookies) | |
| 167 | await cdp.send("Network.setCookie", { | |
| 168 | name: cookie.name, | |
| 169 | value: cookie.value, | |
| 170 | url, | |
| 171 | }); | |
| 165 | 172 | const loaded = cdp.once("Page.loadEventFired"); |
| 166 | 173 | await cdp.send("Page.navigate", { url }); |
| 167 | 174 | await loaded; |
| 168 | 175 | const { result } = await cdp.send("Runtime.evaluate", { |
| 169 | expression: `(() => { | |
| 176 | expression, | |
| 177 | returnByValue: true, | |
| 178 | }); | |
| 179 | return result.value as string; | |
| 180 | } finally { | |
| 181 | ws?.close(); | |
| 182 | proc.kill("SIGKILL"); | |
| 183 | await proc.exited.catch(() => {}); | |
| 184 | rmSync(profile, { recursive: true, force: true }); | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | /** Load `url` and report how wide the document ended up. */ | |
| 189 | export async function measurePageWidth( | |
| 190 | binary: string, | |
| 191 | url: string, | |
| 192 | viewport: { width: number; height: number }, | |
| 193 | cookie: { name: string; value: string }, | |
| 194 | ): Promise<PageWidth> { | |
| 195 | const expression = `(() => { | |
| 170 | 196 | // documentElement.clientWidth, not window.innerWidth: after an |
| 171 | 197 | // in-process navigation under an active device-metrics override, |
| 172 | 198 | // headless Chromium leaves window.innerWidth stale while every |
| @@ -186,14 +212,80 @@ export async function measurePageWidth( | ||
| 186 | 212 | viewportWidth: vw, |
| 187 | 213 | overflowing, |
| 188 | 214 | }); |
| 189 | })()`, | |
| 190 | returnByValue: true, | |
| 191 | }); | |
| 192 | return JSON.parse(result.value as string) as PageWidth; | |
| 215 | })()`; | |
| 216 | return JSON.parse( | |
| 217 | await evaluateLoaded(binary, url, viewport, [cookie], expression), | |
| 218 | ) as PageWidth; | |
| 219 | } | |
| 220 | ||
| 221 | /** A button that looks busy: marked `aria-busy`, or holding a running animation. */ | |
| 222 | export interface BusyButton { | |
| 223 | button: string; | |
| 224 | why: string; | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Load `url` at rest and list every `button` and `.btn` that is busy or | |
| 229 | * spinning: `aria-busy="true"` on it, or inside it an element (or the | |
| 230 | * button itself) whose computed `animation-name` is not `none`. | |
| 231 | */ | |
| 232 | export async function findBusyButtons( | |
| 233 | binary: string, | |
| 234 | url: string, | |
| 235 | viewport: { width: number; height: number }, | |
| 236 | cookies: { name: string; value: string }[], | |
| 237 | scheme: "light" | "dark", | |
| 238 | ): Promise<{ buttons: number; busy: BusyButton[] }> { | |
| 239 | const expression = `(() => { | |
| 240 | const name = (el) => | |
| 241 | el.tagName.toLowerCase() + | |
| 242 | (typeof el.className === "string" && el.className.trim() | |
| 243 | ? "." + el.className.trim().split(/\\s+/).join(".") | |
| 244 | : "") + " [" + (el.textContent || "").trim().slice(0, 30) + "]"; | |
| 245 | const all = [...document.querySelectorAll("button, .btn")]; | |
| 246 | const busy = []; | |
| 247 | for (const b of all) { | |
| 248 | if (b.getAttribute("aria-busy") === "true") | |
| 249 | busy.push({ button: name(b), why: "aria-busy" }); | |
| 250 | for (const el of [b, ...b.querySelectorAll("*")]) { | |
| 251 | const a = getComputedStyle(el).animationName; | |
| 252 | if (a !== "none") | |
| 253 | busy.push({ button: name(b), why: "animation " + a + " on " + el.tagName.toLowerCase() + "." + String(el.getAttribute("class")) }); | |
| 254 | } | |
| 255 | } | |
| 256 | return JSON.stringify({ buttons: all.length, busy }); | |
| 257 | })()`; | |
| 258 | return JSON.parse( | |
| 259 | await evaluateLoaded(binary, url, viewport, cookies, expression, scheme), | |
| 260 | ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Whether `binary` can actually start headless here. A binary on the path | |
| 265 | * is not a browser that runs: a sandbox that forbids sockets kills it at | |
| 266 | * launch. Tests skip, named, on `false` rather than fail or pass silently. | |
| 267 | */ | |
| 268 | export function chromiumRuns(binary: string): boolean { | |
| 269 | const profile = mkdtempSync(join(tmpdir(), "forge-probe-")); | |
| 270 | try { | |
| 271 | const probe = Bun.spawnSync( | |
| 272 | [ | |
| 273 | binary, | |
| 274 | "--headless=new", | |
| 275 | "--no-sandbox", | |
| 276 | "--disable-gpu", | |
| 277 | "--disable-dev-shm-usage", | |
| 278 | "--no-first-run", | |
| 279 | `--user-data-dir=${profile}`, | |
| 280 | "--dump-dom", | |
| 281 | "about:blank", | |
| 282 | ], | |
| 283 | { env: childEnv({}), stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: 15_000 }, | |
| 284 | ); | |
| 285 | return probe.exitCode === 0 && probe.stdout.toString().includes("<html"); | |
| 286 | } catch { | |
| 287 | return false; | |
| 193 | 288 | } finally { |
| 194 | ws?.close(); | |
| 195 | proc.kill("SIGKILL"); | |
| 196 | await proc.exited.catch(() => {}); | |
| 197 | 289 | rmSync(profile, { recursive: true, force: true }); |
| 198 | 290 | } |
| 199 | 291 | } |
test/home-buttons-rest.test.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * No button spins at rest on Home (#3067, the browser half of #3039). | |
| 3 | * | |
| 4 | * `test/button-rest.test.ts` reads the source for `icon: "loader"`; this loads | |
| 5 | * `/` as an admin in a real headless Chromium, with a stalled step's card on | |
| 6 | * the page, and asks the computed style: no `button` or `.btn` is | |
| 7 | * `aria-busy`, and nothing inside one runs an animation. It goes red if | |
| 8 | * `flow_card.ts` goes back to the loader icon. | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { createApp, type App } from "../src/app.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { createUser } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { findChromium } from "../src/looks/capture.ts"; | |
| 22 | import { chromiumRuns, findBusyButtons } from "./helpers/chrome.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const found = findChromium(); | |
| 29 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 30 | if (chromium === undefined) { | |
| 31 | console.log( | |
| 32 | "SKIPPING: no chromium that starts on this machine, so the Home buttons-at-rest test cannot run", | |
| 33 | ); | |
| 34 | } | |
| 35 | ||
| 36 | describe.skipIf(chromium === undefined)("Home's buttons rest", () => { | |
| 37 | let dataDir: string; | |
| 38 | let app: App; | |
| 39 | let servers: Servers; | |
| 40 | let cookies: { name: string; value: string }[] = []; | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-home-rest-")); | |
| 44 | app = createApp({ dataDir, port: 0 }); | |
| 45 | servers = await serve(app); | |
| 46 | const made = await createUser(app.db, { | |
| 47 | origin: "seed", | |
| 48 | trusted: true, | |
| 49 | username: "keeper", | |
| 50 | email: "[email protected]", | |
| 51 | password: "a-good-passphrase", | |
| 52 | adminIfFirst: true, | |
| 53 | }); | |
| 54 | if (!made.ok) throw new Error(made.error); | |
| 55 | promote(app.db, made.value); | |
| 56 | const admin = made.value; | |
| 57 | cookies = [ | |
| 58 | { name: "forge_session", value: createSession(app.db, admin.id).token }, | |
| 59 | { name: "forge_mode", value: "studio" }, | |
| 60 | ]; | |
| 61 | const repo = await createRepo(app, admin, { name: "garden", visibility: "public" }); | |
| 62 | if (!repo.ok) throw new Error("repo"); | |
| 63 | const seeded = await commitToBranch(app, admin, repo.value, { | |
| 64 | message: "seed", | |
| 65 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 66 | }); | |
| 67 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 68 | const c = await commitToBranch(app, admin, repo.value, { | |
| 69 | branch: "stalled", | |
| 70 | base: "main", | |
| 71 | message: "stalled", | |
| 72 | files: [{ path: "s.md", content: "x\n" }], | |
| 73 | }); | |
| 74 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 75 | const pull = await openPull(app, admin, repo.value, { | |
| 76 | title: "A stalled change", | |
| 77 | body: "", | |
| 78 | baseBranch: "main", | |
| 79 | headBranch: "stalled", | |
| 80 | }); | |
| 81 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 82 | // Old enough that nobody asking for a check is a stall. | |
| 83 | app.db | |
| 84 | .query("UPDATE issue SET created_at = ?2 WHERE id = ?1") | |
| 85 | .run(pull.value.id, new Date(Date.now() - 5 * 3_600_000).toISOString()); | |
| 86 | }, 30_000); | |
| 87 | ||
| 88 | afterAll(async () => { | |
| 89 | servers.stop(); | |
| 90 | app.close(); | |
| 91 | await rm(dataDir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | for (const [label, width] of [ | |
| 95 | ["desktop", 1280], | |
| 96 | ["360px", 360], | |
| 97 | ] as const) | |
| 98 | for (const scheme of ["light", "dark"] as const) | |
| 99 | test(`${label}, ${scheme}: with a stalled step on the page, no button is busy or animating`, async () => { | |
| 100 | const page = await findBusyButtons( | |
| 101 | chromium!, | |
| 102 | `${servers.url}/`, | |
| 103 | { width, height: 800 }, | |
| 104 | cookies, | |
| 105 | scheme, | |
| 106 | ); | |
| 107 | // The stalled step's button is on the page, so the check has teeth. | |
| 108 | const html = await ( | |
| 109 | await fetch(`${servers.url}/`, { | |
| 110 | headers: { cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; ") }, | |
| 111 | }) | |
| 112 | ).text(); | |
| 113 | expect(html).toContain("data-flow-card="); | |
| 114 | expect(page.buttons).toBeGreaterThan(0); | |
| 115 | expect(page.busy).toEqual([]); | |
| 116 | }, 20_000); | |
| 117 | }); |
test/web-phone.test.ts +4 −3
| @@ -15,14 +15,15 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | import { findChromium } from "../src/looks/capture.ts"; |
| 17 | 17 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | import { measurePageWidth } from "./helpers/chrome.ts"; | |
| 18 | import { chromiumRuns, measurePageWidth } from "./helpers/chrome.ts"; | |
| 19 | 19 | |
| 20 | 20 | quietLogs(); |
| 21 | 21 | |
| 22 | const chromium = findChromium(); | |
| 22 | const found = findChromium(); | |
| 23 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 23 | 24 | if (chromium === undefined) { |
| 24 | 25 | console.log( |
| 25 | "SKIPPING: no chromium on this machine, so the phone-width test cannot run", | |
| 26 | "SKIPPING: no chromium that starts on this machine, so the phone-width test cannot run", | |
| 26 | 27 | ); |
| 27 | 28 | } |
| 28 | 29 |
new above
offline
This only proves a flow card exists, not that the stalled step's button is in it. Assert on a button inside
[data-flow-card](e.g. return per-card button counts from the helper) so the test cannot pass on a card with no button.