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

A test fails when a new danger button posts straight from a page instead of through the confirm page #7989

Merged opened this

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

Merged

scout/forge:scout/7984-a-test-fails-when-a-new-danger-button-po → main 1 commit fork

  • Merged. scout/7984-a-test-fails-when-a-new-danger-button-po was merged into main as 79de554.

Review

Sign in to review this change.
1 file changed +106 −0
test/danger-buttons.test.ts added +106
@@ -0,0 +1,106 @@
1 // Every destructive button asks first. A `btn(..., { variant: "danger" })`
2 // inside a plain `<form method="post">` deletes on one click; new ones should
3 // go through `confirmAction` from src/web/ui.ts, which links to /-/confirm.
4 // The scan is plain text, kept narrow so it never fails on correct code.
5 import { describe, expect, test } from "bun:test";
6 import { readdirSync, readFileSync } from "node:fs";
7 import { join, relative } from "node:path";
8
9 const root = join(import.meta.dir, "..");
10 const web = join(root, "src", "web");
11
12 /** Today's danger POST buttons that do not ask first, one `file — reason` per line. */
13 const allowed: readonly { file: string; button: string; reason: string }[] = [
14 { file: "src/web/pages/boards-view.ts", button: `"Remove"`, reason: "board panel Remove; the panel index is in the body" },
15 { file: "src/web/pages/boards-view.ts", button: `"Delete this board"`, reason: "board delete; waits for its confirm issue" },
16 { file: "src/web/pages/org.ts", button: `"Remove"`, reason: "team member remove; waits for its confirm issue" },
17 { file: "src/web/pages/edit.ts", button: `"Delete this file"`, reason: "the delete page is the confirmation, reached by a link" },
18 { file: "src/web/pages/detach.ts", button: `"Detach from parent"`, reason: "the detach page is the confirmation, reached from settings" },
19 { file: "src/web/pages/pull.ts", button: `"Land past the gate"`, reason: "asks for a typed reason of at least 8 characters" },
20 { file: "src/web/studio/notes.ts", button: "label", reason: "owner note ban; undone by unban on the same row" },
21 ];
22
23 type DangerPost = { file: string; line: number; button: string };
24
25 /** The index of the paren closing the one at `open`, or -1. */
26 function closing(text: string, open: number): number {
27 let depth = 0;
28 for (let i = open; i < text.length; i++) {
29 const c = text[i];
30 if (c === "(") depth++;
31 else if (c === ")" && --depth === 0) return i;
32 }
33 return -1;
34 }
35
36 /** Danger buttons that post straight from a form with no confirm field. */
37 function dangerPosts(file: string, text: string): DangerPost[] {
38 const found: DangerPost[] = [];
39 for (const m of text.matchAll(/variant:\s*"danger"/g)) {
40 const at = m.index;
41 // The btn( call holding this option, close enough to be the same call.
42 const call = Math.max(text.lastIndexOf("btn(", at), text.lastIndexOf("iconBtn(", at));
43 if (call < 0 || at - call > 400) continue;
44 const open = text.indexOf("(", call);
45 const end = closing(text, open);
46 if (end < at) continue;
47 const args = text.slice(open + 1, end);
48 if (/\bhref:/.test(args)) continue; // a link, as confirmAction makes
49 // The enclosing form: the last <form before the button, not yet closed.
50 const form = text.lastIndexOf("<form", at);
51 if (form < 0 || text.slice(form, at).includes("</form")) continue;
52 const tag = text.slice(form, text.indexOf(">", form) + 1);
53 if (!/method="post"/i.test(tag)) continue;
54 const close = text.indexOf("</form", at);
55 const body = text.slice(form, close < 0 ? text.length : close);
56 if (/name(=|:\s*)"confirm"/.test(body)) continue; // the form is the confirmation
57 const button = args.slice(0, args.search(/,|$/)).trim();
58 const line = text.slice(0, at).split("\n").length;
59 found.push({ file, line, button });
60 }
61 return found;
62 }
63
64 function sources(dir: string): string[] {
65 return readdirSync(dir, { recursive: true, encoding: "utf8" })
66 .filter((f) => f.endsWith(".ts"))
67 .map((f) => join(dir, f));
68 }
69
70 describe("danger buttons", () => {
71 test("a new danger button inside a post form is found", () => {
72 const page = 'html`<form method="post" action="/x">${btn("Wipe", { variant: "danger" })}</form>`';
73 expect(dangerPosts("x.ts", page)).toEqual([{ file: "x.ts", line: 1, button: `"Wipe"` }]);
74 });
75
76 test("links, confirm pages and get forms are not", () => {
77 const cases = [
78 'html`<form method="post" action="/x">${btn("Wipe", { href: "/-/confirm?to=/x", variant: "danger" })}</form>`',
79 'html`<form method="post" action="/x"><input name="confirm" />${btn("Wipe", { variant: "danger" })}</form>`',
80 'html`<form method="post" action="/x">${formField({ name: "confirm" })}${btn("Wipe", { variant: "danger" })}</form>`',
81 'html`<form method="get" action="/x">${btn("Wipe", { variant: "danger" })}</form>`',
82 'html`<form method="post" action="/x"></form>${btn("Wipe", { variant: "danger" })}`',
83 'const tone = { variant: "danger" };',
84 ];
85 for (const page of cases) expect(dangerPosts("x.ts", page)).toEqual([]);
86 });
87
88 test("every danger button in src/web asks first or is on the list", () => {
89 const found = sources(web).flatMap((path) =>
90 dangerPosts(relative(root, path).replaceAll("\\", "/"), readFileSync(path, "utf8")),
91 );
92 const listed = (d: { file: string; button: string }) =>
93 allowed.some((a) => a.file === d.file && a.button === d.button);
94 const stale = allowed.filter((a) => !found.some((d) => d.file === a.file && d.button === a.button));
95 if (stale.length > 0)
96 console.log(
97 `danger-buttons: these listed cases are gone; drop them from the list:\n${stale
98 .map((a) => ` ${a.file} ${a.button} — ${a.reason}`)
99 .join("\n")}`,
100 );
101 const unasked = found
102 .filter((d) => !listed(d))
103 .map((d) => `${d.file}:${d.line} ${d.button} posts without asking: use confirmAction from src/web/ui.ts`);
104 expect(unasked).toEqual([]);
105 });
106 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs