Public: anyone can read this; the crew can change it.
Settings › Crew, the list of the forge's agents, speaks the home page's language #9845
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9826-settings-crew-the-list-of-the-forge-s-ag → main 7 commits fork 4 threads · 4 unresolved
docs/design/areas/settings.md +7
| @@ -126,6 +126,13 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w | ||
| 126 | 126 | `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as |
| 127 | 127 | before. `test/trusted-sources.test.ts` holds the shape. |
| 128 | 128 | |
| 129 | Settings › Crew (`crewPage()` and `crewReadiness()` in `src/web/pages/crew.ts`, #9826) adds no class and has no card. The | |
| 130 | readiness words come first: a danger notice (no runner, runner cannot run, unsandboxed) is an existing | |
| 131 | `.toast[data-tone="danger"]` with `role="alert"`. Spending, Agents, Scorecard, Model review, the per-model, per-prompt and | |
| 132 | per-day tables, Handed to the planner and Restore drill are each a `<section class="settings-block">` with an `<h2>` (an | |
| 133 | optional muted note under it) and keep their `.table-scroll` tables. With no agent, one plain sentence under "No agents yet". | |
| 134 | New agent is the last block, its form posting to `/settings/crew`. Pinned by `test/crew-language.test.ts`. | |
| 135 | ||
| 129 | 136 | Settings › Prompts (`promptsView()` in `src/web/pages/prompts.ts`, #9825) adds no class: two `<section class="settings-block">` blocks, Instagram (the connected account as a `row()` in a `.setting-list` with its Fetch now and Remove forms, or one plain sentence and the add form) and Getting a token. Pinned by `test/prompts-language.test.ts`. |
| 130 | 137 | |
| 131 | 138 | Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`. |
docs/features/crew-list.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # The crew list | |
| 2 | ||
| 3 | A forge admin sees who is on the crew, and whether anything will run their work, in two places. | |
| 4 | ||
| 5 | - **Web: Settings → Crew** (`/settings/crew`, `crewPage()` in `src/web/pages/crew.ts`). One heading, then the readiness words first: "… is ready to run the crew", or a red notice saying no runner is up, the runner cannot run, or agents' shells are unsandboxed. Under it the agents (model, budget, runs, landed, spent, with Edit and Retire), the scorecards, and the New agent form, which posts to the same route as before. With no agent, one sentence says so. A non-admin gets 404. | |
| 6 | - **Terminal: the Crew agents screen** (`src/tui/screens/agents.ts`). Each agent's roles, model, budget and the first line of its brief; `n` creates one, `m`, `b`, `B` and `r` change it, `d` removes it, each after a confirm. | |
| 7 | ||
| 8 | Readiness is the web page's alone: the runner is detected once at boot, and the page says so before an issue is assigned rather than after it waits. `test/crew-language.test.ts` holds the page's shape. |
src/web/pages/crew.ts +64 −84
| @@ -55,9 +55,7 @@ import { html, raw, when, type Html } from "../html.ts"; | ||
| 55 | 55 | import { flashTo, page } from "../layout.ts"; |
| 56 | 56 | import { |
| 57 | 57 | btn, |
| 58 | card, | |
| 59 | 58 | confirmAction, |
| 60 | empty, | |
| 61 | 59 | icon, |
| 62 | 60 | time, |
| 63 | 61 | field as uiField, |
| @@ -100,6 +98,28 @@ function shell(ctx: Ctx, title: string, body: Html, status = 200): Response { | ||
| 100 | 98 | ); |
| 101 | 99 | } |
| 102 | 100 | |
| 101 | interface PanelOptions { | |
| 102 | title: string; | |
| 103 | /** One muted sentence under the heading. */ | |
| 104 | note?: Html; | |
| 105 | id?: string; | |
| 106 | /** Extra attributes on the section (the restore drill's verdict). */ | |
| 107 | attrs?: Html; | |
| 108 | } | |
| 109 | ||
| 110 | /** A titled block of the crew list: heading, a quiet note, then the body, no box. */ | |
| 111 | function panel(body: Html, o: PanelOptions): Html { | |
| 112 | return html`<section | |
| 113 | class="settings-block" | |
| 114 | ${o.id ? html`id="${o.id}"` : null} | |
| 115 | ${o.attrs ?? null} | |
| 116 | > | |
| 117 | <h2>${o.title}</h2> | |
| 118 | ${o.note ? html`<p class="muted small">${o.note}</p>` : null} | |
| 119 | ${body} | |
| 120 | </section>`; | |
| 121 | } | |
| 122 | ||
| 103 | 123 | /** |
| 104 | 124 | * Whether anything will pick up an agent's work. The runner is detected |
| 105 | 125 | * once at boot; this is that answer, said before an issue is assigned |
| @@ -108,46 +128,26 @@ function shell(ctx: Ctx, title: string, body: Html, status = 200): Response { | ||
| 108 | 128 | export function crewReadiness(ctx: Ctx): Html { |
| 109 | 129 | const crew = crewFor(ctx.app); |
| 110 | 130 | if (crew === undefined && isHostedChild(ctx.app)) |
| 111 | return card( | |
| 112 | html`<p> | |
| 113 | ${icon("alert")} This forge is hosted inside another forge's process, | |
| 131 | return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">This forge is hosted inside another forge's process, | |
| 114 | 132 | which has no crew runner for it: assigned issues will wait. The crew |
| 115 | runs once this forge has a process of its own. | |
| 116 | </p>`, | |
| 117 | { tone: "danger", extra: "mb" }, | |
| 118 | ); | |
| 133 | runs once this forge has a process of its own.</div></div>`; | |
outdated
Principle 5/8: |
||
| 119 | 134 | if (crew === undefined) |
| 120 | return card( | |
| 121 | html`<p> | |
| 122 | ${icon("alert")} No crew runner is up on this forge, so assigned issues | |
| 135 | return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">No crew runner is up on this forge, so assigned issues | |
| 123 | 136 | will wait. Start the forge with apps on (the default) and Claude Code |
| 124 | 137 | on <span class="mono">PATH</span> or at |
| 125 | <span class="mono">FORGE_CLAUDE</span>. | |
| 126 | </p>`, | |
| 127 | { tone: "danger", extra: "mb" }, | |
| 128 | ); | |
| 138 | <span class="mono">FORGE_CLAUDE</span>.</div></div>`; | |
| 129 | 139 | if (!crew.ready) |
| 130 | return card( | |
| 131 | html`<p> | |
| 132 | ${icon("alert")} The crew cannot run: ${crew.detail}. Agents can be | |
| 133 | created, but nothing picks up their work until this is fixed. | |
| 134 | </p>`, | |
| 135 | { tone: "danger", extra: "mb" }, | |
| 136 | ); | |
| 140 | return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">The crew cannot run: ${crew.detail}. Agents can be | |
| 141 | created, but nothing picks up their work until this is fixed.</div></div>`; | |
| 137 | 142 | return html`<p class="muted small mb"> |
| 138 | 143 | ${icon("check")} ${crew.detail} is ready to run the crew. |
| 139 | 144 | </p> |
| 140 | 145 | ${when( |
| 141 | 146 | !crew.sandbox.enforced, |
| 142 | 147 | () => |
| 143 | card( | |
| 144 | html`<p> | |
| 145 | ${icon("alert")} Unsandboxed: agents' shells run unrestricted here | |
| 148 | html`<div class="toast" data-tone="danger" role="alert" id="crew-unsandboxed">${icon("alert")}<div class="toast-body">Unsandboxed: agents' shells run unrestricted here | |
| 146 | 149 | (${crew.sandbox.reason}). They can read this forge's data and reach |
| 147 | any host. | |
| 148 | </p>`, | |
| 149 | { tone: "danger", extra: "mb", id: "crew-unsandboxed" }, | |
| 150 | ), | |
| 150 | any host.</div></div>`, | |
| 151 | 151 | )}`; |
| 152 | 152 | } |
| 153 | 153 | |
| @@ -327,7 +327,7 @@ function scorecardCard( | ||
| 327 | 327 | ${s.meanLandMs === null |
| 328 | 328 | ? dash |
| 329 | 329 | : html`<td class="num">${spanText(s.meanLandMs)}</td>`}`}`; |
| 330 | return card( | |
| 330 | return panel( | |
| 331 | 331 | html`<div class="table-scroll"> |
| 332 | 332 | <table class="table" id="crew-scorecard" data-sticky-lead> |
| 333 | 333 | <thead> |
| @@ -378,18 +378,13 @@ function scorecardCard( | ||
| 378 | 378 | </table> |
Non-blocking, principle 1: this Scorecard note is a five-line paragraph that explains what the table columns already show. One sentence. |
||
| 379 | 379 | </div>`, |
| 380 | 380 | { |
| 381 | pad: false, | |
| 382 | extra: "mt mb", | |
| 383 | head: html`${icon("activity")} | |
| 384 | <h2>Scorecard</h2> | |
| 385 | <span class="muted small" | |
| 386 | >Over the pulls each agent opened, and each team's members pooled (an | |
| 381 | title: "Scorecard", | |
| 382 | note: html`Over the pulls each agent opened, and each team's members pooled (an | |
| 387 | 383 | agent on two teams counts in both), in the last ${RECENT_DAYS} days |
| 388 | 384 | and over all time: review rounds are change requests per landed pull, red checks |
| 389 | 385 | the share of finished checks that failed; reverts (the landed pulls |
| 390 | 386 | a merged pull took back) and time to land (the mean from opening to |
| 391 | merge) are all time.</span | |
| 392 | >`, | |
| 387 | merge) are all time.`, | |
| 393 | 388 | }, |
| 394 | 389 | ); |
| 395 | 390 | } |
| @@ -401,7 +396,7 @@ function spendingCard( | ||
| 401 | 396 | ): Html { |
| 402 | 397 | const total = spending.reduce((n, s) => n + s.status.spentTodayUsd, 0); |
| 403 | 398 | const cap = spending.reduce((n, s) => n + s.status.ceilingUsd, 0); |
| 404 | return card( | |
| 399 | return panel( | |
| 405 | 400 | html`<ul class="list"> |
| 406 | 401 | ${spending.map( |
| 407 | 402 | ({ repo, status }) => |
| @@ -436,18 +431,13 @@ function spendingCard( | ||
| 436 | 431 | )} |
| 437 | 432 | </ul>`, |
| 438 | 433 | { |
| 439 | pad: false, | |
| 440 | extra: "mb", | |
| 441 | head: html`${icon("activity")} | |
| 442 | <h2>Spending today</h2> | |
| 443 | <span class="muted small" | |
| 444 | >$${total.toFixed(0)} of $${cap.toFixed(0)} across ${spending.length} | |
| 434 | title: "Spending today", | |
| 435 | note: html`$${total.toFixed(0)} of $${cap.toFixed(0)} across ${spending.length} | |
| 445 | 436 | ${spending.length === 1 ? "app" : "apps"} running on their |
| 446 | 437 | own${forgeCap === undefined |
| 447 | 438 | ? "; no forge-wide ceiling is set (FORGE_CREW_CEILING_USD)" |
| 448 | 439 | : `, under the forge's $${forgeCap.toFixed(0)} a day`}; |
| 449 | the ceilings reset at 00:00 UTC</span | |
| 450 | >`, | |
| 440 | the ceilings reset at 00:00 UTC`, | |
| 451 | 441 | }, |
| 452 | 442 | ); |
| 453 | 443 | } |
| @@ -490,7 +480,7 @@ function modelReviewCard(ctx: Ctx): Html { | ||
| 490 | 480 | const cell = (n: number | null, f: (n: number) => string) => |
| 491 | 481 | n === null ? html`<td class="num muted">—</td>` : html`<td class="num">${f(n)}</td>`; |
| 492 | 482 | const asked = r.changes.filter((c) => c.state === "asked"); |
| 493 | return card( | |
| 483 | return panel( | |
| 494 | 484 | html`<p class="muted small">At most one change of model per agent a week, to the cheapest that holds its quality. |
| 495 | 485 | A step up always asks; a step down ${r.limits.auto ? "applies on its own" : "asks too"} (Settings → Limits). |
| 496 | 486 | Under ${r.limits.minLanded} landed changes nothing. A week after a change the result is reported, and the |
| @@ -573,10 +563,8 @@ function modelReviewCard(ctx: Ctx): Html { | ||
| 573 | 563 | </table> |
| 574 | 564 | </div>`, |
| 575 | 565 | { |
| 576 | extra: "mt mb", | |
| 577 | head: html`${icon("activity")} | |
| 578 | <h2>Model review</h2> | |
| 579 | <span class="muted small">Weekly, from the last ${r.windowDays} days of the ledger</span>`, | |
| 566 | title: "Model review", | |
| 567 | note: html`Weekly, from the last ${r.windowDays} days of the ledger`, | |
| 580 | 568 | }, |
| 581 | 569 | ); |
| 582 | 570 | } |
| @@ -618,12 +606,11 @@ function crewView( | ||
| 618 | 606 | spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined), |
| 619 | 607 | )} |
| 620 | 608 | ${agents.length === 0 |
| 621 | ? empty({ | |
| 622 | icon: "bot", | |
| 623 | title: "No agents yet", | |
| 624 | note: "An agent is an account that works on a repository while you are elsewhere. Make one below, then put it on a repository's crew in that repository's settings.", | |
| 625 | }) | |
| 626 | : card( | |
| 609 | ? html`<section class="mb" id="crew-empty"> | |
| 610 | <h2 class="mb">No agents yet</h2> | |
| 611 | <p class="muted">An agent is an account that works on a repository while you are elsewhere. Make one below, then put it on a repository's crew in that repository's settings.</p> | |
| 612 | </section>` | |
| 613 | : panel( | |
| 627 | 614 | html`<div class="table-scroll"> |
| 628 | 615 | <table class="table"> |
| 629 | 616 | <thead> |
| @@ -699,7 +686,7 @@ function crewView( | ||
| 699 | 686 | </tbody> |
| 700 | 687 | </table> |
| 701 | 688 | </div>`, |
| 702 | { pad: false }, | |
| 689 | { title: "Agents", id: "crew-agents" }, | |
| 703 | 690 | )} |
| 704 | 691 | ${when(agents.length > 0, () => |
| 705 | 692 | scorecardCard( |
| @@ -730,8 +717,9 @@ function crewView( | ||
| 730 | 717 | () => html`<span class="muted">not recorded</span>`, |
| 731 | 718 | )} |
| 732 | 719 | ${handedCard(handover.parked)}${drillCard(handover.drill)} |
| 733 | ${card( | |
| 734 | html`<form method="post" action="/settings/crew"> | |
| 720 | <section class="mt" id="crew-new"> | |
| 721 | <h2>New agent</h2> | |
| 722 | <form method="post" action="/settings/crew"> | |
| 735 | 723 | ${uiField({ |
| 736 | 724 | label: "Username", |
| 737 | 725 | name: "username", |
| @@ -752,18 +740,14 @@ function crewView( | ||
| 752 | 740 | ${btn("Create agent", { variant: "primary", icon: "bot" })} |
| 753 | 741 | ${btn("Teams", { href: "/settings/teams", variant: "ghost" })} |
| 754 | 742 | </div> |
| 755 | </form>`, | |
| 756 | { | |
| 757 | head: html`${icon("bot")} | |
| 758 | <h2>New agent</h2>`, | |
| 759 | }, | |
| 760 | )}`; | |
| 743 | </form> | |
| 744 | </section>`; | |
| 761 | 745 | } |
| 762 | 746 | |
| 763 | 747 | /** Issues the crew parked twice and the planner now holds; nothing when there are none. */ |
| 764 | 748 | function handedCard(parked: CrewHandover["parked"]): Html { |
| 765 | 749 | if (parked.length === 0) return html``; |
| 766 | return card( | |
| 750 | return panel( | |
| 767 | 751 | html`<div class="table-scroll"><table class="table"> |
| 768 | 752 | <thead> |
| 769 | 753 | <tr> |
| @@ -788,10 +772,8 @@ function handedCard(parked: CrewHandover["parked"]): Html { | ||
| 788 | 772 | </tbody> |
| 789 | 773 | </table></div>`, |
| 790 | 774 | { |
| 791 | pad: false, | |
| 792 | 775 | id: "crew-handed", |
| 793 | head: html`${icon("bot")} | |
| 794 | <h2>Handed to the planner</h2>`, | |
| 776 | title: "Handed to the planner", | |
| 795 | 777 | }, |
| 796 | 778 | ); |
| 797 | 779 | } |
| @@ -799,21 +781,19 @@ function handedCard(parked: CrewHandover["parked"]): Html { | ||
| 799 | 781 | /** The last restore drill's verdict; nothing before a first drill. */ |
Non-blocking, principle 6: a failed drill should still read as failed without a box. Put the danger colour and the alert icon on the Failed word. |
||
| 800 | 782 | function drillCard(drill: CrewHandover["drill"]): Html { |
| 801 | 783 | if (drill === undefined) return html``; |
| 802 | return card( | |
| 784 | return panel( | |
| 803 | 785 | html`<p>${drill.verdict === "green" ? "Passed" : "Failed"} ${time(drill.at)}.</p>`, |
| 804 | 786 | { |
| 805 | 787 | id: "crew-drill", |
| 806 | tone: drill.verdict === "green" ? "default" : "danger", | |
| 807 | 788 | attrs: html`data-drill="${drill.verdict}"`, |
| 808 | head: html`${icon("clock")} | |
| 809 | <h2>Restore drill</h2>`, | |
| 789 | title: "Restore drill", | |
| 810 | 790 | }, |
| 811 | 791 | ); |
| 812 | 792 | } |
| 813 | 793 | |
| 814 | 794 | /** Hours the forge ran and pulls it landed, per day of the last week (#7645). */ |
| 815 | 795 | function keeperDaysCard(rows: readonly KeeperDay[]): Html { |
| 816 | return card( | |
| 796 | return panel( | |
| 817 | 797 | html`<div class="table-scroll"> |
| 818 | 798 | <table class="table" id="crew-keeper-days"> |
| 819 | 799 | <thead> |
| @@ -834,14 +814,14 @@ function keeperDaysCard(rows: readonly KeeperDay[]): Html { | ||
| 834 | 814 | </tbody> |
| 835 | 815 | </table> |
| 836 | 816 | </div>`, |
| 837 | { pad: false, head: html`<h2>Forge per day</h2>` }, | |
| 817 | { title: "Forge per day" }, | |
| 838 | 818 | ); |
| 839 | 819 | } |
| 840 | 820 | |
| 841 | 821 | /** Spend and landed pulls per model per day: the escalation, measured. */ |
| 842 | 822 | function modelDaysCard(rows: readonly ModelDay[]): Html { |
| 843 | 823 | if (rows.length === 0) return html``; |
| 844 | return card( | |
| 824 | return panel( | |
| 845 | 825 | html`<div class="table-scroll"> |
| 846 | 826 | <table class="table" id="crew-model-days"> |
| 847 | 827 | <thead> |
| @@ -866,14 +846,14 @@ function modelDaysCard(rows: readonly ModelDay[]): Html { | ||
| 866 | 846 | </tbody> |
| 867 | 847 | </table> |
| 868 | 848 | </div>`, |
| 869 | { pad: false, head: html`<h2>Per model per day</h2>` }, | |
| 849 | { title: "Per model per day" }, | |
| 870 | 850 | ); |
| 871 | 851 | } |
| 872 | 852 | |
| 873 | 853 | /** Runs that ended on work not theirs to do, per day: the pull closed, nothing pushed, already done. */ |
| 874 | 854 | function wastedDaysCard(rows: readonly WastedDay[]): Html { |
| 875 | 855 | if (rows.length === 0) return html``; |
| 876 | return card( | |
| 856 | return panel( | |
| 877 | 857 | html`<div class="table-scroll"> |
| 878 | 858 | <table class="table" id="crew-wasted-days"> |
| 879 | 859 | <thead> |
| @@ -896,7 +876,7 @@ function wastedDaysCard(rows: readonly WastedDay[]): Html { | ||
| 896 | 876 | </tbody> |
| 897 | 877 | </table> |
| 898 | 878 | </div>`, |
| 899 | { pad: false, head: html`<h2>Wasted runs per day</h2>` }, | |
| 879 | { title: "Wasted runs per day" }, | |
| 900 | 880 | ); |
| 901 | 881 | } |
| 902 | 882 | |
| @@ -908,7 +888,7 @@ function splitsCard( | ||
| 908 | 888 | blank: (key: string) => Html, |
| 909 | 889 | ): Html { |
| 910 | 890 | if (rows.length === 0) return html``; |
| 911 | return card( | |
| 891 | return panel( | |
| 912 | 892 | html`<div class="table-scroll"> |
| 913 | 893 | <table class="table"> |
| 914 | 894 | <thead> |
| @@ -935,7 +915,7 @@ function splitsCard( | ||
| 935 | 915 | </tbody> |
| 936 | 916 | </table> |
| 937 | 917 | </div>`, |
| 938 | { pad: false, head: html`<h2>${title}</h2>` }, | |
| 918 | { title }, | |
| 939 | 919 | ); |
| 940 | 920 | } |
| 941 | 921 |
test/crew-language.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** Settings › Crew speaks the home page's language (#9826): one heading, the readiness words, a plain list, the form; no card. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { components } from "../src/web/style/components.ts"; | |
| 7 | import { createApp } from "../src/app.ts"; | |
| 8 | import { createAgent } from "../src/actions/crew.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { serve } from "../src/http/server.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dir = ""; | |
| 18 | let app: ReturnType<typeof createApp>; | |
| 19 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 20 | let admin: User; | |
| 21 | let cookie = ""; | |
| 22 | let other = ""; | |
| 23 | const person = async (username: string, adminIfFirst = false): Promise<User> => { | |
| 24 | const made = await createUser(app.db, { | |
| 25 | origin: "seed", | |
| 26 | trusted: true, | |
| 27 | username, | |
| 28 | email: `${username}@forge.test`, | |
| 29 | adminIfFirst, | |
| 30 | }); | |
| 31 | if (!made.ok) throw new Error(String(made.error)); | |
| 32 | return made.value; | |
| 33 | }; | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dir = await mkdtemp(join(tmpdir(), "forge-crew-lang-")); | |
| 37 | app = createApp({ dataDir: dir, port: 0 }); | |
| 38 | servers = await serve(app); | |
| 39 | admin = await person("alice", true); | |
| 40 | promote(app.db, admin); | |
| 41 | cookie = `forge_session=${createSession(app.db, admin.id).token}`; | |
| 42 | const bob = await person("bob"); | |
| 43 | other = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 44 | }); | |
| 45 | afterAll(async () => { | |
| 46 | servers.stop(); | |
| 47 | app.close(); | |
| 48 | await rm(dir, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | const crewMain = async (): Promise<string> => { | |
| 52 | const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie }, redirect: "manual" }); | |
| 53 | expect(res.status).toBe(200); | |
| 54 | const page = await res.text(); | |
| 55 | return page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 56 | }; | |
| 57 | const speaksPlainly = (main: string): void => { | |
| 58 | expect(main.match(/<h1[\s>]/g)?.length).toBe(1); | |
| 59 | expect(main).not.toContain('class="card'); | |
| 60 | expect(main).not.toContain("card-body"); | |
| 61 | expect(main).toMatch(/runner is up|cannot run|ready to run the crew|hosted inside/); | |
| 62 | expect(main).toContain('action="/settings/crew"'); | |
| 63 | // The danger notice (no runner in this test) wears a class the stylesheet really styles. | |
| 64 | expect(main).toContain('class="toast" data-tone="danger" role="alert"'); | |
| 65 | expect(components).toContain('.toast[data-tone="danger"]'); | |
| 66 | expect(main).not.toContain('class="error'); | |
| 67 | }; | |
| 68 | ||
| 69 | test("with no agent: one heading, the readiness sentence, the empty sentence, the form", async () => { | |
| 70 | const main = await crewMain(); | |
| 71 | speaksPlainly(main); | |
| 72 | expect(main).toContain("No agents yet"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("with an agent: its link is on the page, still no card", async () => { | |
| 76 | const made = await createAgent(app, admin, { username: "bot", brief: "Be brief." }); | |
| 77 | expect(made.ok).toBe(true); | |
| 78 | const main = await crewMain(); | |
| 79 | speaksPlainly(main); | |
| 80 | expect(main).toContain('href="/settings/crew/bot"'); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a non-admin still gets 404", async () => { | |
| 84 | const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: other }, redirect: "manual" }); | |
| 85 | expect(res.status).toBe(404); | |
| 86 | }); |
pad,attrsandtoneare options the callers do not need; this iscard()renamed. Trim to what is used, no dead options.