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

An app's page says how long its golden path took: New app to the first preview, and to production #3678

Merged opened this

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

Merged

scout/forge:scout/3668-an-app-s-page-says-how-long-its-golden-p → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/3668-an-app-s-page-says-how-long-its-golden-p was merged into main as eed74eb.

Review

Sign in to review this change.
10 files changed +250 −7
README.md +4
@@ -253,6 +253,10 @@ The check cell's memory is on the same page under **Check cell**, and in the ter
253253
254254 Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's.
255255
256 ### How long the golden path took
257
258 An app's repository home wears the pace on its app strip, the terminal's **Deploys** screen shows it under the title, and `GET /api/v1/repos/:owner/:name/app` returns it as `firstPreviewMs` and `firstProductionChangeMs`. It measures milliseconds from New app to the first preview that served, and from New app to the first production deploy that began after the first merged pull; a figure is left out until it exists. Anyone who can read the repository sees it (`goldenPath` in `src/apps/golden_path.ts`, `appPace` for the terminal).
259
256260 ### Every app's visitors
257261
258262 An app made from the template ships the same beacon as `static/beacon.js`, posting to `POST /__forge/measure` on the app's own host. The forge answers that door itself (`src/apps/measure.ts`), before the app is woken: same-origin only, 30 reports a minute per client and 600 per app (429), a body over the cap is a 413, and a private app or a sealed pull's preview refuses anyone its gate refuses. The app is the host's, never the body's. Production reports are kept in `app_vital` (`src/apps/vitals.ts`, TTFB, load, LCP, INP and CLS, a visitor hashed under the day's salt, no address, kept 90 days and removed with the app, at most 20,000 rows per app a day); previews store nothing. Only Do-Not-Track opts a visitor out on an app's host: the `forge_measure=off` cookie is set on the forge's host and never reaches an app's. Read the numbers (visitors today and over 7 days, p75 TTFB, load, LCP, INP and CLS) on the app's settings page under **Visitors and speed**, or as `vitals` in an admin's `GET /api/v1/repos/:owner/:name/app`. Both doors show them only to people who administer the repository (`appVisitors` in `src/actions/apps.ts`), so a reader or an anonymous caller sees no `vitals`. The page also draws p75 load for each of the last 30 days as a sparkline (server-rendered, no script); a day with no reports is a gap in the line, not a zero, and an app with none in 30 days says so in words. The same 30 values, oldest first with `null` for a silent day, are `vitals.loadDays` in the API. In the terminal all five are on the second line of the app's **Deploys** screen over ssh, and a third line there shows the 30 days as block characters with `·` for a silent day, then the range.
docs/DESIGN.md +1 −1
@@ -200,7 +200,7 @@ specification.
200200 | **propose card** | `fieldset.editor-target.propose` (`.propose-line`) | — | replaces "Where this lands" when the change is a contribution: the whole outcome in one sentence with **both** repository names, then branch, pull title and pull body. There is no "commit directly" radio, because there is no such option | the same sentence above the commit prompt |
201201 | **run state badge** `runStateBadge()` | `.badge` | `queued` · `running` (attn) · `ok` (success: _Passed_, or _Done_ for an agent run) · `failed` (danger) · `cancelled` (outline) | the one badge a check wears on a pull, a run wears in a list, and a log page wears in its head; the log page's script repaints it on `done` | `[✓ passed]` / `[✗ failed]` / `[· running]` |
202202 | **env state badge** `envStateBadge()` | `.badge` | `stopped` (outline) · `deploying` (attn) · `running` (success) · `failed` (danger) | what production, or a preview, is doing now; `envTone()` is the same answer as a `dot()` for a list row | the same word in the same tone after the environment's name |
203 | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, App (the studio app page)| one line under the where strip: `● running abc1234 · hello.localhost` |
203 | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, the pace (`first preview 2m 14s after New app · in production 9m`, each part once it exists), App (the studio app page)| one line under the where strip: `● running abc1234 · hello.localhost` |
204204 | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; `envFacts()` in the body — commit, how fast it came up, uptime, a link to the deploy run, and the deployment's own note and error, each on its own line; Open (or Wake it) below. It is a pull's preview card; the facts and `envActions()` (Redeploy / Stop, writers only) are functions, not markup, so the studio head and its preview rows render the same ones | a box per environment with the same three lines |
205205 | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line |
206206 | **run log** | `pre.run-log` | `data-run` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script appends the run's lines from the page's one `/events` stream and follows the bottom until the reader scrolls up; the page never reloads itself | a scrolling pane that follows the tail |
src/actions/apps.ts +8
@@ -12,6 +12,7 @@ import { mayView } from "../core/sealed.ts";
1212 import { requireVerified } from "./verify.ts";
1313 import { requireAppRoom } from "./app_limit.ts";
1414 import type { App } from "../app.ts";
15 import { goldenPath, goldenPathLine } from "../apps/golden_path.ts";
1516 import { appVitals, type AppVitals } from "../apps/vitals.ts";
1617 import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts";
1718 import { grantUrl, originFor } from "../apps/proxy.ts";
@@ -491,6 +492,13 @@ export function appVisitors(app: App, actor: User | undefined, repo: Repo): AppV
491492 return record === undefined ? undefined : appVitals(app.db, record.id);
492493 }
493494
495 /** The golden path's pace line, for whoever may read the app's repository; `""` until there is one. */
496 export function appPace(app: App, actor: User | undefined, repo: Repo): string {
497 if (!requireReader(app, actor, repo).ok) return "";
498 const record = getAppByRepo(app.db, repo.id);
499 return record === undefined ? "" : goldenPathLine(goldenPath(app.db, record));
500 }
501
494502 export type EnvError = "not_found" | "no_runtime" | "no_such_env";
495503
496504 /** The URL to open an environment at — via a grant for a private app. */
src/apps/golden_path.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * How long an app's golden path took: from New app to the first preview that
3 * served, and to the first production deploy after the first merged pull.
4 * Plain data in, plain data out; a figure is left out until it exists.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { AppRecord } from "../core/apps.ts";
9
10 export interface GoldenPath {
11 readonly firstPreviewMs?: number;
12 readonly firstProductionChangeMs?: number;
13 }
14
15 export function goldenPath(db: Database, app: Pick<AppRecord, "repoId" | "createdAt">): GoldenPath {
16 const born = Date.parse(app.createdAt);
17 if (Number.isNaN(born)) return {};
18 const preview = db
19 .query<{ at: string | null }, [string]>(
20 `SELECT MIN(serving_at) AS at FROM run

Non-blocking: this scans every run of the repository (only run(repo_id, id DESC) is indexed) on each code-page render and once per app in GET /api/v1/apps. The first-preview time never changes once set, so persist it on the app row when it first appears, or add a partial index on deploy runs.

21 WHERE repo_id = ?1 AND kind = 'deploy' AND env LIKE 'pull-%' AND serving_at IS NOT NULL`,
22 )
23 .get(app.repoId)?.at;
24 const merged = db
25 .query<{ at: string | null }, [string]>(

Non-blocking: for an app enabled on a repository that already has history, this picks a pull merged before New app. The app's very first production deploy then reads as the 'change'. Add AND p.merged_at >= ?2 with app.createdAt.

26 `SELECT MIN(p.merged_at) AS at FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
27 WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.merged_at IS NOT NULL`,
28 )
29 .get(app.repoId)?.at;
30 const production =
31 merged == null
32 ? null
33 : db
34 .query<{ at: string | null }, [string, string]>(
35 `SELECT MIN(serving_at) AS at FROM run
36 WHERE repo_id = ?1 AND kind = 'deploy' AND env = 'production'
37 AND created_at >= ?2 AND serving_at IS NOT NULL`,
38 )
39 .get(app.repoId, merged)?.at;
40 const since = (at: string | null | undefined): number | undefined => {
41 const ms = at == null ? NaN : Date.parse(at) - born;
42 return Number.isNaN(ms) ? undefined : Math.max(0, ms);
43 };
44 const firstPreviewMs = since(preview);
45 const firstProductionChangeMs = since(production);
46 return {
47 ...(firstPreviewMs === undefined ? {} : { firstPreviewMs }),
48 ...(firstProductionChangeMs === undefined ? {} : { firstProductionChangeMs }),
49 };
50 }
51
52 /** `14s`, `2m 14s`, `9m`, `1h 5m`, `3d`: two units at most. */
53 export function spanWords(ms: number): string {
54 const s = Math.round(Math.max(0, ms) / 1000);
55 if (s < 60) return `${s}s`;
56 const m = Math.floor(s / 60);
57 if (m < 60) return s % 60 === 0 || m >= 10 ? `${m}m` : `${m}m ${s % 60}s`;
58 const h = Math.floor(m / 60);
59 if (h < 24) return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
60 const d = Math.floor(h / 24);
61 return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
62 }
63
64 /** The one dense line, or `""` while there is nothing to show. */

