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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7984-a-test-fails-when-a-new-danger-button-po → main 1 commit fork
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 | }); |
new above
offline