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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7231-the-terminal-s-deploys-screen-redeploys → main 4 commits fork
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 | ||
| 187 | 187 | |
| 188 | 188 | 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. |
| 189 | 189 | |
| 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 | ||
| 190 | 192 | 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. |
| 191 | 193 | |
| 192 | 194 | `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 @@ | ||
| 9 | 9 | * Below the deploys, a signed-in viewer sees the app's open pulls, one line |
| 10 | 10 | * each: `#N title · check <word> · <preview address or none>`. A guest sees |
| 11 | 11 | * 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. | |
| 12 | 18 | */ |
| 13 | 19 | |
| 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"; | |
| 15 | 31 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 16 | 32 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 17 | 33 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| 18 | import type { Repo } from "../../core/repos.ts"; | |
| 34 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 19 | 35 | import type { Run } from "../../core/runs.ts"; |
| 20 | 36 | import type { Ctx, Screen } from "../app/app.ts"; |
| 21 | 37 | import { binding } from "../app/keys.ts"; |
| @@ -24,7 +40,7 @@ import { style } from "../term/style.ts"; | ||
| 24 | 40 | import { truncate } from "../term/width.ts"; |
| 25 | 41 | import { fill, len, split } from "../ui/layout.ts"; |
| 26 | 42 | import { emptyState } from "../ui/widgets.ts"; |
| 27 | import { drawList, hitList } from "./common.ts"; | |
| 43 | import { confirmScreen, drawList, hitList, runAsync } from "./common.ts"; | |
| 28 | 44 | import { RunList, openRunLog, runRows } from "./runlist.ts"; |
| 29 | 45 | |
| 30 | 46 | const LIMIT = 50; |
| @@ -39,6 +55,26 @@ function readDeploys(f: Session, repo: Repo): Run[] { | ||
| 39 | 55 | return listed.ok ? listed.value : []; |
| 40 | 56 | } |
| 41 | 57 | |
| 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 | ||
| 42 | 78 | /** `#N title · check <word> · <preview address or none>` */ |
| 43 | 79 | function pullLine(p: AppPullLine): string { |
| 44 | 80 | 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 { | ||
| 54 | 90 | let pace = ""; |
| 55 | 91 | let side: string[] = []; |
| 56 | 92 | let pulls: AppPullLine[] = []; |
| 93 | let production: Production = { state: "", redeploy: false, stop: false }; | |
| 57 | 94 | |
| 58 | 95 | const read = (): void => { |
| 59 | 96 | const runs = readDeploys(f, repo); |
| @@ -68,19 +105,59 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 68 | 105 | pace = appPace(f.app, f.user, repo); |
| 69 | 106 | side = appSideLines(f.app, f.user, repo); |
| 70 | 107 | pulls = appPullLines(f.app, f.user, repo); |
| 108 | production = readProduction(f, repo); | |
| 71 | 109 | loaded = true; |
| 72 | 110 | ctx.invalidate(); |
| 73 | 111 | }; |
| 74 | 112 | |
| 75 | 113 | const open = (run: Run): void => openRunLog(f, ctx, repo, run); |
| 76 | 114 | |
| 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 = { | |
| 78 | 151 | 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 | }, | |
| 84 | 161 | load: async () => read(), |
| 85 | 162 | render(area, s) { |
| 86 | 163 | const c = ctx.theme.c; |
| @@ -104,7 +181,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 104 | 181 | s.text( |
| 105 | 182 | head!.x, |
| 106 | 183 | 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 | ), | |
| 108 | 188 | style(c.mutedFg, c.bg), |
| 109 | 189 | head!.x + head!.w, |
| 110 | 190 | ); |
| @@ -147,7 +227,16 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 147 | 227 | read(); |
| 148 | 228 | return true; |
| 149 | 229 | } |
| 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 | } | |
| 150 | 238 | return false; |
| 151 | 239 | }, |
| 152 | 240 | }; |
| 241 | return screen; | |
| 153 | 242 | } |
src/web/pages/apps.ts +5 −21
| @@ -30,7 +30,8 @@ import { listAllAgents } from "../../actions/crew.ts"; | ||
| 30 | 30 | import { planningAvailable } from "../../crew/plan.ts"; |
| 31 | 31 | import { appNameJs, deriveAppName } from "./apps-name.ts"; |
| 32 | 32 | 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"; | |
| 34 | 35 | import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts"; |
| 35 | 36 | import { firstPreviews, goldenPath, goldenPathLine } from "../../apps/golden_path.ts"; |
| 36 | 37 | import { appHostSuffix } from "../../apps/host.ts"; |
| @@ -142,12 +143,6 @@ export function archivedAppCount(ctx: Ctx): number { | ||
| 142 | 143 | ).size; |
| 143 | 144 | } |
| 144 | 145 | |
| 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 | ||
| 151 | 146 | /** The archived list's Wake: a small form under each row the viewer administers. */ |
| 152 | 147 | function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> { |
| 153 | 148 | const out = new Map<string, Html>(); |
| @@ -792,15 +787,9 @@ function envBack(ctx: Ctx, repo: Repo): string { | ||
| 792 | 787 | function envFailure(ctx: Ctx, repo: Repo, error: string): Response { |
| 793 | 788 | if (error === "not_found" || error === "no_such_env") |
| 794 | 789 | 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 | }; | |
| 801 | 790 | return flashTo(ctx, envBack(ctx, repo), { |
| 802 | 791 | tone: "danger", |
| 803 | message: words[error] ?? "That could not be done.", | |
| 792 | message: envRefusalText(error), | |
| 804 | 793 | }); |
| 805 | 794 | } |
| 806 | 795 | |
| @@ -817,12 +806,7 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> { | ||
| 817 | 806 | return redirect(envBack(ctx, repo), { |
| 818 | 807 | url: ctx.url, |
| 819 | 808 | 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) }, | |
| 826 | 810 | }); |
| 827 | 811 | } |
| 828 | 812 | |
| @@ -834,6 +818,6 @@ export async function appStop(ctx: Ctx): Promise<Response> { | ||
| 834 | 818 | const stopped = await stopEnv(ctx.app, user, repo, env); |
| 835 | 819 | if (!stopped.ok) return envFailure(ctx, repo, stopped.error); |
| 836 | 820 | 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), | |
| 838 | 822 | }); |
| 839 | 823 | } |
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 | }); |
new above
offline