Public: anyone can read this; the crew can change it.
No served page polls: a test fails on a timer or meta refresh that reloads or refetches a page without the live stream #10929
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10921-no-served-page-polls-a-test-fails-on-a-t → main 3 commits fork 5 threads · 5 unresolved
1 file
changed +139
−0
test/no-polling.test.ts +139
| @@ -6,6 +6,81 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { describe, expect, test } from "bun:test"; |
| 8 | 8 | import { Glob } from "bun"; |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp } from "../src/app.ts"; | |
| 13 | import { serve } from "../src/http/server.ts"; | |
| 14 | import { adminCookie } from "./helpers/admin.ts"; | |
| 15 | ||
| 16 | /** | |
| 17 | * The owner's main pages. /crew is not here: it answers 404 for the owner (the | |
| 18 | * crew lives at /<name> and in the feed), so there is no page to render. | |
| 19 | */ | |
| 20 | const OWNER_PAGES = ["/", "/system", "/flow", "/boards", "/explore", "/forges", "/changelog"]; | |
| 21 | ||
| 22 | /** Pages that must hold no timer of any kind, stream or not. */ | |
| 23 | const NO_TIMER_PAGES = ["/system", "/flow"]; | |
| 24 | ||
| 25 | /** Pages that must carry a live stream. /flow is the one named exemption: a form of valves | |
| 26 | * that only the owner's own submit changes; a pushed patch would move a handle under a drag. */ | |
| 27 | const STREAM_PAGES = ["/system"]; | |
| 28 | ||
| 29 | /** A page as the browser gets it: its markup, and every script it runs (fetched files and inline bodies), apart. */ | |
outdated
|
||
| 30 | export interface RenderedPage { html: string; scripts: string[] } | |
| 31 | ||
| 32 | /** A stream a script opens. `data-live` alone, never `data-live-every`, the beat this guard forbids. */ | |
| 33 | const STREAM = /EventSource\s*\(|data-live(?![-\w])/; | |
| 34 | ||
| 35 | /** Script types that hold data, not code. */ | |
| 36 | const DATA_SCRIPT = /\btype\s*=\s*["']?application\/(?:ld\+)?json/i; | |
| 37 | ||
| 38 | /** | |
| 39 | * The scripts a page's markup names: each `src`, wherever it sits among the | |
| 40 | * attributes, and each inline body that is code (JSON blocks are data). | |
| 41 | */ | |
| 42 | export function scriptTags(html: string): { srcs: string[]; inline: string[] } { | |
| 43 | const srcs: string[] = []; | |
| 44 | const inline: string[] = []; | |
| 45 | for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) { | |
| 46 | const attrs = m[1]!; | |
| 47 | const src = /(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrs); | |
| 48 | if (src) srcs.push(src[1] ?? src[2] ?? src[3]!); | |
| 49 | else if (!DATA_SCRIPT.test(attrs) && m[2]!.trim() !== "") inline.push(m[2]!); | |
| 50 | } | |
| 51 | return { srcs, inline }; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * What a rendered page does that polls. A fetching timer is excused only by a | |
| 56 | * stream opened in the same script, and the pages in NO_TIMER_PAGES and | |
| 57 | * STREAM_PAGES are held to more: a stream page opens its stream in a script. | |
outdated
This misses inline scripts and any |
||
| 58 | */ | |
| 59 | export function pagePolls(path: string, page: RenderedPage): string[] { | |
| 60 | const found: string[] = []; | |
| 61 | if (/<meta[^>]+http-equiv=["']?refresh/i.test(page.html)) found.push("meta refresh"); | |
| 62 | if (/data-live-every/.test(page.html + page.scripts.join("\n"))) found.push("data-live-every"); | |
| 63 | for (const code of page.scripts) { | |
| 64 | if (!/setInterval\s*\(/.test(code)) continue; | |
| 65 | if (NO_TIMER_PAGES.includes(path)) found.push("a timer on a page that takes none"); | |
| 66 | else if (/fetch\s*\(|location\.reload/.test(code) && !STREAM.test(code)) found.push("setInterval fetch without a live stream in its script"); | |
| 67 | } | |
| 68 | if (STREAM_PAGES.includes(path) && !page.scripts.some((code) => STREAM.test(code))) found.push("no live stream in its scripts"); | |
| 69 | return found; | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * The page as the browser gets it. A same-origin `src` is fetched and read; a | |
| 74 | * script from anywhere else cannot be read here, so it is named, not skipped. | |
| 75 | */ | |
| 76 | async function render(base: string, path: string, cookie: string): Promise<{ status: number; page: RenderedPage; unread: string[] }> { | |
| 77 | const res = await fetch(base + path, { headers: { cookie } }); | |
| 78 | const html = await res.text(); | |
| 79 | const { srcs, inline } = scriptTags(html); | |
| 80 | const local = srcs.filter((src) => src.startsWith("/") && !src.startsWith("//")); | |
| 81 | const fetched = await Promise.all(local.map(async (src) => (await fetch(base + src)).text())); | |
| 82 | return { status: res.status, page: { html, scripts: [...fetched, ...inline] }, unread: srcs.filter((src) => !local.includes(src)) }; | |
| 83 | } | |
| 9 | 84 | |
| 10 | 85 | /** file -> the patterns it may carry, and why */ |
| 11 | 86 | const ALLOWED: Record<string, { allow: string[]; why: string }> = { |
| @@ -73,3 +148,67 @@ describe("no polling in src/web", () => { | ||
| 73 | 148 | expect(offenders({ "src/web/fleet-graph.ts": "refresh: { seconds: 1 }" })).toHaveLength(1); |
| 74 | 149 | }); |
| 75 | 150 | }); |
| 151 | ||
| 152 | describe("no rendered page polls", () => { | |
| 153 | test("the detector names each kind of polling in a fixture", () => { | |
| 154 | expect(pagePolls("/x", { html: '<meta http-equiv="refresh" content="5">', scripts: [] })).toEqual(["meta refresh"]); | |
| 155 | expect(pagePolls("/x", { html: "<p data-live-every=5>", scripts: [] })).toEqual(["data-live-every"]); | |
| 156 | expect(pagePolls("/x", { html: "", scripts: ["setInterval(() => fetch('/x'), 1000)"] })).toEqual(["setInterval fetch without a live stream in its script"]); | |
| 157 | expect(pagePolls("/x", { html: "", scripts: ["setInterval(() => location.reload(), 1000)"] })).toHaveLength(1); | |
| 158 | // a stream in another script does not excuse the timer | |
| 159 | expect(pagePolls("/x", { html: "", scripts: ["new EventSource('/e')", "setInterval(() => fetch('/x'), 1)"] })).toHaveLength(1); | |
| 160 | // a stream in the same script does; a clock that only redraws text is no poll | |
| 161 | expect(pagePolls("/x", { html: "", scripts: ["new EventSource('/e'); setInterval(() => fetch('/x'), 1)"] })).toEqual([]); | |
| 162 | expect(pagePolls("/x", { html: "", scripts: ["setInterval(() => (el.textContent = now()), 1000)"] })).toEqual([]); | |
| 163 | // /system and /flow take no timer at all, and /system must carry a stream | |
| 164 | expect(pagePolls("/flow", { html: "", scripts: ["setInterval(() => {}, 1)"] })).toEqual(["a timer on a page that takes none"]); | |
| 165 | expect(pagePolls("/system", { html: "", scripts: ["new EventSource('/s')"] })).toEqual([]); | |
| 166 | expect(pagePolls("/system", { html: "", scripts: ["x()"] })).toEqual(["no live stream in its scripts"]); | |
| 167 | // a stream named only in the markup is not a stream /system opens | |
| 168 | expect(pagePolls("/system", { html: "<p>new EventSource('/s')</p>", scripts: ["x()"] })).toEqual(["no live stream in its scripts"]); | |
| 169 | // data-live-every is a beat, not a stream: it excuses no timer | |
| 170 | expect(pagePolls("/x", { html: "", scripts: ["el.dataset['data-live-every']; setInterval(() => fetch('/x'), 1)"] })).toContain("setInterval fetch without a live stream in its script"); | |
| 171 | expect(pagePolls("/x", { html: "", scripts: ["q('[data-live]'); setInterval(() => fetch('/x'), 1)"] })).toEqual([]); | |
| 172 | expect(pagePolls("/flow", { html: "", scripts: ["x()"] })).toEqual([]); | |
| 173 | }); | |
| 174 | ||
| 175 | test("every script a page runs is found: src in any attribute position, inline code, not JSON data", () => { | |
| 176 | const html = `<script src="/assets/a.js" defer></script> | |
| 177 | <script defer src="/assets/b.js"></script> | |
| 178 | <script type="module" src='/c.js'></script> | |
| 179 | <script src=https://elsewhere.example/d.js></script> | |
| 180 | <script>setInterval(() => fetch('/x'), 1000)</script> | |
| 181 | <script type="module">import "/e.js"</script> | |
| 182 | <script type="application/json" id="palette-index">{"setInterval(": 1}</script> | |
| 183 | <script type="application/ld+json">{"fetch(": 1}</script>`; | |
| 184 | const { srcs, inline } = scriptTags(html); | |
| 185 | expect(srcs).toEqual(["/assets/a.js", "/assets/b.js", "/c.js", "https://elsewhere.example/d.js"]); | |
| 186 | expect(inline).toEqual(["setInterval(() => fetch('/x'), 1000)", 'import "/e.js"']); | |
| 187 | // an inline poll is caught once read as a script | |
| 188 | expect(pagePolls("/x", { html, scripts: inline })).toEqual(["setInterval fetch without a live stream in its script"]); | |
| 189 | // data-src is not src | |
| 190 | expect(scriptTags('<script data-src="/x.js">go()</script>')).toEqual({ srcs: [], inline: ["go()"] }); | |
| 191 | }); | |
| 192 | ||
| 193 | test("the owner's main pages poll nowhere, and /system streams", async () => { | |
| 194 | const dataDir = await mkdtemp(join(tmpdir(), "forge-nopoll-")); | |
| 195 | const app = createApp({ dataDir, port: 0 }); | |
| 196 | const servers = await serve(app); | |
| 197 | try { | |
| 198 | const cookie = await adminCookie(app, { username: "lex", email: "[email protected]", password: "no-polling-pass" }); | |
| 199 | const found: string[] = []; | |
| 200 | for (const path of OWNER_PAGES) { | |
| 201 | const { status, page, unread } = await render(servers.url, path, cookie); | |
| 202 | for (const src of unread) found.push(`${path}: a script it cannot read, ${src}`); | |
| 203 | expect(`${path} ${String(status)}`).toBe(`${path} 200`); | |
| 204 | expect(page.scripts.length).toBeGreaterThan(0); // the scripts were read, or the check saw nothing | |
| 205 | for (const hit of pagePolls(path, page)) found.push(`${path}: ${hit}`); | |
| 206 | } | |
| 207 | expect(found).toEqual([]); | |
| 208 | } finally { | |
| 209 | servers.stop(); | |
| 210 | app.close(); | |
| 211 | await rm(dataDir, { recursive: true, force: true }); | |
| 212 | } | |
| 213 | }); | |
| 214 | }); |
/crew is missing without explanation (404 for the owner). Document or fix; also remove the extra blank line at line 19-20.