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

Merged opened this

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

Merged

scout/forge:scout/6288-the-toolbar-page-links-to-the-app-s-othe → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/6288-the-toolbar-page-links-to-the-app-s-othe was merged into main as 59113e2.

Review

Sign in to review this change.
6 files changed +216 −6
src/apps/side.ts +7 −1
@@ -25,9 +25,15 @@ export interface Side {
2525 /** A preview someone can open: up now, or waking on request. */
2626 const OPENABLE = new Set(["running", "asleep"]);
2727
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
2834 export function sideOf(app: SideApp, environment: string, previews: readonly SidePreview[]): Side | null {
2935 if (environment !== "production") return { label: "Open on production", url: app.url };
3036 let newest: SidePreview | undefined;
3137 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 };
3339 }
src/web/studio/toolbar.ts +7 −4
@@ -39,6 +39,7 @@ import { field, readForm } from "../form.ts";
3939 import { html, when, type Html } from "../html.ts";
4040 import { page } from "../layout.ts";
4141 import { originError } from "../pages/thread.ts";
42 import { toolbarSide } from "./toolbar_side.ts";
4243 import { btn, card, envFacts, runStateBadge, badge } from "../ui.ts";
4344
4445 /** The longest page address the bar may carry. */
@@ -155,8 +156,8 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> {
155156 const repo = timePhase(request, "repo", () => loadRepo(ctx));
156157 if (!repo) return timePhase(request, "render", () => notFoundPage(ctx));
157158 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));
160161 const db = ctx.app.db;
161162 const user = ctx.auth.user;
162163 const pull =
@@ -170,6 +171,8 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> {
170171 const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`;
171172 const liveNote = pull ? html`` : await timePhaseAsync(request, "live", () => liveLine(ctx, repo, base));
172173 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" });

Label embeds the full pull title; cap its length so the button stays one line on a phone.

173176 // The issue row carries no head oid; a pull row may be absent (an import in progress).
174177 const joined = pull ? getPull(db, repo.id, pull.number) : undefined;
175178 const change = joined
@@ -183,7 +186,7 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> {
183186 const response = page({
184187 ctx,
185188 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>`,
187190 });
188191 notePhase(request, "render", performance.now() - rendering);
189192 return response;
@@ -242,7 +245,7 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> {
242245 !canWrite(db, repo, user),
243246 () => btn("Make it yours", { href: `${base}/make-it-yours`, icon: "fork" }),
244247 )}
245 ${home}
248 ${other}${home}
246249 </div>
247250 </div>`;
248251 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", () => {
3030 );
3131 expect(sideOf(app, "production", [p(8, "stopped")])).toBeNull();
3232 });
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(() => {
6060 const PAGES: [path: string, who: string, names: string[]][] = [
6161 ["/alice/forge/issues/new", "alice", ["session", "repo", "render"]],
6262 ["/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"]],
6464 ["/alice/forge/make-it-yours", "bob", ["session", "repo", "plan", "render"]],
6565 ];
6666
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("&amp;", "&");
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 envsFor gate in toolbar_side.ts and this still passes. Use a public repo with app access "private", and assert 200 with no link for carol and for a signed-out viewer, plus the link for alice.

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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs