Public: anyone can read this; the crew can change it.
Boards through MCP and the terminal: list, read, write and delete a board from both doors #1910
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1817-boards-through-mcp-and-the-terminal-list → main 1 commit fork
7 files
changed +352
−2
README.md +2 −2
| @@ -848,7 +848,7 @@ work here. | ||
| 848 | 848 | default branches (force-push and deletion refused, honest fast-forwards |
| 849 | 849 | allowed). |
| 850 | 850 | - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else |
| 851 | gets a 404): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), | |
| 851 | gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), | |
| 852 | 852 | drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`) |
| 853 | 853 | with its points in a table; a point opens the rows behind it, keeping your filter. |
| 854 | 854 | `series=<column>` splits each group by a second column: stacked area draws one band per |
| @@ -862,7 +862,7 @@ work here. | ||
| 862 | 862 | duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`. |
| 863 | 863 | A board with a refresh redraws its grid on that beat through the live-region swap |
| 864 | 864 | (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug` |
| 865 | (an agent is 403, anyone but an admin 404; a panel whose query cannot run is refused with 422). No dependency was added. | |
| 865 | (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text. No dependency was added. | |
| 866 | 866 | - **Issues** — labels, threaded comments, close/reopen, markdown. |
| 867 | 867 | - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax |
| 868 | 868 | highlighting composed with intra-line word highlighting, mobile-first, |
scripts/gate.ts +1
| @@ -411,6 +411,7 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 411 | 411 | }, |
| 412 | 412 | { file: "test/tui-app.test.ts", reason: "not yet admitted", issue: 261 }, |
| 413 | 413 | { file: "test/tui-asked.test.ts", reason: "not yet admitted", issue: 261 }, |
| 414 | { file: "test/tui-boards.test.ts", reason: "not yet admitted", issue: 261 }, | |
| 414 | 415 | { file: "test/tui-apps.test.ts", reason: "not yet admitted", issue: 261 }, |
| 415 | 416 | { file: "test/tui-code.test.ts", reason: "not yet admitted", issue: 261 }, |
| 416 | 417 | { file: "test/tui-editor.test.ts", reason: "not yet admitted", issue: 261 }, |
src/http/mcp.ts +66
| @@ -151,6 +151,7 @@ import type { Router } from "./router.ts"; | ||
| 151 | 151 | import { mayReadRuns } from "../actions/runs.ts"; |
| 152 | 152 | import { manifestFor } from "../actions/here.ts"; |
| 153 | 153 | import { clientBucket } from "./peer.ts"; |
| 154 | import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts"; | |
| 154 | 155 | import { fileReport } from "../actions/reports.ts"; |
| 155 | 156 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 156 | 157 | import { placementWords } from "../checks/placement.ts"; |
| @@ -252,6 +253,15 @@ function issueArg(ctx: Ctx, repo: Repo, args: Args, key: string) { | ||
| 252 | 253 | |
| 253 | 254 | const isAnswer = (v: Repo | Answer): v is Answer => "ok" in v; |
| 254 | 255 | |
| 256 | /** Boards are a forge admin's who is a person, as the API gates them: anyone else is forbidden, not told what is there. */ | |
| 257 | const boardRefused = (): Answer => bad("forbidden", "boards are for a forge admin who is a person"); | |
| 258 | const mayBoards = (user: User): boolean => user.isAdmin && user.kind !== "agent"; | |
| 259 | function boardFailed(e: BoardError): Answer { | |
| 260 | if (e === "not_found") return bad("not_found", "no such board"); | |
| 261 | if (typeof e === "object") return bad("refused", e.refused); | |
| 262 | return bad(e, "that board could not be saved"); | |
| 263 | } | |
| 264 | ||
| 255 | 265 | const TOOLS: readonly Tool[] = [ |
| 256 | 266 | { |
| 257 | 267 | name: "apps", |
| @@ -2077,6 +2087,62 @@ const TOOLS: readonly Tool[] = [ | ||
| 2077 | 2087 | }); |
| 2078 | 2088 | }, |
| 2079 | 2089 | }, |
| 2090 | { | |
| 2091 | name: "boards", | |
| 2092 | description: | |
| 2093 | "The forge's boards, for a forge admin who is a person (an agent is refused): each board's slug, title, range, refresh and panels. The same as /boards and GET /api/v1/boards. With `slug`, that one board.", | |
| 2094 | inputSchema: { | |
| 2095 | type: "object", | |
| 2096 | properties: { slug: { type: "string", maxLength: 40, description: "One board, by slug." } }, | |
| 2097 | }, | |
| 2098 | writes: false, | |
| 2099 | async run(ctx, user, args) { | |
| 2100 | if (!mayBoards(user)) return boardRefused(); | |
| 2101 | if (args["slug"] === undefined) { | |
| 2102 | const listed = await listAllBoards(ctx.app, user); | |
| 2103 | return listed.ok | |
| 2104 | ? good({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) }) | |
| 2105 | : boardFailed(listed.error); | |
| 2106 | } | |
| 2107 | const shown = await showBoard(ctx.app, user, str(args["slug"])); | |
| 2108 | return shown.ok ? good({ slug: str(args["slug"]), ...shown.value }) : boardFailed(shown.error); | |
| 2109 | }, | |
| 2110 | }, | |
| 2111 | { | |
| 2112 | name: "board_write", | |
| 2113 | description: | |
| 2114 | "Write a whole board under a slug (a-z, 0-9 and -): made if new, replaced if there. `board` is {title, range: 1h|24h|7d|30d|all, refresh: off|10s|1m, panels: [{title, chart: line|bar|area|number|table|heat, query, w, h}]}. A panel whose query cannot run is refused. For a forge admin who is a person; an agent is refused. The same as PUT /api/v1/boards/:slug.", | |
| 2115 | inputSchema: { | |
| 2116 | type: "object", | |
| 2117 | properties: { | |
| 2118 | slug: { type: "string", maxLength: 40 }, | |
| 2119 | board: { type: "object", description: "The whole board." }, | |
| 2120 | }, | |
| 2121 | required: ["slug", "board"], | |
| 2122 | }, | |
| 2123 | writes: true, | |
| 2124 | async run(ctx, user, args) { | |
| 2125 | if (!mayBoards(user)) return boardRefused(); | |
| 2126 | const done = await writeBoard(ctx.app, user, str(args["slug"]), args["board"]); | |
| 2127 | return done.ok ? good({ slug: str(args["slug"]), ...done.value }) : boardFailed(done.error); | |
| 2128 | }, | |
| 2129 | }, | |
| 2130 | { | |
| 2131 | name: "board_delete", | |
| 2132 | description: | |
| 2133 | "Delete a board by slug (the commit stays in the boards repo's history). For a forge admin who is a person; an agent is refused. The same as DELETE /api/v1/boards/:slug.", | |
| 2134 | inputSchema: { | |
| 2135 | type: "object", | |
| 2136 | properties: { slug: { type: "string", maxLength: 40 } }, | |
| 2137 | required: ["slug"], | |
| 2138 | }, | |
| 2139 | writes: true, | |
| 2140 | async run(ctx, user, args) { | |
| 2141 | if (!mayBoards(user)) return boardRefused(); | |
| 2142 | const done = await deleteBoard(ctx.app, user, str(args["slug"])); | |
| 2143 | return done.ok ? good({ deleted: str(args["slug"]) }) : boardFailed(done.error); | |
| 2144 | }, | |
| 2145 | }, | |
| 2080 | 2146 | ]; |
| 2081 | 2147 | |
| 2082 | 2148 | /** |
src/tui/forge.ts +9
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | openOnWeb, |
| 29 | 29 | runAsync, |
| 30 | 30 | } from "./screens/common.ts"; |
| 31 | import { boardsScreen } from "./screens/boards.ts"; | |
| 31 | 32 | import { askedScreen } from "./screens/asked.ts"; |
| 32 | 33 | import { familyScreen } from "./screens/family.ts"; |
| 33 | 34 | import { familyLines, familyView } from "../spawn/family.ts"; |
| @@ -196,6 +197,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 196 | 197 | "default", |
| 197 | 198 | ), |
| 198 | 199 | }); |
| 200 | // The boards, as the page gates them: a site admin who is a person, never an agent (#1817). | |
| 201 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 202 | commands.push({ | |
| 203 | id: "boards", | |
| 204 | title: "Boards", | |
| 205 | hint: "the forge's boards and their panels", | |
| 206 | run: () => ctx.push(boardsScreen(session, ctx)), | |
| 207 | }); | |
| 199 | 208 | // The pipeline's bottleneck and throughput, as /flow says them: a site admin's (#1554, #1786). |
| 200 | 209 | if (session.user?.isAdmin === true) |
| 201 | 210 | commands.push({ |
src/tui/screens/boards.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * `/boards` in the terminal (#1817): the boards a forge admin who is a person | |
| 3 | * keeps, then one board's panels as text. It reads through the same | |
| 4 | * `listAllBoards` and `showBoard` the page and the API do, so the gate is the | |
| 5 | * action's (an agent or anyone else is told nothing); the palette offers the | |
| 6 | * door only to that person. Read-only: a chart is not drawn, a panel is said | |
| 7 | * as its title, chart, size and the query that feeds it. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Board } from "../../boards/store.ts"; | |
| 11 | import { listAllBoards, showBoard } from "../../actions/boards.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { split, fill, len } from "../ui/layout.ts"; | |
| 16 | import { emptyState } from "../ui/widgets.ts"; | |
| 17 | import { style } from "../term/style.ts"; | |
| 18 | import { truncate } from "../term/width.ts"; | |
| 19 | import { ListState, Scroll, drawList, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts"; | |
| 20 | ||
| 21 | /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */ | |
| 22 | const plain = (text: string): string => | |
| 23 | // eslint-disable-next-line no-control-regex | |
| 24 | text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); | |
| 25 | ||
| 26 | export interface BoardRow extends ListRow { | |
| 27 | readonly slug: string; | |
| 28 | } | |
| 29 | ||
| 30 | /** The boards as rows; nothing for anyone the action refuses. */ | |
| 31 | export async function boardRows(session: Session): Promise<BoardRow[]> { | |
| 32 | const listed = await listAllBoards(session.app, session.user); | |
| 33 | if (!listed.ok) return []; | |
| 34 | return listed.value.map((b) => ({ | |
| 35 | slug: b.slug, | |
| 36 | title: plain(b.board.title), | |
| 37 | secondary: b.slug, | |
| 38 | meta: metaOf(plural(b.board.panels.length, "panel"), b.board.range), | |
| 39 | })); | |
| 40 | } | |
| 41 | ||
| 42 | /** A board, panel by panel, as lines of text. */ | |
| 43 | export function boardLines(board: Board): string[] { | |
| 44 | const lines = [ | |
| 45 | plain(board.title), | |
| 46 | `range ${board.range} · refresh ${board.refresh} · ${plural(board.panels.length, "panel")}`, | |
| 47 | ]; | |
| 48 | for (const p of board.panels) { | |
| 49 | const query = Object.entries(p.query) | |
| 50 | .map(([k, v]) => `${plain(k)}=${plain(typeof v === "string" ? v : JSON.stringify(v))}`) | |
| 51 | .join(" "); | |
| 52 | lines.push("", plain(p.title), ` ${p.chart} · ${p.w}×${p.h}`, ` ${query === "" ? "no query" : query}`); | |
| 53 | } | |
| 54 | return lines; | |
| 55 | } | |
| 56 | ||
| 57 | function boardScreen(ctx: Ctx, board: Board): Screen { | |
| 58 | const scroll = new Scroll(); | |
| 59 | const lines = boardLines(board); | |
| 60 | return { | |
| 61 | title: plain(board.title), | |
| 62 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 63 | render(area, s) { | |
| 64 | const c = ctx.theme.c; | |
| 65 | const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]); | |
| 66 | drawLines(s, area, c, scroll, spans); | |
| 67 | hitScroll(ctx, area, scroll, lines.length); | |
| 68 | }, | |
| 69 | key(e) { | |
| 70 | if (scroll.key(e, lines.length)) return true; | |
| 71 | if (e.key === "esc" || e.key === "q") { | |
| 72 | ctx.pop(); | |
| 73 | return true; | |
| 74 | } | |
| 75 | return false; | |
| 76 | }, | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | export function boardsScreen(f: Session, ctx: Ctx): Screen { | |
| 81 | const state = new ListState(); | |
| 82 | let rows: BoardRow[] = []; | |
| 83 | const read = async (): Promise<void> => { | |
| 84 | rows = await boardRows(f); | |
| 85 | state.clamp(rows.length); | |
| 86 | }; | |
| 87 | const open = (index: number): void => { | |
| 88 | const row = rows[index]; | |
| 89 | if (row === undefined) return; | |
| 90 | runAsync(ctx, async () => { | |
| 91 | const shown = await showBoard(f.app, f.user, row.slug); | |
| 92 | if (!shown.ok) ctx.toast("That board is gone", "default"); | |
| 93 | else ctx.push(boardScreen(ctx, shown.value)); | |
| 94 | }); | |
| 95 | }; | |
| 96 | return { | |
| 97 | title: "Boards", | |
| 98 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 99 | load: read, | |
| 100 | render(area, s) { | |
| 101 | const c = ctx.theme.c; | |
| 102 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 103 | s.text(head!.x, head!.y, truncate(plural(rows.length, "board"), head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 104 | if (rows.length === 0) { | |
| 105 | emptyState(s, body!, c, { | |
| 106 | title: "No boards yet", | |
| 107 | body: "Make one on the web at /boards; it shows up here.", | |
| 108 | }); | |
| 109 | return; | |
| 110 | } | |
| 111 | drawList(s, body!, c, state, rows); | |
| 112 | hitList(ctx, body!, state, rows.length, { open }); | |
| 113 | }, | |
| 114 | key(e) { | |
| 115 | if (state.key(e, rows.length)) return true; | |
| 116 | if (e.key === "enter") { | |
| 117 | open(state.index); | |
| 118 | return true; | |
| 119 | } | |
| 120 | if (e.key === "r") { | |
| 121 | runAsync(ctx, async () => { | |
| 122 | await read(); | |
| 123 | ctx.invalidate(); | |
| 124 | }); | |
| 125 | return true; | |
| 126 | } | |
| 127 | return false; | |
| 128 | }, | |
| 129 | }; | |
| 130 | } |
test/mcp.test.ts +36
| @@ -208,6 +208,9 @@ describe("the forge over MCP", () => { | ||
| 208 | 208 | "capacity", |
| 209 | 209 | "flow", |
| 210 | 210 | "limits", |
| 211 | "boards", | |
| 212 | "board_write", | |
| 213 | "board_delete", | |
| 211 | 214 | "report", |
| 212 | 215 | ]); |
| 213 | 216 | const byName = new Map<string, any>( |
| @@ -621,6 +624,39 @@ describe("the forge over MCP", () => { | ||
| 621 | 624 | expect(limitChanges(app.db)[0]).toMatchObject({ key }); |
| 622 | 625 | }); |
| 623 | 626 | |
| 627 | test("boards: list, read, write and delete for an admin person; an agent and a non-admin are refused", async () => { | |
| 628 | const board = { title: "Runs", range: "24h", refresh: "off", panels: [{ title: "Failed", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }] }; | |
| 629 | const made = await createAgent(app, alice, { username: "boardbot", brief: "x" }); | |
| 630 | if (!made.ok) throw new Error(String(made.error)); | |
| 631 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id); | |
| 632 | const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token; | |
| 633 | for (const who of [bobRw, botToken]) { | |
| 634 | expect((await tool(who, "boards")).error).toContain("forbidden"); | |
| 635 | expect((await tool(who, "board_write", { slug: "runs", board })).error).toContain("forbidden"); | |
| 636 | expect((await tool(who, "board_delete", { slug: "runs" })).error).toContain("forbidden"); | |
| 637 | } | |
| 638 | ||
| 639 | // A read-only token may not write. | |
| 640 | expect((await tool(ro, "board_write", { slug: "runs", board })).error).toContain("forbidden"); | |
| 641 | expect((await tool(rw, "boards")).value.boards).toEqual([]); | |
| 642 | ||
| 643 | const wrote = await tool(rw, "board_write", { slug: "runs", board }); | |
| 644 | expect(wrote.value).toMatchObject({ slug: "runs", title: "Runs" }); | |
| 645 | expect((await tool(rw, "boards")).value.boards.map((b: any) => b.slug)).toEqual(["runs"]); | |
| 646 | expect((await tool(ro, "boards", { slug: "runs" })).value.panels[0].title).toBe("Failed"); | |
| 647 | expect((await tool(rw, "boards", { slug: "nope" })).error).toContain("not_found"); | |
| 648 | ||
| 649 | // A panel that cannot run, or a board that does not validate, is refused. | |
| 650 | const broken = { ...board, panels: [{ ...board.panels[0], query: { source: "nothing" } }] }; | |
| 651 | expect((await tool(rw, "board_write", { slug: "broken", board: broken })).error).toContain("refused"); | |
| 652 | expect((await tool(rw, "board_write", { slug: "Bad Slug", board })).error).toContain("invalid"); | |
| 653 | ||
| 654 | expect((await tool(ro, "board_delete", { slug: "runs" })).error).toContain("forbidden"); | |
| 655 | expect((await tool(rw, "board_delete", { slug: "runs" })).value).toEqual({ deleted: "runs" }); | |
| 656 | expect((await tool(rw, "boards")).value.boards).toEqual([]); | |
| 657 | expect((await tool(rw, "board_delete", { slug: "runs" })).error).toContain("not_found"); | |
| 658 | }); | |
| 659 | ||
| 624 | 660 | test("ui:// card resources are listed, read, and tied to their tools", async () => { |
| 625 | 661 | const list = await call(rw, "resources/list"); |
| 626 | 662 | const uris = list.body.result.resources.map((r: any) => r.uri).sort(); |
test/tui-boards.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * /boards in the terminal (#1817): the screen reads the page's own action, so | |
| 3 | * a forge admin who is a person lists boards and reads their panels as text, | |
| 4 | * and an agent, a member or a guest has neither the door nor the rows. | |
| 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 { writeBoard } from "../src/actions/boards.ts"; | |
| 11 | import { createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 15 | import { boardRows, boardsScreen } from "../src/tui/screens/boards.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | let carol: User; | |
| 25 | let bob: User; | |
| 26 | let bot: User; | |
| 27 | ||
| 28 | const identity = (user?: User) => ({ | |
| 29 | sshUsername: user?.username ?? "git", | |
| 30 | remoteAddress: "127.0.0.1", | |
| 31 | fingerprint: "SHA256:test", | |
| 32 | user, | |
| 33 | }); | |
| 34 | ||
| 35 | async function screenText(user?: User, keys = ""): Promise<string> { | |
| 36 | const terminal = headlessTerminal(120, 30); | |
| 37 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 38 | tui.start(); | |
| 39 | tui.ctx.push(boardsScreen(session, tui.ctx)); | |
| 40 | await settleApp(tui); | |
| 41 | if (keys !== "") { | |
| 42 | terminal.feed(keys); | |
| 43 | await settleApp(tui); | |
| 44 | } | |
| 45 | const text = tui.screen.toText(); | |
| 46 | tui.stop?.(); | |
| 47 | return text; | |
| 48 | } | |
| 49 | ||
| 50 | const doors = (user?: User): string[] => { | |
| 51 | const { app: tui, session } = createForgeApp(app, identity(user), { | |
| 52 | terminal: headlessTerminal(80, 24), | |
| 53 | exitHooks: false, | |
| 54 | }); | |
| 55 | const ids = forgeCommands(session)(tui.ctx).map((c) => c.id); | |
| 56 | tui.stop?.(); | |
| 57 | return ids; | |
| 58 | }; | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dir = await mkdtemp(join(tmpdir(), "forge-tui-boards-")); | |
| 62 | app = createApp({ dataDir: dir, port: 0 }); | |
| 63 | const mk = async (username: string, first = false) => { | |
| 64 | const r = await createUser(app.db, { | |
| 65 | trusted: true, username, email: `${username}@forge.local`, | |
| 66 | password: "a-good-passphrase", adminIfFirst: first, | |
| 67 | }); | |
| 68 | if (!r.ok) throw new Error(r.error); | |
| 69 | return r.value; | |
| 70 | }; | |
| 71 | carol = await mk("carol", true); | |
| 72 | bob = await mk("bob"); | |
| 73 | const made = await createAgent(app, carol, { username: "boardbot", brief: "x" }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id); | |
| 76 | bot = { ...made.value.user, isAdmin: true }; | |
| 77 | const wrote = await writeBoard(app, carol, "runs", { | |
| 78 | title: "Runs this week", | |
| 79 | range: "7d", | |
| 80 | refresh: "off", | |
| 81 | panels: [{ title: "Failed runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }], | |
| 82 | }); | |
| 83 | if (!wrote.ok) throw new Error(JSON.stringify(wrote.error)); | |
| 84 | }, 30_000); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | app.close(); | |
| 88 | await rm(dir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | describe("tui boards", () => { | |
| 92 | test("an admin person sees the boards and reads a board's panels", async () => { | |
| 93 | expect(await screenText(carol)).toContain("Runs this week"); | |
| 94 | const text = await screenText(carol, "\r"); | |
| 95 | expect(text).toContain("Failed runs"); | |
| 96 | expect(text).toContain("number"); | |
| 97 | expect(text).toContain("source=runs"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("nobody else has rows or the door", async () => { | |
| 101 | for (const who of [bob, bot, undefined]) { | |
| 102 | expect(await boardRows(new Session(app, identity(who)))).toEqual([]); | |
| 103 | expect(doors(who)).not.toContain("boards"); | |
| 104 | expect(await screenText(who)).not.toContain("Runs this week"); | |
| 105 | } | |
| 106 | expect(doors(carol)).toContain("boards"); | |
| 107 | }); | |
| 108 | }); |
new above
offline