Public: anyone can read this; the crew can change it.
Stop on an app's production or preview goes through the confirm page, which says the address goes quiet and the data stays #7993
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7977-stop-on-an-app-s-production-or-preview-g → main 1 commit fork
6 files
changed +206
−11
src/web/pages/apps.ts +6 −1
| @@ -783,7 +783,12 @@ async function envForm( | ||
| 783 | 783 | * a form field; with no referer the fallback is the repository's home. |
| 784 | 784 | */ |
| 785 | 785 | function envBack(ctx: Ctx, repo: Repo): string { |
| 786 | return safeBack(ctx) ?? repoBase(repo); | |
| 786 | // Arriving from the confirm page, going "back" would ask again; the | |
| 787 | // repository's page is where the person can see what changed. | |
| 788 | const back = safeBack(ctx); | |
| 789 | if (back === undefined || back.split("?")[0] === "/-/confirm") | |
| 790 | return repoBase(repo); | |
| 791 | return back; | |
| 787 | 792 | } |
| 788 | 793 | |
| 789 | 794 | function envFailure(ctx: Ctx, repo: Repo, error: string): Response { |
src/web/pages/search.ts +10
| @@ -661,6 +661,16 @@ const DESTRUCTIVE: { | ||
| 661 | 661 | phrase: "", |
| 662 | 662 | }), |
| 663 | 663 | }, |
| 664 | { | |
| 665 | pattern: /^\/([^/]+)\/([^/]+)\/app\/(production|pull-[1-9]\d*)\/stop$/, | |
| 666 | describe: (m) => ({ | |
| 667 | verb: "Stop", | |
| 668 | title: `Stop ${m[3]} of ${m[1]}/${m[2]}?`, | |
| 669 | detail: | |
| 670 | "It stops answering at its address until someone presses Redeploy. Its data stays where it is.", | |
| 671 | phrase: "", | |
| 672 | }), | |
| 673 | }, | |
| 664 | 674 | { |
| 665 | 675 | pattern: /^\/settings\/crew\/([^/]+)\/delete$/, |
| 666 | 676 | describe: (m) => ({ |
src/web/ui.ts +5 −3
| @@ -609,9 +609,11 @@ export function envActions( | ||
| 609 | 609 | ${when( |
| 610 | 610 | env.state !== "stopped", |
| 611 | 611 | () => |
| 612 | html`<form method="post" action="${base}/app/${env.env}/stop"> | |
| 613 | ${btn("Stop", { size: "sm", icon: "square" })} | |
| 614 | </form>`, | |
| 612 | confirmAction({ | |
| 613 | action: `${base}/app/${env.env}/stop`, | |
| 614 | verb: "Stop", | |
| 615 | size: "sm", | |
| 616 | }), | |
| 615 | 617 | )}`, |
| 616 | 618 | ); |
| 617 | 619 | } |
test/confirm-app-stop.test.ts added +178
| @@ -0,0 +1,178 @@ | ||
| 1 | /** | |
| 2 | * Stopping production or a preview asks first (#7977): the head card's Stop | |
| 3 | * is a link to the confirm page, the confirm page knows the route and words | |
| 4 | * it, and its own Stop lands the person on the app, not back on the | |
| 5 | * question they just answered. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { enableApp } from "../src/actions/apps.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 16 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | ||
| 22 | const PASSWORD = "a-good-passphrase"; | |
| 23 | const TO = "/alice/notes/app/production/stop"; | |
| 24 | const CONFIRM = `/-/confirm?to=${encodeURIComponent(TO)}`; | |
| 25 | ||
| 26 | describe("Stop on an app's environment goes through the confirm page", () => { | |
| 27 | let dataDir: string; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let base: string; | |
| 31 | let repo: Repo; | |
| 32 | let runtime: AppsRuntime; | |
| 33 | let alice: string; | |
| 34 | let bob: string; | |
| 35 | ||
| 36 | const signIn = async (username: string): Promise<string> => | |
| 37 | ( | |
| 38 | ( | |
| 39 | await fetch(`${base}/signin`, { | |
| 40 | method: "POST", | |
| 41 | body: new URLSearchParams({ username, password: PASSWORD }), | |
| 42 | redirect: "manual", | |
| 43 | }) | |
| 44 | ).headers.get("set-cookie") ?? "" | |
| 45 | ).split(";")[0]!; | |
| 46 | ||
| 47 | const person = async (username: string, admin = false) => { | |
| 48 | const made = await createUser(app.db, { | |
| 49 | origin: "seed", | |
| 50 | trusted: true, | |
| 51 | username, | |
| 52 | email: `${username}@forge.local`, | |
| 53 | password: PASSWORD, | |
| 54 | adminIfFirst: admin, | |
| 55 | }); | |
| 56 | if (!made.ok) throw new Error(made.error); | |
| 57 | promote(app.db, made.value); | |
| 58 | return made.value; | |
| 59 | }; | |
| 60 | ||
| 61 | /** Production serving, as the runtime holds it, with no real deploy. */ | |
| 62 | const putLive = (): void => { | |
| 63 | const record = getAppByRepo(app.db, repo.id)!; | |
| 64 | ( | |
| 65 | runtime as unknown as { envs: Map<string, Record<string, unknown>> } | |
| 66 | ).envs.set(`${record.id}/production`, { | |
| 67 | app: record, | |
| 68 | env: "production", | |
| 69 | chain: Promise.resolve(), | |
| 70 | restarts: [], | |
| 71 | live: { | |
| 72 | commitOid: "a".repeat(40), | |
| 73 | handle: { port: 4321, stop: async () => {} }, | |
| 74 | runId: "fake", | |
| 75 | since: new Date().toISOString(), | |
| 76 | manifest: { tty: null }, | |
| 77 | }, | |
| 78 | }); | |
| 79 | }; | |
| 80 | ||
| 81 | const get = (cookie: string, path: string): Promise<Response> => | |
| 82 | fetch(`${base}${path}`, { | |
| 83 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 84 | redirect: "manual", | |
| 85 | }); | |
| 86 | ||
| 87 | beforeAll(async () => { | |
| 88 | dataDir = await mkdtemp(join(tmpdir(), "forge-confirm-app-stop-")); | |
| 89 | app = createApp({ dataDir, port: 0 }); | |
| 90 | servers = await serve(app); | |
| 91 | base = servers.url; | |
| 92 | runtime = new AppsRuntime(app, { | |
| 93 | domain: "apps.test", | |
| 94 | scheme: "http", | |
| 95 | publicPort: servers.port, | |
| 96 | forgeUrl: servers.url, | |
| 97 | concurrency: 1, | |
| 98 | }); | |
| 99 | const a = await person("alice", true); | |
| 100 | await person("bob"); | |
| 101 | alice = await signIn("alice"); | |
| 102 | bob = await signIn("bob"); | |
| 103 | const made = await createRepo(app, a, { | |
| 104 | name: "notes", | |
| 105 | visibility: "public", | |
| 106 | description: "a place for notes", | |
| 107 | }); | |
| 108 | if (!made.ok) throw new Error(String(made.error)); | |
| 109 | repo = made.value; | |
| 110 | const seeded = await commitToBranch(app, a, repo, { | |
| 111 | message: "Start", | |
| 112 | files: [{ path: "README.md", content: "notes\n" }], | |
| 113 | }); | |
| 114 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 115 | const enabled = await enableApp(app, a, repo, { slug: "notes" }); | |
| 116 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 117 | }); | |
| 118 | ||
| 119 | afterAll(async () => { | |
| 120 | await runtime.stop(); | |
| 121 | servers.stop(); | |
| 122 | app.close(); | |
| 123 | await rm(dataDir, { recursive: true, force: true }); | |
| 124 | }); | |
| 125 | ||
| 126 | test("the app page's Stop is a link to the confirm page; Redeploy stays a form", async () => { | |
| 127 | putLive(); | |
| 128 | const html = await (await get(alice, "/alice/notes")).text(); | |
| 129 | expect(html).toContain(`href="${CONFIRM}"`); | |
| 130 | expect(html).not.toContain(`action="${TO}"`); | |
| 131 | expect(html).toContain('action="/alice/notes/app/production/redeploy"'); | |
| 132 | }); | |
| 133 | ||
| 134 | test("the confirm page words a Stop of production for a writer, and knows no made-up env", async () => { | |
| 135 | const page = await get(alice, CONFIRM); | |
| 136 | expect(page.status).toBe(200); | |
| 137 | const html = (await page.text()).replace(/\s+/g, " "); | |
| 138 | expect(html).toContain("Stop production of alice/notes?"); | |
| 139 | expect(html).toContain( | |
| 140 | "It stops answering at its address until someone presses Redeploy. Its data stays where it is.", | |
| 141 | ); | |
| 142 | expect(html).toContain(`action="${TO}"`); | |
| 143 | const preview = await get( | |
| 144 | alice, | |
| 145 | `/-/confirm?to=${encodeURIComponent("/alice/notes/app/pull-3/stop")}`, | |
| 146 | ); | |
| 147 | expect(preview.status).toBe(200); | |
| 148 | expect(await preview.text()).toContain("Stop pull-3 of alice/notes?"); | |
| 149 | const bogus = await get( | |
| 150 | alice, | |
| 151 | `/-/confirm?to=${encodeURIComponent("/alice/notes/app/evil/stop")}`, | |
| 152 | ); | |
| 153 | expect(bogus.status).toBe(404); | |
| 154 | }); | |
| 155 | ||
| 156 | test("Stop from the confirm page lands on the app, not back on /-/confirm", async () => { | |
| 157 | putLive(); | |
| 158 | const stopped = await fetch(`${base}${TO}`, { | |
| 159 | method: "POST", | |
| 160 | headers: { | |
| 161 | cookie: `${alice}; forge_mode=studio`, | |
| 162 | origin: base, | |
| 163 | referer: `${base}${CONFIRM}`, | |
| 164 | }, | |
| 165 | body: new URLSearchParams({}), | |
| 166 | redirect: "manual", | |
| 167 | }); | |
| 168 | expect(stopped.status).toBe(303); | |
| 169 | expect(stopped.headers.get("location")).toBe("/alice/notes"); | |
| 170 | }); | |
| 171 | ||
| 172 | test("a reader is never offered Stop", async () => { | |
| 173 | putLive(); | |
| 174 | const html = await (await get(bob, "/alice/notes")).text(); | |
| 175 | expect(html).toContain("serving"); | |
| 176 | expect(html).not.toContain(encodeURIComponent(TO)); | |
| 177 | }); | |
| 178 | }); |
test/studio-door.test.ts +3 −3
| @@ -748,7 +748,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 748 | 748 | expect(mine).not.toContain("<install>"); |
| 749 | 749 | expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`); |
| 750 | 750 | expect(mine).toContain('action="/alice/notes/app/production/redeploy"'); |
| 751 | expect(mine).toContain('action="/alice/notes/app/production/stop"'); | |
| 751 | expect(mine).toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"'); | |
| 752 | 752 | expect(mine).not.toContain('href="/alice/notes/app/production/open"'); |
| 753 | 753 | expect(mine).toContain('title="The last deploy failed"'); |
| 754 | 754 | |
| @@ -757,7 +757,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 757 | 757 | expect(theirs).toContain("the deploy failed: <install> exited 1"); |
| 758 | 758 | expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`); |
| 759 | 759 | expect(theirs).not.toContain("/app/production/redeploy"); |
| 760 | expect(theirs).not.toContain("/app/production/stop"); | |
| 760 | expect(theirs).not.toContain("%2Fapp%2Fproduction%2Fstop"); | |
| 761 | 761 | expect(theirs).not.toContain('href="/alice/notes/app/production/open"'); |
| 762 | 762 | }); |
| 763 | 763 | |
| @@ -777,7 +777,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 777 | 777 | const mine = await head(writer); |
| 778 | 778 | expect(mine).toContain("not running"); |
| 779 | 779 | expect(mine).toContain('action="/alice/notes/app/production/redeploy"'); |
| 780 | expect(mine).not.toContain('action="/alice/notes/app/production/stop"'); | |
| 780 | expect(mine).not.toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"'); | |
| 781 | 781 | expect(mine).not.toContain('href="/alice/notes/app/production/open"'); |
| 782 | 782 | expect(mine).toContain('title="Not running"'); |
| 783 | 783 | expect(await shelfBadge(writer)).toBe("not running"); |
test/studio-search.test.ts +4 −4
| @@ -798,7 +798,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 798 | 798 | putLive(); |
| 799 | 799 | const serving = await studioPage(); |
| 800 | 800 | expect(serving).toContain("serving"); |
| 801 | expect(serving).toContain('action="/alice/notes/app/production/stop"'); | |
| 801 | expect(serving).toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"'); | |
| 802 | 802 | // The page a person stays on is the one that refreshes itself: its live |
| 803 | 803 | // mount asks for the app's state on a deploy event. |
| 804 | 804 | expect(serving).toContain('data-app="1"'); |
| @@ -817,7 +817,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 817 | 817 | expect(html).toContain('id="app-head"'); |
| 818 | 818 | expect(html).toContain("not running"); |
| 819 | 819 | expect(html).toContain('action="/alice/notes/app/production/redeploy"'); |
| 820 | expect(html).not.toContain('action="/alice/notes/app/production/stop"'); | |
| 820 | expect(html).not.toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"'); | |
| 821 | 821 | }); |
| 822 | 822 | |
| 823 | 823 | test("pressed on a change's page, Stop comes back to that change", async () => { |
| @@ -931,7 +931,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 931 | 931 | }); |
| 932 | 932 | const owner = await studioPage(); |
| 933 | 933 | expect(owner).toContain(`action="/alice/notes/app/${env}/redeploy"`); |
| 934 | expect(owner).toContain(`action="/alice/notes/app/${env}/stop"`); | |
| 934 | expect(owner).toContain(`href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2F${env}%2Fstop"`); | |
| 935 | 935 | expect(owner).toContain(`href="/alice/notes/app/${env}/open"`); |
| 936 | 936 | expect(owner).toContain('href="/alice/notes/settings/app"'); |
| 937 | 937 | |
| @@ -940,7 +940,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 940 | 940 | ).text(); |
| 941 | 941 | expect(stranger).toContain(`href="/alice/notes/app/${env}/open"`); |
| 942 | 942 | expect(stranger).not.toContain("/redeploy"); |
| 943 | expect(stranger).not.toContain("/stop"); | |
| 943 | expect(stranger).not.toContain("%2Fstop"); | |
| 944 | 944 | expect(stranger).not.toContain('href="/alice/notes/settings/app"'); |
| 945 | 945 | }); |
| 946 | 946 | }); |
new above
offline