Public: anyone can read this; the crew can change it.
A client's first screen lists their apps in plain sentences, each saying whether its agents work or wait #1871
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1812-a-client-s-first-screen-lists-their-apps → main 2 commits fork 1 thread · 1 unresolved
8 files
changed +454
−5
README.md +7
| @@ -82,6 +82,13 @@ ssh -p 2222 localhost # the terminal UI | ||
| 82 | 82 | git clone ssh://localhost:2222/you/hello.git # git, same port |
| 83 | 83 | |
| 84 | 84 | |
| 85 | On a forge made for a client (one that was spawned, signed in by its claim | |
| 86 | link), the home is their apps, one plain sentence each and one more saying | |
| 87 | whether its agents are working, about to start, or waiting: no map, board or | |
| 88 | settings until asked. `/?all=1` is the whole home, and its "Just my apps" link | |
| 89 | comes back. The terminal's first screen opens with the same lines, from the | |
| 90 | same query (`src/core/client-apps.ts`). | |
| 91 | ||
| 85 | 92 | On a host that cannot hold cells or the crew's shell sandbox, a site admin |
| 86 | 93 | sees `Reduced protection: …` in one line, under the filter on the terminal's |
| 87 | 94 | first screen and in the web footer; nobody else does, and a fully protected |
scripts/gate.ts +1
| @@ -28,6 +28,7 @@ export type Exclusion = { | ||
| 28 | 28 | |
| 29 | 29 | export const GATE: readonly string[] = [ |
| 30 | 30 | "test/accounts-retire.test.ts", |
| 31 | "test/client-home.test.ts", | |
| 31 | 32 | "test/cloud-use.test.ts", |
| 32 | 33 | "test/accounts.test.ts", |
| 33 | 34 | "test/asks-mute.test.ts", |
src/core/client-apps.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * A client's apps and how busy their agents are (#1812): one row per app on | |
| 3 | * the forge made for them, with the agent runs going and the ones lined up. | |
| 4 | * The web home and the terminal both read it, so they cannot disagree. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { spawnedRepos } from "./next-steps.ts"; | |
| 9 | import type { Repo } from "./repos.ts"; | |
| 10 | import type { User } from "./users.ts"; | |
| 11 | ||
| 12 | export interface ClientApp { | |
| 13 | readonly repo: Repo; | |
| 14 | /** Agent runs going now. */ | |
| 15 | readonly working: number; | |
| 16 | /** Agent runs lined up, none going yet. */ | |
| 17 | readonly queued: number; | |
| 18 | } | |
| 19 | ||
| 20 | /** The apps `user` can see on a spawned forge, each with its agent counts. */ | |
| 21 | export async function clientAppRows( | |
| 22 | app: App, | |
| 23 | user: User, | |
| 24 | ): Promise<readonly ClientApp[]> { | |
| 25 | const repos = await spawnedRepos(app, user); | |
| 26 | if (repos.length === 0) return []; | |
| 27 | const rows = app.db | |
| 28 | .query<{ repo_id: string; state: string; n: number }, string[]>( | |
| 29 | `SELECT repo_id, state, COUNT(*) AS n FROM run | |
| 30 | WHERE kind = 'agent' AND state IN ('queued','running') | |
| 31 | AND repo_id IN (${repos.map((_, i) => `?${i + 1}`).join(",")}) | |
| 32 | GROUP BY repo_id, state`, | |
| 33 | ) | |
| 34 | .all(...repos.map((r) => r.id)); | |
| 35 | return repos.map((repo) => { | |
| 36 | const own = rows.filter((r) => r.repo_id === repo.id); | |
| 37 | const n = (state: string): number => | |
| 38 | own.find((r) => r.state === state)?.n ?? 0; | |
| 39 | return { repo, working: n("running"), queued: n("queued") }; | |
| 40 | }); | |
| 41 | } |
src/tui/screens/dashboard.ts +34 −2
| @@ -17,6 +17,9 @@ | ||
| 17 | 17 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 18 | 18 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 19 | 19 | import { forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| 20 | import { clientAppRows } from "../../core/client-apps.ts"; | |
| 21 | import { nextStepsFor } from "../../core/next-steps.ts"; | |
| 22 | import { agentsLine } from "../../web/studio/client_home.ts"; | |
| 20 | 23 | import { mayView } from "../../core/sealed.ts"; |
| 21 | 24 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 22 | 25 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| @@ -115,6 +118,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 115 | 118 | /** Why planning is paused, for the autonomous apps where it is; as on the board. */ |
| 116 | 119 | let holds = new Map<string, string>(); |
| 117 | 120 | let forge: string[] | undefined; |
| 121 | /** A client's apps, one sentence each on whether its agents work or wait (#1812). */ | |
| 122 | let clientLines: string[] = []; | |
| 118 | 123 | /** The check lane, as the board's strip says it; null when nothing is queued. */ |
| 119 | 124 | let lane: string | null = null; |
| 120 | 125 | const filter = new Filter(); |
| @@ -126,6 +131,19 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 126 | 131 | }; |
| 127 | 132 | let focus: PaneId = "repos"; |
| 128 | 133 | |
| 134 | const loadClientLines = async (): Promise<void> => { | |
| 135 | clientLines = []; | |
| 136 | if (!f.user || (await nextStepsFor(f.app, f.user)) === undefined) return; | |
| 137 | const apps = await clientAppRows(f.app, f.user); | |
| 138 | clientLines = | |
| 139 | apps.length === 0 | |
| 140 | ? ["Your apps will appear here."] | |
| 141 | : apps.map( | |
| 142 | (a) => `${a.repo.name}: ${agentsLine(a.working, a.queued)}`, | |
| 143 | ); | |
| 144 | ctx.invalidate(); | |
| 145 | }; | |
| 146 | ||
| 129 | 147 | const reload = (): void => { |
| 130 | 148 | // The server remembers the *choice* and nothing about the laptop, so |
| 131 | 149 | // this is a list of decisions — never a status that would be stale. |
| @@ -489,7 +507,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 489 | 507 | // functions of the outbox, so one subscription refreshes all of them. |
| 490 | 508 | live(f, signal, {}, reload); |
| 491 | 509 | reload(); |
| 492 | return Promise.resolve(); | |
| 510 | return loadClientLines(); | |
| 493 | 511 | }, |
| 494 | 512 | render(area, s) { |
| 495 | 513 | const c = ctx.theme.c; |
| @@ -497,10 +515,24 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 497 | 515 | // A site admin's forge strip, as the board's: "All fine" or a line |
| 498 | 516 | // per cause. Anyone else gets no rows and no reads. |
| 499 | 517 | let body = area; |
| 518 | if (clientLines.length > 0) { | |
| 519 | const lines = clientLines.slice(0, 5); | |
| 520 | const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]); | |
| 521 | lines.forEach((line, i) => | |
| 522 | s.text( | |
| 523 | strip!.x, | |
| 524 | strip!.y + i, | |
| 525 | truncate(line, strip!.w), | |
| 526 | style(c.fg, c.bg), | |
| 527 | strip!.x + strip!.w, | |
| 528 | ), | |
| 529 | ); | |
| 530 | body = rest!; | |
| 531 | } | |
| 500 | 532 | if (forge !== undefined) { |
| 501 | 533 | const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3); |
| 502 | 534 | if (lane !== null) lines.push(`Lane: ${lane}`); |
| 503 | const [strip, rest] = split(area, "v", [len(lines.length), fill(1)]); | |
| 535 | const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]); | |
| 504 | 536 | lines.forEach((line, i) => |
| 505 | 537 | s.text( |
| 506 | 538 | strip!.x, |
src/web/pages/next-steps.ts +9 −1
| @@ -84,8 +84,8 @@ function stepRow(step: NextStep, primary: boolean, body: Html | null): Html { | ||
| 84 | 84 | export async function nextStepsCard( |
| 85 | 85 | ctx: Ctx, |
| 86 | 86 | user: User, |
| 87 | steps: readonly NextStep[] | undefined, | |
| 87 | 88 | ): Promise<Html | null> { |
| 88 | const steps = await nextStepsFor(ctx.app, user); | |
| 89 | 89 | if (!steps || steps.every((s) => s.done)) return null; |
| 90 | 90 | const repos = await spawnedRepos(ctx.app, user); |
| 91 | 91 | const crew = crewAtWork(ctx, repos); |
| @@ -134,3 +134,11 @@ export async function nextStepsCard( | ||
| 134 | 134 | { id: "next-steps", head: html`<h2>Your next steps</h2>` }, |
| 135 | 135 | ); |
| 136 | 136 | } |
| 137 | ||
| 138 | /** The steps once per request, for the card and for the lean home to share. */ | |
| 139 | export function nextStepsOf( | |
| 140 | ctx: Ctx, | |
| 141 | user: User, | |
| 142 | ): Promise<readonly NextStep[] | undefined> { | |
| 143 | return nextStepsFor(ctx.app, user); | |
| 144 | } |
src/web/studio/client_home.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * A client's first screen (#1812): signed in on a forge that was made for | |
| 3 | * them, the home is their apps, each in one plain sentence and one more that | |
| 4 | * says whether its agents are working or waiting. No map, board or settings | |
| 5 | * until asked (`/?all=1` is the whole home). Words only a developer knows | |
| 6 | * stay off it; `test/client-home.test.ts` holds the list. | |
| 7 | */ | |
| 8 | ||
| 9 | import { clientAppRows } from "../../core/client-apps.ts"; | |
| 10 | import type { User } from "../../core/users.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { repoBase } from "../chrome.ts"; | |
| 13 | import { html, type Html } from "../html.ts"; | |
| 14 | import { card } from "../ui.ts"; | |
| 15 | ||
| 16 | /** The sentence about the agents, from how many are going and how many are lined up. */ | |
| 17 | export function agentsLine(working: number, queued: number): string { | |
| 18 | if (working > 0) | |
| 19 | return working === 1 | |
| 20 | ? "One agent is working on it now." | |
| 21 | : `${working} agents are working on it now.`; | |
| 22 | if (queued > 0) return "Its agents are about to start."; | |
| 23 | return "Its agents are waiting for something to do."; | |
| 24 | } | |
| 25 | ||
| 26 | /** The card of apps; an empty one says they will appear. The caller decides who is a client. */ | |
| 27 | export async function clientApps(ctx: Ctx, user: User): Promise<Html> { | |
| 28 | const apps = await clientAppRows(ctx.app, user); | |
| 29 | const rows = apps.map(({ repo, working, queued }) => { | |
| 30 | const about = repo.description !== "" ? repo.description : "An app of yours."; | |
| 31 | return html`<li class="client-app"> | |
| 32 | <a href="${repoBase(repo)}"><b>${repo.name}</b></a> | |
| 33 | <span class="client-app-about">${about}</span> | |
| 34 | <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}" | |
| 35 | >${agentsLine(working, queued)}</span | |
| 36 | > | |
| 37 | </li>`; | |
| 38 | }); | |
| 39 | return card( | |
| 40 | rows.length === 0 | |
| 41 | ? html`<p class="muted">Your apps will appear here.</p>` | |
| 42 | : html`<ul class="list client-apps"> | |
| 43 | ${rows} | |
| 44 | </ul>`, | |
| 45 | { id: "client-apps", head: html`<h2>Your apps</h2>` }, | |
| 46 | ); | |
| 47 | } |
src/web/studio/home.ts +30 −2
| @@ -50,8 +50,9 @@ import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; | ||
| 50 | 50 | import { homeTabs } from "./home_tabs.ts"; |
| 51 | 51 | import { crewKeyNote } from "./crew.ts"; |
| 52 | 52 | import { everydayCard } from "../pages/everyday.ts"; |
| 53 | import { nextStepsCard } from "../pages/next-steps.ts"; | |
| 53 | import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; | |
| 54 | 54 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| 55 | import { clientApps } from "./client_home.ts"; | |
| 55 | 56 | |
| 56 | 57 | /** The events that can change what is on you. */ |
| 57 | 58 | const ON_YOU_LIVE = |
| @@ -185,9 +186,36 @@ export async function studioHome( | ||
| 185 | 186 | ); |
| 186 | 187 | // On a spawned forge its owner's first steps come first, and carry the |
| 187 | 188 | // everyday account themselves (#600); anywhere else, #803's card asks. |
| 188 | const firstSteps = await nextStepsCard(ctx, user); | |
| 189 | const steps = await nextStepsOf(ctx, user); | |
| 190 | const firstSteps = await nextStepsCard(ctx, user, steps); | |
| 191 | // Its owner sees their apps first and nothing else until they ask (#1812). | |
| 192 | if ( | |
| 193 | ctx.url.searchParams.get("all") === null && | |
| 194 | steps !== undefined | |
| 195 | ) { | |
| 196 | const apps = await clientApps(ctx, user); | |
| 197 | const lean = html`<h1 class="sr-only">Home</h1> | |
| 198 | ${firstSteps} ${address} ${crewKeyNote(ctx)} ${apps} | |
| 199 | <div class="stack"> | |
| 200 | ${composer(ctx, user)} | |
| 201 | <p class="small muted"> | |
| 202 | <a href="/?all=1">Show everything</a> | |
| 203 | </p> | |
| 204 | </div>`; | |
| 205 | return page({ | |
| 206 | ctx, | |
| 207 | title: "Home", | |
| 208 | body: lean, | |
| 209 | }); | |
| 210 | } | |
| 189 | 211 | const body = html`<h1 class="sr-only">Home</h1> |
| 190 | 212 | ${firstSteps ?? everydayCard(ctx, user)} ${address} |
| 213 | ${when( | |
| 214 | steps, | |
| 215 | () => html`<p class="small muted" id="lean-home"> | |
| 216 | <a href="/">Just my apps</a> | |
| 217 | </p>`, | |
| 218 | )} | |
| 191 | 219 | ${homeTabs("feed")} |
| 192 | 220 | <div class="dash"> |
| 193 | 221 | <div class="dash-main stack"> |
test/client-home.test.ts added +285
| @@ -0,0 +1,285 @@ | ||
| 1 | /** | |
| 2 | * A client's first screen (#1812): signed in through a child forge's claim | |
| 3 | * link, a person sees their apps, one plain line each and whether its agents | |
| 4 | * work or wait; no map, board or settings until asked, and none of the | |
| 5 | * developer's words. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { createSession } from "../src/core/sessions.ts"; | |
| 17 | import { linkEveryday } from "../src/core/everyday.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { commitFiles } from "../src/git/write.ts"; | |
| 20 | import { repoDir } from "../src/git/repos.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 23 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 24 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | ||
| 31 | const PASSWORD = "a-long-first-login-passphrase"; | |
| 32 | const FORGE_JSON = JSON.stringify({ | |
| 33 | app: { run: "bun run index.ts", health: "/" }, | |
| 34 | }); | |
| 35 | ||
| 36 | describe("a client's first screen", () => { | |
| 37 | let dir = ""; | |
| 38 | let parent: App; | |
| 39 | let parentServers: Servers; | |
| 40 | let child: App; | |
| 41 | let servers: Servers; | |
| 42 | let owner = ""; | |
| 43 | ||
| 44 | const url = (path: string) => `${servers.url}${path}`; | |
| 45 | const page = async (cookie: string, path = "/") => | |
| 46 | (await (await fetch(url(path), { headers: { cookie } })).text()).replace( | |
| 47 | /\s+/g, | |
| 48 | " ", | |
| 49 | ); | |
| 50 | const post = (cookie: string, path: string, form: Record<string, string>) => | |
| 51 | fetch(url(path), { | |
| 52 | method: "POST", | |
| 53 | headers: { cookie, origin: servers.url }, | |
| 54 | body: new URLSearchParams(form), | |
| 55 | redirect: "manual", | |
| 56 | }); | |
| 57 | ||
| 58 | beforeAll(async () => { | |
| 59 | dir = await mkdtemp(join(tmpdir(), "forge-first-login-")); | |
| 60 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 61 | const made = await createUser(parent.db, { | |
| 62 | trusted: true, | |
| 63 | username: "alice", | |
| 64 | email: "[email protected]", | |
| 65 | password: PASSWORD, | |
| 66 | isAdmin: true, | |
| 67 | }); | |
| 68 | if (!made.ok) throw new Error("user"); | |
| 69 | const alice = made.value; | |
| 70 | const repo = await createRepo(parent, alice, { name: "bakery" }); | |
| 71 | if (!repo.ok) throw new Error("repo"); | |
| 72 | const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), { | |
| 73 | branch: "main", | |
| 74 | message: "bakery", | |
| 75 | files: [ | |
| 76 | { path: "forge.json", content: FORGE_JSON }, | |
| 77 | { | |
| 78 | path: "index.ts", | |
| 79 | content: "Bun.serve({fetch(){return new Response('hi')}})\n", | |
| 80 | }, | |
| 81 | ], | |
| 82 | author: { name: "a", email: "[email protected]" }, | |
| 83 | }); | |
| 84 | if (!c.ok) throw new Error("commit"); | |
| 85 | const atlas = await createUser(parent.db, { | |
| 86 | username: "atlas", | |
| 87 | email: "[email protected]", | |
| 88 | agent: true, | |
| 89 | }); | |
| 90 | if (!atlas.ok) throw new Error("agent"); | |
| 91 | createAgentRow(parent.db, { | |
| 92 | userId: atlas.value.id, | |
| 93 | brief: "plan", | |
| 94 | createdBy: alice.id, | |
| 95 | }); | |
| 96 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); | |
| 97 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 98 | parentServers = await serve(parent); | |
| 99 | const spawned = await spawnForge( | |
| 100 | parent, | |
| 101 | alice, | |
| 102 | { | |
| 103 | name: "maria", | |
| 104 | org: "marias", | |
| 105 | apps: ["alice/bakery"], | |
| 106 | carryBriefs: true, | |
| 107 | carrySubscriptions: false, | |
| 108 | direction: "Run Maria's bakery.", | |
| 109 | }, | |
| 110 | { | |
| 111 | catalog: [ | |
| 112 | { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 }, | |
| 113 | ], | |
| 114 | briefs: [{ agent: "atlas", text: "plan" }], | |
| 115 | subscriptions: [], | |
| 116 | }, | |
| 117 | { | |
| 118 | placement: hostedPlacement({ host: "forge.test" }), | |
| 119 | request: new Request("https://forge.test/x", { | |
| 120 | method: "POST", | |
| 121 | headers: { origin: "https://forge.test" }, | |
| 122 | }), | |
| 123 | url: new URL("https://forge.test/x"), | |
| 124 | }, | |
| 125 | ); | |
| 126 | if (!spawned.ok) throw new Error(JSON.stringify(spawned.error)); | |
| 127 | child = createApp({ | |
| 128 | dataDir: childDirOf(parent, spawned.value.bodyId), | |
| 129 | port: 0, | |
| 130 | }); | |
| 131 | await firstBoot(child); | |
| 132 | servers = await serve(child); | |
| 133 | const link = spawned.value.claimUrl!; | |
| 134 | const token = link.split("/claim#")[1]!; | |
| 135 | const claimed = await fetch(url("/claim"), { | |
| 136 | method: "POST", | |
| 137 | headers: { origin: servers.url }, | |
| 138 | body: new URLSearchParams({ | |
| 139 | token, | |
| 140 | username: "maria", | |
| 141 | email: "[email protected]", | |
| 142 | password: PASSWORD, | |
| 143 | }), | |
| 144 | redirect: "manual", | |
| 145 | }); | |
| 146 | if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`); | |
| 147 | owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 148 | }, 60_000); | |
| 149 | ||
| 150 | afterAll(async () => { | |
| 151 | servers.stop(); | |
| 152 | child.close(); | |
| 153 | parentServers.stop(); | |
| 154 | parent.close(); | |
| 155 | await rm(dir, { recursive: true, force: true }); | |
| 156 | }); | |
| 157 | ||
| 158 | /** What the page itself says, below the bar every page has. */ | |
| 159 | const main = (html: string): string => html.slice(html.indexOf("<main")); | |
| 160 | const words = (html: string): string => | |
| 161 | main(html) | |
| 162 | .replace(/<(script|style)[\s\S]*?<\/\1>/g, " ") | |
| 163 | .replace(/<[^>]*>/g, " ") | |
| 164 | .replace(/\s+/g, " "); | |
| 165 | ||
| 166 | test("the home is their apps, each a sentence and a line on its agents, with no map, board or settings", async () => { | |
| 167 | const home = main(await page(owner)); | |
| 168 | const apps = home.slice(home.indexOf('id="client-apps"')); | |
| 169 | expect(apps).toContain('href="/marias/bakery"'); | |
| 170 | expect(apps).toMatch(/data-agents="(working|waiting)"/); | |
| 171 | expect(apps).toContain("agents are waiting"); | |
| 172 | expect(home).not.toContain('id="live-apps"'); | |
| 173 | for (const gone of ['href="/map"', 'href="/board"', 'href="/audit"', 'href="/settings"']) | |
| 174 | expect(home).not.toContain(gone); | |
| 175 | expect(home).toContain('href="/?all=1"'); | |
| 176 | }); | |
| 177 | ||
| 178 | test("a running agent turns the line to working", async () => { | |
| 179 | const repo = child.db | |
| 180 | .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'") | |
| 181 | .get()!; | |
| 182 | child.db | |
| 183 | .query("INSERT INTO run (id, repo_id, kind, state) VALUES ('r-client-1', ?1, 'agent', 'running')") | |
| 184 | .run(repo.id); | |
| 185 | const home = await page(owner); | |
| 186 | expect(home).toContain('data-agents="working"'); | |
| 187 | expect(home).toContain("One agent is working on it now."); | |
| 188 | child.db.query("DELETE FROM run WHERE id = 'r-client-1'").run(); | |
| 189 | }); | |
| 190 | ||
| 191 | test("none of the developer's words appear on it, composer included", async () => { | |
| 192 | const text = words(await page(owner)); | |
| 193 | expect(text).toContain("Your apps"); | |
| 194 | for (const word of ["pull", "branch", "commit", "merge", "CI", "repository", "repositories"]) | |
| 195 | expect(text).not.toMatch(new RegExp(`\\b${word}`, "i")); | |
| 196 | }); | |
| 197 | ||
| 198 | test("asked for, the whole home is still there", async () => { | |
| 199 | const home = await page(owner, "/?all=1"); | |
| 200 | expect(home).toContain('id="live-apps"'); | |
| 201 | expect(home).not.toContain('id="client-apps"'); | |
| 202 | }); | |
| 203 | ||
| 204 | test("a plain reader gets the ordinary home", async () => { | |
| 205 | const reader = await createUser(child.db, { | |
| 206 | trusted: true, | |
| 207 | username: "reader", | |
| 208 | email: "[email protected]", | |
| 209 | password: PASSWORD, | |
| 210 | }); | |
| 211 | if (!reader.ok) throw new Error("reader"); | |
| 212 | const session = createSession(child.db, reader.value.id); | |
| 213 | expect(await page(`forge_session=${session.token}`)).not.toContain('id="client-apps"'); | |
| 214 | }); | |
| 215 | ||
| 216 | const visibleWords = async (cookie: string): Promise<string> => { | |
| 217 | const all = main(await page(cookie)); | |
| 218 | // The composer's form; the command palette beside it is the bar's, not the home's. | |
| 219 | const from = all.indexOf('id="composer"'); | |
| 220 | const m = all.slice(from, all.indexOf("</form>", from)); | |
| 221 | const attrs = [...m.matchAll(/\b(?:placeholder|aria-label|title|alt)="([^"]*)"/g)] | |
| 222 | .map((x) => x[1]) | |
| 223 | .join(" "); | |
| 224 | return `${words(m)} ${attrs}`; | |
| 225 | }; | |
| 226 | ||
| 227 | test("the composer's words, labels and placeholders carry none either", async () => { | |
| 228 | const home = await page(owner); | |
| 229 | expect(home).toContain('id="composer"'); | |
| 230 | const text = await visibleWords(owner); | |
| 231 | for (const word of ["pull", "branch", "commit", "merge", "repository", "repositories"]) | |
| 232 | expect(text).not.toMatch(new RegExp(`\\b${word}`, "i")); | |
| 233 | }); | |
| 234 | ||
| 235 | test("the lean home ships no live-update script and the whole home links back to it", async () => { | |
| 236 | expect(await page(owner)).not.toContain("EventSource"); | |
| 237 | const all = await page(owner, "/?all=1"); | |
| 238 | expect(all).toContain('id="lean-home"'); | |
| 239 | expect(all).toContain('href="/"'); | |
| 240 | }); | |
| 241 | ||
| 242 | test("queued agents and no running ones read as about to start", async () => { | |
| 243 | const repo = child.db | |
| 244 | .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'") | |
| 245 | .get()!; | |
| 246 | child.db | |
| 247 | .query("INSERT INTO run (id, repo_id, kind, state) VALUES ('r-client-2', ?1, 'agent', 'queued')") | |
| 248 | .run(repo.id); | |
| 249 | const home = await page(owner); | |
| 250 | expect(home).toContain("Its agents are about to start."); | |
| 251 | expect(home).toContain('data-agents="waiting"'); | |
| 252 | child.db.query("DELETE FROM run WHERE id = 'r-client-2'").run(); | |
| 253 | }); | |
| 254 | ||
| 255 | test("an everyday account that cannot see the apps is told they will appear, and no more", async () => { | |
| 256 | const maria = getUserByName(child.db, "maria")!; | |
| 257 | const daily = await createUser(child.db, { | |
| 258 | trusted: true, | |
| 259 | username: "mariadaily", | |
| 260 | email: "[email protected]", | |
| 261 | password: PASSWORD, | |
| 262 | }); | |
| 263 | if (!daily.ok) throw new Error(JSON.stringify(daily.error)); | |
| 264 | linkEveryday(child.db, maria, daily.value); | |
| 265 | const cookie = `forge_session=${createSession(child.db, daily.value.id).token}`; | |
| 266 | const home = await page(cookie); | |
| 267 | expect(home).toContain('id="client-apps"'); | |
| 268 | expect(home).toContain("Your apps will appear here."); | |
| 269 | expect(home).not.toContain('href="/marias/bakery"'); | |
| 270 | }); | |
| 271 | ||
| 272 | test("the terminal's first screen says the same about each app", async () => { | |
| 273 | const maria = getUserByName(child.db, "maria")!; | |
| 274 | const terminal = headlessTerminal(120, 30); | |
| 275 | const { app } = createForgeApp( | |
| 276 | child, | |
| 277 | { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: maria }, | |
| 278 | { terminal, exitHooks: false }, | |
| 279 | ); | |
| 280 | app.start(); | |
| 281 | await settleApp(app); | |
| 282 | expect(app.screen.toText()).toContain("bakery: Its agents are waiting for something to do."); | |
| 283 | app.stop(); | |
| 284 | }); | |
| 285 | }); |
new above
offline
Apps past five vanish without a hint. Add a "+N more" line so the terminal does not look like it has fewer apps than the web.