Public: anyone can read this; the crew can change it.
Settings › Move to another owner fits a 390 px phone, and the pull quotes its measured width #10321
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10320-settings-move-to-another-owner-fits-a-39 → main 3 commits fork 4 threads · 4 unresolved
test/settings-move-fit.test.ts added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | /** | |
| 2 | * Settings › Move to another owner fits a 390 px phone (#10320). Three holds: | |
| 3 | * the refused state (a 64-character unknown owner and its refusal sentence) is | |
| 4 | * rendered inside the #move section; the stylesheet rules that lay out the | |
| 5 | * field, its error and the actions row fix no width over 390 px and forbid no | |
| 6 | * wrapping (rules inside @media/@supports/@container blocks included); and, | |
| 7 | * where a Chromium runs, the page is measured at 390x844 both as served and | |
| 8 | * with the refused section swapped in. | |
| 9 | * | |
| 10 | * Limit: the stylesheet holds read the source text of base, components and | |
| 11 | * content, not computed layout; the Chromium measurement is what catches an | |
| 12 | * overflow, and it is skipped where no Chromium starts. | |
| 13 | */ | |
| 14 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 15 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 16 | import { tmpdir } from "node:os"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import { createApp, type App } from "../src/app.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { utilities as base } from "../src/web/style/base.ts"; | |
| 21 | import { components } from "../src/web/style/components.ts"; | |
| 22 | import { content } from "../src/web/style/content.ts"; | |
| 23 | import { findChromium } from "../src/looks/capture.ts"; | |
| 24 | import { adminCookie } from "./helpers/admin.ts"; | |
| 25 | import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts"; | |
| 26 | ||
| 27 | const LONG = "o".repeat(64); | |
| 28 | let dir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let cookie: string; | |
| 32 | let refused: string; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dir = await mkdtemp(join(tmpdir(), "forge-move-fit-")); | |
| 36 | app = createApp({ dataDir: dir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "settings-password" }); | |
| 39 | await fetch(`${servers.url}/new`, { | |
| 40 | method: "POST", | |
| 41 | headers: { cookie }, | |
| 42 | body: new URLSearchParams({ name: "proj", visibility: "private", description: "x" }), | |
| 43 | redirect: "manual", | |
| 44 | }); | |
| 45 | const res = await fetch(`${servers.url}/olive/proj/settings/transfer`, { | |
| 46 | method: "POST", | |
outdated
The regexes only scan the section's HTML, not the CSS that gives the field its width, so they pass whatever the stylesheet does and cannot catch an overflow. Assert against the stylesheet rules for the form field and actions classes (no fixed width, no nowrap), or rely on the measured phone look. |
||
| 47 | headers: { cookie, origin: servers.url }, | |
| 48 | body: new URLSearchParams({ owner: LONG }), | |
| 49 | redirect: "manual", | |
| 50 | }); | |
| 51 | expect(res.status).toBe(422); | |
| 52 | refused = await res.text(); | |
| 53 | }); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | await rm(dir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | function moveSection(page: string): string { | |
| 62 | const from = page.indexOf('id="move"'); | |
| 63 | expect(from).toBeGreaterThan(-1); | |
| 64 | return page.slice(from, page.indexOf("</section>", from)); | |
| 65 | } | |
| 66 | ||
| 67 | describe("Move to another owner, refused", () => { | |
| 68 | test("the long owner and its refusal sentence render inside the section", () => { | |
outdated
rulesFor matches '[^{}@]+{...}' so rules nested in @media are mis-parsed or skipped; a width over 390 px added in a media query would pass this test. Cover media blocks or state the limit here. |
||
| 69 | const section = moveSection(refused); | |
| 70 | expect(section).toContain(`value="${LONG}"`); | |
| 71 | expect(section).toMatch(/class="error"[^>]*>[^<]+/); | |
| 72 | expect(section).not.toMatch(/\b(?:min-)?width\s*[:=]|nowrap|white-space|\bsize="/i); | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | /** | |
| 77 | * Every `selector { body }` in `css`, with conditional group rules unwrapped | |
| 78 | * first so a rule inside `@media (…) { … }` is read like a top-level one; the | |
| 79 | * group's closing brace is left behind as a separator. | |
| 80 | */ | |
| 81 | function rules(css: string): { selector: string; body: string }[] { | |
| 82 | const flat = css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/@(?:media|supports|container|layer)[^{;]*\{/g, ""); | |
| 83 | return [...flat.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ selector: m[1]!.trim(), body: m[2]! })); | |
| 84 | } | |
| 85 | ||
| 86 | describe("the rules that lay the section out", () => { | |
| 87 | const css = [base, components, content].join("\n"); | |
| 88 | const rulesFor = (sel: RegExp, from = css) => rules(from).filter((r) => sel.test(r.selector)).map((r) => r.body); | |
| 89 | const selectors = /(?:^|[\s,])(?:\.field\b|\.input\b|\.form-actions\b|\.settings-block\b|\.muted\b)/; | |
| 90 | ||
| 91 | test("they exist", () => { | |
| 92 | expect(rulesFor(selectors).length).toBeGreaterThan(5); | |
| 93 | expect(rulesFor(/\.form-actions\s*$/).join("")).toContain("flex-wrap: wrap"); | |
| 94 | expect(rulesFor(/\.field .error/).join("")).toContain("overflow-wrap: anywhere"); | |
| 95 | }); | |
| 96 | ||
outdated
This is the only assertion that can fail on a real overflow and it is skipped where Chromium does not start. Say in the pull whether it ran in the check, and quote the logged measurement in the description (box 1). |
||
| 97 | test("a rule inside a media block is read", () => { | |
| 98 | const nested = "@media (max-width: 600px) {\n .x { color: red; }\n .field .input { width: 480px; }\n}\n.y { margin: 0; }"; | |
| 99 | expect(rulesFor(selectors, nested)).toEqual([" width: 480px; "]); | |
| 100 | expect(rulesFor(/\.y$/, nested)).toEqual([" margin: 0; "]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("none fixes a width over 390 px or forbids wrapping", () => { | |
| 104 | const over: string[] = []; | |
| 105 | for (const body of rulesFor(selectors)) { | |
| 106 | expect(body).not.toMatch(/white-space:\s*(?:nowrap|pre)\b/); | |
| 107 | for (const m of body.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) { | |
| 108 | const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16); | |
| 109 | if (px > 390) over.push(m[0]); | |
| 110 | } | |
| 111 | } | |
| 112 | expect(over).toEqual([]); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | const found = findChromium(); | |
| 117 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 118 | if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the Move phone look is not measured"); | |
| 119 | ||
| 120 | describe.skipIf(chromium === undefined)("Settings › Move, measured at 390x844", () => { | |
| 121 | const measureWith = (section: string | null) => `(() => { | |
| 122 | const swap = ${JSON.stringify(section)}; | |
| 123 | const el = document.getElementById("move"); | |
| 124 | if (swap !== null && el) el.outerHTML = swap; | |
| 125 | const move = document.getElementById("move"); | |
| 126 | const vw = window.innerWidth; | |
| 127 | const scope = move ? [move, ...move.querySelectorAll("*")] : []; | |
| 128 | const overflowing = scope.filter((e) => e.getBoundingClientRect().right > vw + 1).map((e) => e.tagName.toLowerCase()); | |
| 129 | return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, innerWidth: vw, scoped: scope.length, error: !!move?.querySelector(".error"), overflowing }); | |
| 130 | })()`; | |
| 131 | const run = async (label: string, section: string | null) => { | |
| 132 | const name = cookie.slice(0, cookie.indexOf("=")); | |
| 133 | const session = { name, value: cookie.slice(name.length + 1) }; | |
| 134 | const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/olive/proj/settings#move`, { width: 390, height: 844 }, [session], measureWith(section))) as { scrollWidth: number; innerWidth: number; scoped: number; error: boolean; overflowing: string[] }; | |
| 135 | console.log(`settings#move ${label} 390x844: ${JSON.stringify(m)}`); | |
| 136 | expect(m.scoped).toBeGreaterThan(0); | |
| 137 | expect(m.overflowing).toEqual([]); | |
| 138 | expect(m.scrollWidth).toBeLessThanOrEqual(m.innerWidth); | |
| 139 | return m; | |
| 140 | }; | |
| 141 | test("as served, no sideways scroll", async () => { | |
| 142 | await run("served", null); | |
| 143 | }, 30_000); | |
| 144 | test("refused with a 64-character owner, no sideways scroll", async () => { | |
| 145 | const section = refused.slice(refused.lastIndexOf("<section", refused.indexOf('id="move"')), refused.indexOf("</section>", refused.indexOf('id="move"')) + "</section>".length); | |
| 146 | expect(section.startsWith('<section class="settings-block" id="move"')).toBe(true); | |
| 147 | const m = await run("refused", section); | |
| 148 | expect(m.error).toBe(true); | |
| 149 | }, 30_000); | |
| 150 | }); |
The header claims the refused owner and its refusal sentence are covered. The test never triggers a refusal; make the test do it or fix the comment.