Public: anyone can read this; the crew can change it.
"Open the app" is offered before there is an app to open #170
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/164-open-the-app-is-offered-before-there-is → main 1 commit fork
2 files
changed +90
−12
src/web/studio/previews.ts +35 −9
| @@ -39,6 +39,34 @@ import { | ||
| 39 | 39 | import { lookNow } from "../pages/looks.ts"; |
| 40 | 40 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; |
| 41 | 41 | |
| 42 | /** | |
| 43 | * The primary button, offered only once production answers — a preview | |
| 44 | * whose tests failed is still serving, so `running` is enough, `error` | |
| 45 | * clear or not (#118). Anything short of that gets the same button shape, | |
| 46 | * disabled, with the reason it is not there in its title: never a link to | |
| 47 | * a host nothing is serving yet. | |
| 48 | */ | |
| 49 | function openAppBtn(base: string, env: EnvStatus | undefined): Html { | |
| 50 | if (env?.state === "running") | |
| 51 | return btn("Open the app", { | |
| 52 | href: `${base}/app/production/open`, | |
| 53 | variant: "primary", | |
| 54 | icon: "external", | |
| 55 | }); | |
| 56 | const reason = | |
| 57 | env === undefined || env.state === "stopped" | |
| 58 | ? "Not running" | |
| 59 | : env.state === "deploying" | |
| 60 | ? "Still deploying" | |
| 61 | : "The last deploy failed"; | |
| 62 | return btn("Open the app", { | |
| 63 | variant: "primary", | |
| 64 | icon: "external", | |
| 65 | disabled: true, | |
| 66 | title: reason, | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 42 | 70 | /** The three words the studio uses for a preview, whatever its check says. */ |
| 43 | 71 | function previewBadge(state: EnvState | undefined): Html { |
| 44 | 72 | if (state === "running") |
| @@ -190,10 +218,12 @@ export function appPanels( | ||
| 190 | 218 | ${repo.description || "No description yet."} |
| 191 | 219 | ${url === undefined |
| 192 | 220 | ? null |
| 193 | : html`· | |
| 194 | <a href="${base}/app/production/open" class="mono" | |
| 195 | >${url.replace(/^https?:\/\//, "")}</a | |
| 196 | >`} | |
| 221 | : production?.state === "running" | |
| 222 | ? html`· | |
| 223 | <a href="${base}/app/production/open" class="mono" | |
| 224 | >${url.replace(/^https?:\/\//, "")}</a | |
| 225 | >` | |
| 226 | : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`} | |
| 197 | 227 | </p> |
| 198 | 228 | ${when(production, (env) => envFacts(base, env))} |
| 199 | 229 | ${when(writer, () => |
| @@ -206,11 +236,7 @@ export function appPanels( | ||
| 206 | 236 | </div> |
| 207 | 237 | <span class="spacer"></span> |
| 208 | 238 | ${lookNow(ctx, repo, record)} |
| 209 | ${btn("Open the app", { | |
| 210 | href: `${base}/app/production/open`, | |
| 211 | variant: "primary", | |
| 212 | icon: "external", | |
| 213 | })} | |
| 239 | ${openAppBtn(base, production)} | |
| 214 | 240 | ${when(production, (env) => envActions(base, env, writer))} |
| 215 | 241 | </div>`; |
| 216 | 242 | return { head, previews, deployTailing: tail !== undefined }; |
test/studio.test.ts +55 −3
| @@ -1913,6 +1913,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 1913 | 1913 | readonly chain: Promise<void>; |
| 1914 | 1914 | readonly restarts: number[]; |
| 1915 | 1915 | readonly live?: unknown; |
| 1916 | readonly deploying?: unknown; | |
| 1916 | 1917 | readonly lastError?: string; |
| 1917 | 1918 | readonly lastRunId?: string; |
| 1918 | 1919 | } |
| @@ -1994,7 +1995,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 1994 | 1995 | await rm(dataDir, { recursive: true, force: true }); |
| 1995 | 1996 | }); |
| 1996 | 1997 | |
| 1997 | test("a failed production says what failed, links its deploy, and offers Redeploy to a writer only", async () => { | |
| 1998 | test("a failed production says what failed, links its deploy, and offers Redeploy to a writer only — and offers no live link to the host", async () => { | |
| 1998 | 1999 | put({ |
| 1999 | 2000 | lastError: "the deploy failed: <install> exited 1", |
| 2000 | 2001 | lastRunId: deploy, |
| @@ -2007,6 +2008,8 @@ describe("the studio head says why the app is not up", () => { | ||
| 2007 | 2008 | expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`); |
| 2008 | 2009 | expect(mine).toContain('action="/alice/notes/app/production/redeploy"'); |
| 2009 | 2010 | expect(mine).toContain('action="/alice/notes/app/production/stop"'); |
| 2011 | expect(mine).not.toContain('href="/alice/notes/app/production/open"'); | |
| 2012 | expect(mine).toContain('title="The last deploy failed"'); | |
| 2010 | 2013 | |
| 2011 | 2014 | const theirs = await head(reader); |
| 2012 | 2015 | expect(theirs).toContain("not up"); |
| @@ -2014,17 +2017,30 @@ describe("the studio head says why the app is not up", () => { | ||
| 2014 | 2017 | expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`); |
| 2015 | 2018 | expect(theirs).not.toContain("/app/production/redeploy"); |
| 2016 | 2019 | expect(theirs).not.toContain("/app/production/stop"); |
| 2020 | expect(theirs).not.toContain('href="/alice/notes/app/production/open"'); | |
| 2017 | 2021 | }); |
| 2018 | 2022 | |
| 2019 | test("a stopped production says it is not running and offers Redeploy, not Stop", async () => { | |
| 2023 | test("a stopped production says it is not running and offers Redeploy, not Stop — and no live link to the host", async () => { | |
| 2020 | 2024 | put({ lastRunId: deploy }); |
| 2021 | 2025 | const mine = await head(writer); |
| 2022 | 2026 | expect(mine).toContain("not running"); |
| 2023 | 2027 | expect(mine).toContain('action="/alice/notes/app/production/redeploy"'); |
| 2024 | 2028 | expect(mine).not.toContain('action="/alice/notes/app/production/stop"'); |
| 2029 | expect(mine).not.toContain('href="/alice/notes/app/production/open"'); | |
| 2030 | expect(mine).toContain('title="Not running"'); | |
| 2025 | 2031 | }); |
| 2026 | 2032 | |
| 2027 | test("a serving app with red tests still reads as serving, with the failure in its own words", async () => { | |
| 2033 | test("a deploying production says so where the button was, not a link to a host nothing is serving yet", async () => { | |
| 2034 | put({ | |
| 2035 | deploying: { run: { id: deploy, commitOid: "0".repeat(40) } }, | |
| 2036 | }); | |
| 2037 | const mine = await head(writer); | |
| 2038 | expect(mine).toContain("starting"); | |
| 2039 | expect(mine).not.toContain('href="/alice/notes/app/production/open"'); | |
| 2040 | expect(mine).toContain('title="Still deploying"'); | |
| 2041 | }); | |
| 2042 | ||
| 2043 | test("a serving app with red tests still reads as serving, with the failure in its own words, and is still open-able", async () => { | |
| 2028 | 2044 | put({ |
| 2029 | 2045 | live: { |
| 2030 | 2046 | commitOid: "0".repeat(40), |
| @@ -2039,6 +2055,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 2039 | 2055 | expect(mine).toContain("serving · tests failed"); |
| 2040 | 2056 | expect(mine).not.toContain("not up"); |
| 2041 | 2057 | expect(mine).toContain("serving · tests failed: 1 of 3 tests failed"); |
| 2058 | expect(mine).toContain('href="/alice/notes/app/production/open"'); | |
| 2042 | 2059 | }); |
| 2043 | 2060 | }); |
| 2044 | 2061 | |
| @@ -2546,6 +2563,41 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 2546 | 2563 | // already has. That gap is the whole point of the issue. |
| 2547 | 2564 | expect(getRun(app.db, deployRun.id)?.state).toBe("running"); |
| 2548 | 2565 | }); |
| 2566 | ||
| 2567 | test("production's Open the app link appears only once it is serving, and the fragment after a swap flips it", async () => { | |
| 2568 | const link = 'href="/alice/vault/app/production/open"'; | |
| 2569 | const deployRun = createRun(app.db, { | |
| 2570 | repoId: repo.id, | |
| 2571 | kind: "deploy", | |
| 2572 | env: "production", | |
| 2573 | commitOid: "cafed00d", | |
| 2574 | reason: "push", | |
| 2575 | }); | |
| 2576 | claimRun(app.db, deployRun.id); | |
| 2577 | putEnv("production", { | |
| 2578 | deploying: { | |
| 2579 | run: { id: deployRun.id, commitOid: "cafed00d" }, | |
| 2580 | abort: new AbortController(), | |
| 2581 | }, | |
| 2582 | }); | |
| 2583 | const deploying = await (await get("/alice/vault/app/state", owner)).text(); | |
| 2584 | expect(deploying).toContain("starting"); | |
| 2585 | expect(deploying).not.toContain(link); | |
| 2586 | ||
| 2587 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); | |
| 2588 | putEnv("production", { | |
| 2589 | live: { | |
| 2590 | commitOid: "cafed00d", | |
| 2591 | handle: { port: 4325 }, | |
| 2592 | runId: deployRun.id, | |
| 2593 | since: new Date().toISOString(), | |
| 2594 | manifest: { tty: null }, | |
| 2595 | }, | |
| 2596 | }); | |
| 2597 | const serving = await (await get("/alice/vault/app/state", owner)).text(); | |
| 2598 | expect(serving).toContain(">live<"); | |
| 2599 | expect(serving).toContain(link); | |
| 2600 | }); | |
| 2549 | 2601 | }); |
| 2550 | 2602 | |
| 2551 | 2603 | /** |
new above
offline