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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7475-the-terminal-s-deploys-screen-lists-an-a → main 4 commits fork

  • Merged. torque/7475-the-terminal-s-deploys-screen-lists-an-a was merged into main as 198a66e.

Review

Sign in to review this change.
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):
180180 (each with what it carries), `enter` for the log, `r` to refresh. A signed-in
181181 reader also sees `Production: <url>` and, when a pull's preview is up or
182182 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
184190 doors as nobody, a user and an admin, read-only — one line per door with
185191 its status and `ok` or what was wrong, `r` to knock again; the result is
186192 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
539539 }));
540540 }
541541
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
542567 /**
543568 * The other side of the app for its production terminal, gated as the pill
544569 * 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 @@
66 * through `listRepoRuns` (so it is gated like every other run read) and
77 * joined to `deploy_pull` in one grouped read for `carries #3 #5`.
88 *
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
1017 * each: `#N title · check <word> · <preview address or none>`. A guest sees
1118 * none.
1219 *
@@ -36,14 +43,17 @@
3643
3744 import {
3845 appPace,
46 appPreviews,
3947 appPullLines,
4048 appSideLines,
4149 appView,
4250 appVisitors,
4351 redeployEnv,
4452 stopEnv,
53 type AppPreview,
4554 type AppPullLine,
4655 } from "../../actions/apps.ts";
56 import { envPhrase } from "../../apps/env_phrase.ts";
4757 import {
4858 addAppDomain,
4959 checkAppDomain,
@@ -75,6 +85,7 @@ import type { Run } from "../../core/runs.ts";
7585 import type { Ctx, Screen } from "../app/app.ts";
7686 import { binding } from "../app/keys.ts";
7787 import type { Session } from "../session.ts";
88 import type { Rect } from "../term/screen.ts";
7889 import { style } from "../term/style.ts";
7990 import { truncate } from "../term/width.ts";
8091 import { fill, len, split } from "../ui/layout.ts";
@@ -91,6 +102,9 @@ import {
91102 type ListRow,
92103 } from "./common.ts";
93104 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";
94108 import { appMembersScreen } from "./appmembers.ts";
95109
96110 const LIMIT = 50;
@@ -131,6 +145,17 @@ function pullLine(p: AppPullLine): string {
131145 return `#${p.number} ${title} · check ${p.check} · ${p.preview ?? "none"}`;
132146 }
133147
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
134159 /**
135160 * "Reached, not declared": the hosts the app's code called that `forge.json`
136161 * 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 {
161186 let side: string[] = [];
162187 let egress: string[] = [];
163188 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;
164194 let production: Production = { state: "", redeploy: false, stop: false };
165195 let members = false;
166196 /** The app's domains, or undefined for anyone `listAppDomains` refuses. */
@@ -180,6 +210,9 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
180210 side = appSideLines(f.app, f.user, repo);
181211 egress = egressLines(f, repo);
182212 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;
183216 production = readProduction(f, repo);
184217 // The action's gate: a person who can write, never an agent.
185218 members =
@@ -194,6 +227,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
194227 };
195228
196229 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));
197231 // The MCP tool's gate, read once: only a writer is offered the knock.
198232 const mayProbe = canWrite(f.db, repo, f.user);
199233
@@ -237,7 +271,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
237271 get keys() {
238272 return [
239273 binding(["j", "k"], "move"),
240 binding(["enter"], "open the log"),
274 binding(["enter"], inPreviews ? "open the pull" : "open the log"),
241275 binding(["r"], "refresh (the open pulls below the deploys: check, preview)"),
242276 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
243277 ...(production.stop ? [binding(["S"], "stop production")] : []),
@@ -250,7 +284,30 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
250284 render(area, s) {
251285 const c = ctx.theme.c;
252286 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", [
254311 len(
255312 1 +
256313 (visitors === "" ? 0 : 1) +
@@ -261,9 +318,11 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
261318 (named === "" ? 0 : 1),
262319 ),
263320 fill(1),
321 len(previews === undefined ? 0 : shown + 1),
264322 len(pulls.length === 0 ? 0 : pulls.length + 1),
265323 ]);
266324 const body = upper;
325 if (previews !== undefined) drawPreviews(middle!);
267326 pulls.forEach((p, i) =>
268327 s.text(
269328 tail!.x,
@@ -305,16 +364,38 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
305364 ? row
306365 : { ...row, secondary: [row.secondary, text].filter((t) => t !== "").join(" · ") };
307366 });
308 drawList(s, body!, c, list.state, rows);
367 drawList(s, body!, c, list.state, rows, !inPreviews);
309368 hitList(ctx, body!, list.state, rows.length, {
369 select: () => {
370 inPreviews = false;
371 ctx.invalidate();
372 },
310373 open: (index) => {
374 inPreviews = false;
311375 const run = list.runs[index];
312376 if (run !== undefined) open(run);
313377 },
314378 });
315379 },
316380 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 }
318399 if (e.key === "enter") {
319400 const run = list.selected;
320401 if (run !== undefined) open(run);
src/tui/screens/runwords.ts +6
@@ -4,6 +4,7 @@
44 * neither imports the other.
55 */
66
7 import type { EnvPhraseTone } from "../../apps/env_phrase.ts";
78 import type { RunState } from "../../core/runs.ts";
89 import type { Tone } from "../ui/widgets.ts";
910
@@ -32,3 +33,8 @@ export function envLabel(env: string): string {
3233 export function costText(costUsd: number): string {
3334 return costUsd > 0 ? `$${costUsd.toFixed(2)}` : "";
3435 }
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 @@
1010 * reaches for an inline `style` attribute, because the CSP forbids it.
1111 */
1212
13 import { VERIFYING_NOTE, type EnvState, type EnvStatus } from "../apps/runtime.ts";
13 import { type EnvState, type EnvStatus } from "../apps/runtime.ts";
1414 import type { PreviewData } from "../apps/data.ts";
1515 import type { Mark } from "../core/marks.ts";
1616 import type { RunKind, RunState } from "../core/runs.ts";
@@ -413,36 +413,7 @@ export function envTone(state: EnvState | undefined): Tone {
413413 : "default";
414414 }
415415
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";
446417
447418 export interface EnvFactsOptions {
448419 /** 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs