Public: anyone can read this; the crew can change it.
The toolbar page links to the app's other side: production from a preview, the newest open preview from production #6295
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6288-the-toolbar-page-links-to-the-app-s-othe → main 4 commits fork 5 threads · 5 unresolved
src/apps/side.ts +7 −1
| @@ -25,9 +25,15 @@ export interface Side { | ||
| 25 | 25 | /** A preview someone can open: up now, or waking on request. */ |
| 26 | 26 | const OPENABLE = new Set(["running", "asleep"]); |
| 27 | 27 | |
| 28 | /** A title short enough to keep the button on one line on a phone. */ | |
| 29 | function clip(title: string): string { | |
| 30 | const chars = [...title]; | |
| 31 | return chars.length <= 32 ? title : `${chars.slice(0, 31).join("")}…`; | |
| 32 | } | |
| 33 | ||
| 28 | 34 | export function sideOf(app: SideApp, environment: string, previews: readonly SidePreview[]): Side | null { |
| 29 | 35 | if (environment !== "production") return { label: "Open on production", url: app.url }; |
| 30 | 36 | let newest: SidePreview | undefined; |
| 31 | 37 | for (const p of previews) if (OPENABLE.has(p.state) && (newest === undefined || p.number > newest.number)) newest = p; |
| 32 | return newest === undefined ? null : { label: `Newest preview: #${newest.number} ${newest.title}`, url: newest.url }; | |
| 38 | return newest === undefined ? null : { label: `Newest preview: #${newest.number} ${clip(newest.title)}`, url: newest.url }; | |
| 33 | 39 | } |
src/web/studio/toolbar.ts +7 −4
| @@ -39,6 +39,7 @@ import { field, readForm } from "../form.ts"; | ||
| 39 | 39 | import { html, when, type Html } from "../html.ts"; |
| 40 | 40 | import { page } from "../layout.ts"; |
| 41 | 41 | import { originError } from "../pages/thread.ts"; |
| 42 | import { toolbarSide } from "./toolbar_side.ts"; | |
| 42 | 43 | import { btn, card, envFacts, runStateBadge, badge } from "../ui.ts"; |
| 43 | 44 | |
| 44 | 45 | /** The longest page address the bar may carry. */ |
| @@ -155,8 +156,8 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> { | ||
| 155 | 156 | const repo = timePhase(request, "repo", () => loadRepo(ctx)); |
| 156 | 157 | if (!repo) return timePhase(request, "render", () => notFoundPage(ctx)); |
| 157 | 158 | const where = parseEnv(ctx.url.searchParams.get("env") ?? "production"); |
| 158 | if (where === undefined || timePhase(request, "app-row", () => getAppByRepo(ctx.app.db, repo.id)) === undefined) | |
| 159 | return timePhase(request, "render", () => notFoundPage(ctx)); | |
| 159 | const record = where === undefined ? undefined : timePhase(request, "app-row", () => getAppByRepo(ctx.app.db, repo.id)); | |
| 160 | if (where === undefined || record === undefined) return timePhase(request, "render", () => notFoundPage(ctx)); | |
| 160 | 161 | const db = ctx.app.db; |
| 161 | 162 | const user = ctx.auth.user; |
| 162 | 163 | const pull = |
| @@ -170,6 +171,8 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> { | ||
| 170 | 171 | const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`; |
| 171 | 172 | const liveNote = pull ? html`` : await timePhaseAsync(request, "live", () => liveLine(ctx, repo, base)); |
| 172 | 173 | const home = btn("Home", { href: "/", variant: "ghost" }); |
| 174 | const side = timePhase(request, "side", () => toolbarSide(ctx, repo, record, where.env, address)); | |
| 175 | const other = side === null ? html`` : btn(side.label, { href: side.url, variant: "ghost", icon: "external" }); | |
| 173 | 176 | // The issue row carries no head oid; a pull row may be absent (an import in progress). |
| 174 | 177 | const joined = pull ? getPull(db, repo.id, pull.number) : undefined; |
| 175 | 178 | const change = joined |
| @@ -183,7 +186,7 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> { | ||
| 183 | 186 | const response = page({ |
| 184 | 187 | ctx, |
| 185 | 188 | title: `${repo.name} · forge`, |
| 186 | body: html`<div class="narrow-wide">${pageHead(here)}${change}${liveNote}<div class="form-actions">${btn("Sign in to comment", { href: `/signin?next=${next}`, variant: "primary" })}${when(open, () => btn("Report a problem, no account needed", { href: report }))}${home}</div></div>`, | |
| 189 | body: html`<div class="narrow-wide">${pageHead(here)}${change}${liveNote}<div class="form-actions">${btn("Sign in to comment", { href: `/signin?next=${next}`, variant: "primary" })}${other}${when(open, () => btn("Report a problem, no account needed", { href: report }))}${home}</div></div>`, | |
| 187 | 190 | }); |
| 188 | 191 | notePhase(request, "render", performance.now() - rendering); |
| 189 | 192 | return response; |
| @@ -242,7 +245,7 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> { | ||
| 242 | 245 | !canWrite(db, repo, user), |
| 243 | 246 | () => btn("Make it yours", { href: `${base}/make-it-yours`, icon: "fork" }), |
| 244 | 247 | )} |
| 245 | ${home} | |
| 248 | ${other}${home} | |
| 246 | 249 | </div> |
| 247 | 250 | </div>`; |
| 248 | 251 | const response = page({ ctx, title: `${repo.name} · forge`, body }); |
src/web/studio/toolbar_side.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * The toolbar page's link to the app's other side: from a preview, production; | |
| 3 | * from production, the newest open preview the viewer may see. The viewer's | |
| 4 | * reach is `envsFor`'s (a private app shows a stranger nothing), the choice is | |
| 5 | * `sideOf`'s, and the address keeps this page's path: the other side's origin | |
| 6 | * joined to it as text, never resolved against it, so a `//` path stays on | |
| 7 | * the app. | |
| 8 | */ | |
| 9 | ||
| 10 | import { sideOf, type Side } from "../../apps/side.ts"; | |
| 11 | import { envPull } from "../../apps/data.ts"; | |
| 12 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; | |
| 13 | import type { AppRecord } from "../../core/apps.ts"; | |
| 14 | import { getIssuesByNumbers, numberKey } from "../../core/issues.ts"; | |
| 15 | import type { Repo } from "../../core/repos.ts"; | |
| 16 | import { mayView } from "../../core/sealed.ts"; | |
| 17 | import type { Ctx } from "../../http/context.ts"; | |
| 18 | ||
| 19 | /** `target` with the path of `address` (the page being viewed), or `target` alone. */ | |
| 20 | export function joinPath(target: string, address: string): string { | |
| 21 | const to = URL.parse(target); | |
| 22 | if (!to) return ""; | |
| 23 | const path = URL.parse(address)?.pathname ?? "/"; | |
| 24 | return `${to.origin}${path}`; | |
| 25 | } | |
| 26 | ||
| 27 | /** The other side for a viewer, or null: nothing to open, or nothing theirs to see. */ | |
| 28 | export function toolbarSide( | |
| 29 | ctx: Ctx, | |
| 30 | repo: Repo, | |
| 31 | record: AppRecord, | |
| 32 | environment: string, | |
| 33 | address: string, | |
| 34 | ): Side | null { | |
| 35 | const runtime = appsRuntime(ctx.app); | |
| 36 | if (runtime === undefined) return null; | |
| 37 | const envs = envsFor(ctx.app, ctx.auth.user, record); | |
This gate is right, and it is the only thing that keeps a private app's preview URLs and pull titles from a reader of a public repo. No test reaches it yet (see test/toolbar-side.test.ts:104). |
||
| 38 | if (envs.length === 0) return null; | |
| 39 | const titles = new Map<number, string>(); | |
Limit 200 open pulls: with more open, the newest preview may be dropped. Query by the pull numbers envs reports instead. |
||
| 40 | if (environment === "production") { | |
| 41 | const numbers = envs.flatMap((e) => envPull(e.env) ?? []); | |
| 42 | const found = getIssuesByNumbers(ctx.app.db, numbers.map((number) => ({ repoId: repo.id, number }))); | |
| 43 | for (const number of numbers) { | |
| 44 | const issue = found.get(numberKey(repo.id, number)); | |
| 45 | if (issue && issue.kind === "pull" && issue.state === "open" && mayView(ctx.app.db, repo, issue, ctx.auth.user)) | |
| 46 | titles.set(number, issue.title); | |
| 47 | } | |
| 48 | } | |
| 49 | const previews = envs.flatMap((e) => { | |
| 50 | const n = envPull(e.env); | |
| 51 | const title = n === undefined ? undefined : titles.get(n); | |
| 52 | return n === undefined || title === undefined ? [] : [{ number: n, title, state: e.state, url: e.url }]; | |
| 53 | }); | |
| 54 | const side = sideOf({ url: runtime.urlFor(record, "production") }, environment, previews); | |
| 55 | if (side === null) return null; | |
| 56 | const url = joinPath(side.url, address); | |
| 57 | return url === "" ? null : { label: side.label, url }; | |
| 58 | } |
test/apps-side.test.ts +5
| @@ -30,3 +30,8 @@ test("stopped, failed and deploying previews are skipped", () => { | ||
| 30 | 30 | ); |
| 31 | 31 | expect(sideOf(app, "production", [p(8, "stopped")])).toBeNull(); |
| 32 | 32 | }); |
| 33 | ||
| 34 | test("a long pull title is clipped so the label stays one line", () => { | |
| 35 | const label = sideOf(app, "production", [p(2, "running", "x".repeat(200))])?.label ?? ""; | |
| 36 | expect(label).toBe(`Newest preview: #2 ${"x".repeat(31)}…`); | |
| 37 | }); |
test/repo-pages-timing.test.ts +1 −1
| @@ -60,7 +60,7 @@ afterAll(() => { | ||
| 60 | 60 | const PAGES: [path: string, who: string, names: string[]][] = [ |
| 61 | 61 | ["/alice/forge/issues/new", "alice", ["session", "repo", "render"]], |
| 62 | 62 | ["/alice/forge/report", "alice", ["session", "repo", "access", "render"]], |
| 63 | ["/alice/forge/toolbar", "alice", ["session", "repo", "app-row", "live", "runs", "render"]], | |
| 63 | ["/alice/forge/toolbar", "alice", ["session", "repo", "app-row", "live", "side", "runs", "render"]], | |
| 64 | 64 | ["/alice/forge/make-it-yours", "bob", ["session", "repo", "plan", "render"]], |
| 65 | 65 | ]; |
| 66 | 66 |
test/toolbar-side.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** The toolbar page's link to the app's other side (#6288). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { newApp } from "../src/actions/apps.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 9 | import { getAppByRepo, updateApp } from "../src/core/apps.ts"; | |
| 10 | import { createIssue } from "../src/core/issues.ts"; | |
| 11 | import { getRepo } from "../src/core/repos.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { proveAll } from "./helpers/verified.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let root = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let runtime: AppsRuntime; | |
| 23 | let envs: EnvStatus[] = []; | |
| 24 | const pulls: number[] = []; | |
| 25 | const cookies: Record<string, string> = {}; | |
| 26 | ||
| 27 | const env = (name: string, state: EnvStatus["state"] = "running"): EnvStatus => | |
| 28 | ({ env: name, state, commitOid: "a".repeat(40), url: `http://${name}.apps.test` }) as EnvStatus; | |
| 29 | ||
| 30 | async function signIn(username: string): Promise<string> { | |
| 31 | const r = await fetch(`${servers.url}/signin`, { | |
| 32 | method: "POST", headers: { origin: servers.url }, | |
| 33 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), redirect: "manual", | |
| 34 | }); | |
| 35 | return (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 36 | } | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | root = mkdtempSync(join(tmpdir(), "forge-toolbar-side-")); | |
| 40 | app = createApp({ dataDir: root, port: 0, host: "forge.test" }); | |
| 41 | servers = await serve(app); | |
| 42 | runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1 }); | |
| 43 | await runtime.start(); | |
| 44 | runtime.status = () => envs; | |
| 45 | const users = []; | |
| 46 | for (const [username, first] of [["alice", true], ["carol", false]] as const) { | |
| 47 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 48 | if (!u.ok) throw new Error(u.error); | |
| 49 | users.push(u.value); | |
| 50 | } | |
| 51 | proveAll(app); | |
| 52 | for (const [name, visibility] of [["pub", "public"], ["priv", "private"]] as const) { | |
| 53 | const made = await newApp(app, users[0]!, { name, description: "x", visibility }); | |
| 54 | if (!made.ok) throw new Error(String(made.error)); | |
| 55 | } | |
| 56 | for (const n of [1, 2]) { | |
| 57 | const repo = getRepo(app.db, "alice", "pub")!; | |
| 58 | // Pull numbers come from the issue counter; the open "pulls" below are enough for titles. | |
| 59 | const made = createIssue(app.db, { repoId: repo.id, authorId: users[0]!.id, title: `Change ${n}`, kind: "pull" }); | |
| 60 | if (!made.ok) throw new Error(made.error); | |
| 61 | pulls.push(made.value.number); | |
| 62 | } | |
| 63 | cookies.alice = await signIn("alice"); | |
| 64 | cookies.carol = await signIn("carol"); | |
| 65 | }, 60_000); | |
| 66 | afterAll(async () => { | |
| 67 | await runtime.stop(); | |
| 68 | servers.stop(); | |
| 69 | app.close(); | |
| 70 | rmSync(root, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | const toolbar = async (repo: string, query: string, who?: string) => { | |
| 74 | const res = await fetch(`${servers.url}/alice/${repo}/toolbar?${query}`, { headers: who ? { cookie: cookies[who]! } : {} }); | |
| 75 | return { res, text: await res.text() }; | |
| 76 | }; | |
| 77 | const hrefOf = (text: string, label: string): string | undefined => | |
| 78 | new RegExp(`<a[^>]*href="([^"]*)"[^>]*>\\s*(?:<[^>]*>\\s*)*${label}`).exec(text)?.[1]?.replaceAll("&", "&"); | |
| 79 | ||
| 80 | test("a reader on a preview is offered production, at this page's path", async () => { | |
| 81 | envs = [env("production"), env(`pull-${pulls[0]}`)]; | |
| 82 | const page = encodeURIComponent(`http://pull-${pulls[0]}.apps.test/a/b?token=1`); | |
| 83 | const { text } = await toolbar("pub", `env=pull-${pulls[0]}&page=${page}`, "carol"); | |
| 84 | expect(hrefOf(text, "Open on production")).toBe(`${runtime.urlFor(getAppRecord(), "production")}/a/b`); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a reader on production is offered the newest open preview", async () => { | |
| 88 | envs = [env("production"), env(`pull-${pulls[0]}`), env(`pull-${pulls[1]}`), env("pull-999", "failed")]; | |
| 89 | const { text } = await toolbar("pub", "env=production", undefined); | |
| 90 | const label = /Newest preview: #(\d+) Change \d/.exec(text); | |
| 91 | expect(label).not.toBeNull(); | |
| 92 | expect(label![1]).toBe(String(pulls[1])); | |
| 93 | expect(hrefOf(text, `Newest preview: #${pulls[1]}`)).toBe(`http://pull-${pulls[1]}.apps.test/`); | |
| 94 | }); | |
| 95 | ||
| 96 | test("production with no open preview has no link", async () => { | |
| 97 | envs = [env("production")]; | |
| 98 | expect((await toolbar("pub", "env=production", "carol")).text).not.toContain("Newest preview"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a public repository's private app shows a stranger no link, and a writer the link", async () => { | |
| 102 | const alice = getUserByName(app.db, "alice")!; | |
| 103 | const made = await newApp(app, alice, { name: "gated", description: "x", visibility: "public" }); | |
| 104 | if (!made.ok) throw new Error(String(made.error)); | |
outdated
Vacuous: both viewers get a 404 here, because the repo itself is private. Remove the |
||
| 105 | updateApp(app.db, made.value.app.id, { access: "private" }); | |
| 106 | const repo = getRepo(app.db, "alice", "gated")!; | |
| 107 | const pull = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "Secret change", kind: "pull" }); | |
| 108 | if (!pull.ok) throw new Error(pull.error); | |
| 109 | const n = pull.value.number; | |
| 110 | envs = [env("production"), env(`pull-${n}`)]; | |
| 111 | for (const who of [undefined, "carol"]) { | |
| 112 | const onProduction = await toolbar("gated", "env=production", who); | |
| 113 | expect(onProduction.res.status).toBe(200); | |
| 114 | expect(onProduction.text).not.toContain("Newest preview"); | |
| 115 | const onPreview = await toolbar("gated", `env=pull-${n}`, who); | |
| 116 | expect(onPreview.res.status).toBe(200); | |
| 117 | expect(onPreview.text).not.toContain("Open on production"); | |
| 118 | } | |
| 119 | expect((await toolbar("gated", "env=production", "alice")).text).toContain(`Newest preview: #${n} Secret change`); | |
| 120 | expect((await toolbar("gated", `env=pull-${n}`, "alice")).text).toContain("Open on production"); | |
| 121 | }); | |
outdated
Move this import to the top with the others. |
||
| 122 | ||
| 123 | test("a // page path stays on the app", async () => { | |
| 124 | envs = [env("production"), env(`pull-${pulls[0]}`)]; | |
| 125 | const page = encodeURIComponent(`http://pull-${pulls[0]}.apps.test//evil.test/x`); | |
| 126 | const { text } = await toolbar("pub", `env=pull-${pulls[0]}&page=${page}`, "carol"); | |
| 127 | expect(hrefOf(text, "Open on production")).toBe(`${runtime.urlFor(getAppRecord(), "production")}//evil.test/x`); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the read is named side in Server-Timing", async () => { | |
| 131 | envs = [env("production")]; | |
| 132 | const { res } = await toolbar("pub", "env=production", "carol"); | |
| 133 | expect(res.headers.get("server-timing")).toContain("side"); | |
| 134 | }); | |
| 135 | ||
| 136 | function getAppRecord() { | |
| 137 | return getAppByRepo(app.db, getRepo(app.db, "alice", "pub")!.id)!; | |
| 138 | } |
Label embeds the full pull title; cap its length so the button stays one line on a phone.