Public: anyone can read this; the crew can change it.
Home is the living pipeline: KPIs, what is moving, what shipped, your apps, and one box to tell the crew #8050
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8015-home-is-the-living-pipeline-kpis-what-is → main 14 commits fork
63 files
changed +750
−159
docs/design/areas/home.md added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | # Home | |
| 2 | ||
| 3 | - `.moving` — the living home's two lists (Being built, Just shipped), drawn by `movingRegion` in `src/web/studio/living.ts`: a one-column grid on a phone, two equal columns from 700px. Each list always holds three rows, so a quiet list is as tall as a busy one and nothing below it moves. | |
| 4 | - `.tell-box` — home's one-line box that tells the crew: a `bar` whose text input takes the free width (`flex: 1 1 14rem`) and wraps the app picker and Tell under it on a phone. |
docs/design/audit.md +1
| @@ -54,6 +54,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 54 | 54 | - `/board/returns` — `pages/returns.ts` · what the window's dollars bought · `/settings/subscriptions` spend tables, `/settings/usage` · none seen. |
| 55 | 55 | - `/big` — `pages/board.ts` (`bigRedirect`) · a redirect kept for old addresses into the board's wall mode · it is `/board` · none (no page of its own). |
| 56 | 56 | - `/boards` — `pages/boards-view.ts` · saved queries as panels · `/board` · none seen. |
| 57 | - `/feed` — `studio/home.ts` · the composer and one timeline, what home was before it became the living pipeline (#8015) · `/` · none seen. | |
| 57 | 58 | - `/boards/data` — `pages/boards.ts` · the boards' query/data surface · `/board` · none seen. |
| 58 | 59 | - `/asks` — `studio/asks_page.ts` · what the crew is waiting on me for · `/` "needs me" list · colours 2. |
| 59 | 60 | - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen. |
docs/features/living-home.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # The living home (#8015) | |
| 2 | ||
| 3 | Signed in, `/` is five things and nothing else: | |
| 4 | ||
| 5 | 1. **The box** — one line that tells the crew (`POST /tell`, the same action the feed's composer posts to). A phone's share sheet fills it. | |
| 6 | 2. **The figures** (admins) — the KPI tiles and any alarm, one calm line each. Everyone else gets the live version line. | |
| 7 | 3. **Now** (site admins) — `check › review › land › live`, each the sum of the `/boards` stages it names (`nowOf` in `src/flow/moving.ts`), each a link. The counts take in every repository, private ones too, so no one else is shown them. | |
| 8 | 4. **Being built / Just shipped** — three rows each, every row a link to its issue or pull, read for the viewer (a repository they cannot read, and a sealed report, are never named). One live region, `#live-moving`, redrawn from `/live/moving` when a run or a merge moves; it always holds six rows, so nothing below it shifts. | |
| 9 | 5. **Your apps** — a chip per app and a plus; `#live-chips`, redrawn from `/live/chips`. | |
| 10 | ||
| 11 | The terminal's home carries the same lines (`movingLines` in `src/tui/screens/moving.ts`): Now for a site admin, then being built and just shipped, read for the viewer; its tell is the box. | |
| 12 | ||
| 13 | The feed — the composer, the timeline, what is on you, since you were here, pinned panels — is `/feed`, linked from home. | |
| 14 | ||
| 15 | Left out on purpose: the waiting-on-you line, the clog, vitals and keeper lines (the boards and the Flow dialog still show them), and "slowest right now" (it needs a timing read the home does not have). The box posts to `/tell` today; it becomes the `/talk` entry (#7652) when that lands. | |
| 16 | ||
| 17 | The two scans behind the lists are read once per write (`writeStamp`), so a warm home reads no tables for them. |
forge.json +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "health": "/healthz", |
src/core/reserved.ts +1
| @@ -109,6 +109,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 109 | 109 | "recover", |
| 110 | 110 | "www", |
| 111 | 111 | "asked", |
| 112 | "feed", | |
| 112 | 113 | "review", |
| 113 | 114 | "claim", |
| 114 | 115 | "first-admin", |
src/flow/moving.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 2 | * What is moving through the forge, for the home (#8015): the builds running | |
| 3 | * now, the changes that just shipped, and the pipeline in four words. | |
| 4 | * Plain data out. Every row is read for a viewer: a repository they cannot | |
| 5 | * read, and a sealed report, are never named. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { canRead, getReposByIds, type Repo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { writeStamp } from "../core/write_clock.ts"; | |
| 12 | import type { PipelineStage } from "./pipeline.ts"; | |
| 13 | ||
| 14 | /** One line of a list: who, what, where it goes, and when. */ | |
| 15 | export interface MovingRow { | |
| 16 | readonly who: string; | |
| 17 | readonly title: string; | |
| 18 | readonly href: string; | |
| 19 | readonly at: string; | |
| 20 | /** `owner/name`, for the row's title attribute. */ | |
| 21 | readonly repo: string; | |
| 22 | } | |
| 23 | ||
| 24 | export const MOVING_ROWS = 3; | |
| 25 | /** Rows read before the viewer's access filters them; bounded, so the read is. */ | |
| 26 | const SCAN = 40; | |
| 27 | ||
| 28 | interface Raw { | |
| 29 | repo_id: string; | |
| 30 | number: number; | |
| 31 | title: string; | |
| 32 | sealed: number; | |
| 33 | who: string | null; | |
| 34 | at: string; | |
| 35 | } | |
| 36 | ||
| 37 | const RAW_MAX_AGE_MS = 30_000; | |
| 38 | const kept = new WeakMap<Database, Map<string, { stamp: string; at: number; raw: Raw[] }>>(); | |
| 39 | ||
| 40 | /** A scan read once per write: the same rows serve every viewer until anything is written (or 30 s pass). */ | |
| 41 | function scanned(db: Database, key: string, read: () => Raw[], now = Date.now()): Raw[] { | |
| 42 | const stamp = writeStamp(db); | |
| 43 | if (stamp === undefined) return read(); | |
| 44 | let byKey = kept.get(db); | |
| 45 | if (byKey === undefined) kept.set(db, (byKey = new Map())); | |
| 46 | const had = byKey.get(key); | |
| 47 | if (had !== undefined && had.stamp === stamp && now - had.at < RAW_MAX_AGE_MS) return had.raw; | |
| 48 | const raw = read(); | |
| 49 | byKey.set(key, { stamp, at: now, raw }); | |
| 50 | return raw; | |
| 51 | } | |
| 52 | ||
| 53 | function visible(db: Database, viewer: User, raw: readonly Raw[], path: string, limit: number): MovingRow[] { | |
| 54 | const repos = getReposByIds(db, raw.map((r) => r.repo_id)); | |
| 55 | const readable = new Map<string, Repo | undefined>(); | |
| 56 | const out: MovingRow[] = []; | |
| 57 | for (const r of raw) { | |
| 58 | if (r.sealed !== 0) continue; | |
| 59 | if (!readable.has(r.repo_id)) { | |
| 60 | const repo = repos.get(r.repo_id); | |
| 61 | readable.set(r.repo_id, repo !== undefined && canRead(db, repo, viewer) ? repo : undefined); | |
| 62 | } | |
| 63 | const repo = readable.get(r.repo_id); | |
| 64 | if (repo === undefined) continue; | |
| 65 | out.push({ | |
| 66 | who: r.who ?? "", | |
| 67 | title: r.title, | |
| 68 | href: `/${repo.ownerName}/${repo.name}/${path}/${r.number}`, | |
| 69 | at: r.at, | |
| 70 | repo: `${repo.ownerName}/${repo.name}`, | |
| 71 | }); | |
| 72 | if (out.length === limit) break; | |
| 73 | } | |
| 74 | return out; | |
| 75 | } | |
| 76 | ||
| 77 | /** Agent builds running now (reviews are not building), newest first, each on the issue it works. */ | |
| 78 | export function readBuilding(db: Database, viewer: User, limit = MOVING_ROWS): MovingRow[] { | |
| 79 | const raw = scanned(db, "building", () => db | |
| 80 | .query<Raw, [number]>( | |
| 81 | `SELECT r.repo_id, i.number, i.title, i.sealed, u.username AS who, COALESCE(r.started_at, r.created_at) AS at | |
| 82 | FROM run r JOIN issue i ON i.id = r.issue_id LEFT JOIN user u ON u.id = r.actor_id | |
| 83 | WHERE r.kind = 'agent' AND r.state = 'running' AND r.reason != 'review' | |
| 84 | ORDER BY at DESC LIMIT ?1`, | |
| 85 | ) | |
| 86 | .all(SCAN)); | |
| 87 | return visible(db, viewer, raw, "issues", limit); | |
| 88 | } | |
| 89 | ||
| 90 | /** Changes merged most recently, newest first, each linking to its pull. */ | |
| 91 | export function readShipped(db: Database, viewer: User, limit = MOVING_ROWS): MovingRow[] { | |
| 92 | const raw = scanned(db, "shipped", () => db | |
| 93 | .query<Raw, [number]>( | |
| 94 | `SELECT i.repo_id, i.number, i.title, i.sealed, u.username AS who, p.merged_at AS at | |
| 95 | FROM pull p JOIN issue i ON i.id = p.issue_id LEFT JOIN user u ON u.id = p.merged_by | |
| 96 | WHERE p.merged_at IS NOT NULL | |
| 97 | ORDER BY p.merged_at DESC LIMIT ?1`, | |
| 98 | ) | |
| 99 | .all(SCAN)); | |
| 100 | return visible(db, viewer, raw, "pulls", limit); | |
| 101 | } | |
| 102 | ||
| 103 | /** The pipeline in the four words the forge uses; each count is the sum of the `/boards` stages it names. */ | |
| 104 | export interface Now { | |
| 105 | readonly check: number; | |
| 106 | readonly review: number; | |
| 107 | readonly land: number; | |
| 108 | readonly live: number; | |
| 109 | } | |
| 110 | ||
| 111 | export function nowOf(stages: readonly PipelineStage[]): Now { | |
| 112 | const n = (key: PipelineStage["key"]): number => { | |
| 113 | const s = stages.find((x) => x.key === key); | |
| 114 | return s === undefined ? 0 : s.waiting + s.active; | |
| 115 | }; | |
| 116 | return { | |
| 117 | check: n("check-queue") + n("checking"), | |
| 118 | review: n("review"), | |
| 119 | land: n("approved"), | |
| 120 | live: n("live"), | |
| 121 | }; | |
| 122 | } |
src/http/server.ts +5
| @@ -288,6 +288,7 @@ import { | ||
| 288 | 288 | import { keeperAnswer } from "../web/studio/keeper_row.ts"; |
| 289 | 289 | import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts"; |
| 290 | 290 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| 291 | import { liveChipsFragment, liveMovingFragment } from "../web/studio/living.ts"; | |
| 291 | 292 | import { HOME_REGIONS } from "../web/studio/home_last.ts"; |
| 292 | 293 | import { serviceWorker } from "../web/sw.ts"; |
| 293 | 294 | import { explorePage } from "../web/studio/explore.ts"; |
| @@ -297,6 +298,7 @@ import { | ||
| 297 | 298 | } from "../web/studio/make_it_yours.ts"; |
| 298 | 299 | import { |
| 299 | 300 | frontUpdate, |
| 301 | feedPage, | |
| 300 | 302 | liveAppsFragment, |
| 301 | 303 | liveClientAppsFragment, |
| 302 | 304 | liveHomeFragment, |
| @@ -853,6 +855,9 @@ export function buildRouter(): Router<Handler> { | ||
| 853 | 855 | router.add("GET", "/live/on-you", liveOnYouFragment); |
| 854 | 856 | router.add("GET", "/live/client-apps", liveClientAppsFragment); |
| 855 | 857 | router.add("GET", "/live/feed", liveFeedFragment); |
| 858 | router.add("GET", "/live/moving", liveMovingFragment); | |
| 859 | router.add("GET", "/live/chips", liveChipsFragment); | |
| 860 | router.add("GET", "/feed", feedPage); | |
| 856 | 861 | // The home's late regions, each drawn alone (#7660). |
| 857 | 862 | for (const name of HOME_REGIONS) router.add("GET", `/live/home/${name}`, (ctx) => liveHomeFragment(ctx, name)); |
| 858 | 863 | // Whether a hostname is an app's verified domain — what Caddy's |
src/tui/screens/dashboard.ts +12
| @@ -28,6 +28,8 @@ import { agentsLine } from "../../web/studio/client_home.ts"; | ||
| 28 | 28 | import { lastVisit } from "../../core/home_visit.ts"; |
| 29 | 29 | import { sinceDigest } from "../../web/studio/since.ts"; |
| 30 | 30 | import { askForSomething } from "./tell.ts"; |
| 31 | import { movingLines } from "./moving.ts"; | |
| 32 | import { pipelineNow } from "../../web/studio/living.ts"; | |
| 31 | 33 | import { askForEveryday, everydayOf, EVERYDAY_WHY, keepAdmin } from "../../core/everyday.ts"; |
| 32 | 34 | import { mayView } from "../../core/sealed.ts"; |
| 33 | 35 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts"; |
| @@ -199,6 +201,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 199 | 201 | let clientLines: string[] = []; |
| 200 | 202 | /** "Since you were here" and its lines: read from the web home's last-visit row, never written here. */ |
| 201 | 203 | let sinceLines: string[] = []; |
| 204 | /** The web home's living pipeline (#8015): Now for a site admin, then being built and just shipped. */ | |
| 205 | let moving: string[] = []; | |
| 202 | 206 | /** The ids of those apps, so the key that asks for something stays on them. */ |
| 203 | 207 | let clientRepoIds = new Set<string>(); |
| 204 | 208 | /** The live address of each of those apps that is running, by repo id; `o` shows it. */ |
| @@ -370,6 +374,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 370 | 374 | lane = forgeLane(f.app, f.user); |
| 371 | 375 | flaky = forgeFlakes(f.app, f.user); |
| 372 | 376 | decider = deciderLine(f.app, f.user); |
| 377 | moving = f.user ? movingLines(f.db, f.user, f.user.isAdmin ? pipelineNow(f.db) : undefined) : []; | |
| 373 | 378 | const commit = running().commit; |
| 374 | 379 | const cleared = lastClearedNote(f.db, now); |
| 375 | 380 | figures = |
| @@ -1033,6 +1038,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1033 | 1038 | ); |
| 1034 | 1039 | body = rest!; |
| 1035 | 1040 | } |
| 1041 | if (moving.length > 0) { | |
| 1042 | const [strip, rest] = split(body, "v", [len(moving.length), fill(1)]); | |
| 1043 | moving.forEach((line, i) => | |
| 1044 | s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w), | |
| 1045 | ); | |
| 1046 | body = rest!; | |
| 1047 | } | |
| 1036 | 1048 | // A short terminal drops the last figures before it drops the list. |
| 1037 | 1049 | const shown = figures.slice(0, Math.max(0, body.h - FIGURES_ROOM)); |
| 1038 | 1050 | if (shown.length > 0) { |
src/tui/screens/moving.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * The terminal home's living pipeline (#8015), the web home's in lines: the | |
| 3 | * pipeline in four words for a site admin, then what is being built and what | |
| 4 | * just shipped, each filtered to what the viewer reads by the same reads the | |
| 5 | * web uses. The tell box is the home's own tell, already there. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { User } from "../../core/users.ts"; | |
| 10 | import { readBuilding, readShipped, type MovingRow, type Now } from "../../flow/moving.ts"; | |
| 11 | import { plain } from "./boards.ts"; | |
| 12 | ||
| 13 | const listLine = (label: string, rows: readonly MovingRow[], empty: string): string => | |
| 14 | `${label} ${rows.length === 0 ? empty : rows.map((r) => `${plain(r.title)} (${plain(r.repo)})`).join(" · ")}`; | |
| 15 | ||
| 16 | /** `now` is passed only for a site admin: the counts take in every repository, private ones too. */ | |
| 17 | export function movingLines(db: Database, viewer: User, now: Now | undefined): string[] { | |
| 18 | return [ | |
| 19 | ...(now === undefined ? [] : [`Now check ${now.check} › review ${now.review} › land ${now.land} › ${now.live} live`]), | |
| 20 | listLine("Being built", readBuilding(db, viewer), "nothing right now"), | |
| 21 | listLine("Just shipped", readShipped(db, viewer), "nothing yet"), | |
| 22 | ]; | |
| 23 | } |
src/web/studio/actions.ts +6 −4
| @@ -172,14 +172,16 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 172 | 172 | const form = await readForm(ctx, "rw"); |
| 173 | 173 | if (form instanceof Response) return form; |
| 174 | 174 | const target = field(form, "app"); |
| 175 | // The feed's composer asks to come back to the feed; any other box, to home. | |
| 176 | const home = backTo(form, "/"); | |
| 175 | 177 | // "the forge" is no app: the words are kept as a thought, exactly as typed. |
| 176 | 178 | if (target === FORGE_TARGET) { |
| 177 | 179 | if (field(form, "text").trim() === "") |
| 178 | return flashOrJson(ctx, "/", { tone: "danger", message: SAY_SOMETHING_WORDS }); | |
| 180 | return flashOrJson(ctx, home, { tone: "danger", message: SAY_SOMETHING_WORDS }); | |
| 179 | 181 | const kept = keepThought(ctx.app, ctx.auth.user, field(form, "text"), "composer"); |
| 180 | 182 | return flashOrJson( |
| 181 | 183 | ctx, |
| 182 | "/", | |
| 184 | home, | |
| 183 | 185 | kept.ok |
| 184 | 186 | ? { message: "Received." } |
| 185 | 187 | : { |
| @@ -187,13 +189,13 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 187 | 189 | message: |
| 188 | 190 | kept.error === "too_long" ? "That is too long to keep." : "That could not be kept.", |
| 189 | 191 | }, |
| 190 | { href: "/" }, | |
| 192 | { href: home }, | |
| 191 | 193 | ); |
| 192 | 194 | } |
| 193 | 195 | const [owner = "", name = "", ...extra] = target.split("/"); |
| 194 | 196 | const repo = extra.length === 0 ? getRepo(ctx.app.db, owner, name) : undefined; |
| 195 | 197 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx); |
| 196 | return tellFrom(ctx, repo, form, "/"); | |
| 198 | return tellFrom(ctx, repo, form, home); | |
| 197 | 199 | } |
| 198 | 200 | |
| 199 | 201 |
src/web/studio/feed.ts +7 −3
| @@ -226,6 +226,7 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 226 | 226 | })}`; |
| 227 | 227 | const form = html`<form id="composer" class="card" method="post" action="/tell"> |
| 228 | 228 | <div class="card-body stack"> |
| 229 | <input type="hidden" name="back" value="/feed" /> | |
| 229 | 230 | <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea> |
| 230 | 231 | <div class="bar"> |
| 231 | 232 | ${repos.length === 0 |
| @@ -341,14 +342,14 @@ export function caughtUp(items: readonly FeedItem[], next: string | null, cursor | ||
| 341 | 342 | // Ranked by score, not time: the last card is not always the oldest. |
| 342 | 343 | const oldest = items.reduce<string | undefined>((min, item) => (min === undefined || item.at < min ? item.at : min), undefined); |
| 343 | 344 | if (oldest === undefined) return null; |
| 344 | const newest = cursor === undefined ? html`` : html`<a href="/">Newest</a>`; | |
| 345 | const newest = cursor === undefined ? html`` : html`<a href="/feed">Newest</a>`; | |
| 345 | 346 | return next === null |
| 346 | 347 | ? html`<p class="bar muted small" data-caught-up="end"><span>That's everything</span><span class="spacer"></span>${newest}</p>` |
| 347 | 348 | : html`<p class="bar muted small" data-caught-up="more"> |
| 348 | 349 | <span>You're caught up to ${time(oldest)}</span> |
| 349 | 350 | <span class="spacer"></span> |
| 350 | 351 | ${newest} |
| 351 | <a href="/?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a> | |
| 352 | <a href="/feed?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a> | |
| 352 | 353 | </p>`; |
| 353 | 354 | } |
| 354 | 355 | |
| @@ -403,6 +404,9 @@ export function liveFeedFragment(ctx: Ctx): Response { | ||
| 403 | 404 | return response; |
| 404 | 405 | } |
| 405 | 406 | |
| 407 | /** Offline: the worker keeps the last home and the page that shows it is stale (`sw.ts`). */ | |
| 408 | export const registerWorkerJs = `if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {});`; | |
| 409 | ||
| 406 | 410 | /** |
| 407 | 411 | * When a card is hidden, the day divider just above it goes with it, unless |
| 408 | 412 | * a later card of that day is still showing — then the divider moves down to |
| @@ -599,7 +603,7 @@ ${dayKeepJs} | ||
| 599 | 603 | if (navigator.serviceWorker && navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('clear'); |
| 600 | 604 | } |
| 601 | 605 | }); |
| 602 | if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {}); | |
| 606 | ${registerWorkerJs} | |
| 603 | 607 | paint(); |
| 604 | 608 | flush(); |
| 605 | 609 | })(); |
src/web/studio/feed_style.ts +7
| @@ -12,6 +12,13 @@ export const feedCss = /* css */ ` | ||
| 12 | 12 | @media (max-width: 899px) { |
| 13 | 13 | #live-apps { display: none; } |
| 14 | 14 | } |
| 15 | /* Home's living pipeline (#8015): two lists side by side on a desk. Each list always holds three rows, so nothing below moves. */ | |
| 16 | .moving { display: grid; grid-template-columns: 1fr; gap: 12px; } | |
| 17 | @media (min-width: 700px) { | |
| 18 | .moving { grid-template-columns: 1fr 1fr; } | |
| 19 | } | |
| 20 | .moving .row { min-height: 44px; } | |
| 21 | .tell-box > input[name="text"] { flex: 1 1 14rem; min-width: 0; } | |
| 15 | 22 | @media (max-width: 600px) { |
| 16 | 23 | .feed .bar .btn { padding: 8px 10px; } |
| 17 | 24 | .feed .disclosure > summary { min-height: 44px; display: flex; align-items: center; } |
src/web/studio/home.ts +48 −27
| @@ -51,10 +51,10 @@ import { | ||
| 51 | 51 | import { writableAppsOf } from "./asks.ts"; |
| 52 | 52 | import { needsYouSection } from "./asks_page.ts"; |
| 53 | 53 | import { flowCards } from "./flow_card.ts"; |
| 54 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; | |
| 54 | import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts"; | |
| 55 | import { appChips, movingRegion, tellBox } from "./living.ts"; | |
| 55 | 56 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 56 | import { keeperRow } from "./keeper_row.ts"; | |
| 57 | import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; | |
| 57 | import { kpiRow, waitingLine } from "./kpi_row.ts"; | |
| 58 | 58 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 59 | 59 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 60 | 60 | import { idleReason } from "../../crew/idle_reason.ts"; |
| @@ -228,7 +228,7 @@ export function liveOnYouFragment(ctx: Ctx): Response { | ||
| 228 | 228 | * How each late region of home is drawn for `user` (#7660): the page serves |
| 229 | 229 | * the last drawing from memory and `/live/home/<name>` draws it fresh. |
| 230 | 230 | */ |
| 231 | function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promise<Html>> { | |
| 231 | export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promise<Html>> { | |
| 232 | 232 | const { db } = ctx.app; |
| 233 | 233 | const request = ctx.request; |
| 234 | 234 | return { |
| @@ -237,8 +237,6 @@ function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promis | ||
| 237 | 237 | const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn); |
| 238 | 238 | const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; |
| 239 | 239 | return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))} |
| 240 | ${timePhase(request, "vitals", () => vitalsRow(db))} ${clogRow(db)} | |
| 241 | ${keeperRow(db, true)} | |
| 242 | 240 | ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`; |
| 243 | 241 | }, |
| 244 | 242 | waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)), |
| @@ -316,9 +314,46 @@ export async function studioHome( | ||
| 316 | 314 | } |
| 317 | 315 | // The client's first screen names its own phases (`clientApps`). |
| 318 | 316 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 319 | const { repos: appRepos } = timePhase(ctx.request, "repos", () => writableAppsOf(ctx, user)); | |
| 320 | 317 | // Render is what is left of the page once the named reads are taken out. |
| 321 | 318 | const rendering = performance.now(); |
| 319 | const access = timePhase(ctx.request, "access", () => accessFingerprint(ctx.app.db, user)); | |
| 320 | const ask = firstSteps ?? everydayCard(ctx, user); | |
| 321 | const figures = user.isAdmin && steps === undefined; | |
| 322 | const kpis = figures | |
| 323 | ? await homeRegion(ctx.app.db, ctx.request, user.id, "kpis", homeVariant(ctx.url), access, homeDraws(ctx, user).kpis) | |
| 324 | : liveLine(ctx); | |
| 325 | const body = html`<h1 class="sr-only">Home</h1> | |
| 326 | ${tellBox(ctx, user)} | |
| 327 | ${ask} ${address} | |
| 328 | <section class="stack" aria-label="The forge">${kpis}</section> | |
| 329 | ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))} | |
| 330 | ${appChips(ctx, user)} | |
| 331 | ${when( | |
| 332 | steps, | |
| 333 | () => html`<p class="small muted" id="lean-home"><a href="/">Just my apps</a></p>`, | |
| 334 | )} | |
| 335 | <p class="small muted"><a href="/feed">The feed</a></p>`; | |
| 336 | const res = page({ | |
| 337 | ctx, | |
| 338 | title: "Home", | |
| 339 | body, | |
| 340 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${refreshJs}\n${registerWorkerJs}${ask ? `\n${passkeyJs}` : ""}`, | |
| 341 | }); | |
| 342 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access"]); | |
| 343 | return res; | |
| 344 | } | |
| 345 | ||
| 346 | /** | |
| 347 | * The feed (#8015), kept off home: the composer, then one timeline — what | |
| 348 | * home was before it became the living pipeline — with your apps and what | |
| 349 | * is on you beside it on a desk. | |
| 350 | */ | |
| 351 | export async function feedPage(ctx: Ctx): Promise<Response> { | |
| 352 | const user = ctx.auth.user; | |
| 353 | if (!user) return signInFirst(ctx); | |
| 354 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 355 | const { repos: appRepos } = timePhase(ctx.request, "repos", () => writableAppsOf(ctx, user)); | |
| 356 | const rendering = performance.now(); | |
| 322 | 357 | const visitedAt = Date.now(); |
| 323 | 358 | const before = timePhase(ctx.request, "visit", () => |
| 324 | 359 | isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id), |
| @@ -328,18 +363,9 @@ export async function studioHome( | ||
| 328 | 363 | const access = timePhase(ctx.request, "access", () => accessFingerprint(ctx.app.db, user)); |
| 329 | 364 | const regionOf = (name: HomeRegion): Promise<Html> => |
| 330 | 365 | homeRegion(ctx.app.db, ctx.request, user.id, name, homeVariant(ctx.url), access, draws[name]); |
| 331 | const ask = firstSteps ?? everydayCard(ctx, user); | |
| 332 | const figures = user.isAdmin && steps === undefined; | |
| 333 | const body = html`<h1 class="sr-only">Home</h1> | |
| 334 | ${ask} ${address} ${figures ? await regionOf("kpis") : liveLine(ctx)} | |
| 366 | const body = html`<h1 class="sr-only">Feed</h1> | |
| 335 | 367 | ${await regionOf("waiting")} |
| 336 | ${figures ? await regionOf("panels") : null} | |
| 337 | ${when( | |
| 338 | steps, | |
| 339 | () => html`<p class="small muted" id="lean-home"> | |
| 340 | <a href="/">Just my apps</a> | |
| 341 | </p>`, | |
| 342 | )} | |
| 368 | ${user.isAdmin ? await regionOf("panels") : null} | |
| 343 | 369 | ${since} |
| 344 | 370 | ${await regionOf("asked")} |
| 345 | 371 | <div class="split" data-rail="wide"> |
| @@ -350,12 +376,7 @@ export async function studioHome( | ||
| 350 | 376 | ${await regionOf("feed")} |
| 351 | 377 | </div> |
| 352 | 378 | <aside class="stack"> |
| 353 | ${ | |
| 354 | // The crew's missing key is the owner's to fix, not the first | |
| 355 | // thing above every post: beside the timeline on a desk, after it | |
| 356 | // on a phone. | |
| 357 | user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null | |
| 358 | } | |
| 379 | ${user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null} | |
| 359 | 380 | ${appsRegion(ctx, user)} |
| 360 | 381 | ${terminalCard(ctx)} ${downloadsCard(ctx, user)} |
| 361 | 382 | ${orgsCard(ctx, user)} |
| @@ -363,11 +384,11 @@ export async function studioHome( | ||
| 363 | 384 | </div>`; |
| 364 | 385 | const res = page({ |
| 365 | 386 | ctx, |
| 366 | title: "Home", | |
| 387 | title: "Feed", | |
| 367 | 388 | body, |
| 368 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${refreshJs}${ask ? `\n${passkeyJs}` : ""}`, | |
| 389 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${refreshJs}`, | |
| 369 | 390 | }); |
| 370 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "vitals", "waiting", "panels", "feed", "visit", "since", "access"]); | |
| 391 | notePhaseRest(ctx.request, "render", rendering, ["waiting", "feed", "asked", "visit", "since", "access", "repos"]); | |
| 371 | 392 | return res; |
| 372 | 393 | } |
| 373 | 394 |
src/web/studio/home_tabs.ts +1 −1
| @@ -27,7 +27,7 @@ export function homeTabs(current: HomeTab, unshipped = 0): Html { | ||
| 27 | 27 | }); |
| 28 | 28 | return html`<div class="mb"> |
| 29 | 29 | ${tabs( |
| 30 | [item("feed", "/", "Feed"), item("apps", "/apps", "Your apps"), item("asked", "/asked", "Asked", unshipped)], | |
| 30 | [item("feed", "/feed", "Feed"), item("apps", "/apps", "Your apps"), item("asked", "/asked", "Asked", unshipped)], | |
| 31 | 31 | "Home", |
| 32 | 32 | )} |
| 33 | 33 | </div>`; |
src/web/studio/kpi_row.ts +1 −17
| @@ -11,9 +11,7 @@ import type { Database } from "bun:sqlite"; | ||
| 11 | 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; |
| 12 | 12 | import type { IdleReason } from "../../crew/idle_reason.ts"; |
| 13 | 13 | import type { KpiTimer, Kpis } from "../../flow/kpis.ts"; |
| 14 | import { clogLines, readClog } from "../../flow/clog_read.ts"; | |
| 15 | import { vitalsLine } from "../../flow/vitals.ts"; | |
| 16 | import { cachedKpis, cachedVitals, cachedWaiting } from "./home_cache.ts"; | |
| 14 | import { cachedKpis, cachedWaiting } from "./home_cache.ts"; | |
| 17 | 15 | import { escapeHtml, html, raw, type Html } from "../html.ts"; |
| 18 | 16 | import { BOOTED } from "../../release/gate.ts"; |
| 19 | 17 | import { running } from "../../kernel/version.ts"; |
| @@ -192,17 +190,3 @@ export function waitingLine(db: Database, user: User, now: Date = new Date()): H | ||
| 192 | 190 | // The fold is a form, which cannot sit in the paragraph: it follows it. |
| 193 | 191 | return html`${words === null ? null : html`<p class="small muted break stack stack-tight"><span>${words}</span><a href="/review">Review them</a></p>`}${stale.length === 0 ? null : staleArchive(stale)}`; |
| 194 | 192 | } |
| 195 | ||
| 196 | /** Home's flow vitals for an admin (#5935): one muted line of counts; the open count links to the boards. */ | |
| 197 | export function vitalsRow(db: Database, now: Date = new Date()): Html { | |
| 198 | const v = cachedVitals(db, now); | |
| 199 | const rest = vitalsLine(v).split(" · ").slice(1).join(" · "); | |
| 200 | return html`<p class="small muted"><a href="/boards">${v.open} open pulls</a> · ${rest}</p>`; | |
| 201 | } | |
| 202 | ||
| 203 | /** Home's clog lines for an admin (#6757): the clog standing now and the last cleared note; nothing when there is neither. */ | |
| 204 | export function clogRow(db: Database, now: Date = new Date()): Html { | |
| 205 | const lines = clogLines(readClog(db, now), now.getTime()); | |
| 206 | if (lines.length === 0) return html``; | |
| 207 | return html`${lines.map((l) => html`<p class="small muted break">${l}</p>`)}`; | |
| 208 | } |
src/web/studio/living.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * The home's living pipeline (#8015): the pipeline in four words, what is | |
| 3 | * being built, what just shipped, and the box that tells the crew. Each part | |
| 4 | * is plain rows with a link to the thing it names. Being built and just | |
| 5 | * shipped are one live region (`/live/moving`) that the shared stream | |
| 6 | * redraws when a run or a merge moves; it always holds the same number of | |
| 7 | * rows, so nothing below it shifts. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { MOVING_ROWS, nowOf, readBuilding, readShipped, type MovingRow, type Now } from "../../flow/moving.ts"; | |
| 12 | import { readPipeline } from "../../flow/pipeline.ts"; | |
| 13 | import type { User } from "../../core/users.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { html, type Html } from "../html.ts"; | |
| 16 | import { fragment } from "../layout.ts"; | |
| 17 | import { btn, listCard, row, timeAgo } from "../ui.ts"; | |
| 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 19 | import { writableAppsOf } from "./asks.ts"; | |
| 20 | import { FORGE_TARGET, sharedDraft } from "./feed.ts"; | |
| 21 | import { repoBase } from "../chrome.ts"; | |
| 22 | ||
| 23 | /** The events that move a build or a shipment. */ | |
| 24 | const MOVING_LIVE = "run.started,run.finished,pull.merged,pull.opened,pull.closed,app.serving"; | |
| 25 | const NOW_TTL_MS = 3_000; | |
| 26 | // One entry per forge, not per viewer: the counts are forge-wide, so only a site admin is ever shown them. | |
| 27 | const nows = new WeakMap<Database, { at: number; now: Now }>(); | |
| 28 | ||
| 29 | /** Admin only: the caller has checked. The pipeline counts, read at most every few seconds: the same stages `/boards` draws. */ | |
| 30 | export function pipelineNow(db: Database, at = Date.now()): Now { | |
| 31 | const kept = nows.get(db); | |
| 32 | if (kept !== undefined && at - kept.at < NOW_TTL_MS) return kept.now; | |
| 33 | const now = nowOf(readPipeline(db, new Date(at))); | |
| 34 | nows.set(db, { at, now }); | |
| 35 | return now; | |
| 36 | } | |
| 37 | ||
| 38 | /** `check 10 › review 1 › land 3 › live`: each word links to the boards where the same stage is. */ | |
| 39 | export function nowLine(now: Now, boards = true): Html { | |
| 40 | // An agent admin has no boards to open: the word is then plain. | |
| 41 | const part = (word: string, n: number) => | |
| 42 | boards ? html`<a href="/boards"><strong>${String(n)}</strong> ${word}</a>` : html`<span><strong>${String(n)}</strong> ${word}</span>`; | |
| 43 | return html`<section aria-label="Now"> | |
| 44 | <h2 class="small muted">Now</h2> | |
| 45 | <p class="bar" id="now-line"> | |
| 46 | ${part("check", now.check)} <span class="muted" aria-hidden="true">›</span> | |
| 47 | ${part("review", now.review)} <span class="muted" aria-hidden="true">›</span> | |
| 48 | ${part("land", now.land)} <span class="muted" aria-hidden="true">›</span> | |
| 49 | <a href="/releases"><strong>${String(now.live)}</strong> live</a> | |
| 50 | </p> | |
| 51 | </section>`; | |
| 52 | } | |
| 53 | ||
| 54 | function listOf(label: string, rows: readonly MovingRow[], empty: string): Html { | |
| 55 | const shown = rows.map((r) => | |
| 56 | row({ | |
| 57 | href: r.href, | |
| 58 | ...(r.who === "" ? {} : { lead: html`<span class="muted small">${r.who}</span>` }), | |
| 59 | title: html`<span class="truncate" title="${r.repo}">${r.title}</span>`, | |
| 60 | trail: html`<span class="muted small">${timeAgo(r.at)}</span>`, | |
| 61 | }), | |
| 62 | ); | |
| 63 | // Always MOVING_ROWS rows: a quiet slot is as tall as a busy one. | |
| 64 | for (let i = shown.length; i < MOVING_ROWS; i++) | |
| 65 | shown.push(row({ title: html`<span class="muted small">${i === 0 ? empty : " "}</span>` })); | |
| 66 | return html`<section aria-label="${label}"> | |
| 67 | <h2 class="small muted">${label}</h2> | |
| 68 | ${listCard(shown)} | |
| 69 | </section>`; | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * The pipeline, then being built and just shipped side by side on a desk and | |
| 74 | * stacked on a phone; one live region. The pipeline counts every repository, | |
| 75 | * private ones too, so only a site admin, who reads them all, sees the line. | |
| 76 | */ | |
| 77 | export function movingRegion(db: Database, viewer: User): Html { | |
| 78 | const building = readBuilding(db, viewer); | |
| 79 | const shipped = readShipped(db, viewer); | |
| 80 | return html`<div id="live-moving" class="stack" data-live="${MOVING_LIVE}" data-live-src="/live/moving"> | |
| 81 | ${viewer.isAdmin ? nowLine(pipelineNow(db), viewer.kind !== "agent") : null} | |
| 82 | <div class="moving"> | |
| 83 | ${listOf("Being built", building, "Nothing is being built right now")} | |
| 84 | ${listOf("Just shipped", shipped, "Nothing has shipped yet")} | |
| 85 | </div> | |
| 86 | </div>`; | |
| 87 | } | |
| 88 | ||
| 89 | // GET /live/moving — the two lists alone, for the region that shows them. | |
| 90 | export function liveMovingFragment(ctx: Ctx): Response { | |
| 91 | const user = ctx.auth.user; | |
| 92 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 93 | if (!user) { | |
| 94 | notePhase(ctx.request, "render", 0); | |
| 95 | return fragment(html``, { status: 404 }); | |
| 96 | } | |
| 97 | const body = timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user)); | |
| 98 | notePhase(ctx.request, "render", 0); | |
| 99 | return fragment(body, { request: ctx.request }); | |
| 100 | } | |
| 101 | ||
| 102 | /** The box that tells the crew: one line, the app it goes to, Tell. Posts to `/tell` as the composer does. */ | |
| 103 | export function tellBox(ctx: Ctx, user: User): Html { | |
| 104 | const { repos } = writableAppsOf(ctx, user); | |
| 105 | const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`; | |
| 106 | return html`<form id="tell-box" class="bar tell-box" method="post" action="/tell"> | |
| 107 | <input class="input" name="text" maxlength="2000" required placeholder="Tell the crew…" aria-label="Tell the crew" data-draft="${user.id}" value="${sharedDraft(ctx.url.searchParams).replace(/\s+/g, " ")}" /> | |
| 108 | ${repos.length === 0 | |
| 109 | ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" />` | |
| 110 | : html`<select name="app" class="input" aria-label="Send to"> | |
| 111 | ${repos.map((r) => html`<option value="${slug(r)}">${slug(r)}</option>`)} | |
| 112 | <option value="${FORGE_TARGET}">the forge</option> | |
| 113 | </select>`} | |
| 114 | ${btn("Tell", { variant: "primary" })} | |
| 115 | </form>`; | |
| 116 | } | |
| 117 | ||
| 118 | /** Your apps as chips, each a link to the app, and a plus for a new one. */ | |
| 119 | export function appChips(ctx: Ctx, user: User): Html { | |
| 120 | const { repos } = writableAppsOf(ctx, user); | |
| 121 | return html`<section aria-label="Your apps" id="live-chips" data-live="app.serving,app.stopped,app.created,app.deleted" data-live-src="/live/chips"> | |
| 122 | <h2 class="small muted">Your apps</h2> | |
| 123 | <div class="bar"> | |
| 124 | ${repos.slice(0, 12).map((r) => btn(r.name, { href: repoBase(r), size: "sm" }))} | |
| 125 | ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })} | |
| 126 | ${repos.length > 12 ? btn(`All ${repos.length}`, { href: "/apps", size: "sm" }) : null} | |
| 127 | </div> | |
| 128 | </section>`; | |
| 129 | } | |
| 130 | ||
| 131 | // GET /live/chips — the chips alone. | |
| 132 | export function liveChipsFragment(ctx: Ctx): Response { | |
| 133 | const user = ctx.auth.user; | |
| 134 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 135 | notePhase(ctx.request, "render", 0); | |
| 136 | if (!user) return fragment(html``, { status: 404 }); | |
| 137 | return fragment(appChips(ctx, user), { request: ctx.request }); | |
| 138 | } |
test/asked-tab-count.test.ts +3 −3
| @@ -72,7 +72,7 @@ test("homeTabs reads plain Asked at 0 or when not given, and the count otherwise | ||
| 72 | 72 | |
| 73 | 73 | test("with no asks the Asked tab is plain on all three pages", async () => { |
| 74 | 74 | forgetHome(app.db); |
| 75 | for (const path of ["/?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked"); | |
| 75 | for (const path of ["/feed?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked"); | |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | 78 | test("three unshipped asks read Asked 3 on `/`, `/apps` and `/asked`", async () => { |
| @@ -81,8 +81,8 @@ test("three unshipped asks read Asked 3 on `/`, `/apps` and `/asked`", async () | ||
| 81 | 81 | if (!ask.ok) throw new Error(ask.error); |
| 82 | 82 | } |
| 83 | 83 | forgetHome(app.db); |
| 84 | for (const path of ["/?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked 3"); | |
| 85 | const res = await fetch(`${servers.url}/?all=1`, { headers: { cookie } }); | |
| 84 | for (const path of ["/feed?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked 3"); | |
| 85 | const res = await fetch(`${servers.url}/feed?all=1`, { headers: { cookie } }); | |
| 86 | 86 | await res.arrayBuffer(); |
| 87 | 87 | expect(res.headers.get("server-timing") ?? "").toContain("asked;"); |
| 88 | 88 | }); |
test/asked.test.ts +1 −1
| @@ -279,7 +279,7 @@ describe("what you asked for", () => { | ||
| 279 | 279 | expect(page.indexOf("Fix the gate")).toBeLessThan(page.indexOf("Plant tomatoes")); |
| 280 | 280 | // Reached from the board and the feed's header, not only the asks page. |
| 281 | 281 | expect(await (await fetch(`${servers.url}/board`, { headers: { cookie } })).text()).toContain('href="/asked"'); |
| 282 | expect(await (await fetch(`${servers.url}/`, { headers: { cookie } })).text()).toContain('href="/asked"'); | |
| 282 | expect(await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text()).toContain('href="/asked"'); | |
| 283 | 283 | const flagged = ( |
| 284 | 284 | await ( |
| 285 | 285 | await fetch(`${servers.url}/asked?show=flagged`, { |
test/audit.test.ts +1 −1
| @@ -432,7 +432,7 @@ describe("the audit reads every app's policy from git", () => { | ||
| 432 | 432 | |
| 433 | 433 | test("the way in: the studio home links the audit, and an app's page says its policy in one line", async () => { |
| 434 | 434 | const home = await ( |
| 435 | await fetch(`${servers.url}/`, { | |
| 435 | await fetch(`${servers.url}/feed`, { | |
| 436 | 436 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 437 | 437 | }) |
| 438 | 438 | ).text(); |
test/board-pins.test.ts +1 −1
| @@ -26,7 +26,7 @@ const post = (path: string, fields: Record<string, string>, who = "alice", heade | ||
| 26 | 26 | headers: { authorization: `Bearer ${tok[who]}`, ...headers }, |
| 27 | 27 | }); |
| 28 | 28 | const home = async (who: string) => |
| 29 | (await fetch(servers.url + "/", { headers: { authorization: `Bearer ${tok[who]}` } })).text(); | |
| 29 | (await fetch(servers.url + "/feed", { headers: { authorization: `Bearer ${tok[who]}` } })).text(); | |
| 30 | 30 | const titles = (h: string) => [...h.matchAll(/<h2>(Pin [ABC])<\/h2>/g)].map((m) => m[1]); |
| 31 | 31 | |
| 32 | 32 | beforeAll(async () => { |
test/client-home.test.ts +2 −2
| @@ -176,7 +176,7 @@ describe("a client's first screen", () => { | ||
| 176 | 176 | expect(apps).toContain('href="/marias/bakery"'); |
| 177 | 177 | expect(apps).toMatch(/data-agents="(working|waiting)"/); |
| 178 | 178 | expect(apps).toContain("agents are waiting"); |
| 179 | expect(home).not.toContain('id="live-apps"'); | |
| 179 | expect(home).not.toContain('id="live-chips"'); | |
| 180 | 180 | for (const gone of ['href="/map"', 'href="/board"', 'href="/audit"', 'href="/settings"']) |
| 181 | 181 | expect(home).not.toContain(gone); |
| 182 | 182 | expect(home).toContain('href="/?all=1"'); |
| @@ -359,7 +359,7 @@ describe("a client's first screen", () => { | ||
| 359 | 359 | |
| 360 | 360 | test("asked for, the whole home is still there", async () => { |
| 361 | 361 | const home = await page(owner, "/?all=1"); |
| 362 | expect(home).toContain('id="live-apps"'); | |
| 362 | expect(home).toContain('id="live-chips"'); | |
| 363 | 363 | expect(home).not.toContain('id="client-apps"'); |
| 364 | 364 | }); |
| 365 | 365 |
test/composer-draft.test.ts +21 −4
| @@ -47,7 +47,7 @@ beforeAll(async () => { | ||
| 47 | 47 | afterAll(() => servers.stop()); |
| 48 | 48 | |
| 49 | 49 | test("the composer names its user for the draft key, and the script stays small", async () => { |
| 50 | const res = await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } }); | |
| 50 | const res = await fetch(`${servers.url}/feed`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } }); | |
| 51 | 51 | const page = await res.text(); |
| 52 | 52 | expect(page).toContain(`data-draft="${alice.id}"`); |
| 53 | 53 | expect(feedJs).toContain("forgeDraft("); |
| @@ -153,7 +153,7 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload, | ||
| 153 | 153 | const load = async () => { |
| 154 | 154 | const loaded = once("Page.loadEventFired"); |
| 155 | 155 | loaded.catch(() => {}); |
| 156 | await send("Page.navigate", { url: `${servers.url}/` }); | |
| 156 | await send("Page.navigate", { url: `${servers.url}/feed` }); | |
| 157 | 157 | await loaded; |
| 158 | 158 | await waitFor(`!!document.getElementById("composer")`); |
| 159 | 159 | }; |
| @@ -176,7 +176,7 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload, | ||
| 176 | 176 | |
| 177 | 177 | await load(); |
| 178 | 178 | await type("Water the tomatoes at dawn"); |
| 179 | await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/`)}) === "Water the tomatoes at dawn"`); | |
| 179 | await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/feed`)}) === "Water the tomatoes at dawn"`); | |
| 180 | 180 | await load(); |
| 181 | 181 | expect(await run(`${box}.value`)).toBe("Water the tomatoes at dawn"); |
| 182 | 182 | |
| @@ -203,7 +203,7 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload, | ||
| 203 | 203 | await type("Mulch first"); |
| 204 | 204 | await run(`(() => { const c = document.getElementById("composer"); const o = document.createElement("option"); o.value = "alice/nowhere"; c.elements.app.append(o); c.elements.app.value = "alice/nowhere"; c.requestSubmit(); })()`); |
| 205 | 205 | await waitFor(`!localStorage.getItem("forge.outbox") || localStorage.getItem("forge.outbox") === "[]"`); |
| 206 | await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/`)}) === "Mulch first"`); | |
| 206 | await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/feed`)}) === "Mulch first"`); | |
| 207 | 207 | expect(await run(`${box}.value`)).toBe("Mulch first"); |
| 208 | 208 | await load(); |
| 209 | 209 | expect(await run(`${box}.value`)).toBe("Mulch first"); |
| @@ -215,3 +215,20 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload, | ||
| 215 | 215 | }, 90000); |
| 216 | 216 | |
| 217 | 217 | test.skipIf(!noChromium)("no chromium on this machine: the composer draft browser test did not run", () => {}); |
| 218 | ||
| 219 | test("a tell sent from the feed's composer comes back to the feed, the forge's too", async () => { | |
| 220 | // Its own session: the browser test before it signs its session out. | |
| 221 | const cookie = createSession(app.db, alice.id).token; | |
| 222 | const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } })).text(); | |
| 223 | expect(page).toContain('name="back" value="/feed"'); | |
| 224 | for (const app of ["forge", "alice/garden"]) { | |
| 225 | const res = await fetch(`${servers.url}/tell`, { | |
| 226 | method: "POST", | |
| 227 | redirect: "manual", | |
| 228 | headers: { cookie: `forge_session=${cookie}; forge_mode=studio`, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 229 | body: new URLSearchParams({ app, text: "Plant beans", back: "/feed" }), | |
| 230 | }); | |
| 231 | expect(res.status).toBeLessThan(400); | |
| 232 | expect(new URL(res.headers.get("location") ?? "", servers.url).pathname).toBe("/feed"); | |
| 233 | } | |
| 234 | }); |
test/digest-doors.test.ts +1 −1
| @@ -47,7 +47,7 @@ afterAll(async () => { | ||
| 47 | 47 | |
| 48 | 48 | test("web, terminal and MCP give identical digest lines for one seeded forge", async () => { |
| 49 | 49 | const cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=studio`; |
| 50 | const page = await fetch(`${servers.url}/`, { headers: { cookie } }).then((r) => r.text()); | |
| 50 | const page = await fetch(`${servers.url}/feed`, { headers: { cookie } }).then((r) => r.text()); | |
| 51 | 51 | const d = sinceDigest(app.db, alice, lastVisit(app.db, alice.id), Date.now())!; |
| 52 | 52 | expect(d).toBeDefined(); |
| 53 | 53 | const lines = d.lines; |
test/feed-caught-up.test.ts +6 −6
| @@ -26,7 +26,7 @@ describe("the timeline's caught-up line", () => { | ||
| 26 | 26 | for (let i = 0; i < n; i++) |
| 27 | 27 | app.bus.append({ kind: "run.finished", repoId: repo.id, actorId: alice.id, payload: { kind: "agent", state: "ok", summary: `work ${i}`, runId: `r${i}` } }); |
| 28 | 28 | }; |
| 29 | const home = (path = "/") => freshHome(servers.url, cookie, path); | |
| 29 | const home = (path = "/feed") => freshHome(servers.url, cookie, path); | |
| 30 | 30 | |
| 31 | 31 | beforeAll(async () => { |
| 32 | 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-caught-up-")); |
| @@ -62,18 +62,18 @@ describe("the timeline's caught-up line", () => { | ||
| 62 | 62 | const text = await home(); |
| 63 | 63 | expect(text).toContain("You're caught up to"); |
| 64 | 64 | expect(text).not.toContain("That's everything"); |
| 65 | const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(text); | |
| 65 | const href = /href="(\/feed\?cursor=[^"]+)"[^>]*>Older posts/.exec(text); | |
| 66 | 66 | expect(href).not.toBeNull(); |
| 67 | 67 | const older = await home(href![1]!.replaceAll("&", "&")); |
| 68 | 68 | expect(older).toContain("data-item="); |
| 69 | 69 | expect(older).toContain("That's everything"); |
| 70 | expect(older).toContain('<a href="/">Newest</a>'); | |
| 70 | expect(older).toContain('<a href="/feed">Newest</a>'); | |
| 71 | 71 | expect(text).not.toContain(">Newest<"); |
| 72 | 72 | }); |
| 73 | 73 | |
| 74 | 74 | test("a page after the first carries its own address, so a chip refetch stays on it", async () => { |
| 75 | const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&", "&"); | |
| 76 | const cursor = href.slice("/?cursor=".length).split("&day=")[0]; | |
| 75 | const href = /href="(\/feed\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&", "&"); | |
| 76 | const cursor = href.slice("/feed?cursor=".length).split("&day=")[0]; | |
| 77 | 77 | const older = await home(href); |
| 78 | 78 | expect(older).toContain(`data-live-src="/live/feed?cursor=${cursor}&day=`); |
| 79 | 79 | expect(await home()).toContain('data-live-src="/live/feed"'); |
| @@ -88,7 +88,7 @@ describe("the timeline's caught-up line", () => { | ||
| 88 | 88 | }); |
| 89 | 89 | |
| 90 | 90 | test("a cursor not issued here is the first page", async () => { |
| 91 | const text = await home("/?cursor=garbage"); | |
| 91 | const text = await home("/feed?cursor=garbage"); | |
| 92 | 92 | expect(text).toContain("You're caught up to"); |
| 93 | 93 | }); |
| 94 | 94 | }); |
test/feed-chips.test.ts +1 −1
| @@ -171,7 +171,7 @@ describe("the chips of a forge", () => { | ||
| 171 | 171 | test("the home draws the chips, pressed unless switched off", async () => { |
| 172 | 172 | const session = createSession(app.db, bob.id); |
| 173 | 173 | setChip(app.db, bob.id, { kind: "kind", value: "crew" }, true); |
| 174 | const page = await (await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${session.token}` } })).text(); | |
| 174 | const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `forge_session=${session.token}` } })).text(); | |
| 175 | 175 | expect(page).toContain('data-value="crew"'); |
| 176 | 176 | expect(page).toMatch(/data-chip="kind"\s+data-value="crew"\s+aria-pressed="false"/); |
| 177 | 177 | expect(page).toMatch(/data-chip="kind"\s+data-value="reviews"\s+aria-pressed="true"/); |
test/feed-day-divider.test.ts +4 −4
| @@ -57,7 +57,7 @@ describe("day dividers on the home timeline", () => { | ||
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | 59 | test("three days give three dividers in order, once each", async () => { |
| 60 | const labels = dividerLabels(await get("/")); | |
| 60 | const labels = dividerLabels(await get("/feed")); | |
| 61 | 61 | expect(labels).toHaveLength(3); |
| 62 | 62 | expect(labels.slice(0, 2)).toEqual(["Today", "Yesterday"]); |
| 63 | 63 | }); |
| @@ -67,13 +67,13 @@ describe("day dividers on the home timeline", () => { | ||
| 67 | 67 | const e = app.bus.append({ kind: "run.finished", repoId: repoId, actorId: aliceId, payload: { kind: "agent", state: "ok", summary: `more ${i}`, runId: `m${i}` } }); |
| 68 | 68 | app.db.query("UPDATE event SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - i * 1000).toISOString(), e.id); |
| 69 | 69 | } |
| 70 | const first = await get("/"); | |
| 70 | const first = await get("/feed"); | |
| 71 | 71 | expect(dividerLabels(first)[0]).toBe("Today"); |
| 72 | const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(first)![1]!.replaceAll("&", "&"); | |
| 72 | const href = /href="(\/feed\?cursor=[^"]+)"[^>]*>Older posts/.exec(first)![1]!.replaceAll("&", "&"); | |
| 73 | 73 | const older = await get(href); |
| 74 | 74 | expect(older).toContain("data-item="); |
| 75 | 75 | expect(dividerLabels(older)).not.toContain("Today"); |
| 76 | const live = await get(href.replace("/?", "/live/feed?")); | |
| 76 | const live = await get(href.replace("/feed?", "/live/feed?")); | |
| 77 | 77 | expect(dividerLabels(live)).toEqual(dividerLabels(older)); |
| 78 | 78 | }); |
| 79 | 79 |
test/feed-failed-run.test.ts +2 −2
| @@ -78,7 +78,7 @@ describe("a failed run's feed post", () => { | ||
| 78 | 78 | |
| 79 | 79 | test("home reads the words and links to the line", async () => { |
| 80 | 80 | expect(ranOn).toBe(1); |
| 81 | const text = await (await fetch(`${servers.url}/`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text(); | |
| 81 | const text = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text(); | |
| 82 | 82 | expect(text).toContain("an agent run on #1 failed: error: Cannot find module"); |
| 83 | 83 | expect(text).toContain(`/alice/garden/runs/${runId}?all=1#L3`); |
| 84 | 84 | }); |
| @@ -97,7 +97,7 @@ describe("a failed run's feed post", () => { | ||
| 97 | 97 | }); |
| 98 | 98 | |
| 99 | 99 | test("a reader who is not crew gets the summary and the plain link, never the log line", async () => { |
| 100 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie: `${bobCookie}; forge_mode=studio` } })).text(); | |
| 100 | const home = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `${bobCookie}; forge_mode=studio` } })).text(); | |
| 101 | 101 | expect(home).toContain("failed"); |
| 102 | 102 | expect(home).not.toContain("Cannot find module"); |
| 103 | 103 | expect(home).not.toContain("#L3"); |
test/feed-page.test.ts +4 −4
| @@ -78,7 +78,7 @@ describe("the feed on the home page", () => { | ||
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | 80 | test("/ renders the ranked feed on first paint, escaped", async () => { |
| 81 | const res = await get("/", aliceCookie); | |
| 81 | const res = await get("/feed", aliceCookie); | |
| 82 | 82 | expect(res.status).toBe(200); |
| 83 | 83 | const text = await res.text(); |
| 84 | 84 | expect(text).toContain('id="live-feed"'); |
| @@ -91,7 +91,7 @@ describe("the feed on the home page", () => { | ||
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | test("a card links into the thing and steers or tells from where it is", async () => { |
| 94 | const text = await (await get("/", aliceCookie)).text(); | |
| 94 | const text = await (await get("/feed", aliceCookie)).text(); | |
| 95 | 95 | expect(text).toContain('href="/alice/open/runs/r1"'); |
| 96 | 96 | expect(text).toContain('action="/alice/open/runs/r1/steer"'); |
| 97 | 97 | const item = readFeed(app.db, alice).items[0]!; |
| @@ -119,7 +119,7 @@ describe("the feed on the home page", () => { | ||
| 119 | 119 | }); |
| 120 | 120 | |
| 121 | 121 | test("a viewer sees only their own feed: no leak from a private app", async () => { |
| 122 | const text = await (await get("/", bobCookie)).text(); | |
| 122 | const text = await (await get("/feed", bobCookie)).text(); | |
| 123 | 123 | expect(text).not.toContain("alice/secret"); |
| 124 | 124 | expect(text).not.toContain("hidden work"); |
| 125 | 125 | expect(text).toContain("alice/open"); |
| @@ -151,7 +151,7 @@ describe("the feed on the home page", () => { | ||
| 151 | 151 | }); |
| 152 | 152 | |
| 153 | 153 | test("the page carries the feed script and a card is focusable for j/k", async () => { |
| 154 | const text = await (await get("/", aliceCookie)).text(); | |
| 154 | const text = await (await get("/feed", aliceCookie)).text(); | |
| 155 | 155 | expect(text).toContain("data-nav"); |
| 156 | 156 | expect(text).toContain("/api/v1/feed/interactions"); |
| 157 | 157 | }); |
test/feed-reviewed.test.ts +1 −1
| @@ -126,7 +126,7 @@ test("each verdict is said in the post, asked-for-changes first, on / and in the | ||
| 126 | 126 | .filter((w) => w.startsWith("approved ") || w.startsWith("asked for changes")); |
| 127 | 127 | expect(posts.sort()).toEqual([APPROVED, CHANGES].sort()); |
| 128 | 128 | |
| 129 | const home = await fetch(`${servers.url}/`, { headers: { cookie } }); | |
| 129 | const home = await fetch(`${servers.url}/feed`, { headers: { cookie } }); | |
| 130 | 130 | const html = (await home.text()).replace(/\s+/g, " "); |
| 131 | 131 | const at = (s: string): number => html.indexOf(s); |
| 132 | 132 | expect(at(APPROVED)).toBeGreaterThan(-1); |
test/feed-two-taps.test.ts +2 −2
| @@ -210,7 +210,7 @@ const COVERED = ["pull", "run", "look", "release", "ask", "decision"]; | ||
| 210 | 210 | const NOT_COVERED = ["milestone", "map", "cost", "tell", "reply", "choose"]; |
| 211 | 211 | |
| 212 | 212 | let home: string[] | undefined; |
| 213 | const homeHrefs = async (): Promise<string[]> => (home ??= hrefs(await (await get("/")).text())); | |
| 213 | const homeHrefs = async (): Promise<string[]> => (home ??= hrefs(await (await get("/feed")).text())); | |
| 214 | 214 | |
| 215 | 215 | /** |
| 216 | 216 | * Tap 1: the post's `link` is an href on the home page a person sees, and it |
| @@ -236,7 +236,7 @@ const one = (items: Item[], kind: string, says: RegExp): Item => { | ||
| 236 | 236 | }; |
| 237 | 237 | |
| 238 | 238 | test("the feed carries a post of every covered kind and no kind unaccounted for", async () => { |
| 239 | expect((await get("/")).status).toBe(200); | |
| 239 | expect((await get("/feed")).status).toBe(200); | |
| 240 | 240 | const kinds = new Set((await feed()).map((i) => i.kind)); |
| 241 | 241 | for (const k of COVERED) expect(kinds.has(k), k).toBe(true); |
| 242 | 242 | for (const k of kinds) expect([...COVERED, ...NOT_COVERED], `kind ${k} needs a two-taps test`).toContain(k); |
test/flow-card.test.ts +1 −1
| @@ -83,7 +83,7 @@ describe("stalled steps on the home", () => { | ||
| 83 | 83 | .run(r.id, hoursAgo(hours), state); |
| 84 | 84 | }; |
| 85 | 85 | const home = async (): Promise<string> => |
| 86 | (await fetch(`${servers.url}/`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text(); | |
| 86 | (await fetch(`${servers.url}/feed`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text(); | |
| 87 | 87 | const stalled = (user = alice) => readStalled(app.db, user, new Date()); |
| 88 | 88 | |
| 89 | 89 | beforeAll(async () => { |
test/flow-clog-door.test.ts +2 −4
| @@ -130,13 +130,11 @@ test("clogLines: the standing clog, then the last cleared note", () => { | ||
| 130 | 130 | expect(clogLines({ standing: null, last: null }, T0.getTime())).toEqual([]); |
| 131 | 131 | }); |
| 132 | 132 | |
| 133 | test("the admin sees the clog on home and in the Flow dialog; a guest and an agent see neither", async () => { | |
| 133 | test("the clog is in the Flow dialog for the admin, never on home (#8015), never to a guest or an agent", async () => { | |
| 134 | 134 | reset(); |
| 135 | 135 | app.db.run("INSERT INTO clog_state (id, since, started_at, first_reasons, quiet) VALUES (1, ?1, ?1, ?2, 0)", [Date.now(), JSON.stringify(["31 pulls conflict <b>"])]); |
| 136 | 136 | const home = async (cookie: string): Promise<string> => (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); |
| 137 | const mine = await home(adminCookie); | |
| 138 | expect(mine).toContain("31 pulls conflict <b>"); | |
| 139 | expect(mine).not.toContain("<b>"); | |
| 137 | expect(await home(adminCookie)).not.toContain("pulls conflict"); | |
| 140 | 138 | expect(await home(guestCookie)).not.toContain("pulls conflict"); |
| 141 | 139 | expect(flowLines(app.db, new Date(), [], undefined, []).join("\n")).toContain("31 pulls conflict"); |
| 142 | 140 | expect(flowLines(app.db, new Date(), [], { queues: [] }, []).join("\n")).not.toContain("31 pulls conflict"); |
test/flow-keeper.test.ts +4 −3
| @@ -92,12 +92,13 @@ test("2026-11-01: ready issues wait and no builder runs: it names the cause and | ||
| 92 | 92 | expect(swept).toBe(1); |
| 93 | 93 | }); |
| 94 | 94 | |
| 95 | test("the cause and last act show on home, the boards panel, and the MCP flow report", async () => { | |
| 95 | test("the cause and last act show on the boards panel and the MCP flow report, not on home (#8015)", async () => { | |
| 96 | 96 | const home = await (await fetch(`${servers.url}/`, { headers: cookie() })).text(); |
| 97 | expect(home).toContain('id="keeper"'); | |
| 98 | expect(home).toContain("Binding landings:"); | |
| 97 | expect(home).not.toContain('id="keeper"'); | |
| 99 | 98 | const boards = await (await fetch(`${servers.url}/boards`, { headers: cookie() })).text(); |
| 100 | 99 | expect(boards).toContain("unstuck assigned issues"); |
| 100 | expect(boards).toContain('id="keeper"'); | |
| 101 | expect(boards).toContain("Binding landings:"); | |
| 101 | 102 | const report = flowFor(app, { kind: "owner" }); |
| 102 | 103 | expect(report.keeper.cause).toContain("no builder is running"); |
| 103 | 104 | expect(report.keeper.last).toContain("unstuck assigned issues"); |
test/flow-vitals.test.ts +2 −2
| @@ -71,8 +71,8 @@ describe("flow vitals", () => { | ||
| 71 | 71 | redirect: "manual", |
| 72 | 72 | }); |
| 73 | 73 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 74 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 75 | expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted · 0 red, 0 queued · $0.00 stranded"); | |
| 74 | // Home no longer carries the line (#8015): it is the tick line and the flow tool. | |
| 75 | expect(await (await fetch(`${servers.url}/`, { headers: { cookie } })).text()).not.toContain("open pulls</a>"); | |
| 76 | 76 | } finally { |
| 77 | 77 | await servers.stop(); |
| 78 | 78 | app.close(); |
test/front-door-timing.test.ts +2 −2
| @@ -27,7 +27,7 @@ const parts = (res: Response): string[] => (res.headers.get("server-timing") ?? | ||
| 27 | 27 | |
| 28 | 28 | test("the signed-in home names its session, reads and render and a cold feed read", async () => { |
| 29 | 29 | const get = async (): Promise<Response> => { |
| 30 | const res = await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } }); | |
| 30 | const res = await fetch(`${f.base}/feed`, { headers: { cookie: f.ownerCookie } }); | |
| 31 | 31 | await res.arrayBuffer(); |
| 32 | 32 | return res; |
| 33 | 33 | }; |
| @@ -39,7 +39,7 @@ test("the signed-in home names its session, reads and render and a cold feed rea | ||
| 39 | 39 | const res = await get(); |
| 40 | 40 | const all = parts(res); |
| 41 | 41 | const names = all.map((p) => p.split(";")[0]!); |
| 42 | for (const n of ["session", "steps", "repos", "kpis", "panels", "feed", "render"]) | |
| 42 | for (const n of ["session", "repos", "panels", "feed", "render"]) | |
| 43 | 43 | expect(names.filter((x) => x === n).length).toBe(1); |
| 44 | 44 | // A miss is a bare `feed;dur=…`: a hit would carry `desc="hit"`. |
| 45 | 45 | expect(all.find((p) => p.startsWith("feed;"))).toMatch(/^feed;dur=\d+(\.\d+)?$/); |
test/home-200.test.ts +3 −3
| @@ -39,12 +39,12 @@ test("the second look at home as the owner costs a bounded number of statements, | ||
| 39 | 39 | expectWork(await statements(f.app.db, async () => void (await look())), { max: 70 }); |
| 40 | 40 | // The page's own total is the sum of named phases: nothing over 20 ms hides in "app" alone. |
| 41 | 41 | // Every phase home.ts and the feed time: one missing here fails as a plain expect, which the gate's budget retry never re-runs. |
| 42 | const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "render"]); | |
| 42 | const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "moving", "render"]); | |
| 43 | 43 | for (const p of slow) expect(known.has(p.name)).toBe(true); |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | const waiting = async (): Promise<number> => { |
| 47 | const text = await freshHome(f.base, f.ownerCookie); | |
| 47 | const text = await freshHome(f.base, f.ownerCookie, "/feed"); | |
| 48 | 48 | return Number(/(\d+) waiting on you/.exec(text)?.[1] ?? 0); |
| 49 | 49 | }; |
| 50 | 50 | |
| @@ -52,7 +52,7 @@ test("the waiting line is a hit on the second look and a write puts it back", as | ||
| 52 | 52 | const first = await waiting(); |
| 53 | 53 | expect(first).toBeGreaterThan(0); |
| 54 | 54 | expect(await waiting()).toBe(first); |
| 55 | const timing = (await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } })).headers.get("server-timing") ?? ""; | |
| 55 | const timing = (await fetch(`${f.base}/feed`, { headers: { cookie: f.ownerCookie } })).headers.get("server-timing") ?? ""; | |
| 56 | 56 | expect(timing).toMatch(/waiting;dur=[\d.]+;desc="hit"/); |
| 57 | 57 | // Every ask is put down in one write: the line is gone at once, not in 30 seconds. |
| 58 | 58 | f.app.db.query("UPDATE issue SET assignee_id = NULL").run(); |
test/home-archive.test.ts +1 −1
| @@ -82,7 +82,7 @@ describe("home archive", () => { | ||
| 82 | 82 | |
| 83 | 83 | const body = (...ids: string[]) => ids.map((id) => `repo=${id}`).join("&"); |
| 84 | 84 | const home = async (cookie: string) => (await get("/live/apps", cookie)).text(); |
| 85 | const full = async (cookie: string) => (await get("/", cookie)).text(); | |
| 85 | const full = async (cookie: string) => (await get("/feed", cookie)).text(); | |
| 86 | 86 | |
| 87 | 87 | test("an admin archives two, the answer lists them, Undo brings them back", async () => { |
| 88 | 88 | const before = await full(rootCookie); |
test/home-buttons-rest.test.ts +3 −3
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | * No button spins at rest on Home (#3067, the browser half of #3039). |
| 3 | 3 | * |
| 4 | 4 | * `test/button-rest.test.ts` reads the source for `icon: "loader"`; this loads |
| 5 | * `/` as an admin in a real headless Chromium, with a stalled step's card on | |
| 5 | * `/feed` as an admin in a real headless Chromium, with a stalled step's card on | |
| 6 | 6 | * the page, and asks the computed style: no `button` or `.btn` is |
| 7 | 7 | * `aria-busy`, and nothing inside one runs an animation. It goes red if |
| 8 | 8 | * `flow_card.ts` goes back to the loader icon. |
| @@ -99,14 +99,14 @@ describe.skipIf(chromium === undefined)("Home's buttons rest", () => { | ||
| 99 | 99 | test(`${label}, ${scheme}: with a stalled step on the page, no button is busy or animating`, async () => { |
| 100 | 100 | const page = await findBusyButtons( |
| 101 | 101 | chromium!, |
| 102 | `${servers.url}/`, | |
| 102 | `${servers.url}/feed`, | |
| 103 | 103 | { width, height: 800 }, |
| 104 | 104 | cookies, |
| 105 | 105 | scheme, |
| 106 | 106 | ); |
| 107 | 107 | // The stalled step's button is on the page, so the check has teeth. |
| 108 | 108 | const html = await ( |
| 109 | await fetch(`${servers.url}/`, { | |
| 109 | await fetch(`${servers.url}/feed`, { | |
| 110 | 110 | headers: { cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; ") }, |
| 111 | 111 | }) |
| 112 | 112 | ).text(); |
test/home-cache.test.ts +15 −5
| @@ -14,7 +14,18 @@ import { kpiCache, panelCache } from "../src/web/studio/home_cache.ts"; | ||
| 14 | 14 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; |
| 15 | 15 | |
| 16 | 16 | let f: SnappyFixture; |
| 17 | const home = (cookie?: string): Promise<string> => freshHome(f.base, cookie); | |
| 17 | /** Home and the feed page, which together hold what home was: the figures, then the panels and the timeline. */ | |
| 18 | const home = async (cookie?: string): Promise<string> => (await freshHome(f.base, cookie)) + (await freshHome(f.base, cookie, "/feed")); | |
| 19 | /** Both pages' answers: the worst status and every phase either named. */ | |
| 20 | async function both(cookie: string): Promise<{ status: number; timing: string }> { | |
| 21 | const answers = []; | |
| 22 | for (const path of ["/", "/feed"]) { | |
| 23 | const res = await fetch(`${f.base}${path}`, { headers: { cookie } }); | |
| 24 | await res.arrayBuffer(); | |
| 25 | answers.push(res); | |
| 26 | } | |
| 27 | return { status: Math.max(...answers.map((r) => r.status)), timing: answers.map((r) => r.headers.get("server-timing") ?? "").join(", ") }; | |
| 28 | } | |
| 18 | 29 | |
| 19 | 30 | beforeAll(async () => { |
| 20 | 31 | f = await snappyFixture(); |
| @@ -42,7 +53,7 @@ test("a warm home reads neither the KPIs nor the panels again, even after a writ | ||
| 42 | 53 | await home(f.ownerCookie); |
| 43 | 54 | expect([kpi.misses, panel.misses]).toEqual([km, pm]); |
| 44 | 55 | // The second look is a hit one layer in front of those caches (`home_last.ts`): both phases say so. |
| 45 | const timing = (await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } })).headers.get("server-timing") ?? ""; | |
| 56 | const { timing } = await both(f.ownerCookie); | |
| 46 | 57 | expect(timing).toMatch(/kpis;dur=[\d.]+;desc="hit"/); |
| 47 | 58 | expect(timing).toMatch(/panels;dur=[\d.]+;desc="hit"/); |
| 48 | 59 | |
| @@ -84,10 +95,9 @@ interface Counted { | ||
| 84 | 95 | async function counted(): Promise<Counted> { |
| 85 | 96 | const before = writeStamp(f.app.db); |
| 86 | 97 | f.counter.reset(); |
| 87 | const res = await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } }); | |
| 88 | await res.arrayBuffer(); | |
| 98 | const res = await both(f.ownerCookie); | |
| 89 | 99 | const ran = new Map(f.counter.top(10_000).map((s) => [s.sql, s.times])); |
| 90 | return { status: res.status, hits: phases(res.headers.get("server-timing")), ran, before, after: writeStamp(f.app.db) }; | |
| 100 | return { status: res.status, hits: phases(res.timing), ran, before, after: writeStamp(f.app.db) }; | |
| 91 | 101 | } |
| 92 | 102 | |
| 93 | 103 | /** |
test/home-eval.test.ts +1 −1
| @@ -84,7 +84,7 @@ test("the real home never contradicts its own figures; with one figure changed i | ||
| 84 | 84 | const honest = await run([servers.url]); |
| 85 | 85 | expect(honest.out).toMatch(/errors 0 ·/); |
| 86 | 86 | expect(honest.out).not.toMatch(/^wrong /m); |
| 87 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 87 | const home = await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text(); | |
| 88 | 88 | expect(home).toContain("3 waiting on you"); |
| 89 | 89 | // A stand-in in front of the forge: the real home with one figure wrong, every API read passed through. |
| 90 | 90 | stub = Bun.serve({ |
test/home-feed.test.ts +13 −13
| @@ -125,13 +125,13 @@ describe("home is the feed", () => { | ||
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | 127 | test("the composer is a §5 card, and no .composer class remains", async () => { |
| 128 | const text = await (await get("/", aliceCookie)).text(); | |
| 128 | const text = await (await get("/feed", aliceCookie)).text(); | |
| 129 | 129 | expect(text).toMatch(/<form id="composer" class="card" method="post" action="\/tell">\s*<div class="card-body/); |
| 130 | 130 | expect(text).not.toContain('class="composer'); |
| 131 | 131 | }); |
| 132 | 132 | |
| 133 | 133 | test("the composer's action bar is §5's bar, not .composer-bar", async () => { |
| 134 | const text = await (await get("/", aliceCookie)).text(); | |
| 134 | const text = await (await get("/feed", aliceCookie)).text(); | |
| 135 | 135 | const form = text.slice(text.indexOf('id="composer"'), text.indexOf("</form>", text.indexOf('id="composer"'))); |
| 136 | 136 | expect(form).not.toContain("composer-bar"); |
| 137 | 137 | expect(form).toContain('<div class="bar">'); |
| @@ -142,7 +142,7 @@ describe("home is the feed", () => { | ||
| 142 | 142 | }); |
| 143 | 143 | |
| 144 | 144 | test("signed in, / is the composer above one timeline; your apps one step away", async () => { |
| 145 | const text = await (await get("/", aliceCookie)).text(); | |
| 145 | const text = await (await get("/feed", aliceCookie)).text(); | |
| 146 | 146 | const composer = text.indexOf('id="composer"'); |
| 147 | 147 | const feed = text.indexOf('id="live-feed"'); |
| 148 | 148 | expect(composer).toBeGreaterThan(-1); |
| @@ -183,7 +183,7 @@ describe("home is the feed", () => { | ||
| 183 | 183 | const asked = await (await get("/asked", aliceCookie)).text(); |
| 184 | 184 | expect(asked).toMatch(/class="tabs"[\s\S]*?href="\/asked"\s+aria-current="page"/); |
| 185 | 185 | expect(asked).toContain('href="/"'); |
| 186 | expect(text).toMatch(/class="tabs"[\s\S]*?href="\/"\s+aria-current="page"/); | |
| 186 | expect(text).toMatch(/class="tabs"[\s\S]*?href="\/feed"\s+aria-current="page"/); | |
| 187 | 187 | for (const page of [apps, asked]) { |
| 188 | 188 | expect(page).not.toContain("home-tabs"); |
| 189 | 189 | for (const w of ["Feed", "Your apps", "Asked"]) expect(page).toContain(w); |
| @@ -191,7 +191,7 @@ describe("home is the feed", () => { | ||
| 191 | 191 | }); |
| 192 | 192 | |
| 193 | 193 | test("a reader's timeline has the public repository's posts and nothing private or configured", async () => { |
| 194 | const text = await (await get("/", bobCookie)).text(); | |
| 194 | const text = await (await get("/feed", bobCookie)).text(); | |
| 195 | 195 | expect(text).toContain("Water the tomatoes at dawn"); |
| 196 | 196 | expect(text).not.toContain("The combination is 4-8-15"); |
| 197 | 197 | expect(text).not.toContain("alice/vault"); |
| @@ -222,7 +222,7 @@ describe("home is the feed", () => { | ||
| 222 | 222 | text: 'Look at this </textarea><script>alert(1)</script>', |
| 223 | 223 | url: "https://example.com/gauge", |
| 224 | 224 | }); |
| 225 | const res = await get(`/?${q}`, aliceCookie); | |
| 225 | const res = await get(`/feed?${q}`, aliceCookie); | |
| 226 | 226 | const text = await res.text(); |
| 227 | 227 | const box = /<textarea[^>]*name="text"[^>]*>([\s\S]*?)<\/textarea>/.exec(text); |
| 228 | 228 | expect(box?.[1]).toContain("Rain gauges\nLook at this </textarea><script>"); |
| @@ -323,13 +323,13 @@ describe("home is the feed", () => { | ||
| 323 | 323 | ) |
| 324 | 324 | .get(alice.id); |
| 325 | 325 | expect(row).toEqual({ text, source: "composer" }); |
| 326 | const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`); | |
| 326 | const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`, "/feed"); | |
| 327 | 327 | expect(page).toContain("<script>alert(1)</script> & more"); |
| 328 | 328 | expect(page).not.toContain("<script>alert(1)</script>"); |
| 329 | 329 | expect(page).toContain("Received"); |
| 330 | 330 | expect(page).toContain('<option value="forge">the forge</option>'); |
| 331 | 331 | // It is the author's alone, and another site's form is refused. |
| 332 | const bobs = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`); | |
| 332 | const bobs = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed"); | |
| 333 | 333 | expect(bobs).not.toContain("alert(1)"); |
| 334 | 334 | const forged = await fetch(`${servers.url}/tell`, { |
| 335 | 335 | method: "POST", |
| @@ -342,21 +342,21 @@ describe("home is the feed", () => { | ||
| 342 | 342 | }); |
| 343 | 343 | |
| 344 | 344 | test("a person with no apps can still send to the forge, and a day-old thought leaves the feed", async () => { |
| 345 | const empty = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`); | |
| 345 | const empty = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed"); | |
| 346 | 346 | expect(empty).toContain('name="app" value="forge"'); |
| 347 | 347 | expect(empty).toContain("No apps yet"); |
| 348 | 348 | const old = new Date(Date.now() - 2 * 86_400_000).toISOString(); |
| 349 | 349 | app.db |
| 350 | 350 | .query("INSERT INTO thought (id, author, text, source, received_at) VALUES ('old1', ?1, 'ancient musing', 'composer', ?2)") |
| 351 | 351 | .run(bob.id, old); |
| 352 | expect(await freshHome(servers.url, `${bobCookie}; forge_mode=studio`)).not.toContain("ancient musing"); | |
| 352 | expect(await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed")).not.toContain("ancient musing"); | |
| 353 | 353 | await fetch(`${servers.url}/tell`, { |
| 354 | 354 | method: "POST", |
| 355 | 355 | headers: { cookie: bobCookie, origin: servers.url }, |
| 356 | 356 | body: new URLSearchParams({ app: "forge", text: "fresh musing" }), |
| 357 | 357 | redirect: "manual", |
| 358 | 358 | }); |
| 359 | const after = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`); | |
| 359 | const after = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed"); | |
| 360 | 360 | expect(after).toContain("fresh musing"); |
| 361 | 361 | expect(after).not.toContain("ancient musing"); |
| 362 | 362 | }); |
| @@ -366,7 +366,7 @@ describe("home is the feed", () => { | ||
| 366 | 366 | const serving = (payload: Record<string, unknown>) => |
| 367 | 367 | app.bus.append({ kind: "app.serving", repoId: garden.id, payload: { app: "garden", env: "production", commitOid: oid, ...payload } }); |
| 368 | 368 | serving({ version: "0.7.251", pulls: [{ title: "Water <b>early</b>", number: 1 }, { title: "Mulch", number: 2 }, { title: "Weed", number: 3 }] }); |
| 369 | const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`); | |
| 369 | const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`, "/feed"); | |
| 370 | 370 | expect(page).toContain("0.7.251 is serving: Water <b>early</b> and 2 more"); |
| 371 | 371 | expect(page).not.toContain("<b>early</b>"); |
| 372 | 372 | expect(page).toContain(`href="/alice/garden/commit/${oid}"`); |
| @@ -424,7 +424,7 @@ describe("home is the feed", () => { | ||
| 424 | 424 | if (!made.ok) throw new Error("issue"); |
| 425 | 425 | expect(assignIssue(app, alice, garden, made.value, alice).ok).toBe(true); |
| 426 | 426 | } |
| 427 | const page = await (await get("/", aliceCookie)).text(); | |
| 427 | const page = await (await get("/feed", aliceCookie)).text(); | |
| 428 | 428 | // The timeline may still tell of a filed issue as a post; the asks are |
| 429 | 429 | // what must be gone, so the page is read up to the timeline. |
| 430 | 430 | const home = page.slice(0, page.indexOf('id="live-feed"')); |
test/home-frame.test.ts +7 −3
| @@ -24,7 +24,7 @@ afterAll(async () => { | ||
| 24 | 24 | await f.close(); |
| 25 | 25 | }); |
| 26 | 26 | |
| 27 | const look = async (cookie: string, path = "/") => { | |
| 27 | const look = async (cookie: string, path = "/feed") => { | |
| 28 | 28 | const res = await fetch(`${f.base}${path}`, { headers: { cookie } }); |
| 29 | 29 | const text = await res.text(); |
| 30 | 30 | const app = /app;dur=([\d.]+)/.exec(res.headers.get("server-timing") ?? ""); |
| @@ -66,11 +66,14 @@ test("after a write, signed-in home is answered from memory within the budget an | ||
| 66 | 66 | median = times[Math.floor(times.length / 2)]!; |
| 67 | 67 | } |
| 68 | 68 | expect(median).toBeLessThan(APP_BUDGET_MS); |
| 69 | for (const name of ["kpis", "waiting", "panels", "asked", "feed"]) { | |
| 69 | for (const name of ["waiting", "panels", "asked", "feed"]) { | |
| 70 | 70 | expect(marked).toContain(`id="home-${name}"`); |
| 71 | 71 | } |
| 72 | 72 | expect(marked).toContain('data-refresh="/live/home/feed"'); |
| 73 | expect(marked).toContain('data-refresh="/live/home/kpis"'); | |
| 73 | // The figures are on home itself, the feed's page holds the rest. | |
| 74 | await look(f.ownerCookie, "/"); | |
| 75 | write(99); | |
| 76 | expect((await look(f.ownerCookie, "/")).text).toContain('data-refresh="/live/home/kpis"'); | |
| 74 | 77 | }); |
| 75 | 78 | |
| 76 | 79 | test("the first response stays under its byte limit", async () => { |
| @@ -96,6 +99,7 @@ test("each late region answers alone, to its own viewer, and to no one else", as | ||
| 96 | 99 | |
| 97 | 100 | test("one viewer's remembered regions never reach another", async () => { |
| 98 | 101 | await look(f.ownerCookie); |
| 102 | await look(f.ownerCookie, "/"); | |
| 99 | 103 | write(99); |
| 100 | 104 | const peer = await look(f.peerCookie); |
| 101 | 105 | const owners = await look(f.ownerCookie); |
test/home-kpi-row.test.ts +1 −1
| @@ -379,7 +379,7 @@ describe("home waiting line", () => { | ||
| 379 | 379 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 380 | 380 | const review = await (await fetch(`${servers.url}/review`, { headers: { cookie } })).text(); |
| 381 | 381 | expect(review.split("<li").filter((r) => r.includes('class="row"')).length).toBe(stale.length); |
| 382 | return await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 382 | return await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text(); | |
| 383 | 383 | } finally { |
| 384 | 384 | await servers.stop(); |
| 385 | 385 | app.close(); |
test/home-living.test.ts added +218
| @@ -0,0 +1,218 @@ | ||
| 1 | /** | |
| 2 | * Home is the living pipeline (#8015): the box, the figures, the pipeline in | |
| 3 | * four words, what is being built and what just shipped (each row a link), | |
| 4 | * your apps; the feed is its own page. Rows are read for the viewer. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { MOVING_ROWS, nowOf, readBuilding, readShipped } from "../src/flow/moving.ts"; | |
| 17 | import type { PipelineStage } from "../src/flow/pipeline.ts"; | |
| 18 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 19 | import { statements } from "./helpers/statements.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import { dashboardScreen } from "../src/tui/screens/dashboard.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | ||
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let alice: User; | |
| 30 | let bob: User; | |
| 31 | let aliceCookie: string; | |
| 32 | let bobCookie: string; | |
| 33 | let rootCookie: string; | |
| 34 | let root: User; | |
| 35 | let openRepoId: string; | |
| 36 | let secretRepoId: string; | |
| 37 | ||
| 38 | const get = (path: string, cookie?: string) => fetch(`${servers.url}${path}`, { headers: cookie ? { cookie } : {}, redirect: "manual" }); | |
| 39 | const home = async (cookie?: string) => (await get("/", cookie)).text(); | |
| 40 | ||
| 41 | /** The terminal's home, as the viewer's session draws it. */ | |
| 42 | async function terminalHome(user: User): Promise<string> { | |
| 43 | const { app: tui, session } = createForgeApp( | |
| 44 | app, | |
| 45 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 46 | { terminal: headlessTerminal(200, 40), exitHooks: false }, | |
| 47 | ); | |
| 48 | tui.start(); | |
| 49 | tui.ctx.push(dashboardScreen(session, tui.ctx)); | |
| 50 | await settleApp(tui); | |
| 51 | const text = tui.screen.toText(); | |
| 52 | tui.stop?.(); | |
| 53 | return text; | |
| 54 | } | |
| 55 | ||
| 56 | function merged(repoId: string, title: string, minutesAgo: number) { | |
| 57 | const made = createIssue(app.db, { repoId, authorId: alice.id, title, kind: "pull" }); | |
| 58 | if (!made.ok) throw new Error(made.error); | |
| 59 | app.db | |
| 60 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_by) VALUES (?, 'main', ?, 'b', 'h', ?, ?)") | |
| 61 | .run(made.value.id, `h-${title}`, new Date(Date.now() - minutesAgo * 60_000).toISOString(), alice.id); | |
| 62 | return made.value; | |
| 63 | } | |
| 64 | ||
| 65 | function building(repoId: string, title: string) { | |
| 66 | const made = createIssue(app.db, { repoId, authorId: alice.id, title, kind: "issue" }); | |
| 67 | if (!made.ok) throw new Error(made.error); | |
| 68 | app.db | |
| 69 | .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state, actor_id, started_at) VALUES (?, ?, 'agent', ?, 'c', 'running', ?, ?)") | |
| 70 | .run(`zzzz-${crypto.randomUUID()}`, repoId, made.value.id, alice.id, new Date().toISOString()); | |
| 71 | return made.value; | |
| 72 | } | |
| 73 | ||
| 74 | beforeAll(async () => { | |
| 75 | dataDir = await mkdtemp(join(tmpdir(), "forge-home-living-")); | |
| 76 | app = createApp({ dataDir, port: 0 }); | |
| 77 | servers = await serve(app); | |
| 78 | const mk = async (username: string, first = false) => { | |
| 79 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, allowReserved: true, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 80 | if (!r.ok) throw new Error(r.error); | |
| 81 | promote(app.db, r.value); | |
| 82 | return r.value; | |
| 83 | }; | |
| 84 | root = await mk("root", true); | |
| 85 | rootCookie = `forge_session=${createSession(app.db, root.id).token}`; | |
| 86 | alice = await mk("alice"); | |
| 87 | bob = await mk("bob"); | |
| 88 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 89 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 90 | const open = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 91 | const secret = await createRepo(app, alice, { name: "secret", visibility: "private" }); | |
| 92 | if (!open.ok || !secret.ok) throw new Error("repos"); | |
| 93 | openRepoId = open.value.id; | |
| 94 | secretRepoId = secret.value.id; | |
| 95 | }); | |
| 96 | ||
| 97 | afterAll(async () => { | |
| 98 | servers.stop(); | |
| 99 | app.close(); | |
| 100 | await rm(dataDir, { recursive: true, force: true }); | |
| 101 | }); | |
| 102 | ||
| 103 | describe("home", () => { | |
| 104 | test("holds the box, both lists and your apps, and no feed", async () => { | |
| 105 | const page = await home(aliceCookie); | |
| 106 | expect(page).toContain('id="tell-box"'); | |
| 107 | expect(page).toContain('action="/tell"'); | |
| 108 | expect(page).toContain("Being built"); | |
| 109 | expect(page).toContain("Just shipped"); | |
| 110 | expect(page).toContain('id="live-chips"'); | |
| 111 | expect(page).toContain('href="/apps/new"'); | |
| 112 | expect(page).toContain('href="/feed"'); | |
| 113 | for (const gone of ['id="live-feed"', 'id="composer"', "waiting on you", "id=\"keeper\""]) expect(page).not.toContain(gone); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a site admin sees the pipeline in four words, each linking to where it is", async () => { | |
| 117 | const page = await home(rootCookie); | |
| 118 | expect(page).toContain('id="now-line"'); | |
| 119 | for (const word of ["check", "review", "land"]) expect(page).toContain(`</strong> ${word}</a>`); | |
| 120 | expect(page).toMatch(/href="\/releases"><strong>\d+<\/strong> live<\/a>/); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the forge-wide pipeline counts never reach a viewer who is not a site admin", async () => { | |
| 124 | building(secretRepoId, "Counted build"); | |
| 125 | for (const cookie of [aliceCookie, bobCookie]) { | |
| 126 | expect(await home(cookie)).not.toContain('id="now-line"'); | |
| 127 | expect(await (await get("/live/moving", cookie)).text()).not.toContain('id="now-line"'); | |
| 128 | } | |
| 129 | expect(await (await get("/live/moving", rootCookie)).text()).toContain('id="now-line"'); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the terminal home carries the same lines: Now for a site admin, being built and just shipped as the viewer reads them", async () => { | |
| 133 | building(openRepoId, "Terminal build"); | |
| 134 | building(secretRepoId, "Terminal secret"); | |
| 135 | const mine = await terminalHome(alice); | |
| 136 | expect(mine).toContain("Being built"); | |
| 137 | expect(mine).toContain("Just shipped"); | |
| 138 | expect(mine).toContain("Terminal build"); | |
| 139 | expect(mine).not.toContain("Now check"); | |
| 140 | const theirs = await terminalHome(bob); | |
| 141 | expect(theirs).toContain("Being built"); | |
| 142 | expect(theirs).not.toContain("Terminal secret"); | |
| 143 | expect(await terminalHome(root)).toMatch(/Now {2}check \d+ › review \d+ › land \d+ › \d+ live/); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a stranger gets the door, not the box", async () => { | |
| 147 | const page = await home(); | |
| 148 | expect(page).not.toContain('id="tell-box"'); | |
| 149 | expect(page).not.toContain("Being built"); | |
| 150 | }); | |
| 151 | ||
| 152 | test("being built and just shipped are links to what they name, escaped, and always hold the same number of rows", async () => { | |
| 153 | const quiet = await (await get("/live/moving", aliceCookie)).text(); | |
| 154 | const rows = (t: string) => t.split('class="row"').length - 1; | |
| 155 | const base = rows(quiet); | |
| 156 | expect(base).toBe(2 * MOVING_ROWS); | |
| 157 | const issue = building(openRepoId, "Teach <b>trails</b>"); | |
| 158 | const pull = merged(openRepoId, "Instant home", 2); | |
| 159 | const page = await home(aliceCookie); | |
| 160 | expect(page).toContain(`href="/alice/open/issues/${issue.number}"`); | |
| 161 | expect(page).toContain(`href="/alice/open/pulls/${pull.number}"`); | |
| 162 | expect(page).toContain("Teach <b>trails</b>"); | |
| 163 | expect(page).not.toContain("<b>trails"); | |
| 164 | const busy = await (await get("/live/moving", aliceCookie)).text(); | |
| 165 | expect(rows(busy)).toBe(base); | |
| 166 | }); | |
| 167 | ||
| 168 | test("a row in a repository the viewer cannot read is never named", async () => { | |
| 169 | building(secretRepoId, "Hidden build"); | |
| 170 | merged(secretRepoId, "Hidden ship", 1); | |
| 171 | expect(await home(aliceCookie)).toContain("Hidden ship"); | |
| 172 | for (const cookie of [bobCookie]) { | |
| 173 | const page = await home(cookie); | |
| 174 | expect(page).not.toContain("Hidden ship"); | |
| 175 | expect(page).not.toContain("Hidden build"); | |
| 176 | expect(page).not.toContain("alice/secret"); | |
| 177 | } | |
| 178 | expect(readShipped(app.db, bob).map((r) => r.title)).not.toContain("Hidden ship"); | |
| 179 | expect(readBuilding(app.db, bob).map((r) => r.title)).not.toContain("Hidden build"); | |
| 180 | }); | |
| 181 | ||
| 182 | test("the live fragment is for the signed in, and the region is wired to the stream", async () => { | |
| 183 | expect((await get("/live/moving")).status).toBe(404); | |
| 184 | expect((await get("/live/chips")).status).toBe(404); | |
| 185 | const chips = await get("/live/chips", aliceCookie); | |
| 186 | expect(chips.status).toBe(200); | |
| 187 | expect(await chips.text()).toContain('id="live-chips"'); | |
| 188 | expect(await home(aliceCookie)).toMatch(/id="live-moving"[^>]*data-live="[^"]*pull\.merged[^"]*"[^>]*data-live-src="\/live\/moving"/); | |
| 189 | }); | |
| 190 | ||
| 191 | test("a warm home costs a bounded number of statements, the two scans read once per write", async () => { | |
| 192 | await home(aliceCookie); | |
| 193 | const warm = await requestStatements(app.db, `${servers.url}/`, { headers: { cookie: aliceCookie } }); | |
| 194 | expectWork(warm, { max: 40 }); | |
| 195 | const scans = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text()))); | |
| 196 | const again = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text()))); | |
| 197 | expect(again).toBeLessThanOrEqual(scans); | |
| 198 | }); | |
| 199 | }); | |
| 200 | ||
| 201 | describe("the pipeline in four words", () => { | |
| 202 | const stage = (key: PipelineStage["key"], waiting: number, active: number) => ({ key, waiting, active }) as PipelineStage; | |
| 203 | test("each word is the sum of the boards' stages it names", () => { | |
| 204 | const now = nowOf([stage("check-queue", 4, 0), stage("checking", 0, 6), stage("review", 1, 0), stage("approved", 3, 0), stage("live", 0, 0), stage("ready", 9, 9)]); | |
| 205 | expect(now).toEqual({ check: 10, review: 1, land: 3, live: 0 }); | |
| 206 | }); | |
| 207 | }); | |
| 208 | ||
| 209 | describe("the feed page", () => { | |
| 210 | test("holds the composer and the timeline; a stranger is sent to sign in", async () => { | |
| 211 | const page = await (await get("/feed", aliceCookie)).text(); | |
| 212 | expect(page).toContain('id="composer"'); | |
| 213 | expect(page).toContain('id="live-feed"'); | |
| 214 | expect(page).not.toContain("Being built"); | |
| 215 | const stranger = await get("/feed"); | |
| 216 | expect(stranger.status).not.toBe(200); | |
| 217 | }); | |
| 218 | }); |
test/home-since.test.ts +4 −4
| @@ -67,7 +67,7 @@ test("home heads the digest with the window, lists only landed pulls the viewer | ||
| 67 | 67 | db.query("DELETE FROM pull WHERE issue_id = ?1").run(`since-${priv.id}-3`); |
| 68 | 68 | |
| 69 | 69 | db.query("INSERT INTO home_visit (user_id, at) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET at = excluded.at").run(f.owner.id, Date.now() - 3 * H); |
| 70 | const res = await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie } }); | |
| 70 | const res = await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie } }); | |
| 71 | 71 | const text = await res.text(); |
| 72 | 72 | expect(text).toContain("Since you were here · 3 hours"); |
| 73 | 73 | expect(text).toContain("1 landed"); |
| @@ -80,7 +80,7 @@ test("home heads the digest with the window, lists only landed pulls the viewer | ||
| 80 | 80 | test("a first-time user, with no home_visit row, gets the 24 hour heading", async () => { |
| 81 | 81 | const db = f.app.db; |
| 82 | 82 | db.query("DELETE FROM home_visit WHERE user_id = ?1").run(f.owner.id); |
| 83 | const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie } })).text(); | |
| 83 | const text = await (await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie } })).text(); | |
| 84 | 84 | expect(text).toContain("Since you were here · 1 day<"); |
| 85 | 85 | expect(text).toContain("1 landed"); |
| 86 | 86 | expect(lastVisit(db, f.owner.id)).toBeDefined(); |
| @@ -118,12 +118,12 @@ test("a prefetch, a preview or a non-document fetch leaves home_visit unchanged; | ||
| 118 | 118 | { "sec-fetch-dest": "iframe" }, |
| 119 | 119 | ]) { |
| 120 | 120 | set(); |
| 121 | const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie, ...headers } })).text(); | |
| 121 | const text = await (await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie, ...headers } })).text(); | |
| 122 | 122 | expect(text).toContain("Since you were here · 3 hours"); |
| 123 | 123 | expect(lastVisit(db, f.owner.id)).toBe(at); |
| 124 | 124 | } |
| 125 | 125 | set(); |
| 126 | await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie, "sec-fetch-dest": "document" } })).text(); | |
| 126 | await (await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie, "sec-fetch-dest": "document" } })).text(); | |
| 127 | 127 | expect(lastVisit(db, f.owner.id)).toBeGreaterThan(at); |
| 128 | 128 | }); |
| 129 | 129 |
test/home-timing.test.ts +7 −5
| @@ -13,11 +13,13 @@ afterAll(async () => { | ||
| 13 | 13 | await f.close(); |
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | async function timing(cookie?: string): Promise<string[]> { | |
| 17 | const res = await fetch(`${f.base}/`, { headers: cookie ? { cookie } : {} }); | |
| 16 | async function timing(cookie?: string, path = "/"): Promise<string[]> { | |
| 17 | const res = await fetch(`${f.base}${path}`, { headers: cookie ? { cookie } : {} }); | |
| 18 | 18 | await res.arrayBuffer(); |
| 19 | 19 | return (res.headers.get("server-timing") ?? "").split(", "); |
| 20 | 20 | } |
| 21 | /** Home's phases and the feed page's together: what home was before the feed moved off it. */ | |
| 22 | const timings = async (cookie?: string): Promise<string[]> => [...(await timing(cookie)), ...(await timing(cookie, "/feed"))]; | |
| 21 | 23 | const names = (parts: string[]): string[] => parts.map((p) => p.split(";")[0]!); |
| 22 | 24 | |
| 23 | 25 | test("a cold look at the owner's home names each part of the kpis read", async () => { |
| @@ -27,8 +29,8 @@ test("a cold look at the owner's home names each part of the kpis read", async ( | ||
| 27 | 29 | }); |
| 28 | 30 | |
| 29 | 31 | test("the owner's home names feed, kpis and panels; a warm look is a hit", async () => { |
| 30 | await timing(f.ownerCookie); | |
| 31 | const warm = await timing(f.ownerCookie); | |
| 32 | await timings(f.ownerCookie); | |
| 33 | const warm = await timings(f.ownerCookie); | |
| 32 | 34 | expect(names(warm)).toEqual(expect.arrayContaining(["feed", "kpis", "panels"])); |
| 33 | 35 | expect(names(warm)).not.toContain("shelf"); |
| 34 | 36 | expect(warm.find((p) => p.startsWith("kpis;"))).toMatch(/^kpis;dur=\d+\.\d;desc="hit"$/); |
| @@ -36,7 +38,7 @@ test("the owner's home names feed, kpis and panels; a warm look is a hit", async | ||
| 36 | 38 | }); |
| 37 | 39 | |
| 38 | 40 | test("a signed-in non-admin gets the feed and none of the admin-only phases", async () => { |
| 39 | const got = names(await timing(f.peerCookie)); | |
| 41 | const got = names(await timings(f.peerCookie)); | |
| 40 | 42 | expect(got).toContain("feed"); |
| 41 | 43 | expect(got).not.toContain("kpis"); |
| 42 | 44 | for (const part of KPI_PARTS) expect(got).not.toContain(part); |
test/home-walk.test.ts +1 −1
| @@ -58,7 +58,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app | ||
| 58 | 58 | if (!repo.ok) throw new Error("repo"); |
| 59 | 59 | expect(addToCrew(app, root, repo.value, "scribe").ok).toBe(true); |
| 60 | 60 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}; forge_mode=studio`; |
| 61 | const home = (u: User) => freshHome(servers.url, cookie(u)); | |
| 61 | const home = (u: User) => freshHome(servers.url, cookie(u), "/feed"); | |
| 62 | 62 | |
| 63 | 63 | // The composer comes first, then the timeline. |
| 64 | 64 | const first = await home(alice); |
test/live-pages.test.ts +1 −1
| @@ -156,7 +156,7 @@ describe("live browsing pages", () => { | ||
| 156 | 156 | ).split(";")[0]!; |
| 157 | 157 | |
| 158 | 158 | const html = await ( |
| 159 | await fetch(`${base}/`, { | |
| 159 | await fetch(`${base}/feed`, { | |
| 160 | 160 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 161 | 161 | }) |
| 162 | 162 | ).text(); |
test/live.test.ts +1 −1
| @@ -127,7 +127,7 @@ describe("the viewer's stream", () => { | ||
| 127 | 127 | }); |
| 128 | 128 | |
| 129 | 129 | test("the studio pages carry live regions and the script that keeps them fresh", async () => { |
| 130 | const res = await fetch(`${servers.url}/`, { | |
| 130 | const res = await fetch(`${servers.url}/feed`, { | |
| 131 | 131 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 132 | 132 | }); |
| 133 | 133 | const page = await res.text(); |
test/offline.test.ts +2 −2
| @@ -156,7 +156,7 @@ describe("the service worker and the server", () => { | ||
| 156 | 156 | }); |
| 157 | 157 | |
| 158 | 158 | test("the home page registers the worker, has the note, and allows workers", async () => { |
| 159 | const res = await fetch(`${servers.url}/`, { headers: { cookie } }); | |
| 159 | const res = await fetch(`${servers.url}/feed`, { headers: { cookie } }); | |
| 160 | 160 | const text = await res.text(); |
| 161 | 161 | expect(text).toContain("serviceWorker"); |
| 162 | 162 | expect(text).toContain('id="feed-note"'); |
| @@ -165,7 +165,7 @@ describe("the service worker and the server", () => { | ||
| 165 | 165 | }); |
| 166 | 166 | |
| 167 | 167 | test("the live client resumes /events from the last seen id", async () => { |
| 168 | const text = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 168 | const text = await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text(); | |
| 169 | 169 | expect(text).toContain("/events?since="); |
| 170 | 170 | expect(text).toContain("'online'"); |
| 171 | 171 | }); |
test/page-text.test.ts +2 −2
| @@ -56,7 +56,7 @@ test("the real home reads as text: heading and waiting count, no CSS, script or | ||
| 56 | 56 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`) |
| 57 | 57 | .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id); |
| 58 | 58 | } |
| 59 | const raw = await get("/"); | |
| 59 | const raw = await get("/feed"); | |
| 60 | 60 | expect(raw).toContain("<script"); |
| 61 | 61 | expect(raw).toContain("<style"); |
| 62 | 62 | const text = pageText(raw); |
| @@ -92,7 +92,7 @@ const run = async (args: string[], env: Record<string, string> = {}) => { | ||
| 92 | 92 | }; |
| 93 | 93 | |
| 94 | 94 | test("scripts/home-read.ts prints the text and the stats line, sends FORGE_COOKIE and never prints it", async () => { |
| 95 | const r = await run([`${servers.url}/`], { FORGE_COOKIE: cookie }); | |
| 95 | const r = await run([`${servers.url}/feed`], { FORGE_COOKIE: cookie }); | |
| 96 | 96 | expect(r.code).toBe(0); |
| 97 | 97 | expect(r.out.trimEnd().split("\n").pop()).toMatch(/^chars \d+ · words \d+ · fetched in \d+ ms$/); |
| 98 | 98 | expect(r.out).toContain("Feed Your apps Asked"); |
test/releases-page.test.ts +2 −2
| @@ -170,8 +170,8 @@ describe("the releases page", () => { | ||
| 170 | 170 | const cookie = await login("alice", "a-good-passphrase"); |
| 171 | 171 | const body = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); |
| 172 | 172 | expect(body).toContain('href="/releases"'); |
| 173 | expect(body).toContain("live:"); | |
| 174 | expect(body).toContain("aaaaaaa"); | |
| 173 | // An admin's tile, or the plain line anyone else gets: both name the live version. | |
| 174 | expect(body).toMatch(/live: <span class="sha">aaaaaaa|Live version: a{7}/); | |
| 175 | 175 | }); |
| 176 | 176 | |
| 177 | 177 | test("a signed-out visitor is sent to sign in", async () => { |
test/review-stale.test.ts +1 −1
| @@ -63,7 +63,7 @@ describe("one count on Home, /review and the MCP", () => { | ||
| 63 | 63 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 64 | 64 | const get = async (path: string) => await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 65 | 65 | |
| 66 | const home = await get("/"); | |
| 66 | const home = await get("/feed"); | |
| 67 | 67 | const said = Number(/(\d+) waiting on you/.exec(home)?.[1]); |
| 68 | 68 | expect(home).toContain("2 stale, archive?"); |
| 69 | 69 | expect(home).toContain('action="/asks/bulk"'); |
test/ssh-site.test.ts +2 −2
| @@ -71,7 +71,7 @@ describe("the terminal on the website", () => { | ||
| 71 | 71 | }); |
| 72 | 72 | |
| 73 | 73 | test("the dashboard has a Terminal card, with the host key", async () => { |
| 74 | const page = await fetch(`${base}/`, { headers: { cookie } }).then((r) => | |
| 74 | const page = await fetch(`${base}/feed`, { headers: { cookie } }).then((r) => | |
| 75 | 75 | r.text(), |
| 76 | 76 | ); |
| 77 | 77 | expect(page).toContain("<h2>Terminal</h2>"); |
| @@ -91,7 +91,7 @@ describe("the terminal on the website", () => { | ||
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | test("the dashboard's Terminal card has no terminal-line, keeps the keys link", async () => { |
| 94 | const page = await fetch(`${base}/`, { headers: { cookie } }).then((r) => | |
| 94 | const page = await fetch(`${base}/feed`, { headers: { cookie } }).then((r) => | |
| 95 | 95 | r.text(), |
| 96 | 96 | ); |
| 97 | 97 | expect(page).not.toContain("terminal-line"); |
test/steering-four-places.test.ts +1 −1
| @@ -92,7 +92,7 @@ describe("a tell from the four places", () => { | ||
| 92 | 92 | expect(pill.status).toBe(200); |
| 93 | 93 | |
| 94 | 94 | // 3. the share sheet: land on the composer with a draft, then post it |
| 95 | const landed = await fetch(`${servers.url}/?text=${encodeURIComponent("Tell from share")}`, { | |
| 95 | const landed = await fetch(`${servers.url}/feed?text=${encodeURIComponent("Tell from share")}`, { | |
| 96 | 96 | headers: { authorization: `Bearer ${token}` }, |
| 97 | 97 | }); |
| 98 | 98 | expect(landed.status).toBe(200); |
test/studio-changes.test.ts +1 −1
| @@ -1129,7 +1129,7 @@ describe("the studio, continued", () => { | ||
| 1129 | 1129 | |
| 1130 | 1130 | // Erin's front door: the empty state and New app, never alice's public |
| 1131 | 1131 | // app — #69's case, finally reachable. |
| 1132 | const erinHome = await (await erinGet("/")).text(); | |
| 1132 | const erinHome = await (await erinGet("/feed")).text(); | |
| 1133 | 1133 | expect(erinHome).toContain("No apps yet"); |
| 1134 | 1134 | expect(erinHome).toContain('href="/apps/new"'); |
| 1135 | 1135 | expect(erinHome).toContain('href="/erin"'); |
test/studio-door.test.ts +1 −1
| @@ -527,7 +527,7 @@ describe("the studio home keeps itself current", () => { | ||
| 527 | 527 | let readme: Issue; |
| 528 | 528 | |
| 529 | 529 | const get = (extraCookie = "") => |
| 530 | fetch(`${base}/`, { | |
| 530 | fetch(`${base}/feed`, { | |
| 531 | 531 | headers: { |
| 532 | 532 | cookie: `${cookie}${extraCookie}; forge_mode=studio`, |
| 533 | 533 | }, |
test/studio.test.ts +2 −2
| @@ -174,7 +174,7 @@ describe("the studio", () => { | ||
| 174 | 174 | description: "", |
| 175 | 175 | }); |
| 176 | 176 | if (!later.ok) throw new Error(String(later.error)); |
| 177 | const html = await (await get("/")).text(); | |
| 177 | const html = await (await get("/feed")).text(); | |
| 178 | 178 | const block = html.match( |
| 179 | 179 | /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/, |
| 180 | 180 | )?.[1]; |
| @@ -276,7 +276,7 @@ describe("the studio", () => { | ||
| 276 | 276 | }); |
| 277 | 277 | |
| 278 | 278 | test("the home and an app's page lay main and rail with .split's wide rail; no dash class is left (#4748)", async () => { |
| 279 | for (const path of ["/", "/alice/notes"]) { | |
| 279 | for (const path of ["/feed", "/alice/notes"]) { | |
| 280 | 280 | const page = await (await get(path)).text(); |
| 281 | 281 | expect(page).not.toMatch(/class="[^"]*\bdash(?:-main|-rail)?\b/); |
| 282 | 282 | expect(page).toContain('<div class="split" data-rail="wide">'); |
test/surfaces-adversarial.test.ts +1 −1
| @@ -230,7 +230,7 @@ describe("surfaces adversarial: web", () => { | ||
| 230 | 230 | }, |
| 231 | 231 | ); |
| 232 | 232 | expect(assignedToAlice.status).toBe(303); |
| 233 | const dash = await bodyOf(alice, "/"); | |
| 233 | const dash = await bodyOf(alice, "/feed"); | |
| 234 | 234 | expect(dash).not.toContain(payload); |
| 235 | 235 | expect(dash).toContain(escaped); |
| 236 | 236 |
test/web-apps.test.ts +1 −1
| @@ -291,7 +291,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 291 | 291 | |
| 292 | 292 | test("the dashboard and /apps list the app with its state and URL", async () => { |
| 293 | 293 | // The home's rail names your apps and offers New app (#625). |
| 294 | const dash = await bodyOf(alice, "/"); | |
| 294 | const dash = await bodyOf(alice, "/feed"); | |
| 295 | 295 | expect(dash).toContain("<h2>Your apps</h2>"); |
| 296 | 296 | expect(dash).toContain("alice/hello"); |
| 297 | 297 | expect(dash).toContain('href="/apps/new"'); |
test/web-browse.test.ts +1 −1
| @@ -607,7 +607,7 @@ describe("browsing an origin repository on the web", () => { | ||
| 607 | 607 | const pages = { |
| 608 | 608 | repo: await bodyOf(alice, "/alice/widgets"), |
| 609 | 609 | picker: await bodyOf(alice, `/settings/origins/${originId}/repos`), |
| 610 | home: await bodyOf(alice, "/"), | |
| 610 | home: await bodyOf(alice, "/feed"), | |
| 611 | 611 | search: await bodyOf(alice, "/search?q=widgets"), |
| 612 | 612 | }; |
| 613 | 613 | const strip = stripOf(pages.repo); |
test/web-origins.test.ts +1 −1
| @@ -599,7 +599,7 @@ describe("origins on the web", () => { | ||
| 599 | 599 | expect(picker).toContain("mirrored as"); |
| 600 | 600 | expect(picker).toContain("this machine"); |
| 601 | 601 | |
| 602 | const dash = await (await asUser(`${alice}; forge_mode=dev`, "/")).text(); | |
| 602 | const dash = await (await asUser(`${alice}; forge_mode=dev`, "/feed")).text(); | |
| 603 | 603 | expect(dash).toContain("On your machine"); |
| 604 | 604 | expect(dash).toContain("forge sync"); |
| 605 | 605 | expect(dash).toContain('href="/alice/widgets"'); |
test/xss-everywhere.test.ts +1 −1
| @@ -138,7 +138,7 @@ describe("one hostile string, every surface", () => { | ||
| 138 | 138 | }); |
| 139 | 139 | |
| 140 | 140 | const pages: [string, () => string][] = [ |
| 141 | ["the home feed", () => "/"], | |
| 141 | ["the home feed", () => "/feed"], | |
| 142 | 142 | ["the issue", () => `/alice/game/issues/${issueNo}`], |
| 143 | 143 | ["the pull", () => `/alice/game/pulls/${pullNo}`], |
| 144 | 144 | ["the issue list", () => "/alice/game/issues"], |
new above
offline