The terminal door is missing. This line should also show on the TUI deploys screen beside visitorsLine (src/tui/screens/deploys.ts), and the README should say where to find it.

65 export function goldenPathLine(path: GoldenPath): string {
66 const parts: string[] = [];
67 if (path.firstPreviewMs !== undefined)
68 parts.push(`first preview ${spanWords(path.firstPreviewMs)} after New app`);
69 if (path.firstProductionChangeMs !== undefined)
70 parts.push(`in production ${spanWords(path.firstProductionChangeMs)}`);
71 return parts.join(" · ");
72 }
src/http/api.ts +2
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { goldenPath } from "../apps/golden_path.ts";
1112 import { upstreamPayload } from "../spawn/upstream.ts";
1213 import { setRole } from "../actions/roles.ts";
1314 import { listPeople, NOT_A_MEMBER } from "../core/roles.ts";
@@ -1051,6 +1052,7 @@ function appJson(
10511052 ) {
10521053 return {
10531054 ...record,
1055 ...goldenPath(ctx.app.db, record),

No test covers this spread. Add one API test that checks firstPreviewMs (and that it is left out before any preview served) on GET …/app.

10541056 repo: { owner: repo.ownerName, name: repo.name },
10551057 url: runtime?.urlFor(record, "production") ?? null,
10561058 domains: listDomains(ctx.app.db, record.id)
src/tui/screens/deploys.ts +9 −6
@@ -7,7 +7,7 @@
77 * joined to `deploy_pull` in one grouped read for `carries #3 #5`.
88 */
99
10 import { appVisitors } from "../../actions/apps.ts";
10 import { appPace, appVisitors } from "../../actions/apps.ts";
1111 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
1212 import { listRepoRuns } from "../../actions/runs.ts";
1313 import { carriedByRuns, carriesText } from "../../core/carried.ts";
@@ -41,6 +41,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4141 let loaded = false;
4242 let visitors = "";
4343 let trend = "";
44 let pace = "";
4445
4546 const read = (): void => {
4647 const runs = readDeploys(f, repo);
@@ -52,6 +53,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5253 const seen = appVisitors(f.app, f.user, repo);
5354 visitors = seen === undefined ? "" : visitorsLine(seen);
5455 trend = seen === undefined ? "" : trendLine(seen.loadDays);
56 pace = appPace(f.app, f.user, repo);
5557 loaded = true;
5658 ctx.invalidate();
5759 };
@@ -68,7 +70,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6870 load: async () => read(),
6971 render(area, s) {
7072 const c = ctx.theme.c;
71 const [head, body] = split(area, "v", [len(visitors === "" ? 1 : trend === "" ? 2 : 3), fill(1)]);
73 const [head, body] = split(area, "v", [len(1 + (visitors === "" ? 0 : 1) + (trend === "" ? 0 : 1) + (pace === "" ? 0 : 1)), fill(1)]);
7274 s.text(
7375 head!.x,
7476 head!.y,
@@ -76,10 +78,11 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7678 style(c.mutedFg, c.bg),
7779 head!.x + head!.w,
7880 );
79 if (visitors !== "")
80 s.text(head!.x, head!.y + 1, truncate(visitors, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
81 if (trend !== "")
82 s.text(head!.x, head!.y + 2, truncate(trend, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
81 [visitors, trend, pace]
82 .filter((line) => line !== "")
83 .forEach((line, i) =>
84 s.text(head!.x, head!.y + 1 + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w),
85 );
8386 if (list.runs.length === 0) {
8487 emptyState(s, body!, c, {
8588 title: loaded ? "No deploys" : "Reading…",
src/web/pages/apps.ts +3
@@ -29,6 +29,7 @@ import { planningAvailable } from "../../crew/plan.ts";
2929 import { appNameJs, deriveAppName } from "./apps-name.ts";
3030 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
3131 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
32 import { goldenPath, goldenPathLine } from "../../apps/golden_path.ts";
3233 import { appHostSuffix } from "../../apps/host.ts";
3334 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
3435 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
@@ -469,6 +470,7 @@ export function appStrip(ctx: Ctx, repo: Repo): Html {
469470 const production = environments.find((env) => env.env === "production");
470471 const previews = environments.length - (production === undefined ? 0 : 1);
471472 const url = appsRuntime(ctx.app)?.urlFor(record, "production");
473 const pace = goldenPathLine(goldenPath(ctx.app.db, record));
472474 return html`<div class="app-strip">
473475 ${icon("rocket")}
474476 ${serving
@@ -494,6 +496,7 @@ export function appStrip(ctx: Ctx, repo: Repo): Html {
494496 >${previews} preview${previews === 1 ? "" : "s"}</span
495497 >`,
496498 )}
499 ${when(pace, (line) => html`<span class="muted">${line}</span>`)}

No test fails if this line is removed. Add a page test with a served preview that expects the text, and one with no preview that expects it absent. Also add the line to the app strip row in docs/DESIGN.md.

497500 </div>`;
498501 }
499502
test/apps-api.test.ts +16
@@ -674,6 +674,22 @@ describe("apps, runs and the crew over the API", () => {
674674 ).toBe(true);
675675 }, 90_000);
676676
677 test("the app carries firstPreviewMs once a preview has served, and not before", async () => {
678 const read = async () =>
679 (await (await api("/api/v1/repos/alice/hello/app")).json()) as { firstPreviewMs?: number };
680 app.db.query("UPDATE run SET serving_at = NULL WHERE env LIKE 'pull-%'").run();
681 expect((await read()).firstPreviewMs).toBeUndefined();
682 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'hello'").get()!.id;
683 app.db
684 .query(
685 `INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at)
686 VALUES ('gp-pace', ?1, 'deploy', 'pull-77', 'ok', ?2, ?2)`,
687 )
688 .run(repoId, new Date(Date.now() + 60_000).toISOString());
689 expect(typeof (await read()).firstPreviewMs).toBe("number");
690 app.db.query("DELETE FROM run WHERE id = 'gp-pace'").run();
691 });
692
677693 test("a private repo's app is not_found to strangers; disabling removes it", async () => {
678694 await api("/api/v1/repos/alice/hello", {
679695 method: "PATCH",
test/golden-path.test.ts added +122
@@ -0,0 +1,122 @@
1 /** How long New app took to its first preview and to production. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { goldenPath, goldenPathLine, spanWords } from "../src/apps/golden_path.ts";
10 import { appPace } from "../src/actions/apps.ts";
11 import { getRepo } from "../src/core/repos.ts";
12 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 const T0 = Date.parse("2026-01-01T00:00:00.000Z");
19 const at = (ms: number): string => new Date(T0 + ms).toISOString();
20
21 describe("the golden path's figures", () => {
22 let dataDir = "";
23 let app: App;
24 let alice: User;
25 let n = 0;
26
27 const fresh = async (): Promise<AppRecord> => {
28 n += 1;
29 const repo = await createRepo(app, alice, { name: `gp${n}`, visibility: "private" });
30 if (!repo.ok) throw new Error(repo.error);
31 const made = insertApp(app.db, { repoId: repo.value.id, slug: `gp${n}`, access: "private" });
32 if (!made.ok) throw new Error(made.error);
33 app.db.query("UPDATE app SET created_at = ?1 WHERE id = ?2").run(at(0), made.value.id);
34 return { ...made.value, createdAt: at(0) };
35 };
36 const deploy = (repoId: string, env: string, created: number, serving: number | null): void => {
37 app.db
38 .query(
39 `INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at)
40 VALUES (?1, ?2, 'deploy', ?3, 'ok', ?4, ?5)`,
41 )
42 .run(`r${Math.random()}`, repoId, env, at(created), serving === null ? null : at(serving));
43 };
44 const merge = (repoId: string, number: number, mergedAt: number): void => {
45 const row = app.db
46 .query<{ default_branch: string }, [string]>("SELECT default_branch FROM repo WHERE id = ?1")
47 .get(repoId)!;
48 app.db
49 .query(
50 `INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, ?3, 'pull', 'p', ?4)`,
51 )
52 .run(`i${repoId}${number}`, repoId, number, alice.id);
53 app.db
54 .query(
55 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
56 VALUES (?1, ?2, 'h', 'a', 'b', ?3)`,
57 )
58 .run(`i${repoId}${number}`, row.default_branch, at(mergedAt));
59 };
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-golden-path-"));
63 app = createApp({ dataDir, port: 0 });
64 const made = await createUser(app.db, {
65 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
66 password: "a-good-passphrase", adminIfFirst: true,
67 });
68 if (!made.ok) throw new Error(made.error);
69 alice = made.value;
70 });
71
72 afterAll(async () => {
73 app.close();
74 await rm(dataDir, { recursive: true, force: true });
75 });
76
77 test("an app with no preview yet has no figures and no line", async () => {
78 const record = await fresh();
79 deploy(record.repoId, "pull-1", 1000, null);
80 expect(goldenPath(app.db, record)).toEqual({});
81 expect(goldenPathLine({})).toBe("");
82 });
83
84 test("a preview that served is the first figure", async () => {
85 const record = await fresh();
86 deploy(record.repoId, "pull-2", 60_000, 200_000);
87 deploy(record.repoId, "pull-1", 1000, 134_000);
88 const path = goldenPath(app.db, record);
89 expect(path).toEqual({ firstPreviewMs: 134_000 });
90 expect(goldenPathLine(path)).toBe("first preview 2m 14s after New app");
91 });
92
93 test("a merged change served in production is the second", async () => {
94 const record = await fresh();
95 deploy(record.repoId, "pull-1", 1000, 134_000);
96 deploy(record.repoId, "production", 10_000, 20_000); // before any merge: not the change
97 merge(record.repoId, 1, 400_000);
98 expect(goldenPath(app.db, record)).toEqual({ firstPreviewMs: 134_000 });
99 deploy(record.repoId, "production", 401_000, 540_000);
100 const path = goldenPath(app.db, record);
101 expect(path).toEqual({ firstPreviewMs: 134_000, firstProductionChangeMs: 540_000 });
102 expect(goldenPathLine(path)).toBe("first preview 2m 14s after New app · in production 9m");
103 });
104
105 test("the terminal's line is the same words, for a reader only", async () => {
106 const record = await fresh();
107 deploy(record.repoId, "pull-1", 1000, 134_000);
108 const repo = getRepo(app.db, "alice", `gp${n}`)!;
109 expect(appPace(app, alice, repo)).toBe("first preview 2m 14s after New app");
110 expect(appPace(app, undefined, repo)).toBe("");
111 });
112
113 test("spans read in at most two units", () => {
114 expect(spanWords(14_000)).toBe("14s");
115 expect(spanWords(134_000)).toBe("2m 14s");
116 expect(spanWords(600_000 + 14_000)).toBe("10m");
117 expect(spanWords(7_200_000)).toBe("2h");
118 expect(spanWords(3 * 86_400_000 + 5 * 3_600_000)).toBe("3d 5h");
119 expect(spanWords(3_900_000)).toBe("1h 5m");
120 expect(spanWords(3 * 86_400_000)).toBe("3d");
121 });
122 });
test/web-apps.test.ts +13
@@ -889,6 +889,19 @@ describe("apps, runs and the crew on the web", () => {
889889 expect(flashOf(refused)).toContain("cannot see this repository");
890890 });
891891
892 test("the app strip says how long the first preview took, once one has served", async () => {
893 app.db.query("UPDATE run SET serving_at = NULL WHERE env LIKE 'pull-%'").run();
894 expect(await bodyOf(alice, "/alice/hello/files")).not.toContain("after New app");
895 app.db
896 .query(
897 `INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at)
898 VALUES ('gp-strip', ?1, 'deploy', 'pull-77', 'ok', ?2, ?2)`,
899 )
900 .run(hello.id, new Date(Date.now() + 60_000).toISOString());
901 expect(await bodyOf(alice, "/alice/hello/files")).toMatch(/first preview [^<]* after New app/);
902 app.db.query("DELETE FROM run WHERE id = 'gp-strip'").run();
903 });
904
892905 // ---- CSRF ----
893906
894907 test("every new POST refuses a cross-origin submission", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs