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

"Open the app" is offered before there is an app to open #170

Merged opened this

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

Merged

scout/forge:scout/164-open-the-app-is-offered-before-there-is → main 1 commit fork

  • Merged. scout/164-open-the-app-is-offered-before-there-is was merged into main as 47204fb.

Review

Sign in to review this change.
2 files changed +90 −12
src/web/studio/previews.ts +35 −9
@@ -39,6 +39,34 @@ import {
3939 import { lookNow } from "../pages/looks.ts";
4040 import { LOG_TAIL_CAP, logBox } from "./log.ts";
4141
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
4270 /** The three words the studio uses for a preview, whatever its check says. */
4371 function previewBadge(state: EnvState | undefined): Html {
4472 if (state === "running")
@@ -190,10 +218,12 @@ export function appPanels(
190218 ${repo.description || "No description yet."}
191219 ${url === undefined
192220 ? 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>`}
197227 </p>
198228 ${when(production, (env) => envFacts(base, env))}
199229 ${when(writer, () =>
@@ -206,11 +236,7 @@ export function appPanels(
206236 </div>
207237 <span class="spacer"></span>
208238 ${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)}
214240 ${when(production, (env) => envActions(base, env, writer))}
215241 </div>`;
216242 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", () => {
19131913 readonly chain: Promise<void>;
19141914 readonly restarts: number[];
19151915 readonly live?: unknown;
1916 readonly deploying?: unknown;
19161917 readonly lastError?: string;
19171918 readonly lastRunId?: string;
19181919 }
@@ -1994,7 +1995,7 @@ describe("the studio head says why the app is not up", () => {
19941995 await rm(dataDir, { recursive: true, force: true });
19951996 });
19961997
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 () => {
19981999 put({
19992000 lastError: "the deploy failed: <install> exited 1",
20002001 lastRunId: deploy,
@@ -2007,6 +2008,8 @@ describe("the studio head says why the app is not up", () => {
20072008 expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`);
20082009 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
20092010 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"');
20102013
20112014 const theirs = await head(reader);
20122015 expect(theirs).toContain("not up");
@@ -2014,17 +2017,30 @@ describe("the studio head says why the app is not up", () => {
20142017 expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`);
20152018 expect(theirs).not.toContain("/app/production/redeploy");
20162019 expect(theirs).not.toContain("/app/production/stop");
2020 expect(theirs).not.toContain('href="/alice/notes/app/production/open"');
20172021 });
20182022
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 () => {
20202024 put({ lastRunId: deploy });
20212025 const mine = await head(writer);
20222026 expect(mine).toContain("not running");
20232027 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
20242028 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"');
20252031 });
20262032
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 () => {
20282044 put({
20292045 live: {
20302046 commitOid: "0".repeat(40),
@@ -2039,6 +2055,7 @@ describe("the studio head says why the app is not up", () => {
20392055 expect(mine).toContain("serving · tests failed");
20402056 expect(mine).not.toContain("not up");
20412057 expect(mine).toContain("serving · tests failed: 1 of 3 tests failed");
2058 expect(mine).toContain('href="/alice/notes/app/production/open"');
20422059 });
20432060 });
20442061
@@ -2546,6 +2563,41 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
25462563 // already has. That gap is the whole point of the issue.
25472564 expect(getRun(app.db, deployRun.id)?.state).toBe("running");
25482565 });
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 });
25492601 });
25502602
25512603 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs