Public: anyone can read this; the crew can change it.
A test: 390x844 and 1280x800, no page is covered by a closed overlay (#11831 item 3) #11929
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11854-a-test-390x844-and-1280x800-no-page-is-c → main 5 commits fork 3 threads · 3 unresolved
1 file
changed +135
−0
test/no-closed-overlay.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * No page is covered by a closed overlay (#11831 item 3, #11854). | |
| 3 | * | |
| 4 | * Release 0.7.549 drew a closed palette `<dialog>` over every page on a | |
| 5 | * phone. This drives a real headless Chromium at 390×844 and 1280×800, signed | |
| 6 | * out and signed in, over the forge's own pages: with nothing opened, the | |
| 7 | * element at the centre of the screen is the page's content, never a dialog, | |
| 8 | * popover or large fixed layer. Then the palette is opened and closed with | |
| 9 | * its close button, and the page is back. | |
| 10 | * | |
| 11 | * Only dialogs, popovers and `position: fixed` layers are looked for: an absolute or sticky | |
| 12 | * full-page layer is not detected. The detector is proved by a positive control: with the | |
| 13 | * palette open it must report a dialog, so an empty list is not blindness. | |
| 14 | * | |
| 15 | * Not held here: Escape and tap-outside (a native dialog takes those from a | |
| 16 | * real key and pointer, which this CDP helper does not send; test/palette-dismiss | |
| 17 | * holds them in the client script), and the release-proof half (#11831 item 4). | |
| 18 | */ | |
| 19 | ||
| 20 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 21 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 22 | import { tmpdir } from "node:os"; | |
| 23 | import { join } from "node:path"; | |
| 24 | import { createApp, type App } from "../src/app.ts"; | |
| 25 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 26 | import { findChromium } from "../src/looks/capture.ts"; | |
| 27 | import { quietLogs } from "./helpers/globals.ts"; | |
| 28 | import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | const found = findChromium(); | |
| 33 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 34 | if (chromium === undefined) | |
| 35 | console.log("SKIPPING: no chromium that starts on this machine, so the covered-page test cannot run"); | |
| 36 | ||
| 37 | /** `/search` is left out: it opens the palette as a sheet on purpose (data-palette-auto). */ | |
| 38 | const PAGES = ["/", "/login", "/explore", "/apps", "/changelog"]; | |
| 39 | const VIEWPORTS = [ | |
| 40 | { width: 390, height: 844 }, | |
| 41 | { width: 1280, height: 800 }, | |
| 42 | ]; | |
| 43 | ||
| 44 | /** What is on top at the centre of the screen, and whether it is an overlay. */ | |
| 45 | const COVER = `(() => { | |
| 46 | const vw = document.documentElement.clientWidth, vh = innerHeight; | |
| 47 | const top = document.elementFromPoint(vw / 2, vh / 2); | |
| 48 | const covers = []; | |
| 49 | for (let el = top; el; el = el.parentElement) { | |
| 50 | const cs = getComputedStyle(el), r = el.getBoundingClientRect(); | |
| 51 | const big = r.width * r.height > (vw * vh) / 4; | |
| 52 | const kind = el.tagName === "DIALOG" ? "dialog" : el.hasAttribute("popover") ? "popover" | |
| 53 | : cs.position === "fixed" && big && cs.visibility !== "hidden" ? "fixed layer" : ""; | |
| 54 | if (kind) covers.push(kind + ":" + el.tagName.toLowerCase() + "." + String(el.className)); | |
| 55 | } | |
| 56 | return { signedIn: !!document.querySelector('form[action="/logout"]'), | |
| 57 | top: top ? top.tagName.toLowerCase() + "." + String(top.className) : "none", covers }; | |
| 58 | })()`; | |
| 59 | ||
| 60 | describe.skipIf(chromium === undefined)("no page is covered by a closed overlay", () => { | |
| 61 | let dataDir: string; | |
| 62 | let app: App; | |
| 63 | let servers: Servers; | |
| 64 | let base: string; | |
| 65 | let cookie = { name: "", value: "" }; | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dataDir = await mkdtemp(join(tmpdir(), "forge-cover-")); | |
| 69 | app = createApp({ dataDir, port: 0 }); | |
| 70 | servers = await serve(app); | |
| 71 | base = servers.url; | |
| 72 | const signup = await fetch(`${base}/signup`, { | |
| 73 | method: "POST", | |
| 74 | body: new URLSearchParams({ username: "ada", email: "[email protected]", password: "a-long-web-passphrase" }), | |
| 75 | redirect: "manual", | |
| 76 | }); | |
| 77 | // Split on the first "=" only: a value may carry "=" padding of its own. | |
| 78 | const pair = (signup.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 79 | const eq = pair.indexOf("="); | |
| 80 | cookie = { name: pair.slice(0, eq), value: pair.slice(eq + 1) }; | |
| 81 | expect(cookie.name).not.toBe(""); | |
| 82 | expect(cookie.value).not.toBe(""); | |
| 83 | }, 20_000); | |
| 84 | ||
outdated
Nothing proves signedIn is really signed in. Add an assertion that the page shows the signed-in state, otherwise half the matrix may be the signed-out run twice. |
||
| 85 | afterAll(async () => { | |
| 86 | servers.stop(); | |
| 87 | app.close(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | for (const viewport of VIEWPORTS) | |
| 92 | for (const signedIn of [false, true]) | |
| 93 | for (const path of PAGES) | |
outdated
Add a positive control: with the palette open, COVER must report a dialog. Without it, an empty covers list could just mean the detector is blind. |
||
| 94 | test(`${path} at ${viewport.width}×${viewport.height}, ${signedIn ? "signed in" : "signed out"}: nothing closed is on top`, async () => { | |
| 95 | const out = JSON.parse( | |
| 96 | await evaluateLoaded(chromium!, `${base}${path}`, viewport, signedIn ? [cookie] : [], | |
| 97 | `JSON.stringify(${COVER})`), | |
| 98 | ) as { signedIn: boolean; top: string; covers: string[] }; | |
| 99 | // The cookie must really sign the page in (or out), | |
| 100 | // else half the matrix is one run twice. | |
| 101 | expect(out.signedIn).toBe(signedIn); | |
| 102 | expect(out.covers).toEqual([]); | |
| 103 | }, 20_000); | |
| 104 | ||
| 105 | for (const viewport of VIEWPORTS) | |
| 106 | test(`the palette at ${viewport.width}×${viewport.height}: opens, its close button closes it, the page is back`, async () => { | |
| 107 | const out = JSON.parse( | |
| 108 | await evaluateLoaded(chromium!, `${base}/`, viewport, [cookie], `(async () => { | |
| 109 | const dlg = document.querySelector("dialog.palette, dialog#palette"); | |
| 110 | if (!dlg) return JSON.stringify({ missing: true }); | |
| 111 | const boxBefore = getComputedStyle(dlg).display; | |
| 112 | const opener = document.querySelector("[data-palette]"); | |
| 113 | opener.click(); | |
| 114 | const opened = dlg.open; | |
| 115 | const whileOpen = ${COVER}; | |
| 116 | const close = dlg.querySelector("[data-palette-close]"); | |
| 117 | // Wide screens hide the close button (display:none): close through the dialog itself. | |
| 118 | if (close && getComputedStyle(close).display !== "none") close.click(); else dlg.close(); | |
| 119 | return JSON.stringify({ opened, whileOpen, closed: !dlg.open, boxBefore, boxAfter: getComputedStyle(dlg).display, cover: ${COVER} }); | |
| 120 | })()`), | |
| 121 | ) as { | |
| 122 | missing?: boolean; opened: boolean; closed: boolean; boxBefore: string; boxAfter: string; | |
| 123 | whileOpen: { covers: string[] }; cover: { covers: string[] }; | |
| 124 | }; | |
| 125 | expect(out.missing).toBeUndefined(); | |
| 126 | // A closed dialog has no layout box: it is not drawn at all. | |
| 127 | expect(out.boxBefore).toBe("none"); | |
| 128 | expect(out.opened).toBe(true); | |
| 129 | // Positive control: open, the detector sees a dialog on top. | |
| 130 | expect(out.whileOpen.covers.some((c) => c.startsWith("dialog:"))).toBe(true); | |
| 131 | expect(out.boxAfter).toBe("none"); | |
| 132 | expect(out.closed).toBe(true); | |
| 133 | expect(out.cover.covers).toEqual([]); | |
| 134 | }, 20_000); | |
| 135 | }); |
new above
offline
split("=") truncates a cookie value that contains '='. Split on the first '=' (indexOf/slice), and assert name and value are non-empty so a failed signup fails loudly.