Public: anyone can read this; the crew can change it.
The terminal's deploys screen lists an app's previews under production: one row per open pull with its state and address, Enter opening the pull #7485
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7475-the-terminal-s-deploys-screen-lists-an-a → main 4 commits fork
7 files
changed +451
−37
docs/APPS.md +7 −1
| @@ -180,7 +180,13 @@ Under the box, four starter sentences are plain links (no script): | ||
| 180 | 180 | (each with what it carries), `enter` for the log, `r` to refresh. A signed-in |
| 181 | 181 | reader also sees `Production: <url>` and, when a pull's preview is up or |
| 182 | 182 | asleep, `Newest preview: #N <title> <url>` — the pill's other side, same |
| 183 | gate; a guest sees neither. A writer presses `p` there to probe the running app's | |
| 183 | gate; a guest sees neither. Under the deploys, anyone who may read the | |
| 184 | repository sees a **Previews** group: one row per open pull with a preview, | |
| 185 | `#N <title> · <state> · <url>`, the state in the studio's words (`live`, | |
| 186 | `not up`, `starting`…) from the one runtime read the studio's list makes. | |
| 187 | `j` past the last deploy moves into it and `k` from its first row back; | |
| 188 | `enter` (or a second click) on a preview opens that pull. An app with none | |
| 189 | says `No previews.`. A writer presses `p` there to probe the running app's | |
| 184 | 190 | doors as nobody, a user and an admin, read-only — one line per door with |
| 185 | 191 | its status and `ok` or what was wrong, `r` to knock again; the result is |
| 186 | 192 | kept and the mismatch issue opened or closed as for MCP `probe`. On a |
src/actions/apps.ts +25
| @@ -539,6 +539,31 @@ export function appPullLines(app: App, actor: User | undefined, repo: Repo): App | ||
| 539 | 539 | })); |
| 540 | 540 | } |
| 541 | 541 | |
| 542 | export interface AppPreview { | |
| 543 | readonly number: number; | |
| 544 | readonly title: string; | |
| 545 | /** The preview's environment, as the studio's previews list reads it. */ | |
| 546 | readonly env: EnvStatus; | |
| 547 | } | |
| 548 | ||
| 549 | /** | |
| 550 | * The open pulls that have a preview environment, newest first, each with | |
| 551 | * that environment — the studio's previews list (`studio/previews.ts`) for a | |
| 552 | * door that cannot draw a page. One `status()` read for every row, never one | |
| 553 | * per pull. `undefined` when the actor may not read the repository or it is | |
| 554 | * no app, so "no previews" is only ever said to someone who may see them. | |
| 555 | */ | |
| 556 | export function appPreviews(app: App, actor: User | undefined, repo: Repo): AppPreview[] | undefined { | |
| 557 | if (!requireReader(app, actor, repo).ok) return undefined; | |
| 558 | const record = getAppByRepo(app.db, repo.id); | |
| 559 | if (record === undefined) return undefined; | |
| 560 | const envs = new Map(envsFor(app, actor, record).map((e) => [e.env, e] as const)); | |
| 561 | return openPulls(app.db, repo, actor, 50).flatMap((p) => { | |
| 562 | const env = envs.get(previewEnv(p.number)); | |
| 563 | return env === undefined ? [] : [{ number: p.number, title: p.title, env }]; | |
| 564 | }); | |
| 565 | } | |
| 566 | ||
| 542 | 567 | /** |
| 543 | 568 | * The other side of the app for its production terminal, gated as the pill |
| 544 | 569 | * is (an active reader of the repository; a guest gets none): production's |
src/apps/env_phrase.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * An environment's state in a person's words, with the tone it wears — plain | |
| 3 | * data, so the studio (`web/ui.ts` re-exports it) and the terminal's deploys | |
| 4 | * screen say the same thing without the terminal importing a web module. | |
| 5 | */ | |
| 6 | ||
| 7 | import { VERIFYING_NOTE, type EnvStatus } from "./runtime.ts"; | |
| 8 | ||
| 9 | /** The tones an environment's word wears; the web's `Tone` and the terminal's both hold them. */ | |
| 10 | export type EnvPhraseTone = "default" | "success" | "danger" | "attn"; | |
| 11 | ||
| 12 | /** | |
| 13 | * An environment's state in a person's words rather than a badge's — | |
| 14 | * `live`, `serving · tests failed`, `not up`, `not running` — for a head | |
| 15 | * that speaks one line. A card head wears `envStateBadge` instead. How fast | |
| 16 | * it came up belongs to `envFacts`, which sits under this and says it once. | |
| 17 | * | |
| 18 | * A serving app is never called down because its tests are red: the words | |
| 19 | * say the tests failed, and the tone is not green: it answers, but not | |
| 20 | * verified. While they still run it reads `serving · verifying`, neither | |
| 21 | * green nor failed. | |
| 22 | */ | |
| 23 | export function envPhrase(env: EnvStatus | undefined): { | |
| 24 | word: string; | |
| 25 | tone: EnvPhraseTone; | |
| 26 | } { | |
| 27 | if (env?.dormant !== undefined) return { word: "asleep", tone: "default" }; | |
| 28 | if (env === undefined || env.state === "stopped") | |
| 29 | return { word: "not running", tone: "default" }; | |
| 30 | if (env.state === "failed") return { word: "not up", tone: "danger" }; | |
| 31 | if (env.state === "deploying") return { word: "starting", tone: "default" }; | |
| 32 | // It answers, so it is not "not up" — but a newer push never came up | |
| 33 | // behind it, so it is not plain green either. | |
| 34 | if (env.failedDeploy !== undefined) | |
| 35 | return { word: "live · a newer push failed", tone: "attn" }; | |
| 36 | if (env.error !== undefined) | |
| 37 | return { word: "serving · tests failed", tone: "attn" }; | |
| 38 | if (env.note === VERIFYING_NOTE) | |
| 39 | return { word: "serving · verifying", tone: "default" }; | |
| 40 | return { word: "live", tone: "success" }; | |
| 41 | } |
src/tui/screens/deploys.ts +86 −5
| @@ -6,7 +6,14 @@ | ||
| 6 | 6 | * through `listRepoRuns` (so it is gated like every other run read) and |
| 7 | 7 | * joined to `deploy_pull` in one grouped read for `carries #3 #5`. |
| 8 | 8 | * |
| 9 | * Below the deploys, a signed-in viewer sees the app's open pulls, one line | |
| 9 | * Under production's deploys, anyone who may read the repository sees a | |
| 10 | * "Previews" group: one row per open pull with a preview environment, | |
| 11 | * `#N title · <state> · <address>`, the state in the studio's words | |
| 12 | * (`envPhrase`) and tone, all from one runtime `status()` read | |
| 13 | * (`appPreviews`). `j`/`k` walk from the last deploy into it, and Enter on a | |
| 14 | * preview opens that pull's screen. An app with none says `No previews.`. | |
| 15 | * | |
| 16 | * Below those, a signed-in viewer sees the app's open pulls, one line | |
| 10 | 17 | * each: `#N title · check <word> · <preview address or none>`. A guest sees |
| 11 | 18 | * none. |
| 12 | 19 | * |
| @@ -36,14 +43,17 @@ | ||
| 36 | 43 | |
| 37 | 44 | import { |
| 38 | 45 | appPace, |
| 46 | appPreviews, | |
| 39 | 47 | appPullLines, |
| 40 | 48 | appSideLines, |
| 41 | 49 | appView, |
| 42 | 50 | appVisitors, |
| 43 | 51 | redeployEnv, |
| 44 | 52 | stopEnv, |
| 53 | type AppPreview, | |
| 45 | 54 | type AppPullLine, |
| 46 | 55 | } from "../../actions/apps.ts"; |
| 56 | import { envPhrase } from "../../apps/env_phrase.ts"; | |
| 47 | 57 | import { |
| 48 | 58 | addAppDomain, |
| 49 | 59 | checkAppDomain, |
| @@ -75,6 +85,7 @@ import type { Run } from "../../core/runs.ts"; | ||
| 75 | 85 | import type { Ctx, Screen } from "../app/app.ts"; |
| 76 | 86 | import { binding } from "../app/keys.ts"; |
| 77 | 87 | import type { Session } from "../session.ts"; |
| 88 | import type { Rect } from "../term/screen.ts"; | |
| 78 | 89 | import { style } from "../term/style.ts"; |
| 79 | 90 | import { truncate } from "../term/width.ts"; |
| 80 | 91 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -91,6 +102,9 @@ import { | ||
| 91 | 102 | type ListRow, |
| 92 | 103 | } from "./common.ts"; |
| 93 | 104 | import { RunList, openRunLog, runRows } from "./runlist.ts"; |
| 105 | import { envTone } from "./runwords.ts"; | |
| 106 | import { pullScreen } from "./pull.ts"; | |
| 107 | import { local } from "./where.ts"; | |
| 94 | 108 | import { appMembersScreen } from "./appmembers.ts"; |
| 95 | 109 | |
| 96 | 110 | const LIMIT = 50; |
| @@ -131,6 +145,17 @@ function pullLine(p: AppPullLine): string { | ||
| 131 | 145 | return `#${p.number} ${title} · check ${p.check} · ${p.preview ?? "none"}`; |
| 132 | 146 | } |
| 133 | 147 | |
| 148 | /** Previews drawn before the group scrolls. */ | |
| 149 | const PREVIEW_ROWS = 6; | |
| 150 | ||
| 151 | /** `#N title · live · https://app--4.apps.example`, the dot in the studio's tone. */ | |
| 152 | export function previewRow(p: AppPreview): ListRow { | |
| 153 | const title = p.title.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "").trim(); | |
| 154 | const { word, tone } = envPhrase(p.env); | |
| 155 | const t = envTone(tone); | |
| 156 | return { title: `#${p.number} ${title} · ${word} · ${p.env.url}`, ...(t === undefined ? {} : { tone: t }) }; | |
| 157 | } | |
| 158 | ||
| 134 | 159 | /** |
| 135 | 160 | * "Reached, not declared": the hosts the app's code called that `forge.json` |
| 136 | 161 | * does not declare, as the app's page says them, then the line to add them. |
| @@ -161,6 +186,11 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 161 | 186 | let side: string[] = []; |
| 162 | 187 | let egress: string[] = []; |
| 163 | 188 | let pulls: AppPullLine[] = []; |
| 189 | /** The app's previews, or undefined for anyone who may not see them. */ | |
| 190 | let previews: AppPreview[] | undefined; | |
| 191 | const previewState = new ListState(); | |
| 192 | /** Which list the cursor is in: production's deploys, or the previews under them. */ | |
| 193 | let inPreviews = false; | |
| 164 | 194 | let production: Production = { state: "", redeploy: false, stop: false }; |
| 165 | 195 | let members = false; |
| 166 | 196 | /** The app's domains, or undefined for anyone `listAppDomains` refuses. */ |
| @@ -180,6 +210,9 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 180 | 210 | side = appSideLines(f.app, f.user, repo); |
| 181 | 211 | egress = egressLines(f, repo); |
| 182 | 212 | pulls = appPullLines(f.app, f.user, repo); |
| 213 | previews = appPreviews(f.app, f.user, repo); | |
| 214 | previewState.clamp(previews?.length ?? 0); | |
| 215 | if ((previews?.length ?? 0) === 0) inPreviews = false; | |
| 183 | 216 | production = readProduction(f, repo); |
| 184 | 217 | // The action's gate: a person who can write, never an agent. |
| 185 | 218 | members = |
| @@ -194,6 +227,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 194 | 227 | }; |
| 195 | 228 | |
| 196 | 229 | const open = (run: Run): void => openRunLog(f, ctx, repo, run); |
| 230 | const openPreview = (p: AppPreview): void => ctx.push(pullScreen(f, ctx, local(f, repo), p.number)); | |
| 197 | 231 | // The MCP tool's gate, read once: only a writer is offered the knock. |
| 198 | 232 | const mayProbe = canWrite(f.db, repo, f.user); |
| 199 | 233 | |
| @@ -237,7 +271,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 237 | 271 | get keys() { |
| 238 | 272 | return [ |
| 239 | 273 | binding(["j", "k"], "move"), |
| 240 | binding(["enter"], "open the log"), | |
| 274 | binding(["enter"], inPreviews ? "open the pull" : "open the log"), | |
| 241 | 275 | binding(["r"], "refresh (the open pulls below the deploys: check, preview)"), |
| 242 | 276 | ...(production.redeploy ? [binding(["D"], "redeploy production")] : []), |
| 243 | 277 | ...(production.stop ? [binding(["S"], "stop production")] : []), |
| @@ -250,7 +284,30 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 250 | 284 | render(area, s) { |
| 251 | 285 | const c = ctx.theme.c; |
| 252 | 286 | const named = domainsLine(domains); |
| 253 | const [head, upper, tail] = split(area, "v", [ | |
| 287 | const drawPreviews = (r: Rect): void => { | |
| 288 | s.text(r.x, r.y, truncate("Previews", r.w), style(c.mutedFg, c.bg), r.x + r.w); | |
| 289 | const body = { x: r.x, y: r.y + 1, w: r.w, h: r.h - 1 }; | |
| 290 | if (previews === undefined || previews.length === 0) { | |
| 291 | s.text(body.x, body.y, truncate("No previews.", body.w), style(c.mutedFg, c.bg), body.x + body.w); | |
| 292 | return; | |
| 293 | } | |
| 294 | const rows = previews.map(previewRow); | |
| 295 | drawList(s, body, c, previewState, rows, inPreviews); | |
| 296 | hitList(ctx, body, previewState, rows.length, { | |
| 297 | select: () => { | |
| 298 | inPreviews = true; | |
| 299 | ctx.invalidate(); | |
| 300 | }, | |
| 301 | open: (index) => { | |
| 302 | const p = previews?.[index]; | |
| 303 | if (p === undefined) return; | |
| 304 | inPreviews = true; | |
| 305 | openPreview(p); | |
| 306 | }, | |
| 307 | }); | |
| 308 | }; | |
| 309 | const shown = previews === undefined ? 0 : Math.max(1, Math.min(previews.length, PREVIEW_ROWS)); | |
| 310 | const [head, upper, middle, tail] = split(area, "v", [ | |
| 254 | 311 | len( |
| 255 | 312 | 1 + |
| 256 | 313 | (visitors === "" ? 0 : 1) + |
| @@ -261,9 +318,11 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 261 | 318 | (named === "" ? 0 : 1), |
| 262 | 319 | ), |
| 263 | 320 | fill(1), |
| 321 | len(previews === undefined ? 0 : shown + 1), | |
| 264 | 322 | len(pulls.length === 0 ? 0 : pulls.length + 1), |
| 265 | 323 | ]); |
| 266 | 324 | const body = upper; |
| 325 | if (previews !== undefined) drawPreviews(middle!); | |
| 267 | 326 | pulls.forEach((p, i) => |
| 268 | 327 | s.text( |
| 269 | 328 | tail!.x, |
| @@ -305,16 +364,38 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 305 | 364 | ? row |
| 306 | 365 | : { ...row, secondary: [row.secondary, text].filter((t) => t !== "").join(" · ") }; |
| 307 | 366 | }); |
| 308 | drawList(s, body!, c, list.state, rows); | |
| 367 | drawList(s, body!, c, list.state, rows, !inPreviews); | |
| 309 | 368 | hitList(ctx, body!, list.state, rows.length, { |
| 369 | select: () => { | |
| 370 | inPreviews = false; | |
| 371 | ctx.invalidate(); | |
| 372 | }, | |
| 310 | 373 | open: (index) => { |
| 374 | inPreviews = false; | |
| 311 | 375 | const run = list.runs[index]; |
| 312 | 376 | if (run !== undefined) open(run); |
| 313 | 377 | }, |
| 314 | 378 | }); |
| 315 | 379 | }, |
| 316 | 380 | key(e) { |
| 317 | if (list.state.key(e, list.runs.length)) return true; | |
| 381 | const n = previews?.length ?? 0; | |
| 382 | const down = !e.ctrl && !e.alt && (e.key === "j" || e.key === "down"); | |
| 383 | const up = !e.ctrl && !e.alt && (e.key === "k" || e.key === "up"); | |
| 384 | if (!inPreviews && down && n > 0 && list.state.index >= list.runs.length - 1) { | |
| 385 | inPreviews = true; | |
| 386 | previewState.index = 0; | |
| 387 | return true; | |
| 388 | } | |
| 389 | if (inPreviews && up && previewState.index === 0) { | |
| 390 | inPreviews = list.runs.length === 0; | |
| 391 | return true; | |
| 392 | } | |
| 393 | if (inPreviews ? previewState.key(e, n) : list.state.key(e, list.runs.length)) return true; | |
| 394 | if (e.key === "enter" && inPreviews) { | |
| 395 | const p = previews?.[previewState.index]; | |
| 396 | if (p !== undefined) openPreview(p); | |
| 397 | return true; | |
| 398 | } | |
| 318 | 399 | if (e.key === "enter") { |
| 319 | 400 | const run = list.selected; |
| 320 | 401 | if (run !== undefined) open(run); |
src/tui/screens/runwords.ts +6
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * neither imports the other. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { EnvPhraseTone } from "../../apps/env_phrase.ts"; | |
| 7 | 8 | import type { RunState } from "../../core/runs.ts"; |
| 8 | 9 | import type { Tone } from "../ui/widgets.ts"; |
| 9 | 10 | |
| @@ -32,3 +33,8 @@ export function envLabel(env: string): string { | ||
| 32 | 33 | export function costText(costUsd: number): string { |
| 33 | 34 | return costUsd > 0 ? `$${costUsd.toFixed(2)}` : ""; |
| 34 | 35 | } |
| 36 | ||
| 37 | /** The tone an environment's word wears (`envPhrase`): the studio's, with its plain default left neutral as a queued run is. */ | |
| 38 | export function envTone(tone: EnvPhraseTone): Tone | undefined { | |
| 39 | return tone === "default" ? undefined : tone; | |
| 40 | } |
src/web/ui.ts +2 −31
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * reaches for an inline `style` attribute, because the CSP forbids it. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { VERIFYING_NOTE, type EnvState, type EnvStatus } from "../apps/runtime.ts"; | |
| 13 | import { type EnvState, type EnvStatus } from "../apps/runtime.ts"; | |
| 14 | 14 | import type { PreviewData } from "../apps/data.ts"; |
| 15 | 15 | import type { Mark } from "../core/marks.ts"; |
| 16 | 16 | import type { RunKind, RunState } from "../core/runs.ts"; |
| @@ -413,36 +413,7 @@ export function envTone(state: EnvState | undefined): Tone { | ||
| 413 | 413 | : "default"; |
| 414 | 414 | } |
| 415 | 415 | |
| 416 | /** | |
| 417 | * An environment's state in a person's words rather than a badge's — | |
| 418 | * `live`, `serving · tests failed`, `not up`, `not running` — for a head | |
| 419 | * that speaks one line. A card head wears `envStateBadge` instead. How fast | |
| 420 | * it came up belongs to `envFacts`, which sits under this and says it once. | |
| 421 | * | |
| 422 | * A serving app is never called down because its tests are red: the words | |
| 423 | * say the tests failed, and the tone is not green: it answers, but not | |
| 424 | * verified. While they still run it reads `serving · verifying`, neither | |
| 425 | * green nor failed. | |
| 426 | */ | |
| 427 | export function envPhrase(env: EnvStatus | undefined): { | |
| 428 | word: string; | |
| 429 | tone: Tone; | |
| 430 | } { | |
| 431 | if (env?.dormant !== undefined) return { word: "asleep", tone: "default" }; | |
| 432 | if (env === undefined || env.state === "stopped") | |
| 433 | return { word: "not running", tone: "default" }; | |
| 434 | if (env.state === "failed") return { word: "not up", tone: "danger" }; | |
| 435 | if (env.state === "deploying") return { word: "starting", tone: "default" }; | |
| 436 | // It answers, so it is not "not up" — but a newer push never came up | |
| 437 | // behind it, so it is not plain green either. | |
| 438 | if (env.failedDeploy !== undefined) | |
| 439 | return { word: "live · a newer push failed", tone: "attn" }; | |
| 440 | if (env.error !== undefined) | |
| 441 | return { word: "serving · tests failed", tone: "attn" }; | |
| 442 | if (env.note === VERIFYING_NOTE) | |
| 443 | return { word: "serving · verifying", tone: "default" }; | |
| 444 | return { word: "live", tone: "success" }; | |
| 445 | } | |
| 416 | export { envPhrase } from "../apps/env_phrase.ts"; | |
| 446 | 417 | |
| 447 | 418 | export interface EnvFactsOptions { |
| 448 | 419 | /** Name the environment's address in full — a card has room for it, a |
test/tui-deploys-previews.test.ts added +284
| @@ -0,0 +1,284 @@ | ||
| 1 | /** The deploys screen's Previews group: one row per open pull's preview, Enter opening the pull. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { appPreviews } from "../src/actions/apps.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { signUp } from "../src/actions/users.ts"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 12 | import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import type { User } from "../src/core/users.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { deploysScreen, previewRow } from "../src/tui/screens/deploys.ts"; | |
| 17 | import { envTone } from "../src/tui/screens/runwords.ts"; | |
| 18 | import { createRun } from "../src/core/runs.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dir: string; | |
| 28 | let forge: Forge; | |
| 29 | let runtime: AppsRuntime; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | /** Every `status()` read the runtime answered, by app slug. */ | |
| 33 | const reads: string[] = []; | |
| 34 | /** What `status()` answers, by app slug. */ | |
| 35 | const serving = new Map<string, EnvStatus[]>(); | |
| 36 | ||
| 37 | const env = (e: Partial<EnvStatus> & Pick<EnvStatus, "env" | "state" | "url">): EnvStatus => ({ commitOid: "a".repeat(40), ...e }) as EnvStatus; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dir = await mkdtemp(join(tmpdir(), "forge-tui-deploys-previews-")); | |
| 41 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 42 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 43 | if (!a.ok) throw new Error("signup failed"); | |
| 44 | alice = a.value; | |
| 45 | const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, { slot: fixtureSlot(forge) }); | |
| 46 | if (!b.ok) throw new Error("signup failed"); | |
| 47 | bob = b.value; | |
| 48 | runtime = new AppsRuntime(forge, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 }); | |
| 49 | runtime.status = (record: AppRecord) => { | |
| 50 | reads.push(record.slug); | |
| 51 | return serving.get(record.slug) ?? []; | |
| 52 | }; | |
| 53 | }); | |
| 54 | afterAll(async () => { | |
| 55 | await runtime.stop(); | |
| 56 | forge.close?.(); | |
| 57 | await rm(dir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | /** An app on a fresh repository, with open pulls numbered and titled as given. */ | |
| 61 | async function appWith(name: string, visibility: "public" | "private", pulls: [number, string][]): Promise<Repo> { | |
| 62 | const r = await createRepo(forge, alice, { name, visibility }); | |
| 63 | if (!r.ok) throw new Error("repo failed"); | |
| 64 | const made = createAppRecord(forge.db, { repoId: r.value.id, slug: name, access: "public" }); | |
| 65 | if (!made.ok) throw new Error(made.error); | |
| 66 | for (const [n, title] of pulls) { | |
| 67 | const id = crypto.randomUUID(); | |
| 68 | forge.db | |
| 69 | .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?, ?, ?, 'pull', ?, ?)") | |
| 70 | .run(id, r.value.id, n, title, alice.id); | |
| 71 | forge.db | |
| 72 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', 'h')") | |
| 73 | .run(id, `b${n}`); | |
| 74 | } | |
| 75 | return r.value; | |
| 76 | } | |
| 77 | ||
| 78 | /** The deploys screen held open: keys, clicks by a row's text, and what Enter says it does. */ | |
| 79 | async function hold(who: User, of: Repo) { | |
| 80 | const terminal = headlessTerminal(140, 40); | |
| 81 | const { app, session } = createForgeApp( | |
| 82 | forge, | |
| 83 | { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who }, | |
| 84 | { terminal, exitHooks: false }, | |
| 85 | ); | |
| 86 | app.start(); | |
| 87 | await settleApp(app); | |
| 88 | const screen = deploysScreen(session, app.ctx, of); | |
| 89 | app.push(screen); | |
| 90 | await settleApp(app); | |
| 91 | const text = (): string => app.screen.toText(); | |
| 92 | return { | |
| 93 | text, | |
| 94 | enterHint: (): string | undefined => screen.keys.find((b) => b.keys.includes("enter"))?.help, | |
| 95 | async press(...keys: string[]): Promise<void> { | |
| 96 | for (const k of keys) { | |
| 97 | terminal.feed(k === "enter" ? "\r" : k); | |
| 98 | await settleApp(app); | |
| 99 | } | |
| 100 | }, | |
| 101 | /** Click once on the grid row holding `what`, a little in from its left edge. */ | |
| 102 | async click(what: string): Promise<void> { | |
| 103 | const row = text().split("\n").findIndex((line) => line.includes(what)); | |
| 104 | if (row < 0) throw new Error(`no row holds ${what}`); | |
| 105 | terminal.feed(`\x1b[<0;5;${row + 1}M`); | |
| 106 | terminal.feed(`\x1b[<0;5;${row + 1}m`); | |
| 107 | await settleApp(app); | |
| 108 | }, | |
| 109 | stop: (): void => app.stop(), | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 113 | async function drive(who: User, of: Repo, keys: string[] = []): Promise<string> { | |
| 114 | const terminal = headlessTerminal(140, 30); | |
| 115 | const { app, session } = createForgeApp( | |
| 116 | forge, | |
| 117 | { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who }, | |
| 118 | { terminal, exitHooks: false }, | |
| 119 | ); | |
| 120 | app.start(); | |
| 121 | await settleApp(app); | |
| 122 | app.push(deploysScreen(session, app.ctx, of)); | |
| 123 | await settleApp(app); | |
| 124 | for (const k of keys) { | |
| 125 | terminal.feed(k === "enter" ? "\r" : k); | |
| 126 | await settleApp(app); | |
| 127 | } | |
| 128 | const text = app.screen.toText(); | |
| 129 | app.stop(); | |
| 130 | return text; | |
| 131 | } | |
| 132 | ||
| 133 | test("two previews, one running and one failed, draw a row each in the studio's words, from one status read", async () => { | |
| 134 | const repo = await appWith("ferry", "public", [ | |
| 135 | [3, "Paint the hull"], | |
| 136 | [4, "Fix the rudder"], | |
| 137 | [5, "No preview yet"], | |
| 138 | ]); | |
| 139 | serving.set("ferry", [ | |
| 140 | env({ env: "production", state: "running", url: "https://ferry.apps.test" }), | |
| 141 | env({ env: "pull-3", state: "running", url: "https://ferry--3.apps.test" }), | |
| 142 | env({ env: "pull-4", state: "failed", url: "https://ferry--4.apps.test", error: "exit 1" }), | |
| 143 | ]); | |
| 144 | ||
| 145 | reads.length = 0; | |
| 146 | const listed = appPreviews(forge, alice, repo); | |
| 147 | expect(reads).toEqual(["ferry"]); | |
| 148 | expect(listed?.map((p) => [p.number, p.env.state])).toEqual([ | |
| 149 | [4, "failed"], | |
| 150 | [3, "running"], | |
| 151 | ]); | |
| 152 | ||
| 153 | const text = await drive(alice, repo); | |
| 154 | expect(text).toContain("Previews"); | |
| 155 | expect(text).toContain("#3 Paint the hull · live · https://ferry--3.apps.test"); | |
| 156 | expect(text).toContain("#4 Fix the rudder · not up · https://ferry--4.apps.test"); | |
| 157 | // A pull with no environment is in the open pulls, not the previews. | |
| 158 | expect(text).not.toMatch(/#5 No preview yet · (live|not up|not running)/); | |
| 159 | expect(text).not.toContain("No previews."); | |
| 160 | ||
| 161 | // No deploys, so `j` lands on the first preview (#4, newest) and Enter opens its pull. | |
| 162 | const opened = await drive(alice, repo, ["j", "enter"]); | |
| 163 | expect(opened).toContain("Fix the rudder"); | |
| 164 | expect(opened).not.toContain("Previews"); | |
| 165 | const second = await drive(alice, repo, ["j", "j", "enter"]); | |
| 166 | expect(second).toContain("Paint the hull"); | |
| 167 | expect(second).not.toContain("Previews"); | |
| 168 | }); | |
| 169 | ||
| 170 | test("an app with no previews says so", async () => { | |
| 171 | const repo = await appWith("barge", "public", [[1, "Load the barge"]]); | |
| 172 | serving.set("barge", [env({ env: "production", state: "running", url: "https://barge.apps.test" })]); | |
| 173 | expect(appPreviews(forge, alice, repo)).toEqual([]); | |
| 174 | const text = await drive(alice, repo); | |
| 175 | expect(text).toContain("Previews"); | |
| 176 | expect(text).toContain("No previews."); | |
| 177 | }); | |
| 178 | ||
| 179 | test("someone who may not read the repository sees no previews", async () => { | |
| 180 | const repo = await appWith("sub", "private", [[2, "Dive deeper"]]); | |
| 181 | serving.set("sub", [env({ env: "pull-2", state: "running", url: "https://sub--2.apps.test" })]); | |
| 182 | expect(appPreviews(forge, bob, repo)).toBeUndefined(); | |
| 183 | const text = await drive(bob, repo); | |
| 184 | expect(text).not.toContain("Previews"); | |
| 185 | expect(text).not.toContain("Dive deeper"); | |
| 186 | expect(text).not.toContain("sub--2"); | |
| 187 | expect(await drive(alice, repo)).toContain("#2 Dive deeper · live · https://sub--2.apps.test"); | |
| 188 | }); | |
| 189 | ||
| 190 | test("j and k cross between production's deploys and the previews; Enter opens a log above and a pull below", async () => { | |
| 191 | const repo = await appWith("tug", "public", [[6, "Tow the liner"]]); | |
| 192 | serving.set("tug", [env({ env: "pull-6", state: "running", url: "https://tug--6.apps.test" })]); | |
| 193 | for (const [oid, summary] of [["1", "FIRST-DEPLOY"], ["2", "SECOND-DEPLOY"]] as const) | |
| 194 | createRun(forge.db, { repoId: repo.id, kind: "deploy", env: "production", commitOid: oid.repeat(40), summary }); | |
| 195 | ||
| 196 | const s = await hold(alice, repo); | |
| 197 | try { | |
| 198 | expect(s.text()).toContain("#6 Tow the liner · live · https://tug--6.apps.test"); | |
| 199 | expect(s.enterHint()).toBe("open the log"); | |
| 200 | // On the first deploy, `j` stays among the deploys. | |
| 201 | await s.press("j"); | |
| 202 | expect(s.enterHint()).toBe("open the log"); | |
| 203 | // On the last deploy, `j` moves into the previews. | |
| 204 | await s.press("j"); | |
| 205 | expect(s.enterHint()).toBe("open the pull"); | |
| 206 | // `k` on the first preview returns to the last deploy. | |
| 207 | await s.press("k"); | |
| 208 | expect(s.enterHint()).toBe("open the log"); | |
| 209 | await s.press("enter"); | |
| 210 | expect(s.text()).toContain("Run log"); | |
| 211 | expect(s.text()).not.toContain("Tow the liner"); | |
| 212 | } finally { | |
| 213 | s.stop(); | |
| 214 | } | |
| 215 | ||
| 216 | const t = await hold(alice, repo); | |
| 217 | try { | |
| 218 | await t.press("j", "j", "enter"); | |
| 219 | expect(t.text()).not.toContain("Run log"); | |
| 220 | expect(t.text()).not.toContain("Previews"); | |
| 221 | expect(t.text()).toContain("Tow the liner"); | |
| 222 | } finally { | |
| 223 | t.stop(); | |
| 224 | } | |
| 225 | }); | |
| 226 | ||
| 227 | test("more previews than the group shows scroll, keeping the cursor's row in view", async () => { | |
| 228 | const pulls: [number, string][] = Array.from({ length: 8 }, (_, i) => [20 + i, `Deck ${20 + i}`]); | |
| 229 | const repo = await appWith("liner", "public", pulls); | |
| 230 | serving.set( | |
| 231 | "liner", | |
| 232 | pulls.map(([n]) => env({ env: `pull-${n}`, state: "running", url: `https://liner--${n}.apps.test` })), | |
| 233 | ); | |
| 234 | const s = await hold(alice, repo); | |
| 235 | try { | |
| 236 | // Newest first: #27 … #22 fit, #21 and #20 wait below. | |
| 237 | expect(s.text()).toContain("#27 Deck 27 · live"); | |
| 238 | expect(s.text()).toContain("#22 Deck 22 · live"); | |
| 239 | expect(s.text()).not.toContain("#20 Deck 20 · live"); | |
| 240 | await s.press(..."jjjjjjjj"); | |
| 241 | expect(s.enterHint()).toBe("open the pull"); | |
| 242 | expect(s.text()).toContain("#20 Deck 20 · live"); | |
| 243 | expect(s.text()).not.toContain("#27 Deck 27 · live"); | |
| 244 | await s.press("enter"); | |
| 245 | expect(s.text()).toContain("Deck 20"); | |
| 246 | expect(s.text()).not.toContain("Previews"); | |
| 247 | } finally { | |
| 248 | s.stop(); | |
| 249 | } | |
| 250 | }); | |
| 251 | ||
| 252 | test("a click selects a preview and a second click opens its pull", async () => { | |
| 253 | const repo = await appWith("dinghy", "public", [ | |
| 254 | [1, "Row ashore"], | |
| 255 | [2, "Bail out"], | |
| 256 | ]); | |
| 257 | serving.set("dinghy", [ | |
| 258 | env({ env: "pull-1", state: "running", url: "https://dinghy--1.apps.test" }), | |
| 259 | env({ env: "pull-2", state: "running", url: "https://dinghy--2.apps.test" }), | |
| 260 | ]); | |
| 261 | createRun(forge.db, { repoId: repo.id, kind: "deploy", env: "production", commitOid: "9".repeat(40) }); | |
| 262 | const s = await hold(alice, repo); | |
| 263 | try { | |
| 264 | await s.click("#1 Row ashore · live"); | |
| 265 | expect(s.enterHint()).toBe("open the pull"); | |
| 266 | await s.click("#1 Row ashore · live"); | |
| 267 | expect(s.text()).not.toContain("Previews"); | |
| 268 | expect(s.text()).toContain("Row ashore"); | |
| 269 | expect(s.text()).not.toContain("Bail out"); | |
| 270 | } finally { | |
| 271 | s.stop(); | |
| 272 | } | |
| 273 | }); | |
| 274 | ||
| 275 | test("a row's dot wears the studio's tone: a failed preview danger, a live one success, a starting one none", () => { | |
| 276 | const p = (state: EnvStatus["state"]) => ({ number: 1, title: "t", env: env({ env: "pull-1", state, url: "u" }) }); | |
| 277 | expect(previewRow(p("failed")).tone).toBe("danger"); | |
| 278 | expect(previewRow(p("running")).tone).toBe("success"); | |
| 279 | expect(previewRow(p("deploying")).tone).toBeUndefined(); | |
| 280 | expect(previewRow(p("running")).title).toBe("#1 t · live · u"); | |
| 281 | expect(envTone("danger")).toBe("danger"); | |
| 282 | expect(envTone("attn")).toBe("attn"); | |
| 283 | expect(envTone("default")).toBeUndefined(); | |
| 284 | }); |
new above
offline