Public: anyone can read this; the crew can change it.
The app's studio page shows a new deployment arriving without a reload #53
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/35-the-app-s-studio-page-shows-a-new-deploy → main 1 commit fork
7 files
changed +348
−46
docs/STUDIO.md +4 −1
| @@ -32,7 +32,10 @@ An app's home in the studio (`/:owner/:name`, when the repository is an | ||
| 32 | 32 | app) shows, top to bottom: what it serves and where; the box; what asks |
| 33 | 33 | you; what is happening now. Beside it, who is on the crew and what each is |
| 34 | 34 | for, this week's numbers (shipped, runs, spend, spend per shipped change), |
| 35 | and how the repository runs itself, when it does. | |
| 35 | and how the repository runs itself, when it does. The page keeps itself | |
| 36 | current about deployments: a deploy run for the app being viewed refetches | |
| 37 | the head card and previews list in place, so a change serving in its | |
| 38 | preview shows up without a reload. | |
| 36 | 39 | |
| 37 | 40 | **The box** is the front door. A sentence becomes an issue — the first line |
| 38 | 41 | its title, the rest its body — and the routing decision hands it to a |
src/http/server.ts +4
| @@ -77,6 +77,7 @@ import { | ||
| 77 | 77 | } from "../web/pages/issues.ts"; |
| 78 | 78 | import { globalMapPage, mapPage } from "../web/pages/map.ts"; |
| 79 | 79 | import { |
| 80 | appStateFragment, | |
| 80 | 81 | asksSubmit, |
| 81 | 82 | modeSubmit, |
| 82 | 83 | saySubmit, |
| @@ -597,6 +598,9 @@ export function buildRouter(): Router<Handler> { | ||
| 597 | 598 | // The app a repository runs as, and the runs it produced. Opening an |
| 598 | 599 | // environment goes through the forge so a private app gets its grant. |
| 599 | 600 | router.add("GET", "/:owner/:name/app", appPage); |
| 601 | // The studio page's own live refresh: the head card and previews list, | |
| 602 | // bare, fetched on a deploy run event for the app being viewed. | |
| 603 | router.add("GET", "/:owner/:name/app/state", appStateFragment); | |
| 600 | 604 | router.add("GET", "/:owner/:name/app/:env/open", appOpen); |
| 601 | 605 | // Where a private app sends a person who arrived by its link. |
| 602 | 606 | router.add("GET", "/apps/open", appsOpen); |
src/web/layout.ts +2
| @@ -521,6 +521,8 @@ export function fragment(body: Html, status = 200): Response { | ||
| 521 | 521 | "content-type": "text/html; charset=utf-8", |
| 522 | 522 | "content-security-policy": |
| 523 | 523 | "default-src 'none'; style-src 'none'; sandbox", |
| 524 | "referrer-policy": "same-origin", | |
| 525 | "x-content-type-options": "nosniff", | |
| 524 | 526 | "cache-control": "no-store", |
| 525 | 527 | }, |
| 526 | 528 | }); |
src/web/live.ts +41
| @@ -34,6 +34,12 @@ export interface LiveConfig { | ||
| 34 | 34 | number?: number; |
| 35 | 35 | /** Id of the last comment already on the page (for the ?after= fetch). */ |
| 36 | 36 | lastCommentId?: string; |
| 37 | /** | |
| 38 | * The studio app page: a deploy run event for this repo fetches | |
| 39 | * `<repo>/app/state` and swaps the head card and previews list in place, | |
| 40 | * rather than only offering the pill. | |
| 41 | */ | |
| 42 | app?: boolean; | |
| 37 | 43 | } |
| 38 | 44 | |
| 39 | 45 | /** The config element, the polite status line, and the (hidden) activity pill. */ |
| @@ -46,6 +52,7 @@ export function liveMount(config: LiveConfig): Html { | ||
| 46 | 52 | data-thread="${config.thread ?? ""}" |
| 47 | 53 | data-number="${config.number ?? ""}" |
| 48 | 54 | data-last="${config.lastCommentId ?? ""}" |
| 55 | data-app="${config.app ? "1" : ""}" | |
| 49 | 56 | ></div> |
| 50 | 57 | <p |
| 51 | 58 | class="live-status" |
| @@ -66,6 +73,7 @@ export const liveClientJs = /* js */ ` | ||
| 66 | 73 | var thread = cfg.getAttribute('data-thread'); |
| 67 | 74 | var number = cfg.getAttribute('data-number'); |
| 68 | 75 | var lastId = cfg.getAttribute('data-last') || ''; |
| 76 | var isApp = cfg.getAttribute('data-app') === '1'; | |
| 69 | 77 | var pill = document.getElementById('live-pill'); |
| 70 | 78 | var status = document.getElementById('live-status'); |
| 71 | 79 | var conv = document.getElementById('conversation'); |
| @@ -117,9 +125,42 @@ export const liveClientJs = /* js */ ` | ||
| 117 | 125 | .catch(function () {}); |
| 118 | 126 | } |
| 119 | 127 | |
| 128 | // A deploy run for the app this page is about: refetch the app's own | |
| 129 | // fragment and swap the head card and previews list in place, so a change | |
| 130 | // serving in its preview shows up without a reload. At most one fetch in | |
| 131 | // flight — a burst of queued/started/finished for the same push collapses | |
| 132 | // to whatever the last one finds. | |
| 133 | var appBusy = false, appQueued = false; | |
| 134 | function isDeploy(data) { | |
| 135 | return isApp && data && data.kind && data.kind.indexOf('run.') === 0 && | |
| 136 | data.payload && data.payload.kind === 'deploy'; | |
| 137 | } | |
| 138 | function refreshApp() { | |
| 139 | if (appBusy) { appQueued = true; return; } | |
| 140 | appBusy = true; | |
| 141 | fetch(repo + '/app/state', { headers: { accept: 'text/html' } }) | |
| 142 | .then(function (r) { return r.ok ? r.text() : ''; }) | |
| 143 | .then(function (text) { | |
| 144 | if (!text) return; | |
| 145 | var holder = document.createElement('div'); | |
| 146 | holder.innerHTML = text; | |
| 147 | ['app-head', 'app-previews'].forEach(function (id) { | |
| 148 | var mine = document.getElementById(id); | |
| 149 | var theirs = holder.querySelector('#' + id); | |
| 150 | if (mine && theirs) mine.replaceWith(theirs); | |
| 151 | }); | |
| 152 | }) | |
| 153 | .catch(function () {}) | |
| 154 | .then(function () { | |
| 155 | appBusy = false; | |
| 156 | if (appQueued) { appQueued = false; refreshApp(); } | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 120 | 160 | function onEvent(e) { |
| 121 | 161 | var data; |
| 122 | 162 | try { data = JSON.parse(e.data); } catch (_) { return; } |
| 163 | if (isDeploy(data)) { refreshApp(); return; } | |
| 123 | 164 | if (relevant(data) && conv && (data.kind === thread + '.commented')) { |
| 124 | 165 | appendNew(); |
| 125 | 166 | } else if (relevant(data) || !thread) { |
src/web/pages/studio.ts +80 −44
| @@ -79,7 +79,7 @@ import { | ||
| 79 | 79 | import { field, readForm } from "../form.ts"; |
| 80 | 80 | import { html, when, type Html } from "../html.ts"; |
| 81 | 81 | import { asksJs, liveRegionsJs } from "../client.ts"; |
| 82 | import { flashOrJson, flashTo, page, redirect } from "../layout.ts"; | |
| 82 | import { flashOrJson, flashTo, fragment, page, redirect } from "../layout.ts"; | |
| 83 | 83 | import { liveClientJs, liveMount } from "../live.ts"; |
| 84 | 84 | import { isMode, modeCookie } from "../mode.ts"; |
| 85 | 85 | import { |
| @@ -668,51 +668,20 @@ function previewsPanel( | ||
| 668 | 668 | ); |
| 669 | 669 | } |
| 670 | 670 | |
| 671 | // ---- the app in the studio ---- | |
| 672 | ||
| 673 | export async function studioApp( | |
| 671 | /** | |
| 672 | * The head card and the previews list: what production is doing, and every | |
| 673 | * open pull's own environment. One function, called for the page's first | |
| 674 | * paint and for the fragment a deploy event refreshes in place | |
| 675 | * (`appStateFragment`) — the two never draw two copies of the same markup. | |
| 676 | */ | |
| 677 | function appPanels( | |
| 674 | 678 | ctx: Ctx, |
| 675 | 679 | repo: Repo, |
| 676 | 680 | record: AppRecord, |
| 677 | ): Promise<Response> { | |
| 681 | openPulls: readonly Pull[], | |
| 682 | ): { head: Html; previews: Html | null } { | |
| 678 | 683 | const db = ctx.app.db; |
| 679 | 684 | const base = repoBase(repo); |
| 680 | const crew = listCrewRoles(db, repo.id); | |
| 681 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent); | |
| 682 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 }); | |
| 683 | const actors = getUsersByIds( | |
| 684 | db, | |
| 685 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 686 | ); | |
| 687 | const issues = new Map<string, Issue>(); | |
| 688 | for (const r of runs) | |
| 689 | if (r.issueId !== null && !issues.has(r.issueId)) { | |
| 690 | const i = getIssueById(db, r.issueId); | |
| 691 | if (i) issues.set(r.issueId, i); | |
| 692 | } | |
| 693 | const decisions = withLikely(ctx, decisionsFor(ctx, [repo])); | |
| 694 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); | |
| 695 | const pullChecks = latestRunsForIssues( | |
| 696 | db, | |
| 697 | openPulls.map((p) => p.id), | |
| 698 | ); | |
| 699 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); | |
| 700 | const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( | |
| 701 | (r) => r.createdAt >= week, | |
| 702 | ); | |
| 703 | const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0); | |
| 704 | const shipped = listPulls(db, repo.id, { | |
| 705 | state: "closed", | |
| 706 | limit: 100, | |
| 707 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length; | |
| 708 | const ledgerWeek = decisionStats(db, repo.id, week); | |
| 709 | const autonomy = | |
| 710 | repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 711 | const moving = runs.some( | |
| 712 | (r) => r.state === "running" || r.state === "queued", | |
| 713 | ); | |
| 714 | ||
| 715 | // ---- the app itself ---- | |
| 716 | 685 | const runtime = appsRuntime(ctx.app); |
| 717 | 686 | const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record); |
| 718 | 687 | const previews = previewsPanel(base, runtime, record, openPulls, envs); |
| @@ -753,6 +722,73 @@ export async function studioApp( | ||
| 753 | 722 | })} |
| 754 | 723 | ${when(production, (env) => envActions(base, env, writer))} |
| 755 | 724 | </div>`; |
| 725 | return { head, previews }; | |
| 726 | } | |
| 727 | ||
| 728 | /** | |
| 729 | * GET /:owner/:name/app/state — the head card and previews list, bare. The | |
| 730 | * studio page's own live client (`live.ts`) fetches this on a deploy run | |
| 731 | * event for the app being viewed and swaps the two in place, so a change | |
| 732 | * serving in its preview shows up without a reload. Authorized exactly as | |
| 733 | * the studio page: a viewer who cannot see the app gets the same 404. | |
| 734 | */ | |
| 735 | export function appStateFragment(ctx: Ctx): Response { | |
| 736 | const repo = loadRepo(ctx); | |
| 737 | if (!repo) return fragment(html``, 404); | |
| 738 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 739 | if (!record) return fragment(html``, 404); | |
| 740 | const openPulls = listPulls(ctx.app.db, repo.id, { | |
| 741 | state: "open", | |
| 742 | limit: 10, | |
| 743 | }); | |
| 744 | const { head, previews } = appPanels(ctx, repo, record, openPulls); | |
| 745 | return fragment(html`<div id="app-head">${head}</div> | |
| 746 | <div id="app-previews">${previews}</div>`); | |
| 747 | } | |
| 748 | ||
| 749 | // ---- the app in the studio ---- | |
| 750 | ||
| 751 | export async function studioApp( | |
| 752 | ctx: Ctx, | |
| 753 | repo: Repo, | |
| 754 | record: AppRecord, | |
| 755 | ): Promise<Response> { | |
| 756 | const db = ctx.app.db; | |
| 757 | const base = repoBase(repo); | |
| 758 | const crew = listCrewRoles(db, repo.id); | |
| 759 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent); | |
| 760 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 }); | |
| 761 | const actors = getUsersByIds( | |
| 762 | db, | |
| 763 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 764 | ); | |
| 765 | const issues = new Map<string, Issue>(); | |
| 766 | for (const r of runs) | |
| 767 | if (r.issueId !== null && !issues.has(r.issueId)) { | |
| 768 | const i = getIssueById(db, r.issueId); | |
| 769 | if (i) issues.set(r.issueId, i); | |
| 770 | } | |
| 771 | const decisions = withLikely(ctx, decisionsFor(ctx, [repo])); | |
| 772 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); | |
| 773 | const pullChecks = latestRunsForIssues( | |
| 774 | db, | |
| 775 | openPulls.map((p) => p.id), | |
| 776 | ); | |
| 777 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); | |
| 778 | const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( | |
| 779 | (r) => r.createdAt >= week, | |
| 780 | ); | |
| 781 | const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0); | |
| 782 | const shipped = listPulls(db, repo.id, { | |
| 783 | state: "closed", | |
| 784 | limit: 100, | |
| 785 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length; | |
| 786 | const ledgerWeek = decisionStats(db, repo.id, week); | |
| 787 | const autonomy = | |
| 788 | repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 789 | ||
| 790 | // ---- the app itself ---- | |
| 791 | const { head, previews } = appPanels(ctx, repo, record, openPulls); | |
| 756 | 792 | |
| 757 | 793 | // ---- the box ---- |
| 758 | 794 | const tell = html`<form |
| @@ -1011,7 +1047,7 @@ export async function studioApp( | ||
| 1011 | 1047 | compact: true, |
| 1012 | 1048 | ownTitle: true, |
| 1013 | 1049 | })} |
| 1014 | ${head} | |
| 1050 | <div id="app-head">${head}</div> | |
| 1015 | 1051 | <div class="dash"> |
| 1016 | 1052 | <div class="dash-main stack"> |
| 1017 | 1053 | ${tell} |
| @@ -1022,7 +1058,7 @@ export async function studioApp( | ||
| 1022 | 1058 | > |
| 1023 | 1059 | ${asks} |
| 1024 | 1060 | </div> |
| 1025 | ${previews} | |
| 1061 | <div id="app-previews">${previews}</div> | |
| 1026 | 1062 | <div |
| 1027 | 1063 | id="live-now" |
| 1028 | 1064 | data-live="run.*,pull.*,issue.*" |
| @@ -1035,7 +1071,7 @@ export async function studioApp( | ||
| 1035 | 1071 | ${crewCard} ${weekCard} ${ownCard} ${more} |
| 1036 | 1072 | </aside> |
| 1037 | 1073 | </div> |
| 1038 | ${when(moving, () => liveMount({ repo: base }))}`; | |
| 1074 | ${liveMount({ repo: base, app: true })}`; | |
| 1039 | 1075 | return page({ |
| 1040 | 1076 | ctx, |
| 1041 | 1077 | title: `${repo.name}`, |
test/live-pages.test.ts +35
| @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 10 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | import { enableApp } from "../src/actions/apps.ts"; | |
| 13 | 14 | import { editFiles } from "../src/actions/contents.ts"; |
| 14 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 15 | 16 | import { createRepo } from "../src/core/repos.ts"; |
| @@ -70,4 +71,38 @@ describe("live browsing pages", () => { | ||
| 70 | 71 | expect(html).toContain('id="live"'); |
| 71 | 72 | expect(html).toContain("new EventSource"); |
| 72 | 73 | }); |
| 74 | ||
| 75 | test("the studio app page wires up the live client with data-app, so a deploy run event refetches the app's own fragment", async () => { | |
| 76 | const user = await createUser(app.db, { | |
| 77 | username: "nia", | |
| 78 | email: "[email protected]", | |
| 79 | password: "live-pages-pass", | |
| 80 | adminIfFirst: true, | |
| 81 | }); | |
| 82 | if (!user.ok) throw new Error("user"); | |
| 83 | const repo = await createRepo(app.db, app.reposRoot, { | |
| 84 | ownerId: user.value.id, | |
| 85 | name: "runner", | |
| 86 | visibility: "public", | |
| 87 | }); | |
| 88 | if (!repo.ok) throw new Error("repo"); | |
| 89 | const committed = await editFiles(app, user.value, repo.value, { | |
| 90 | files: [{ path: "README.md", content: "# runner\n" }], | |
| 91 | message: "first commit", | |
| 92 | }); | |
| 93 | if (!committed.ok) throw new Error("commit"); | |
| 94 | const enabled = await enableApp(app, user.value, repo.value, { | |
| 95 | slug: "runner", | |
| 96 | }); | |
| 97 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 98 | ||
| 99 | const html = await ( | |
| 100 | await fetch(`${base}/nia/runner`, { | |
| 101 | headers: { cookie: "forge_mode=studio" }, | |
| 102 | }) | |
| 103 | ).text(); | |
| 104 | expect(html).toContain('id="live"'); | |
| 105 | expect(html).toContain('data-app="1"'); | |
| 106 | expect(html).toContain("new EventSource"); | |
| 107 | }); | |
| 73 | 108 | }); |
test/studio.test.ts +182 −1
| @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { dirname, join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | import { enableApp } from "../src/actions/apps.ts"; |
| 12 | import type { EnvName } from "../src/apps/data.ts"; | |
| 12 | import { previewEnv, type EnvName } from "../src/apps/data.ts"; | |
| 13 | 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 14 | 14 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 15 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| @@ -915,3 +915,184 @@ describe("the studio head says why the app is not up", () => { | ||
| 915 | 915 | expect(mine).toContain("serving · tests failed: 1 of 3 tests failed"); |
| 916 | 916 | }); |
| 917 | 917 | }); |
| 918 | ||
| 919 | /** | |
| 920 | * GET /:owner/:name/app/state — the fragment the studio page's live client | |
| 921 | * fetches on a deploy run event, and swaps into `#app-head`/`#app-previews` | |
| 922 | * in place of a reload. It is authorized exactly as the studio page, and | |
| 923 | * renders from the same `appPanels` function the page's first paint does. | |
| 924 | */ | |
| 925 | describe("the app's live fragment: /:owner/:name/app/state", () => { | |
| 926 | let dataDir: string; | |
| 927 | let app: App; | |
| 928 | let servers: Servers; | |
| 929 | let base: string; | |
| 930 | let runtime: AppsRuntime; | |
| 931 | let repo: Repo; | |
| 932 | let record: AppRecord; | |
| 933 | let owner: string; | |
| 934 | let stranger: string; | |
| 935 | ||
| 936 | interface PreviewSlot { | |
| 937 | readonly app: AppRecord; | |
| 938 | readonly env: EnvName; | |
| 939 | readonly chain: Promise<void>; | |
| 940 | readonly restarts: number[]; | |
| 941 | deploying?: unknown; | |
| 942 | live?: unknown; | |
| 943 | lastError?: string; | |
| 944 | lastRunId?: string; | |
| 945 | } | |
| 946 | const putEnv = ( | |
| 947 | env: EnvName, | |
| 948 | slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">, | |
| 949 | ): void => { | |
| 950 | (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set( | |
| 951 | `${record.id}/${env}`, | |
| 952 | { app: record, env, chain: Promise.resolve(), restarts: [], ...slot }, | |
| 953 | ); | |
| 954 | }; | |
| 955 | ||
| 956 | const signIn = async (username: string): Promise<string> => | |
| 957 | ( | |
| 958 | ( | |
| 959 | await fetch(`${base}/signin`, { | |
| 960 | method: "POST", | |
| 961 | body: new URLSearchParams({ | |
| 962 | username, | |
| 963 | password: "a-good-passphrase", | |
| 964 | }), | |
| 965 | redirect: "manual", | |
| 966 | }) | |
| 967 | ).headers.get("set-cookie") ?? "" | |
| 968 | ).split(";")[0]!; | |
| 969 | ||
| 970 | const get = (path: string, cookie: string) => | |
| 971 | fetch(`${base}${path}`, { | |
| 972 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 973 | redirect: "manual", | |
| 974 | }); | |
| 975 | ||
| 976 | beforeAll(async () => { | |
| 977 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-state-")); | |
| 978 | app = createApp({ dataDir, port: 0 }); | |
| 979 | servers = await serve(app); | |
| 980 | base = servers.url; | |
| 981 | runtime = new AppsRuntime(app, { | |
| 982 | domain: "state.test", | |
| 983 | scheme: "http", | |
| 984 | publicPort: servers.port, | |
| 985 | forgeUrl: servers.url, | |
| 986 | concurrency: 1, | |
| 987 | }); | |
| 988 | const a = await createUser(app.db, { | |
| 989 | username: "alice", | |
| 990 | email: "[email protected]", | |
| 991 | password: "a-good-passphrase", | |
| 992 | adminIfFirst: true, | |
| 993 | }); | |
| 994 | if (!a.ok) throw new Error(a.error); | |
| 995 | const c = await createUser(app.db, { | |
| 996 | username: "carol", | |
| 997 | email: "[email protected]", | |
| 998 | password: "a-good-passphrase", | |
| 999 | }); | |
| 1000 | if (!c.ok) throw new Error(c.error); | |
| 1001 | owner = await signIn("alice"); | |
| 1002 | stranger = await signIn("carol"); | |
| 1003 | const made = await createRepo(app, a.value, { | |
| 1004 | name: "vault", | |
| 1005 | visibility: "private", | |
| 1006 | description: "", | |
| 1007 | }); | |
| 1008 | if (!made.ok) throw new Error(String(made.error)); | |
| 1009 | repo = made.value; | |
| 1010 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 1011 | message: "Start", | |
| 1012 | files: [{ path: "README.md", content: "v\n" }], | |
| 1013 | }); | |
| 1014 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 1015 | const enabled = await enableApp(app, a.value, repo, { slug: "vault" }); | |
| 1016 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 1017 | record = getAppByRepo(app.db, repo.id)!; | |
| 1018 | }); | |
| 1019 | ||
| 1020 | afterAll(async () => { | |
| 1021 | servers.stop(); | |
| 1022 | app.close(); | |
| 1023 | await rm(dataDir, { recursive: true, force: true }); | |
| 1024 | }); | |
| 1025 | ||
| 1026 | test("renders the head card for a viewer who may see it, with the security floor's headers; refuses one who may not, same as the page", async () => { | |
| 1027 | const fragment = await get("/alice/vault/app/state", owner); | |
| 1028 | expect(fragment.status).toBe(200); | |
| 1029 | expect(fragment.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 1030 | expect(fragment.headers.get("referrer-policy")).toBe("same-origin"); | |
| 1031 | const text = await fragment.text(); | |
| 1032 | expect(text).toContain('id="app-head"'); | |
| 1033 | expect(text).toContain('id="app-previews"'); | |
| 1034 | expect(text).toContain("not running"); | |
| 1035 | ||
| 1036 | const page = await (await get("/alice/vault", owner)).text(); | |
| 1037 | expect(page).toContain("not running"); | |
| 1038 | ||
| 1039 | const refusedFragment = await get("/alice/vault/app/state", stranger); | |
| 1040 | expect(refusedFragment.status).toBe(404); | |
| 1041 | const refusedPage = await get("/alice/vault", stranger); | |
| 1042 | expect(refusedPage.status).toBe(404); | |
| 1043 | }); | |
| 1044 | ||
| 1045 | test("a preview flips from deploying to live between two calls, and the link stays correct", async () => { | |
| 1046 | const alice = getUserByName(app.db, "alice")!; | |
| 1047 | const branch = await commitToBranch(app, alice, repo, { | |
| 1048 | branch: "feature/x", | |
| 1049 | base: repo.defaultBranch, | |
| 1050 | message: "Add X", | |
| 1051 | files: [{ path: "X.md", content: "x\n" }], | |
| 1052 | }); | |
| 1053 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1054 | const opened = await openPull(app, alice, repo, { | |
| 1055 | title: "Add X", | |
| 1056 | body: "", | |
| 1057 | baseBranch: repo.defaultBranch, | |
| 1058 | headBranch: "feature/x", | |
| 1059 | }); | |
| 1060 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1061 | const pull = opened.value; | |
| 1062 | const env = previewEnv(pull.number); | |
| 1063 | const link = `href="/alice/vault/app/${env}/open"`; | |
| 1064 | ||
| 1065 | const deployRun = createRun(app.db, { | |
| 1066 | repoId: repo.id, | |
| 1067 | kind: "deploy", | |
| 1068 | env, | |
| 1069 | commitOid: pull.headOid, | |
| 1070 | reason: "pull", | |
| 1071 | }); | |
| 1072 | claimRun(app.db, deployRun.id); | |
| 1073 | putEnv(env, { | |
| 1074 | deploying: { | |
| 1075 | run: { id: deployRun.id, commitOid: pull.headOid }, | |
| 1076 | abort: new AbortController(), | |
| 1077 | }, | |
| 1078 | }); | |
| 1079 | const starting = await (await get("/alice/vault/app/state", owner)).text(); | |
| 1080 | expect(starting).toContain("starting"); | |
| 1081 | expect(starting).not.toContain(">live<"); | |
| 1082 | expect(starting).toContain(link); | |
| 1083 | ||
| 1084 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); | |
| 1085 | putEnv(env, { | |
| 1086 | live: { | |
| 1087 | commitOid: pull.headOid, | |
| 1088 | handle: { port: 4321 }, | |
| 1089 | runId: deployRun.id, | |
| 1090 | since: new Date().toISOString(), | |
| 1091 | }, | |
| 1092 | }); | |
| 1093 | const live = await (await get("/alice/vault/app/state", owner)).text(); | |
| 1094 | expect(live).not.toContain("starting"); | |
| 1095 | expect(live).toContain(">live<"); | |
| 1096 | expect(live).toContain(link); | |
| 1097 | }); | |
| 1098 | }); |
new above
offline