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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3668-an-app-s-page-says-how-long-its-golden-p → main 2 commits fork 5 threads · 5 unresolved
README.md +4
| @@ -253,6 +253,10 @@ The check cell's memory is on the same page under **Check cell**, and in the ter | ||
| 253 | 253 | |
| 254 | 254 | 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. |
| 255 | 255 | |
| 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 | ||
| 256 | 260 | ### Every app's visitors |
| 257 | 261 | |
| 258 | 262 | 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. | ||
| 200 | 200 | | **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 | |
| 201 | 201 | | **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]` | |
| 202 | 202 | | **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` | | |
| 204 | 204 | | **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 | |
| 205 | 205 | | **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 | |
| 206 | 206 | | **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"; | ||
| 12 | 12 | import { requireVerified } from "./verify.ts"; |
| 13 | 13 | import { requireAppRoom } from "./app_limit.ts"; |
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | import { goldenPath, goldenPathLine } from "../apps/golden_path.ts"; | |
| 15 | 16 | import { appVitals, type AppVitals } from "../apps/vitals.ts"; |
| 16 | 17 | import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; |
| 17 | 18 | import { grantUrl, originFor } from "../apps/proxy.ts"; |
| @@ -491,6 +492,13 @@ export function appVisitors(app: App, actor: User | undefined, repo: Repo): AppV | ||
| 491 | 492 | return record === undefined ? undefined : appVitals(app.db, record.id); |
| 492 | 493 | } |
| 493 | 494 | |
| 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 | ||
| 494 | 502 | export type EnvError = "not_found" | "no_runtime" | "no_such_env"; |
| 495 | 503 | |
| 496 | 504 | /** 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 | |
| 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 |
||
| 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 |
||
| 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 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { goldenPath } from "../apps/golden_path.ts"; | |
| 11 | 12 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| 12 | 13 | import { setRole } from "../actions/roles.ts"; |
| 13 | 14 | import { listPeople, NOT_A_MEMBER } from "../core/roles.ts"; |
| @@ -1051,6 +1052,7 @@ function appJson( | ||
| 1051 | 1052 | ) { |
| 1052 | 1053 | return { |
| 1053 | 1054 | ...record, |
| 1055 | ...goldenPath(ctx.app.db, record), | |
No test covers this spread. Add one API test that checks |
||
| 1054 | 1056 | repo: { owner: repo.ownerName, name: repo.name }, |
| 1055 | 1057 | url: runtime?.urlFor(record, "production") ?? null, |
| 1056 | 1058 | domains: listDomains(ctx.app.db, record.id) |
src/tui/screens/deploys.ts +9 −6
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * joined to `deploy_pull` in one grouped read for `carries #3 #5`. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appVisitors } from "../../actions/apps.ts"; | |
| 10 | import { appPace, appVisitors } from "../../actions/apps.ts"; | |
| 11 | 11 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 12 | 12 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 13 | 13 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| @@ -41,6 +41,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 41 | 41 | let loaded = false; |
| 42 | 42 | let visitors = ""; |
| 43 | 43 | let trend = ""; |
| 44 | let pace = ""; | |
| 44 | 45 | |
| 45 | 46 | const read = (): void => { |
| 46 | 47 | const runs = readDeploys(f, repo); |
| @@ -52,6 +53,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 52 | 53 | const seen = appVisitors(f.app, f.user, repo); |
| 53 | 54 | visitors = seen === undefined ? "" : visitorsLine(seen); |
| 54 | 55 | trend = seen === undefined ? "" : trendLine(seen.loadDays); |
| 56 | pace = appPace(f.app, f.user, repo); | |
| 55 | 57 | loaded = true; |
| 56 | 58 | ctx.invalidate(); |
| 57 | 59 | }; |
| @@ -68,7 +70,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 68 | 70 | load: async () => read(), |
| 69 | 71 | render(area, s) { |
| 70 | 72 | 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)]); | |
| 72 | 74 | s.text( |
| 73 | 75 | head!.x, |
| 74 | 76 | head!.y, |
| @@ -76,10 +78,11 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 76 | 78 | style(c.mutedFg, c.bg), |
| 77 | 79 | head!.x + head!.w, |
| 78 | 80 | ); |
| 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 | ); | |
| 83 | 86 | if (list.runs.length === 0) { |
| 84 | 87 | emptyState(s, body!, c, { |
| 85 | 88 | title: loaded ? "No deploys" : "Reading…", |
src/web/pages/apps.ts +3
| @@ -29,6 +29,7 @@ import { planningAvailable } from "../../crew/plan.ts"; | ||
| 29 | 29 | import { appNameJs, deriveAppName } from "./apps-name.ts"; |
| 30 | 30 | import { envPull, previewEnv, type EnvName } from "../../apps/data.ts"; |
| 31 | 31 | import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts"; |
| 32 | import { goldenPath, goldenPathLine } from "../../apps/golden_path.ts"; | |
| 32 | 33 | import { appHostSuffix } from "../../apps/host.ts"; |
| 33 | 34 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| 34 | 35 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| @@ -469,6 +470,7 @@ export function appStrip(ctx: Ctx, repo: Repo): Html { | ||
| 469 | 470 | const production = environments.find((env) => env.env === "production"); |
| 470 | 471 | const previews = environments.length - (production === undefined ? 0 : 1); |
| 471 | 472 | const url = appsRuntime(ctx.app)?.urlFor(record, "production"); |
| 473 | const pace = goldenPathLine(goldenPath(ctx.app.db, record)); | |
| 472 | 474 | return html`<div class="app-strip"> |
| 473 | 475 | ${icon("rocket")} |
| 474 | 476 | ${serving |
| @@ -494,6 +496,7 @@ export function appStrip(ctx: Ctx, repo: Repo): Html { | ||
| 494 | 496 | >${previews} preview${previews === 1 ? "" : "s"}</span |
| 495 | 497 | >`, |
| 496 | 498 | )} |
| 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. |
||
| 497 | 500 | </div>`; |
| 498 | 501 | } |
| 499 | 502 |
test/apps-api.test.ts +16
| @@ -674,6 +674,22 @@ describe("apps, runs and the crew over the API", () => { | ||
| 674 | 674 | ).toBe(true); |
| 675 | 675 | }, 90_000); |
| 676 | 676 | |
| 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 | ||
| 677 | 693 | test("a private repo's app is not_found to strangers; disabling removes it", async () => { |
| 678 | 694 | await api("/api/v1/repos/alice/hello", { |
| 679 | 695 | 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", () => { | ||
| 889 | 889 | expect(flashOf(refused)).toContain("cannot see this repository"); |
| 890 | 890 | }); |
| 891 | 891 | |
| 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 | ||
| 892 | 905 | // ---- CSRF ---- |
| 893 | 906 | |
| 894 | 907 | test("every new POST refuses a cross-origin submission", async () => { |
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 inGET /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.