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

A tell sent from the forge pill carries its context: app, page, build and the last console errors #10840

Merged opened this

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

Merged

scout/forge:scout/10827-a-tell-sent-from-the-forge-pill-carries → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/10827-a-tell-sent-from-the-forge-pill-carries was merged into main as e894574.

Review

Sign in to review this change.
11 files changed +308 −4
docs/features/forge-toolbar.md +11 −1
@@ -9,6 +9,16 @@ preview), so you always know where you are and can get back to the forge.
99 byte. A page that already carries the tag is not given a second.
1010 - **What it says:** the app, the environment and, on a preview, a link
1111 "Preview of pull #N" to the pull's page on the forge; Home goes to the forge.
12 - **What a note carries:** a note sent from the pill also carries where it came
13 from, folded at the foot of the issue under "Where this came from": the
14 build the app is serving (from the forge's own record of the running
15 environment, never the client's word) and up to five recent console errors
16 (`console.error`, uncaught errors and unhandled rejections, message text
17 only). Each error is cut to 200 characters, control characters, URL queries
18 and anything shaped like a token, password or key are taken out on the
19 server, and the whole is bounded by the request cap. The page is the issue's
20 existing `Page:` line and is not repeated. The sender sees nothing new; the
21 fold is closed until opened.
1222 - **No layout shift:** it is `position: fixed` inside a closed shadow root and
1323 adds nothing to the page's flow or style sheets.
1424 - **Escaping:** app and pull names travel as escaped `data-*` attributes and are
@@ -19,4 +29,4 @@ preview), so you always know where you are and can get back to the forge.
1929 pill out; the app still works.
2030 - **Off:** `"toolbar": false` in `forge.json`.
2131
22 Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`.
32 Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`, `test/note-context.test.ts`, `test/apps-feedback.test.ts`.
docs/features/notes.md +2
@@ -29,6 +29,8 @@ The terminal has the same door: the palette's "Notes" (`src/tui/screens/thoughtn
2929
3030 A note sent from an app's forge button (or the terminal's "Tell the builders") files a from-app issue, and the sender is told its number at once: "Filed as #N". Anyone the repository would show the issue to (`canRead`, so a signed-in reader of a private repository too, not only a public app on a public repository) also gets the issue's link and the repository's; everyone else gets the number and no link. The list shows `#N` beside each note. The browser button links only to the forge's own origin. In the terminal (`src/tui/screens/notes.ts`) the sender is the SSH user: the confirmation reads "Sent as #N, owner/repo/issues/N" and the list and moderation screens carry `#N`; the terminal has no clickable link.
3131
32 The issue also carries a closed fold, "Where this came from" (`actions/note_context.ts`): the build the app was serving and up to five recent console errors from the sender's page, each cut to 200 characters and scrubbed of anything shaped like a secret or a URL query. The page itself is the issue's `Page:` line, said once. Markdown accepts one flat fold (`<details><summary>Title</summary>` … `</details>`, each on its own line, a title of plain characters); an unclosed one is closed, a nested one is text.
33
3234 The crew's answers come back to the sender: under each note the button's list (and the terminal's "Notes from the app", where picking a note shows them) carries the comments on its issue by the repository's crew and writers, with author and time, oldest first. Anyone else's comments stay on the forge. The button shows the latest five and says how many are earlier.
3335
3436 When a pull that closes a note's issue merges, the forge leaves one plain-words comment on it (`crew/note_closed.ts`): what was wrong and what changed, from the pull's description, with no hashes or links, saying "the next release" until one is cut, then rewritten to name it ("release 57 (10 Nov 2026)"). Both ways a merge closes an issue (the merge itself, and a reviewer's box pass on an already-landed pull) go through `noteShipped`. The forge marks its comment in `note_shipped`, so a person's comment with the same opening never stands in for it. The button shows it as a reply and the note reads shipped.
src/actions/app_notes.ts +4 −1
@@ -20,6 +20,7 @@ import { getUserById, isActive, type User } from "../core/users.ts";
2020 import { crewWithRole, isCrew } from "../core/agents.ts";
2121 import { fence } from "../crew/fence.ts";
2222 import { unadoptedAmong } from "../crew/direct.ts";
23 import { contextFold, type NoteContext } from "./note_context.ts";
2324 import { NOTE_EMPTY } from "../apps/note_words.ts";
2425 import { err, ok, type Result } from "../kernel/result.ts";
2526 import { assignIssue, openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "./issues.ts";
@@ -196,6 +197,8 @@ export interface NoteInput {
196197 /** The page, already reduced to this app's own origin and path. */
197198 readonly page: string;
198199 readonly viewport: string;
200 /** Path, build and console errors, already checked (`parseNoteContext`). */
201 readonly context?: NoteContext;
199202 }
200203
201204 /**
@@ -249,7 +252,7 @@ export async function sendNote(
249252 `- Page: ${input.page === "" ? "unknown" : `\`${input.page.replace(/`/g, "%60")}\``}`,
250253 `- Viewport: ${input.viewport}`,
251254 `- Time: ${new Date().toISOString()}`,
252 ].join("\n");
255 ].join("\n") + (input.context === undefined ? "" : contextFold(input.context));
253256 // The label exists before the issue does, so a mirror's origin can apply it too.
254257 if (getLabel(app.db, repo.id, FROM_APP_LABEL) === undefined) createLabel(app.db, repo.id, FROM_APP_LABEL, LABEL_COLOR);
255258 const unavailable = err({ error: "unavailable", message: "The builders cannot take notes right now." } as const);
src/actions/note_context.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * What a note from the forge pill says about where it came from (#10827):
3 * the build the app was serving and up to five recent console errors, each
4 * cut short. (The page is already the issue's `- Page:` line, so it is not
5 * said twice.) Every field
6 * is checked at the boundary and bounded; what passes is a fold at the foot
7 * of the issue, never its headline. Console text can hold secrets, so only
8 * the message is kept, and anything shaped like a credential or a URL query
9 * is cut out of it. Plain data in, plain data out.
10 */
11
12 export interface NoteContext {
13 readonly build: string;
14 readonly errors: readonly string[];
15 }
16
17 export const ERRORS_MAX = 5;
18 export const ERROR_CHARS = 200;
19 const BUILD = /^[0-9a-f]{7,64}$/;
20
21 /** A message with what looks like a secret or a query string taken out. */
22 export function scrubMessage(raw: string): string {
23 return raw
24 // eslint-disable-next-line no-control-regex
25 .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
26 .replace(/\b(https?:\/\/[^\s?#]*)[?#]\S*/gi, "$1")
27 .replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]{8,}/gi, "$1 [removed]")
28 .replace(/\b([A-Za-z_-]*(?:token|secret|key|password|passwd|auth|session|cookie)[A-Za-z_-]*)\s*[:=]\s*\S+/gi, "$1=[removed]")
29 .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.?[A-Za-z0-9_-]*/g, "[removed]")
30 .replace(/\b[A-Fa-f0-9]{32,}\b/g, "[removed]")
31 .replace(/\s+/g, " ")
32 .trim();
33 }
34
35 /**
36 * The context of a note: `build` is what the forge knows is serving (never
37 * the client's word), `errors` what the client sent. Anything malformed is dropped,
38 * never an error: the note itself still goes.
39 */
40 export function parseNoteContext(errors: unknown, build: string | undefined): NoteContext {
41 const list: string[] = [];
42 if (Array.isArray(errors)) {
43 for (const e of errors.slice(0, ERRORS_MAX)) {
44 if (typeof e !== "string") continue;
45 const m = scrubMessage(e.slice(0, 2000)).slice(0, ERROR_CHARS);
46 if (m !== "") list.push(m);
47 }
48 }
49 return { build: build !== undefined && BUILD.test(build) ? build : "", errors: list };
50 }
51
52 /** The fold appended to a note's issue; empty when there is nothing to say. */
53 export function contextFold(c: NoteContext): string {
54 if (c.build === "" && c.errors.length === 0) return "";
55 const lines = [
56 c.build === "" ? "" : `- Build: \`${c.build}\``,
57 // The sender's browser's words, unchecked: data, not instructions.
58 ...c.errors.map((e) => `- Console error: \`${e.replace(/`/g, "'")}\``),
59 ].filter((l) => l !== "");
60 return ["", "<details><summary>Where this came from</summary>", ...lines, "</details>"].join("\n");
61 }
src/apps/feedback.ts +9 −1
@@ -24,6 +24,7 @@ import { getNote } from "../core/app_notes.ts";
2424 import { PICTURE_MAX, savePicture, sniffPicture } from "../core/note_pictures.ts";
2525 import { getUserById, isActive, type User } from "../core/users.ts";
2626 import { sha256 } from "../kernel/hash.ts";
27 import { parseNoteContext } from "../actions/note_context.ts";
2728 import { NOTE_FAILED } from "./note_words.ts";
2829
2930 export const REQUEST_MAX = 16 * 1024;
@@ -42,6 +43,8 @@ export interface FeedbackAsk {
4243 readonly viewerId: string | undefined;
4344 /** The grant cookie's value, which the panel's proof is bound to. */
4445 readonly grantCookie?: string | undefined;
46 /** The commit the env is serving, when it is up; the client never says it. */
47 readonly build?: string | undefined;
4548 readonly secure: boolean;
4649 /** The client's limit key, from the socket (`clientBucket`). */
4750 readonly source: string;
@@ -181,7 +184,12 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> {
181184 const viewport = typeof body.viewport === "string" && /^\d{2,5}x\d{2,5}$/.test(body.viewport) ? body.viewport : "unknown";
182185 const sent = await sendNote(
183186 ask.app, ask.record, door.repo, ask.env, ask.forgeUrl, sender,
184 { text: typeof body.text === "string" ? body.text : "", page, viewport },
187 {
188 text: typeof body.text === "string" ? body.text : "",
189 page,
190 viewport,
191 context: parseNoteContext(body.errors, ask.build),
192 },
185193 ask.source,
186194 door.viewer,
187195 );
src/apps/proxy.ts +1
@@ -1098,6 +1098,7 @@ export async function proxyApp(
10981098 sealed,
10991099 viewerId,
11001100 grantCookie: cookie,
1101 build: runtime.liveFor(target.app, target.env)?.commitOid,
11011102 secure: proxySecure(request, url, runtime.options.forgeUrl),
11021103 source: clientBucket(request),
11031104 });
src/apps/toolbar.ts +13 −1
@@ -275,6 +275,18 @@ export const toolbarJs = `(function () {
275275 } catch (e) { end(); }
276276 });
277277 }
278 // The last few console errors: message text only, cut short here and again on the server.
279 var errs = [];
280 function noted(m) { errs.push(String(m).slice(0, 200)); if (errs.length > 5) errs.shift(); }
281 if (window.addEventListener) {
282 window.addEventListener("error", function (e) { noted(e && e.message); });
283 window.addEventListener("unhandledrejection", function (e) { var r = e && e.reason; noted(r && r.message ? r.message : r); });
284 }
285 var logged = typeof console === "object" && console ? console.error : null;
286 if (logged) console.error = function () {
287 try { noted(Array.prototype.map.call(arguments, function (a) { return a && a.message ? a.message : typeof a === "string" ? a : ""; }).join(" ")); } catch (x) {}
288 return logged.apply(console, arguments);
289 };
278290 tellBox.addEventListener("submit", function (e) {
279291 e.preventDefault();
280292 var text = tellText.value.trim();
@@ -291,7 +303,7 @@ export const toolbarJs = `(function () {
291303 method: "POST",
292304 credentials: "same-origin",
293305 headers: { "content-type": "application/json" },
294 body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight })
306 body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight, errors: errs.slice(-5) })
295307 })
296308 .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
297309 .then(function (a) {
src/web/markdown.ts +21
@@ -4,6 +4,9 @@
44 * transformation, so the renderer cannot be talked into emitting markup.
55 * CommonMark completeness is a non-goal — READMEs and issue prose are.
66 *
7 * A fold (`<details><summary>Title</summary>` … `</details>`, each on a line
8 * of its own) is the one other tag it emits, with a title it checks.
9 *
710 * A fence with an info string is handed to the tokenizer, so the code samples
811 * in a README read as code. That is the one place this renderer emits markup
912 * it did not escape itself, and it is safe for the same reason the file
@@ -157,6 +160,7 @@ function renderUncached(source: string): Html {
157160 let body: string[] = [];
158161 let align: string[] | null = null;
159162 let skip = -1;
163 let folded = false;
160164
161165 const closeParagraph = () => {
162166 if (paragraph.length > 0) {
@@ -215,6 +219,22 @@ function renderUncached(source: string): Html {
215219 continue;
216220 }
217221
222 // A fold: `<details><summary>Title</summary>` on a line of its own, then
223 // `</details>`. The title is escaped text; nothing else opens a tag.
224 const fold = /^<details><summary>([^<>&"'`]{1,80})<\/summary>$/.exec(raws[index] ?? "");
225 if (fold !== null && !folded) {
226 closeBlocks();
227 out.push(`<details><summary>${fold[1]}</summary>`);
228 folded = true;
229 continue;
230 }
231 if (folded && raws[index] === "</details>") {
232 closeBlocks();
233 out.push("</details>");
234 folded = false;
235 continue;
236 }
237
218238 // A table: a row of pipes over a row of dashes. Both halves have to be
219239 // there — a lone line with a pipe in it is a sentence, not a table.
220240 if (align) {
@@ -278,5 +298,6 @@ function renderUncached(source: string): Html {
278298
279299 if (fence) out.push(closeFence(body, fenceLang));
280300 closeBlocks();
301 if (folded) out.push("</details>");
281302 return raw(out.join("\n"));
282303 }
src/web/style/content.ts +7
@@ -536,6 +536,13 @@ form.inline { display: inline; margin-left: auto; }
536536 font-family: var(--mono); color: var(--muted-fg);
537537 white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible;
538538 }
539 /* A fold in prose (an issue's "Where this came from"): the global reset hides the marker, so the chevron is drawn here;
540 the summary is a native, focusable, Enter/Space control with a row-high target. */
541 .prose details { margin: 12px 0; }
542 .prose summary { cursor: pointer; padding: 10px 0; min-height: 44px; color: var(--muted-fg); font-size: 13px; }
543 .prose summary::before { content: "\\25B8"; display: inline-block; width: 1.2em; }
544 .prose details[open] > summary::before { content: "\\25BE"; }
545 .prose details > ul { margin-top: 0; font-size: 13px; }
539546 .prose code { background: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; }
540547 .prose pre code { background: none; border: none; padding: 0; }
541548 .prose blockquote {
test/apps-feedback.test.ts +36
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { newApp } from "../src/actions/apps.ts";
1313 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
14 import { renderMarkdown } from "../src/web/markdown.ts";
1415 import { toolbarJs } from "../src/apps/toolbar.ts";
1516 import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts";
1617 import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts";
@@ -57,6 +58,7 @@ describe("the forge button takes notes from anyone", () => {
5758 alice = a.value;
5859 for (const [name, visibility] of [
5960 ["notes", "public"],
61 ["context", "public"],
6062 ["diary", "private"],
6163 ] as const) {
6264 const made = await newApp(app, a.value, { name, description: "Words", visibility });
@@ -128,6 +130,40 @@ describe("the forge button takes notes from anyone", () => {
128130 expect(j.note.url).toBe(`${servers.url}/alice/notes/issues/${issue.number}`);
129131 });
130132
133 test("a note's context is stored under a fold: build from the forge, errors scrubbed, capped and escaped", async () => {
134 const spy = spyOn(AppsRuntime.prototype, "liveFor").mockImplementation(() => ({ commitOid: "c0ffee1234567890abcdef" }) as never);
135 try {
136 const send = (extra: Record<string, unknown>, source: string) =>
137 at("context", "/__forge/note", { method: "POST", body: JSON.stringify({ text: "It broke", page: `http://${host("context")}/cart?x=1`, viewport: "390x844", ...extra }) }, "", source);
138 const bodyOf = async (res: Response) => {
139 expect(res.status).toBe(201);
140 const j = (await res.json()) as { note: { url: string } };
141 return getIssue(app.db, getRepo(app.db, "alice", "context")!.id, Number(j.note.url.split("/").pop()))!.body;
142 };
143 const hostile = ["<img src=x onerror=alert(1)> `tick`", "x".repeat(900), "GET http://a.test/p?api_key=sekret failed", "t1", "t2", "t3 beyond five", 7, { a: 1 }];
144 const body = await bodyOf(await send({ errors: hostile, build: "deadbeef" }, "10.7.0.1"));
145 expect(body).toContain("<details><summary>Where this came from</summary>");
146 expect(body).toContain("- Build: `c0ffee1234567890abcdef`"); // the forge's, never the client's `build`
147 expect(body).not.toContain("deadbeef");
148 expect(body).not.toContain("- Path:");
149 expect(body).not.toMatch(/sekret|beyond five/);
150 const lines = body.split("\n").filter((l) => l.startsWith("- Console error:"));
151 expect(lines.length).toBe(5);
152 expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(200 + 20);
153 expect(lines[0]).not.toContain("`tick`");
154 const rendered = String(renderMarkdown(body));
155 expect(rendered).not.toContain("<img");
156 expect(rendered).toContain("&lt;img");
157 // No context at all: the note still goes, the fold only says the build.
158 expect(await bodyOf(await send({ errors: "nope" }, "10.7.0.2"))).not.toContain("Console error");
159 // An oversized request is refused before any of it is read.
160 const big = await send({ errors: ["e".repeat(REQUEST_MAX)] }, "10.7.0.3");
161 expect(big.status).toBe(400);
162 } finally {
163 spy.mockRestore();
164 }
165 });
166
131167 test("the sender sees their notes, and the status follows the issue", async () => {
132168 const first = await post("Add dark mode", "", "10.0.0.2");
133169 const cookie = cookieOf(first);
test/note-context.test.ts added +143
@@ -0,0 +1,143 @@
1 /**
2 * A note from the forge pill carries where it came from (#10827): path, build
3 * and up to five console errors, checked, cut short and folded at the foot of
4 * the issue, never in its title.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { newApp } from "../src/actions/apps.ts";
12 import { sendNote } from "../src/actions/app_notes.ts";
13 import { contextFold, parseNoteContext } from "../src/actions/note_context.ts";
14 import { getAppBySlug } from "../src/core/apps.ts";
15 import { getIssue } from "../src/core/issues.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { toolbarJs } from "../src/apps/toolbar.ts";
19 import { renderMarkdown } from "../src/web/markdown.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const BUILD = "a1b2c3d4e5f6";
25
26 describe("note context, parsed", () => {
27 test("keeps the server's build and says nothing of the page", () => {
28 const c = parseNoteContext([], BUILD);
29 expect(c).toEqual({ build: BUILD, errors: [] });
30 });
31
32 test("keeps five errors, each cut to 200 characters, without secrets or queries", () => {
33 const errs = [
34 "x".repeat(500),
35 "GET https://api.test/v1?api_key=abc123 failed",
36 "Authorization: Bearer abcdefghijklmnop",
37 "token=supersecret",
38 "four",
39 "six is dropped",
40 42,
41 ];
42 const c = parseNoteContext(errs, BUILD);
43 expect(c.errors.length).toBe(5);
44 expect(c.errors[0]!.length).toBe(200);
45 expect(c.errors[1]).toBe("GET https://api.test/v1 failed");
46 expect(c.errors.join(" ")).not.toMatch(/abcdefghijklmnop|supersecret|abc123/);
47 expect(c.errors).not.toContain("six is dropped");
48 });
49
50 test("hostile fields are dropped, not trusted", () => {
51 const c = parseNoteContext("not a list", "../../etc");
52 expect(c).toEqual({ build: "", errors: [] });
53 expect(contextFold(c)).toBe("");
54 });
55 });
56
57 describe("a note's issue", () => {
58 let dataDir = "";
59 let app: App;
60 let owner: User;
61
62 beforeAll(async () => {
63 dataDir = await mkdtemp(join(tmpdir(), "forge-note-context-"));
64 app = createApp({ dataDir, port: 0, host: "forge.test" });
65 const made = await createUser(app.db, {
66 username: "alice", email: "[email protected]", password: "a-good-passphrase",
67 origin: "seed", trusted: true, adminIfFirst: true,
68 });
69 if (!made.ok) throw new Error(String(made.error));
70 owner = made.value;
71 const a = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" });
72 if (!a.ok) throw new Error(String(a.error));
73 });
74 afterAll(async () => {
75 app.close();
76 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
77 });
78
79 test("stores the context under a fold, escaped, and not in the title", async () => {
80 const repo = getRepo(app.db, "alice", "shop")!;
81 const record = getAppBySlug(app.db, "shop")!;
82 const context = parseNoteContext(["<img src=x onerror=alert(1)> `tick` \n</details>", "y".repeat(900)], BUILD);
83 const sent = await sendNote(
84 app, record, repo, "production", undefined,
85 { key: `u:${owner.id}`, name: "alice", person: true },
86 { text: "too small", page: "http://shop.test/cart", viewport: "390x844", context },
87 "10.9.0.1",
88 );
89 if (!sent.ok) throw new Error(sent.error.message);
90 const issue = getIssue(app.db, repo.id, sent.value.number)!;
91 expect(issue.title).toBe("From the app: too small");
92 expect(issue.body).toContain("<details><summary>Where this came from</summary>");
93 expect(issue.body).toContain(`- Build: \`${BUILD}\``);
94 expect(issue.body).not.toContain("- Path:");
95 expect(issue.body.match(/\/cart/g)!.length).toBe(1); // the `- Page:` line says it once
96 const errors = issue.body.split("\n").filter((l) => l.startsWith("- Console error:"));
97 expect(errors.length).toBe(2);
98 expect(errors[1]!.length).toBeLessThanOrEqual(200 + 20);
99 const html = String(renderMarkdown(issue.body));
100 expect(html).toContain("<details><summary>Where this came from</summary>");
101 expect(html).not.toContain("<img");
102 expect(html).toContain("&lt;img");
103 expect(html.match(/<\/details>/g)!.length).toBe(1);
104 });
105 });
106
107 test("the pill sends the last console errors with a note", () => {
outdated

A string-contains check on the script does not show the pill sends errors or the server stores them. Post through the feedback route with errors and hostile context.

108 expect(toolbarJs).toContain("errors: errs.slice(-5)");
109 expect(toolbarJs).toContain('"unhandledrejection"');
110 });
111
112 describe("the fold in user text", () => {
113 const html = (md: string) => String(renderMarkdown(md));
114 const opens = (h: string) => (h.match(/<details>/g) ?? []).length;
115 const closes = (h: string) => (h.match(/<\/details>/g) ?? []).length;
116
117 test("an unclosed fold is closed by the renderer", () => {
118 const h = html("<details><summary>More</summary>\n- one");
119 expect(opens(h)).toBe(1);
120 expect(closes(h)).toBe(1);
121 });
122
123 test("a nested fold is not a second fold: the flat rule holds", () => {
124 const h = html("<details><summary>A</summary>\n<details><summary>B</summary>\nx\n</details>\n</details>");
125 expect(opens(h)).toBe(1);
126 expect(closes(h)).toBe(1);
127 expect(h).toContain("&lt;details&gt;&lt;summary&gt;B");
128 });
129
130 test("a stray close is text, not a tag", () => {
131 const h = html("hello\n</details>");
132 expect(closes(h)).toBe(0);
133 });
134
135 test("a title with disallowed characters is text", () => {
136 for (const title of ['x"onclick="1', "a<b", "a&amp;b", "it's", "`t`", "", "z".repeat(81)]) {
137 const h = html(`<details><summary>${title}</summary>\nbody\n</details>`);
138 expect(opens(h)).toBe(0);
139 expect(h).not.toContain("<summary>");
140 }
141 expect(html("<details><summary>Fine title</summary>\nbody\n</details>")).toContain("<summary>Fine title</summary>");
142 });
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs