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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3067-a-browser-test-loads-home-and-finds-no-b → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3067-a-browser-test-loads-home-and-finds-no-b was merged into main as 8a963c5.

Review

Sign in to review this change.
3 files changed +229 −19
test/helpers/chrome.ts +108 −16
@@ -104,14 +104,17 @@ async function readDebuggerUrl(
104104
105105 /**
106106 * 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.
108109 */
109 export async function measurePageWidth(
110 async function evaluateLoaded(
110111 binary: string,
111112 url: string,
112113 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> {
115118 const profile = mkdtempSync(join(tmpdir(), "forge-phone-"));
116119 const proc = Bun.spawn(
117120 [
@@ -157,16 +160,39 @@ export async function measurePageWidth(
157160 deviceScaleFactor: 1,
158161 mobile: true,
159162 });
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 }],
164165 });
166 for (const cookie of cookies)
167 await cdp.send("Network.setCookie", {
168 name: cookie.name,
169 value: cookie.value,
170 url,
171 });
165172 const loaded = cdp.once("Page.loadEventFired");
166173 await cdp.send("Page.navigate", { url });
167174 await loaded;
168175 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 = `(() => {
170196 // documentElement.clientWidth, not window.innerWidth: after an
171197 // in-process navigation under an active device-metrics override,
172198 // headless Chromium leaves window.innerWidth stale while every
@@ -186,14 +212,80 @@ export async function measurePageWidth(
186212 viewportWidth: vw,
187213 overflowing,
188214 });
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;
193288 } finally {
194 ws?.close();
195 proc.kill("SIGKILL");
196 await proc.exited.catch(() => {});
197289 rmSync(profile, { recursive: true, force: true });
198290 }
199291 }
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();

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.

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";
1515 import { serve, type Servers } from "../src/http/server.ts";
1616 import { findChromium } from "../src/looks/capture.ts";
1717 import { quietLogs } from "./helpers/globals.ts";
18 import { measurePageWidth } from "./helpers/chrome.ts";
18 import { chromiumRuns, measurePageWidth } from "./helpers/chrome.ts";
1919
2020 quietLogs();
2121
22 const chromium = findChromium();
22 const found = findChromium();
23 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
2324 if (chromium === undefined) {
2425 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",
2627 );
2728 }
2829

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs