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

Merged opened this

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

Merged

mason/forge:mason/10921-no-served-page-polls-a-test-fails-on-a-t → main 3 commits fork 5 threads · 5 unresolved

  • Merged. mason/10921-no-served-page-polls-a-test-fails-on-a-t was merged into main as ce46599.

Review

Sign in to review this change.
1 file changed +139 −0
test/no-polling.test.ts +139
@@ -6,6 +6,81 @@
66
77 import { describe, expect, test } from "bun:test";
88 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.
outdated

/crew is missing without explanation (404 for the owner). Document or fix; also remove the extra blank line at line 19-20.

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

data-live\b also matches data-live-every, so that attribute excuses a timer. Use data-live(?![-\w]).

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 <script> whose first attribute is not src. Scan inline bodies (not JSON blocks) and match src in any position.

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 }
984
1085 /** file -> the patterns it may carry, and why */
1186 const ALLOWED: Record<string, { allow: string[]; why: string }> = {
@@ -73,3 +148,67 @@ describe("no polling in src/web", () => {
73148 expect(offenders({ "src/web/fleet-graph.ts": "refresh: { seconds: 1 }" })).toHaveLength(1);
74149 });
75150 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs