Public: anyone can read this; the crew can change it.

Settings › Tokens, Crew, Subscriptions and Usage have before/after height, word count and looks on file #9679

Merged opened this

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

Merged

torque/forge:torque/9662-settings-tokens-crew-subscriptions-and-u → main 3 commits fork 2 threads · 2 unresolved

  • Merged. torque/9662-settings-tokens-crew-subscriptions-and-u was merged into main as e991561.

Review

Sign in to review this change.
4 files changed +308 −0
docs/features/measure-page.md added +21
@@ -0,0 +1,21 @@
1 # Measuring a page: height and words
2
3 `scripts/measure-page.ts` answers "did this page get shorter?" with two numbers per page, the same way before and after a change.
4
5 ```sh
6 # the page now, at 1440 wide, with pictures at 1440 and 390, light and dark
7 bun scripts/measure-page.ts --shots out/ /settings/subscriptions /settings/usage \
8 /alice/demo/settings#proposal-tokens /alice/demo/settings#crew
9
10 # the same pages at an older commit
11 git worktree add ../before <commit>^ && ln -s "$PWD/node_modules" ../before/node_modules
12 bun scripts/measure-page.ts --root ../before /settings/subscriptions …
13
14 # no Chromium on this machine: words only, from the served HTML
15 bun scripts/measure-page.ts --words-only /settings/usage
16 ```
17
18 - **height** is `document.documentElement.scrollHeight` in headless Chromium at `--width` (default 1440); for `path#id` it is that element's box height. When the id is absent (a page from before its sections had ids) the card or section whose `<h2>` is the id with spaces is used.
19 - **words** come from `src/measure/words.ts` in every mode: the text of `<main>` (or of the `#id` element), tags, scripts, styles and comments dropped. In a browser it reads the DOM's HTML back, so the method does not differ between modes.
20 - The script boots the checkout named by `--root` on a throw-away data directory, signs in a seeded admin (`alice`, the fixture the tests use) with a session, and seeds rows so growth per row shows: a repository `alice/demo` with five proposal tokens and three crew agents, and three subscriptions.
21 - One JSON line per path: `{"path","width","height","words"}`; exit 2 on bad usage, 3 when there is no Chromium and `--words-only` was not given.
scripts/measure-page.ts added +170
@@ -0,0 +1,170 @@
1 /**
2 * Measure admin pages: the rendered height (`document.documentElement.
3 * scrollHeight`, or an element's box for `path#id`) in headless Chromium, and
4 * the word count (`src/measure/words.ts`, the one method both modes use).
5 * Usage and the table it prints: docs/features/measure-page.md.
6 *
7 * bun scripts/measure-page.ts [--root DIR] [--width 1440] [--shots DIR] [--words-only] /path[#id] …
8 */
9 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join, resolve } from "node:path";
12 import { childEnv } from "../src/apps/process.ts";
13 import { findChromium } from "../src/looks/capture.ts";
14 import { wordsOf } from "../src/measure/words.ts";
15
16 const args = process.argv.slice(2);
17 const take = (flag: string): string | undefined => {
18 const at = args.indexOf(flag);
19 return at < 0 ? undefined : args.splice(at, 2)[1];
20 };
21 const wordsOnly = args.includes("--words-only");
22 const root = resolve(take("--root") ?? ".");
23 const width = Number(take("--width") ?? 1440);
24 const shots = take("--shots");
25 const paths = args.filter((a) => a.startsWith("/"));
26 if (!Number.isInteger(width) || width < 200 || width > 4000 || paths.length === 0) {
27 console.error("usage: bun scripts/measure-page.ts [--root DIR] [--width N] [--shots DIR] [--words-only] /path[#id] …");
28 process.exit(2);
29 }
30 const binary = findChromium();
31 if (binary === undefined && !wordsOnly) {
32 console.error("no Chromium on this machine (--words-only needs none)");
outdated

Missing semicolon, and line 34 ends in ;;. Tidy both.

33 process.exit(3);
34 }
outdated

The regex word counter (mainWords) and the in-page innerText count measure differently, so the before/after numbers are not comparable across modes. Use one method, extract it to a module, and add a test.

35 const split = (p: string): [string, string] => [p.split("#")[0]!, p.split("#")[1] ?? ""];
36
37 type Send = (method: string, params?: object) => Promise<any>;
38
39 /** One Chromium and its one page, driven over the devtools socket; killed when `run` ends. */
40 async function withPage<T>(run: (send: Send, loaded: () => Promise<void>) => Promise<T>): Promise<T> {
41 const profile = mkdtempSync(join(tmpdir(), "forge-measure-"));
42 const proc = Bun.spawn(
43 [binary!, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--no-first-run", `--user-data-dir=${profile}`, "--remote-debugging-port=0"],
44 { env: childEnv({}), stdin: "ignore", stdout: "ignore", stderr: "pipe" },
45 );
46 let ws: WebSocket | undefined;
47 try {
48 const reader = proc.stderr.getReader();
49 let buf = "";
50 let browser = "";
51 while (browser === "") {
52 const { done, value } = await reader.read();
53 if (done) throw new Error("chromium exited before printing its devtools address");
54 buf += new TextDecoder().decode(value);
55 browser = buf.match(/DevTools listening on (ws:\/\/\S+)/)?.[1] ?? "";
56 }
57 reader.cancel().catch(() => {});
58 const base = browser.replace(/^ws:/, "http:").replace(/\/devtools\/browser\/.*$/, "");
59 const list = (await (await fetch(`${base}/json/list`)).json()) as { type: string; webSocketDebuggerUrl: string }[];
60 ws = new WebSocket(list.find((t) => t.type === "page")!.webSocketDebuggerUrl);
61 await new Promise<void>((ok, bad) => {
62 ws!.addEventListener("open", () => ok());
63 ws!.addEventListener("error", () => bad(new Error("devtools refused the connection")));
64 });
65 let id = 0;
66 const pending = new Map<number, (m: any) => void>();
67 let onLoad: () => void = () => {};
68 ws.addEventListener("message", (e) => {
69 const m = JSON.parse(e.data as string);
70 if (m.id !== undefined) pending.get(m.id)?.(m);
71 else if (m.method === "Page.loadEventFired") onLoad();
72 });
73 const send: Send = (method, params = {}) =>
74 new Promise((ok, bad) => {
75 const n = ++id;
76 const t = setTimeout(() => bad(new Error(`${method} timed out`)), 15_000);
77 pending.set(n, (m) => {
78 clearTimeout(t);
79 if (m.error) bad(new Error(m.error.message));
80 else ok(m.result);
81 });
82 ws!.send(JSON.stringify({ id: n, method, params }));
83 });
84 const loaded = () => new Promise<void>((ok) => (onLoad = ok));
85 return await run(send, loaded);
86 } finally {
87 ws?.close();
88 proc.kill("SIGKILL");
89 await proc.exited.catch(() => {});
90 rmSync(profile, { recursive: true, force: true });
91 }
92 }
93
94 const dir = mkdtempSync(join(tmpdir(), "forge-measure-data-"));
95 const imp = (file: string) => import(join(root, file));
96 const { createApp } = await imp("src/app.ts");
97 const { createUser } = await imp("src/core/users.ts");
98 const { createSession } = await imp("src/core/sessions.ts");
99 const { createRepo } = await imp("src/core/repos.ts");
100 const { mintProposalToken } = await imp("src/core/proposal_token.ts");
101 const { addSubscription } = await imp("src/core/subscriptions.ts");
102 const { createAgent, addToCrew } = await imp("src/actions/crew.ts");
103 const { serve } = await imp("src/http/server.ts");
104 const app = createApp({ dataDir: dir, port: 0 });
105 const servers = await serve(app);
106 const must = (r: { ok: boolean; error?: unknown; value?: any }, what: string) => {
107 if (!r.ok) throw new Error(`seeding ${what}: ${JSON.stringify(r.error)}`);
108 return r.value;
109 };
110 try {
111 const alice = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }), "alice");
112 const actor = { ...alice, isAdmin: true };
113 const repo = must(await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "demo" }), "repo");
114 // Rows, so a page's growth per row shows: five tokens, three crew, three subscriptions.
115 for (let n = 1; n <= 5; n++)
116 must(mintProposalToken(app.db, alice.id, repo.id, { child: `child-${n}`, origin: `https://child-${n}.example.com/` }), "token");
117 for (const [n, role] of ["planner", "builder", "reviewer"].entries()) {
118 must(await createAgent(app, actor, { username: `bot-${n}`, roles: [role] }), "agent");
119 must(addToCrew(app, actor, repo, `bot-${n}`, role), "crew");
120 }
121 for (let n = 1; n <= 3; n++)
122 must(addSubscription(app.db, dir, { name: `plan-${n}`, token: `sk-ant-oat01-${"x".repeat(20)}${n}` }), "subscription");
123 const token = createSession(app.db, alice.id).token;
124
125 /** One page at one size and theme: its height and words; with `out`, a picture. */
126 const look = (target: string, w: number, dark: boolean, out?: string) =>
127 withPage(async (send, loaded) => {
128 const [path, id] = split(target);
129 await send("Page.enable");
130 await send("Network.enable");
131 await send("Emulation.setDeviceMetricsOverride", { width: w, height: w === 1440 ? 900 : 844, deviceScaleFactor: 1, mobile: w < 700 });
132 await send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: dark ? "dark" : "light" }] });
133 await send("Network.setCookie", { name: "forge_session", value: token, url: servers.url });
134 const done = loaded();
135 await send("Page.navigate", { url: `${servers.url}${path}` });
136 await done;
137 await Bun.sleep(400);
138 const r = await send("Runtime.evaluate", {
139 returnByValue: true,
140 expression: `(() => { const id = ${JSON.stringify(id)};
141 const el = id ? (document.getElementById(id) || [...document.querySelectorAll("h2")].find((h) => h.textContent.trim().toLowerCase() === id.replace(/-/g, " "))?.closest(".card, section")) : null;
142 return JSON.stringify({ height: el ? Math.round(el.getBoundingClientRect().height) : document.documentElement.scrollHeight, html: document.documentElement.outerHTML }); })()`,
143 });
144 const got = JSON.parse(r.result.value) as { height: number; html: string };
145 if (out !== undefined) {
146 const s = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true });
147 writeFileSync(out, Buffer.from(s.data, "base64"));
148 }
149 return { height: got.height, words: wordsOf(got.html, id) };
150 });
151
152 if (shots !== undefined) mkdirSync(shots, { recursive: true });
153 for (const target of paths) {
154 if (wordsOnly) {
155 const [path, id] = split(target);
156 const res = await fetch(`${servers.url}${path}`, { headers: { cookie: `forge_session=${token}` } });
157 console.log(JSON.stringify({ path: target, status: res.status, words: wordsOf(await res.text(), id) }));
158 continue;
159 }
160 console.log(JSON.stringify({ path: target, width, ...(await look(target, width, false)) }));
161 if (shots !== undefined)
162 for (const w of [1440, 390])
163 for (const dark of [false, true])
164 await look(target, w, dark, join(shots, `${target.replace(/^\//, "").replace(/\W+/g, "-")}-${w}-${dark ? "dark" : "light"}.png`));
165 }
166 } finally {
167 servers.stop();
168 app.close();
169 rmSync(dir, { recursive: true, force: true });
170 }
src/measure/words.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * Counting the words of a page, one way: from its HTML, whether that HTML was
3 * served or read back from a browser's DOM (`scripts/measure-page.ts`). A
4 * word is a run of non-space characters in the text of `<main>`, or of one
5 * element of it; tags, `<script>`, `<style>` and comments are not text, and
6 * the five entities the page writer produces count as spaces or their
7 * character.
8 */
9
10 /** The text of `html` with markup removed. */
11 export function textOf(html: string): string {
12 return html
13 .replace(/<!--[\s\S]*?-->/g, " ")
14 .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, " ")
15 .replace(/<[^>]*>/g, " ")
16 .replace(/&nbsp;/g, " ")
17 .replace(/&lt;/g, "<")
18 .replace(/&gt;/g, ">")
19 .replace(/&quot;/g, '"')
20 .replace(/&#39;/g, "'")
21 .replace(/&amp;/g, "&");
22 }
23
24 /** The words in `html`'s text. */
25 export const countWords = (html: string): number => textOf(html).split(/\s+/).filter(Boolean).length;
26
27 const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
29 /** The inside of the element whose start tag is at `open` (matched by `tag`), to its balancing end tag. */
30 function inside(html: string, open: RegExpExecArray, tag: string): string {
31 const tags = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
32 tags.lastIndex = open.index + open[0].length;
33 let depth = 1;
34 let end = html.length;
35 for (let m = tags.exec(html); m !== null; m = tags.exec(html)) {
36 depth += m[1] === "/" ? -1 : 1;
37 if (depth === 0) {
38 end = m.index;
39 break;
40 }
41 }
42 return html.slice(open.index + open[0].length, end);
43 }
44
45 /**
46 * The HTML inside the element with this `id`; failing that, inside the card
47 * or section whose `<h2>` reads as the id with its hyphens as spaces (a page
48 * from before its sections had ids). `undefined` when neither is there.
49 */
50 export function sectionHtml(html: string, id: string): string | undefined {
51 const byId = new RegExp(`<([a-z0-9]+)\\b[^>]*\\bid="${escapeRe(id)}"[^>]*>`, "i").exec(html);
52 if (byId !== null) return inside(html, byId, byId[1]!.toLowerCase());
53 const h2 = new RegExp(`<h2[^>]*>\\s*${escapeRe(id.replace(/-/g, " "))}\\s*</h2>`, "i").exec(html);
54 if (h2 === null) return undefined;
55 const starts = [...html.slice(0, h2.index).matchAll(/<(?:div\s+class="card[ "]|section\b)[^>]*>/gi)];
56 const last = starts[starts.length - 1];
57 if (last === undefined) return undefined;
58 const open = /<(div|section)\b[^>]*>/i.exec(html.slice(last.index))!;
59 open.index += last.index;
60 return inside(html, open, open[1]!.toLowerCase());
61 }
62
63 /** The inside of the first `<main>`, or `undefined`. */
64 export function mainHtml(html: string): string | undefined {
65 const open = /<main\b[^>]*>/i.exec(html);
66 return open === null ? undefined : inside(html, open, "main");
67 }
68
69 /** Words in the section `id` of the page, or in its `<main>` when `id` is empty; 0 when it is not there. */
70 export function wordsOf(html: string, id = ""): number {
71 const part = id === "" ? mainHtml(html) : sectionHtml(html, id);
72 return part === undefined ? 0 : countWords(part);
73 }
test/measure-words.test.ts added +44
@@ -0,0 +1,44 @@
1 /** Counting a page's words from its HTML (`src/measure/words.ts`), against hand counts. */
2 import { describe, expect, test } from "bun:test";
3 import { countWords, mainHtml, sectionHtml, wordsOf } from "../src/measure/words.ts";
4
5 describe("countWords", () => {
6 test("counts runs of non-space, across tags and lines", () => {
7 expect(countWords("<p>one two</p>\n<p>three<b>four</b> five</p>")).toBe(5);
8 });
9 test("tags, attributes, scripts, styles and comments are not words", () => {
10 expect(countWords('<a href="/x y z" class="a b">go</a><script>var a = 1 2 3;</script><style>p { a: b }</style><!-- not these -->')).toBe(1);
11 });
12 test("entities: &amp; is one word's character, &nbsp; a space", () => {
13 expect(countWords("a&nbsp;b &amp; c")).toBe(4);
14 expect(countWords("x&lt;y")).toBe(1);
15 });
16 test("nothing is zero", () => {
17 expect(countWords(" <br> ")).toBe(0);
18 });
19 });
20
21 const page = `<header>skip these words</header><main><h1>Title here</h1>
22 <section id="a"><p>one</p><section><p>two three</p></section><p>four</p></section>
23 <div class="card"><div class="card-head"><h2>Proposal tokens</h2></div><div class="card-body"><p>five six</p></div></div>
24 <section id="b">seven</section></main><footer>and these</footer>`;
25
26 describe("wordsOf", () => {
27 test("main only, not the header or footer", () => {
28 expect(wordsOf(page)).toBe(2 + 4 + 4 + 1 + 0 + 0);
29 });
30 test("an element by id ends at its own end tag, past nested ones", () => {
31 expect(wordsOf(page, "a")).toBe(4);
32 expect(wordsOf(page, "b")).toBe(1);
33 });
34 test("an id-less card is found by its h2, and the whole card is counted", () => {
35 expect(sectionHtml(page, "proposal-tokens")).toContain("five six");
36 expect(wordsOf(page, "proposal-tokens")).toBe(4);
37 });
38 test("a missing section or main is 0, and an id is not a pattern", () => {
39 expect(wordsOf(page, "nope")).toBe(0);
40 expect(wordsOf(page, "a.*")).toBe(0);
41 expect(mainHtml("<p>no main</p>")).toBeUndefined();
42 expect(wordsOf("<p>no main</p>")).toBe(0);
43 });
44 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs