Public: anyone can read this; the crew can change it.
A preview shows production's data and never says whose it is #244
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/222-a-preview-shows-production-s-data-and-ne → main 2 commits fork
4 files
changed +72
−0
src/web/pages/apps.ts +3
| @@ -67,6 +67,7 @@ import { | ||
| 67 | 67 | envStateBadge, |
| 68 | 68 | icon, |
| 69 | 69 | listCard, |
| 70 | previewDataNote, | |
| 70 | 71 | row, |
| 71 | 72 | seg, |
| 72 | 73 | selectField, |
| @@ -801,8 +802,10 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 801 | 802 | // A preview's terminal is a different login from production's — its own |
| 802 | 803 | // command, shown only to a writer, same as the app page's. |
| 803 | 804 | const writer = canWrite(ctx.app.db, repo, ctx.auth.user); |
| 805 | const dataNote = previewDataNote(view.value.app.previewData, pull.createdAt); | |
| 804 | 806 | return card( |
| 805 | 807 | html`${envFacts(base, env)} |
| 808 | ${dataNote === null ? null : html`<p class="muted small">${dataNote}</p>`} | |
| 806 | 809 | ${when(writer, () => |
| 807 | 810 | terminalLine( |
| 808 | 811 | terminalFor(ctx.app, view.value.app, env.env, env), |
src/web/studio/previews.ts +5
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | envPhrase, |
| 32 | 32 | icon, |
| 33 | 33 | listCard, |
| 34 | previewDataNote, | |
| 34 | 35 | row, |
| 35 | 36 | terminalLine, |
| 36 | 37 | time, |
| @@ -104,6 +105,10 @@ function previewRow( | ||
| 104 | 105 | if (env?.since) bits.push(html`up ${time(env.since)}`); |
| 105 | 106 | const say = env?.error ?? env?.failedDeploy?.error ?? env?.note; |
| 106 | 107 | if (say) bits.push(html`${say}`); |
| 108 | if (env !== undefined) { | |
| 109 | const dataNote = previewDataNote(record.previewData, pull.createdAt); | |
| 110 | if (dataNote !== null) bits.push(dataNote); | |
| 111 | } | |
| 107 | 112 | return row({ |
| 108 | 113 | ...(href === undefined ? {} : { href, nav: true }), |
| 109 | 114 | lead: icon("rocket"), |
src/web/ui.ts +21
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import type { EnvState, EnvStatus } from "../apps/runtime.ts"; |
| 14 | import type { PreviewData } from "../apps/data.ts"; | |
| 14 | 15 | import type { RunKind, RunState } from "../core/runs.ts"; |
| 15 | 16 | import { icon, type IconName } from "./icons.ts"; |
| 16 | 17 | import { Html, html, raw, when } from "./html.ts"; |
| @@ -390,6 +391,26 @@ export function envFacts( | ||
| 390 | 391 | )}`; |
| 391 | 392 | } |
| 392 | 393 | |
| 394 | /** | |
| 395 | * The one fact a preview says about whose data it has: its own copy of | |
| 396 | * production's, taken the moment the change that opened it did — the only | |
| 397 | * moment `ensureEnvData` (`apps/data.ts`) ever takes one, since a redeploy | |
| 398 | * of the same preview keeps what is already there. That instant is a | |
| 399 | * pull's own `createdAt`, already known wherever a preview is drawn, so | |
| 400 | * this never stats the data directory to answer. An app whose preview | |
| 401 | * policy is `empty` has no production data to name, so there is nothing to | |
| 402 | * say — and production's own card never calls this at all: there is | |
| 403 | * nothing to warn about data it simply *is*. Bare text, not a card or a | |
| 404 | * paragraph: the caller decides how it sits beside the rest of the facts. | |
| 405 | */ | |
| 406 | export function previewDataNote( | |
| 407 | policy: PreviewData, | |
| 408 | openedAt: string, | |
| 409 | ): Html | null { | |
| 410 | if (policy !== "copy") return null; | |
| 411 | return html`its own copy of production's data, taken ${time(openedAt)}`; | |
| 412 | } | |
| 413 | ||
| 393 | 414 | /** |
| 394 | 415 | * An environment's terminal, as copyable monospace text with the host key |
| 395 | 416 | * fingerprint beside it — so a first connection can be trusted deliberately |
test/studio.test.ts +43
| @@ -1738,6 +1738,49 @@ describe("every open pull's preview on the studio page", () => { | ||
| 1738 | 1738 | expect(after).not.toContain("<h2>Previews</h2>"); |
| 1739 | 1739 | expect(after).not.toContain(`pull-${pull.number}/open`); |
| 1740 | 1740 | }, 30_000); |
| 1741 | ||
| 1742 | test("a change's page names its preview's data as its own copy and gives the moment it was taken; production's card does not", async () => { | |
| 1743 | const alice = getUserByName(app.db, "alice")!; | |
| 1744 | const branch = await commitToBranch(app, alice, repo, { | |
| 1745 | branch: "feature/y", | |
| 1746 | base: repo.defaultBranch, | |
| 1747 | message: "Add Y", | |
| 1748 | files: [{ path: "Y.md", content: "y\n" }], | |
| 1749 | }); | |
| 1750 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1751 | const opened = await openPull(app, alice, repo, { | |
| 1752 | title: "Add Y", | |
| 1753 | body: "", | |
| 1754 | baseBranch: repo.defaultBranch, | |
| 1755 | headBranch: "feature/y", | |
| 1756 | }); | |
| 1757 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1758 | const pull = opened.value; | |
| 1759 | const record = getAppByRepo(app.db, repo.id)!; | |
| 1760 | const env = `pull-${pull.number}` as const; | |
| 1761 | ||
| 1762 | const deadline = Date.now() + 30_000; | |
| 1763 | while ( | |
| 1764 | Date.now() < deadline && | |
| 1765 | runtime.envStatus(record, env)?.state !== "running" | |
| 1766 | ) | |
| 1767 | await Bun.sleep(100); | |
| 1768 | expect(runtime.envStatus(record, env)?.state).toBe("running"); | |
| 1769 | ||
| 1770 | const note = "its own copy of production's data, taken"; | |
| 1771 | ||
| 1772 | const appPage = await (await get("/alice/widget")).text(); | |
| 1773 | expect(appPage).toContain(note); | |
| 1774 | // Production's own card, above the previews list, never says it. | |
| 1775 | const production = appPage.slice(0, appPage.indexOf("<h2>Previews</h2>")); | |
| 1776 | expect(production).not.toContain(note); | |
| 1777 | ||
| 1778 | const changePage = await ( | |
| 1779 | await get(`/alice/widget/changes/${pull.number}`) | |
| 1780 | ).text(); | |
| 1781 | expect(changePage).toContain("<h2>Preview</h2>"); | |
| 1782 | expect(changePage).toContain(note); | |
| 1783 | }, 30_000); | |
| 1741 | 1784 | }); |
| 1742 | 1785 | |
| 1743 | 1786 | describe("the studio home does not wait on the decider", () => { |
new above
offline