Public: anyone can read this; the crew can change it.

The terminal's deploys screen redeploys or stops production with a key and a confirm, as MCP `app_deploy` does #7250

Merged opened this

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

Merged

mason/forge:mason/7231-the-terminal-s-deploys-screen-redeploys → main 4 commits fork

  • Merged. mason/7231-the-terminal-s-deploys-screen-redeploys was merged into main as 13e14e7.

Review

Sign in to review this change.
5 files changed +369 −31
docs/TOUR.md +2
@@ -187,6 +187,8 @@ An app's repository home wears the pace on its app strip, the terminal's **Deplo
187187
188188 An app made from the template ships the same beacon as `static/beacon.js`, posting to `POST /__forge/measure` on the app's own host. The forge answers that door itself (`src/apps/measure.ts`), before the app is woken: same-origin only, 30 reports a minute per client and 600 per app (429), a body over the cap is a 413, and a private app or a sealed pull's preview refuses anyone its gate refuses. The app is the host's, never the body's. Production reports are kept in `app_vital` (`src/apps/vitals.ts`, TTFB, load, LCP, INP and CLS, a visitor hashed under the day's salt, no address, kept 90 days and removed with the app, at most 20,000 rows per app a day); previews store nothing. Only Do-Not-Track opts a visitor out on an app's host: the `forge_measure=off` cookie is set on the forge's host and never reaches an app's. Read the numbers (visitors today and over 7 days, p75 TTFB, load, LCP, INP and CLS) on the app's settings page under **Visitors and speed**, or as `vitals` in an admin's `GET /api/v1/repos/:owner/:name/app`. Both doors show them only to people who administer the repository (`appVisitors` in `src/actions/apps.ts`), so a reader or an anonymous caller sees no `vitals`. The page also draws p75 load for each of the last 30 days as a sparkline (server-rendered, no script); a day with no reports is a gap in the line, not a zero, and an app with none in 30 days says so in words. The same 30 values, oldest first with `null` for a silent day, are `vitals.loadDays` in the API. In the terminal all five are on the second line of the app's **Deploys** screen over ssh, and a third line there shows the 30 days as block characters with `·` for a silent day, then the range.
189189
190 A writer of the app's repository redeploys production from the terminal's **Deploys** screen with `D` and stops it with `S`, each after a one-line confirm that names the app and production. They are the page's and MCP `app_deploy`'s actions (`redeployEnv`, `stopEnv`), answered in the page's words (`src/actions/env_words.ts`). The title line shows production's state, a stopped production offers `D` only, and a reader sees neither key.
191
190192 The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker.
191193
192194 `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added.
src/actions/env_words.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * What a person is told after redeploying or stopping an environment
3 * (`redeployEnv`, `stopEnv`). Said the same on the app's page and in the
4 * terminal's deploys screen, from here.
5 */
6
7 import { envPull, type EnvName } from "../apps/data.ts";
8 import type { Run } from "../core/runs.ts";
9 import { VERIFY_FIRST } from "./verify_words.ts";
10
11 /** `production` or `preview of #12`, as a phrase that fits in a sentence. */
12 export function envLabel(env: EnvName): string {
13 const number = envPull(env);
14 return number === undefined ? "production" : `preview of #${number}`;
15 }
16
17 /** A redeploy was queued, or there was nothing new to serve. */
18 export function redeployedText(env: EnvName, run: Run | undefined): string {
19 return run === undefined
20 ? `${envLabel(env)} already serves that commit.`
21 : `Redeploying ${envLabel(env)}.`;
22 }
23
24 /** An environment was stopped. */
25 export function stoppedText(env: EnvName): string {
26 return `Stopped ${envLabel(env)}. Its data is kept; Redeploy brings it back.`;
27 }
28
29 const REFUSALS: Readonly<Record<string, string>> = {
30 forbidden: "You need write access to do that.",
31 unverified: `${VERIFY_FIRST[0]!.toUpperCase()}${VERIFY_FIRST.slice(1)}: open the link the forge mailed you, or ask for another from your account settings.`,
32 not_found: "This repository has no app.",
33 no_such_env: "There is no such environment: name production, or a pull's preview as pull-N.",
34 no_runtime: "Nothing is serving apps on this forge.",
35 nothing_to_deploy:
36 "There is nothing to deploy: the branch has no commits, or the pull request is closed.",
37 };
38
39 /**
40 * Why a redeploy or stop was refused. The page answers `not_found` and
41 * `no_such_env` with its 404 before asking; the terminal says them here.
42 */
43 export function envRefusalText(error: string): string {
44 return REFUSALS[error] ?? "That could not be done.";
45 }
src/tui/screens/deploys.ts +99 −10
@@ -9,13 +9,29 @@
99 * Below the deploys, a signed-in viewer sees the app's open pulls, one line
1010 * each: `#N title · check <word> · <preview address or none>`. A guest sees
1111 * none.
12 *
13 * A writer redeploys production with `D` and stops it with `S`, each after a
14 * one-line confirm, through `redeployEnv` and `stopEnv` — the page's and MCP
15 * `app_deploy`'s actions — and is told the answer in the page's words
16 * (`env_words.ts`). A stopped production offers only `D`; a reader sees
17 * neither key.
1218 */
1319
14 import { appPace, appPullLines, appSideLines, appVisitors, type AppPullLine } from "../../actions/apps.ts";
20 import {
21 appPace,
22 appPullLines,
23 appSideLines,
24 appView,
25 appVisitors,
26 redeployEnv,
27 stopEnv,
28 type AppPullLine,
29 } from "../../actions/apps.ts";
30 import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts";
1531 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
1632 import { listRepoRuns } from "../../actions/runs.ts";
1733 import { carriedByRuns, carriesText } from "../../core/carried.ts";
18 import type { Repo } from "../../core/repos.ts";
34 import { canWrite, type Repo } from "../../core/repos.ts";
1935 import type { Run } from "../../core/runs.ts";
2036 import type { Ctx, Screen } from "../app/app.ts";
2137 import { binding } from "../app/keys.ts";
@@ -24,7 +40,7 @@ import { style } from "../term/style.ts";
2440 import { truncate } from "../term/width.ts";
2541 import { fill, len, split } from "../ui/layout.ts";
2642 import { emptyState } from "../ui/widgets.ts";
27 import { drawList, hitList } from "./common.ts";
43 import { confirmScreen, drawList, hitList, runAsync } from "./common.ts";
2844 import { RunList, openRunLog, runRows } from "./runlist.ts";
2945
3046 const LIMIT = 50;
@@ -39,6 +55,26 @@ function readDeploys(f: Session, repo: Repo): Run[] {
3955 return listed.ok ? listed.value : [];
4056 }
4157
58 /** Production's state word, and what the actor may do to it from here. */
59 interface Production {
60 readonly state: string;
61 readonly redeploy: boolean;
62 readonly stop: boolean;
63 }
64
65 /**
66 * The keys are offered by `canWrite`, the rule `requireWriter` applies; it
67 * only decides what to show. `redeployEnv` and `stopEnv` gate again and
68 * refuse in words (`env_words.ts`) — an unverified writer still sees `D`.
69 */
70 function readProduction(f: Session, repo: Repo): Production {
71 const view = appView(f.app, f.user, repo);
72 if (!view.ok) return { state: "", redeploy: false, stop: false };
73 const state = view.value.environments.find((e) => e.env === "production")?.state ?? "";
74 const writer = view.value.serving && f.user !== undefined && canWrite(f.db, repo, f.user);
75 return { state, redeploy: writer, stop: writer && state !== "" && state !== "stopped" };
76 }
77
4278 /** `#N title · check <word> · <preview address or none>` */
4379 function pullLine(p: AppPullLine): string {
4480 const title = p.title.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "").trim();
@@ -54,6 +90,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5490 let pace = "";
5591 let side: string[] = [];
5692 let pulls: AppPullLine[] = [];
93 let production: Production = { state: "", redeploy: false, stop: false };
5794
5895 const read = (): void => {
5996 const runs = readDeploys(f, repo);
@@ -68,19 +105,59 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
68105 pace = appPace(f.app, f.user, repo);
69106 side = appSideLines(f.app, f.user, repo);
70107 pulls = appPullLines(f.app, f.user, repo);
108 production = readProduction(f, repo);
71109 loaded = true;
72110 ctx.invalidate();
73111 };
74112
75113 const open = (run: Run): void => openRunLog(f, ctx, repo, run);
76114
77 return {
115 const confirm = (verb: "Redeploy" | "Stop", act: () => Promise<{ text: string; refused: boolean }>): void => {
116 ctx.push(
117 confirmScreen(ctx, screen, {
118 title: `${verb} ${envLabel("production")}`,
119 body: [`${verb} ${envLabel("production")} of ${repo.ownerName}/${repo.name}?`],
120 actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }],
121 selected: 1,
122 onChoose: (choice) => {
123 if (choice !== 1) return;
124 runAsync(ctx, async () => {
125 const answer = await act();
126 ctx.toast(answer.text, answer.refused ? "danger" : undefined);
127 read();
128 });
129 },
130 }),
131 );
132 };
133
134 const redeploy = (): void =>
135 confirm("Redeploy", async () => {
136 const queued = await redeployEnv(f.app, f.user, repo, "production");
137 return queued.ok
138 ? { text: redeployedText("production", queued.value), refused: false }
139 : { text: envRefusalText(queued.error), refused: true };
140 });
141
142 const stop = (): void =>
143 confirm("Stop", async () => {
144 const stopped = await stopEnv(f.app, f.user, repo, "production");
145 return stopped.ok
146 ? { text: stoppedText("production"), refused: false }
147 : { text: envRefusalText(stopped.error), refused: true };
148 });
149
150 const screen: Screen = {
78151 title: "Deploys",
79 keys: [
80 binding(["j", "k"], "move"),
81 binding(["enter"], "open the log"),
82 binding(["r"], "refresh (the open pulls below the deploys: check, preview)"),
83 ],
152 get keys() {
153 return [
154 binding(["j", "k"], "move"),
155 binding(["enter"], "open the log"),
156 binding(["r"], "refresh (the open pulls below the deploys: check, preview)"),
157 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
158 ...(production.stop ? [binding(["S"], "stop production")] : []),
159 ];
160 },
84161 load: async () => read(),
85162 render(area, s) {
86163 const c = ctx.theme.c;
@@ -104,7 +181,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
104181 s.text(
105182 head!.x,
106183 head!.y,
107 truncate(`${repo.ownerName}/${repo.name} · production deploys`, head!.w),
184 truncate(
185 `${repo.ownerName}/${repo.name} · production deploys${production.state === "" ? "" : ` · ${production.state}`}`,
186 head!.w,
187 ),
108188 style(c.mutedFg, c.bg),
109189 head!.x + head!.w,
110190 );
@@ -147,7 +227,16 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
147227 read();
148228 return true;
149229 }
230 if (e.key === "D" && production.redeploy) {
231 redeploy();
232 return true;
233 }
234 if (e.key === "S" && production.stop) {
235 stop();
236 return true;
237 }
150238 return false;
151239 },
152240 };
241 return screen;
153242 }
src/web/pages/apps.ts +5 −21
@@ -30,7 +30,8 @@ import { listAllAgents } from "../../actions/crew.ts";
3030 import { planningAvailable } from "../../crew/plan.ts";
3131 import { appNameJs, deriveAppName } from "./apps-name.ts";
3232 import { STARTERS, starterFromQuery, starterHref } from "./apps-starters.ts";
33 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
33 import { previewEnv, type EnvName } from "../../apps/data.ts";
34 import { envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts";
3435 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
3536 import { firstPreviews, goldenPath, goldenPathLine } from "../../apps/golden_path.ts";
3637 import { appHostSuffix } from "../../apps/host.ts";
@@ -142,12 +143,6 @@ export function archivedAppCount(ctx: Ctx): number {
142143 ).size;
143144 }
144145
145 /** `production` or `preview of #12`, as a phrase that fits in a sentence. */
146 export function envLabel(env: EnvName): string {
147 const number = envPull(env);
148 return number === undefined ? "production" : `preview of #${number}`;
149 }
150
151146 /** The archived list's Wake: a small form under each row the viewer administers. */
152147 function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> {
153148 const out = new Map<string, Html>();
@@ -792,15 +787,9 @@ function envBack(ctx: Ctx, repo: Repo): string {
792787 function envFailure(ctx: Ctx, repo: Repo, error: string): Response {
793788 if (error === "not_found" || error === "no_such_env")
794789 return notFoundPage(ctx);
795 const words: Record<string, string> = {
796 forbidden: "You need write access to do that.",
797 no_runtime: "Nothing is serving apps on this forge.",
798 nothing_to_deploy:
799 "There is nothing to deploy: the branch has no commits, or the pull request is closed.",
800 };
801790 return flashTo(ctx, envBack(ctx, repo), {
802791 tone: "danger",
803 message: words[error] ?? "That could not be done.",
792 message: envRefusalText(error),
804793 });
805794 }
806795
@@ -817,12 +806,7 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> {
817806 return redirect(envBack(ctx, repo), {
818807 url: ctx.url,
819808 forgeUrl: ctx.app.config.url,
820 flash: {
821 message:
822 run === undefined
823 ? `${envLabel(env as EnvName)} already serves that commit.`
824 : `Redeploying ${envLabel(env as EnvName)}.`,
825 },
809 flash: { message: redeployedText(env as EnvName, run) },
826810 });
827811 }
828812
@@ -834,6 +818,6 @@ export async function appStop(ctx: Ctx): Promise<Response> {
834818 const stopped = await stopEnv(ctx.app, user, repo, env);
835819 if (!stopped.ok) return envFailure(ctx, repo, stopped.error);
836820 return flashTo(ctx, envBack(ctx, repo), {
837 message: `Stopped ${envLabel(env as EnvName)}. Its data is kept; Redeploy brings it back.`,
821 message: stoppedText(env as EnvName),
838822 });
839823 }
test/tui-deploy-act.test.ts added +218
@@ -0,0 +1,218 @@
1 /**
2 * The deploys screen redeploys and stops production (#7231): `D` and `S`,
3 * each after a one-line confirm, through `redeployEnv` and `stopEnv`, told in
4 * the page's words. A reader sees neither key; a stopped production offers
5 * redeploy only, a refusal toasts in red in the page's words, and a writer
6 * whose repository has no app is offered neither key. The runtime is constructed, not started, with its deploy,
7 * stop and status faked, as `tui-deploys.test.ts` fakes `envStatus`.
8 */
9
10 import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { signUp } from "../src/actions/users.ts";
16 import { createApp, type App as Forge } from "../src/app.ts";
17 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
18 import { createApp as createAppRecord } from "../src/core/apps.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import type { Run } from "../src/core/runs.ts";
21 import type { User } from "../src/core/users.ts";
22 import { markVerified } from "../src/core/verify.ts";
23 import { createForgeApp } from "../src/tui/forge.ts";
24 import { deploysScreen } from "../src/tui/screens/deploys.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { seedAdmin } from "./helpers/admin.ts";
27 import { quietLogs } from "./helpers/globals.ts";
28 import { fixtureSlot } from "./helpers/slot.ts";
29 import { settleApp } from "./helpers/tui.ts";
30 import { proveAll } from "./helpers/verified.ts";
31
32 quietLogs();
33
34 let dir: string;
35 let forge: Forge;
36 let runtime: AppsRuntime;
37 let alice: User;
38 let bob: User;
39 let repo: Repo;
40 let empty: Repo;
41 let unserved: Repo;
42 let state: EnvStatus["state"];
43 let deploys: string[];
44 let stops: string[];
45
46 beforeAll(async () => {
47 dir = await mkdtemp(join(tmpdir(), "forge-tui-deploy-act-"));
48 forge = createApp({ dataDir: dir, port: 0 });
49 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
50 if (!a.ok) throw new Error("signup failed");
51 alice = a.value;
52 const b = await signUp(
53 forge,
54 { username: "bob", email: "[email protected]", password: "a-long-passphrase" },
55 { slot: fixtureSlot(forge) },
56 );
57 if (!b.ok) throw new Error("signup failed");
58 bob = b.value;
59 proveAll(forge);
60 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
61 if (!r.ok) throw new Error("repo failed");
62 repo = r.value;
63 const committed = await commitToBranch(forge, alice, repo, {
64 message: "Light the lamp",
65 files: [{ path: "README.md", content: "lamp\n" }],
66 });
67 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
68 const made = createAppRecord(forge.db, { repoId: repo.id, slug: "lamp", access: "public" });
69 if (!made.ok) throw new Error(made.error);
70 // An app whose repository has no commit yet: a redeploy has nothing to deploy.
71 const e = await createRepo(forge, alice, { name: "dark", visibility: "public" });
72 if (!e.ok) throw new Error("repo failed");
73 empty = e.value;
74 const dark = createAppRecord(forge.db, { repoId: empty.id, slug: "dark", access: "public" });
75 if (!dark.ok) throw new Error(dark.error);
76 // A repository alice writes to that is no app at all.
77 const u = await createRepo(forge, alice, { name: "unlit", visibility: "public" });
78 if (!u.ok) throw new Error("repo failed");
79 unserved = u.value;
80 runtime = new AppsRuntime(forge, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 });
81 runtime.status = (() => [{ env: "production", state } as EnvStatus]) as typeof runtime.status;
82 runtime.requestDeploy = ((_record, _repo, env) => {
83 deploys.push(env);
84 return { id: "run-1" } as Run;
85 }) as typeof runtime.requestDeploy;
86 runtime.stopEnv = (async (_record, env) => {
87 stops.push(env);
88 state = "stopped";
89 }) as typeof runtime.stopEnv;
90 });
91 afterAll(async () => {
92 await runtime.stop();
93 forge.close?.();
94 await rm(dir, { recursive: true, force: true });
95 });
96 beforeEach(() => {
97 state = "running";
98 deploys = [];
99 stops = [];
100 });
101
102 const open = async (who: User, of: Repo = repo) => {
103 const { app, session } = createForgeApp(
104 forge,
105 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
106 { terminal: headlessTerminal(200, 30), exitHooks: false },
107 );
108 app.start();
109 await settleApp(app);
110 const toasts: { text: string; tone: string | undefined }[] = [];
111 const toast = app.toast.bind(app);
112 app.toast = (text, tone) => {
113 toasts.push({ text, tone });
114 toast(text, tone);
115 };
116 const screen = deploysScreen(session, app.ctx, of);
117 app.push(screen);
118 await settleApp(app);
119 const press = async (...keys: string[]): Promise<void> => {
120 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
121 await settleApp(app);
122 };
123 const keys = () => screen.keys.map((b) => b.help);
124 return { app, press, keys, toasts, text: () => app.screen.toText() };
125 };
126
127 test("D asks a confirm naming the app and production, then redeploys and says so", async () => {
128 const h = await open(alice);
129 expect(h.text()).toContain("D redeploy production");
130 await h.press("D");
131 expect(h.text()).toContain("Redeploy production of alice/lamp?");
132 expect(deploys).toEqual([]);
133 await h.press("enter");
134 expect(deploys).toEqual(["production"]);
135 expect(h.text()).toContain("Redeploying production.");
136 h.app.stop();
137 });
138
139 test("cancelling the confirm calls nothing", async () => {
140 const h = await open(alice);
141 await h.press("D");
142 await h.press("esc");
143 await h.press("S");
144 expect(h.text()).toContain("Stop production of alice/lamp?");
145 await h.press("left", "enter");
146 expect(deploys).toEqual([]);
147 expect(stops).toEqual([]);
148 h.app.stop();
149 });
150
151 test("S stops production, the state reads stopped, and only redeploy is left", async () => {
152 const h = await open(alice);
153 expect(h.text()).toContain("production deploys · running");
154 await h.press("S", "enter");
155 expect(stops).toEqual(["production"]);
156 const text = h.text();
157 expect(text).toContain("Stopped production. Its data is kept; Redeploy brings it back.");
158 expect(text).toContain("production deploys · stopped");
159 expect(h.keys()).toContain("redeploy production");
160 expect(h.keys()).not.toContain("stop production");
161 await h.press("S");
162 expect(h.text()).not.toContain("Stop production of");
163 h.app.stop();
164 });
165
166 test("a reader sees neither key, and pressing them does nothing", async () => {
167 const h = await open(bob);
168 const text = h.text();
169 expect(text).not.toContain("redeploy production");
170 expect(text).not.toContain("stop production");
171 expect(h.keys()).not.toContain("redeploy production");
172 expect(h.keys()).not.toContain("stop production");
173 await h.press("D");
174 await h.press("S");
175 expect(h.text()).not.toContain("of alice/lamp?");
176 expect(deploys).toEqual([]);
177 expect(stops).toEqual([]);
178 h.app.stop();
179 });
180
181 test("a refused redeploy toasts in red in the page's words and deploys nothing", async () => {
182 const h = await open(alice, empty);
183 await h.press("D");
184 expect(h.text()).toContain("Redeploy production of alice/dark?");
185 await h.press("enter");
186 expect(deploys).toEqual([]);
187 const nothing = "There is nothing to deploy: the branch has no commits, or the pull request is closed.";
188 expect(h.toasts).toEqual([{ text: nothing, tone: "danger" }]);
189 expect(h.text()).toContain(nothing);
190 h.app.stop();
191 });
192
193 test("a writer whose email is unverified is told to verify it, and nothing deploys", async () => {
194 forge.db.query("UPDATE email_check SET verified_at = NULL WHERE user_id = ?1").run(alice.id);
195 try {
196 const h = await open(alice);
197 await h.press("D", "enter");
198 expect(deploys).toEqual([]);
199 const verify = "Verify your email first: open the link the forge mailed you, or ask for another from your account settings.";
200 expect(h.toasts).toEqual([{ text: verify, tone: "danger" }]);
201 expect(h.text()).toContain(verify);
202 h.app.stop();
203 } finally {
204 markVerified(forge.db, alice.id, new Date().toISOString());
205 }
206 });
207
208 test("a writer whose repository is no app is offered neither key", async () => {
209 const h = await open(alice, unserved);
210 expect(h.keys()).not.toContain("redeploy production");
211 expect(h.keys()).not.toContain("stop production");
212 await h.press("D");
213 await h.press("S");
214 expect(h.text()).not.toContain("of alice/unlit?");
215 expect(deploys).toEqual([]);
216 expect(stops).toEqual([]);
217 h.app.stop();
218 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs