Public: anyone can read this; the crew can change it.
A client's app row says what was last finished on it, in plain words #6093
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6091-a-client-s-app-row-says-what-was-last-fi → main 3 commits fork 4 threads · 4 unresolved
7 files
changed +189
−92
README.md +4 −3
| @@ -350,9 +350,10 @@ link), the home is their apps, one plain sentence each and one more saying | ||
| 350 | 350 | whether its agents are working, about to start, or waiting: no map, board or |
| 351 | 351 | settings until asked. `/?all=1` is the whole home, and its "Just my apps" link |
| 352 | 352 | comes back. Each app row also says what its agents are on ("Working on: …") |
| 353 | or the last change landed in the past 7 days ("Last change: …, 3 days ago"); | |
| 354 | a quiet app says nothing more. The terminal's first screen opens with the same | |
| 355 | lines, from the same queries (`src/core/client-apps.ts`). | |
| 353 | and what was last finished on it ("Finished 3 days ago: …", its newest merged | |
| 354 | pull however old, read for all rows in one query); an app with neither says | |
| 355 | nothing more. Both doors show both lines: the terminal's first screen opens | |
| 356 | with the same lines, worded by the same helpers (`src/core/client-apps.ts`). | |
| 356 | 357 | |
| 357 | 358 | **Reading the home as the eye does (#5735, step 1).** `bun scripts/home-read.ts <url>` fetches a page and prints its visible text, then one line: `chars N · words N · fetched in N ms`. Set `FORGE_COOKIE` (for example `forge_session=…; forge_mode=studio`) to read it as the owner; the cookie is sent and never printed. It exits non-zero on any status but 200. The conversion is `pageText` in `src/web/page_text.ts`: scripts, styles, templates, the head and anything `hidden` or `aria-hidden="true"` are dropped, link text stays, blocks sit on their own lines, entities are decoded. The method then continues: (2) a fresh agent with no context reads only that text and answers in at most 150 words, (3) the steward scores it against ground truth on coverage of the 5 things that mattered, errors, and seconds, (4) repeat after every feed change. The steward runs steps 2–4 and logs them on #5735. |
| 358 | 359 |
docs/design/areas/tell.md +7
| @@ -6,3 +6,10 @@ shifts and Enter sends it with the page's path as `from`. Any failure falls back | ||
| 6 | 6 | |
| 7 | 7 | - `.tell-here` — the fixed box holding that composer: pinned to the bottom of the viewport, at most 620px wide and |
| 8 | 8 | centred, above the page; the `.comment-form` inside drops its margin and takes `--shadow-2` so it reads as raised. |
| 9 | ||
| 10 | ## Client home: `client-app-finished` | |
| 11 | ||
| 12 | A muted line on a client's app row, `Finished <time>: <title>`, for the app's | |
| 13 | newest merged pull. It sits with `client-app-about`, `client-app-agents` and | |
| 14 | `client-app-doing`; it uses `muted` and adds no colour or size. The | |
| 15 | terminal says the same words. |
src/core/client-apps.ts +62 −51
| @@ -54,60 +54,70 @@ export async function clientAppRows( | ||
| 54 | 54 | }); |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | /** What one app's agents are doing, in words a client can read. */ | |
| 58 | export type ClientDoing = | |
| 59 | | { readonly kind: "working"; readonly title: string } | |
| 60 | | { readonly kind: "landed"; readonly title: string; readonly at: string }; | |
| 61 | ||
| 62 | const WEEK_MS = 7 * 24 * 3600 * 1000; | |
| 57 | /** What one app's agents are doing now, in words a client can read. */ | |
| 58 | export interface ClientDoing { | |
| 59 | readonly title: string; | |
| 60 | } | |
| 63 | 61 | |
| 64 | 62 | /** |
| 65 | * For each of `appIds` (repo ids), the title of the work running now, else of | |
| 66 | * the last change landed in the 7 days before `now`; an app with neither is | |
| 67 | * left out. One query, at most one row per app. | |
| 63 | * For each of `appIds` (repo ids), the title of the work running now; an app | |
| 64 | * with none is left out. What landed is `clientFinished`'s. One query, at most | |
| 65 | * one row per app. | |
| 68 | 66 | */ |
| 69 | 67 | export function clientDoings( |
| 70 | 68 | db: Database, |
| 71 | 69 | appIds: readonly string[], |
| 72 | now: Date, | |
| 73 | 70 | ): ReadonlyMap<string, ClientDoing> { |
| 74 | 71 | const doings = new Map<string, ClientDoing>(); |
| 75 | 72 | if (appIds.length === 0) return doings; |
| 76 | const marks = appIds.map((_, i) => `?${i + 3}`).join(","); | |
| 77 | 73 | const rows = db |
| 78 | .query< | |
| 79 | { repo_id: string; kind: string; title: string; at: string }, | |
| 80 | (string | number)[] | |
| 81 | >( | |
| 82 | `SELECT repo_id, kind, title, at FROM ( | |
| 83 | SELECT repo_id, kind, title, at, | |
| 84 | ROW_NUMBER() OVER (PARTITION BY repo_id ORDER BY rank, at DESC) AS n | |
| 85 | FROM ( | |
| 86 | SELECT r.repo_id AS repo_id, 'working' AS kind, i.title AS title, | |
| 87 | COALESCE(r.started_at, r.created_at) AS at, 0 AS rank | |
| 88 | FROM run r JOIN issue i ON i.id = r.issue_id | |
| 89 | WHERE r.kind = 'agent' AND r.state = 'running' | |
| 90 | AND r.repo_id IN (${marks}) | |
| 91 | UNION ALL | |
| 92 | SELECT i.repo_id, 'landed', i.title, p.merged_at, 1 | |
| 93 | FROM issue i JOIN pull p ON p.issue_id = i.id | |
| 94 | WHERE i.kind = 'pull' AND i.state = 'closed' | |
| 95 | AND i.repo_id IN (${marks}) AND p.merged_at >= ?1 | |
| 96 | ) | |
| 97 | ) WHERE n = 1 LIMIT ?2`, | |
| 74 | .query<{ repo_id: string; title: string }, string[]>( | |
| 75 | `SELECT repo_id, title FROM ( | |
| 76 | SELECT r.repo_id AS repo_id, i.title AS title, | |
| 77 | ROW_NUMBER() OVER ( | |
| 78 | PARTITION BY r.repo_id ORDER BY COALESCE(r.started_at, r.created_at) DESC | |
| 79 | ) AS n | |
| 80 | FROM run r JOIN issue i ON i.id = r.issue_id | |
| 81 | WHERE r.kind = 'agent' AND r.state = 'running' | |
| 82 | AND r.repo_id IN (${appIds.map((_, i) => `?${i + 1}`).join(",")}) | |
| 83 | ) WHERE n = 1`, | |
| 98 | 84 | ) |
| 99 | .all(new Date(now.getTime() - WEEK_MS).toISOString(), appIds.length, ...appIds); | |
| 100 | for (const row of rows) { | |
| 101 | doings.set( | |
| 102 | row.repo_id, | |
| 103 | row.kind === "working" | |
| 104 | ? { kind: "working", title: row.title } | |
| 105 | : { kind: "landed", title: row.title, at: row.at }, | |
| 106 | ); | |
| 107 | } | |
| 85 | .all(...appIds); | |
| 86 | for (const row of rows) doings.set(row.repo_id, { title: row.title }); | |
| 108 | 87 | return doings; |
| 109 | 88 | } |
| 110 | 89 | |
| 90 | /** What one app last had finished: the title of its newest merged pull and when. */ | |
| 91 | export interface ClientFinished { | |
| 92 | readonly title: string; | |
| 93 | readonly at: string; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * For each of `appIds` (repo ids), its most recently merged pull, however old; | |
| 98 | * an app with none is left out. One query, at most one row per app. | |
| 99 | */ | |
| 100 | export function clientFinished( | |
| 101 | db: Database, | |
| 102 | appIds: readonly string[], | |
| 103 | ): ReadonlyMap<string, ClientFinished> { | |
| 104 | const finished = new Map<string, ClientFinished>(); | |
| 105 | if (appIds.length === 0) return finished; | |
| 106 | const rows = db | |
| 107 | .query<{ repo_id: string; title: string; at: string }, string[]>( | |
| 108 | `SELECT repo_id, title, at FROM ( | |
| 109 | SELECT i.repo_id AS repo_id, i.title AS title, p.merged_at AS at, | |
| 110 | ROW_NUMBER() OVER (PARTITION BY i.repo_id ORDER BY p.merged_at DESC) AS n | |
| 111 | FROM issue i JOIN pull p ON p.issue_id = i.id | |
| 112 | WHERE i.kind = 'pull' AND p.merged_at IS NOT NULL | |
| 113 | AND i.repo_id IN (${appIds.map((_, i) => `?${i + 1}`).join(",")}) | |
| 114 | ) WHERE n = 1`, | |
| 115 | ) | |
| 116 | .all(...appIds); | |
| 117 | for (const row of rows) finished.set(row.repo_id, { title: row.title, at: row.at }); | |
| 118 | return finished; | |
| 119 | } | |
| 120 | ||
| 111 | 121 | const TITLE_MAX = 80; |
| 112 | 122 | |
| 113 | 123 | /** `title` cut to 80 characters at a word boundary, with "…" when anything was dropped. */ |
| @@ -119,15 +129,16 @@ export function cutTitle(title: string): string { | ||
| 119 | 129 | return `${(space > 0 ? head.slice(0, space) : head).trimEnd()}…`; |
| 120 | 130 | } |
| 121 | 131 | |
| 122 | /** The sentence about what an app's agents are on, or "" for a quiet app. */ | |
| 123 | export function doingLine(doing: ClientDoing | undefined, now: number): string { | |
| 124 | if (!doing) return ""; | |
| 125 | const title = cutTitle(doing.title); | |
| 126 | if (title === "") return ""; | |
| 127 | switch (doing.kind) { | |
| 128 | case "working": | |
| 129 | return `Working on: ${title}`; | |
| 130 | case "landed": | |
| 131 | return `Last change: ${title}, ${ago(Date.parse(doing.at), now)}`; | |
| 132 | } | |
| 132 | /** The sentence about what an app's agents are on, or "" when none is running. */ | |
| 133 | export function doingLine(doing: ClientDoing | undefined): string { | |
| 134 | const title = doing === undefined ? "" : cutTitle(doing.title); | |
| 135 | return title === "" ? "" : `Working on: ${title}`; | |
| 136 | } | |
| 137 | ||
| 138 | /** `Finished 3 days ago: Fix the menu`, or "" for an app with nothing finished; both doors word it so. */ | |
| 139 | export function finishedLine(finished: ClientFinished | undefined, now: number): string { | |
| 140 | const title = finished === undefined ? "" : cutTitle(finished.title); | |
| 141 | if (finished === undefined || title === "") return ""; | |
| 142 | const at = Date.parse(finished.at); | |
| 143 | return Number.isNaN(at) ? "" : `Finished ${ago(at, now)}: ${title}`; | |
The |
||
| 133 | 144 | } |
src/tui/screens/dashboard.ts +10 −3
| @@ -20,7 +20,7 @@ import { forgeLane, forgeNeeds } from "../../actions/health.ts"; | ||
| 20 | 20 | import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts"; |
| 21 | 21 | import { forgeHealth } from "../../kernel/guard.ts"; |
| 22 | 22 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; |
| 23 | import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts"; | |
| 23 | import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts"; | |
| 24 | 24 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 25 | 25 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 26 | 26 | import { askForSomething } from "./tell.ts"; |
| @@ -200,12 +200,19 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 200 | 200 | clientRepoIds = new Set(apps.map((a) => a.repo.id)); |
| 201 | 201 | clientLive = new Map(apps.flatMap((a) => (a.live === undefined ? [] : [[a.repo.id, a.live] as const]))); |
| 202 | 202 | const now = new Date(); |
| 203 | const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now); | |
| 203 | const ids = apps.map((a) => a.repo.id); | |
| 204 | const doings = clientDoings(f.app.db, ids); | |
| 205 | const finished = clientFinished(f.app.db, ids); | |
| 204 | 206 | clientLines = |
| 205 | 207 | apps.length === 0 |
| 206 | 208 | ? ["Your apps will appear here."] |
| 207 | 209 | : apps.map((a) => { |
| 208 | const doing = doingLine(doings.get(a.repo.id), now.getTime()); | |
| 210 | const doing = [ | |
| 211 | finishedLine(finished.get(a.repo.id), now.getTime()), | |
| 212 | doingLine(doings.get(a.repo.id)), | |
| 213 | ] | |
| 214 | .filter((x) => x !== "") | |
| 215 | .join(" "); | |
| 209 | 216 | const mark = a.live === undefined ? "" : " (running, o opens it)"; |
| 210 | 217 | const line = `${a.repo.name}${mark}: ${agentsLine(a.working, a.queued)}`; |
| 211 | 218 | return doing === "" ? line : `${line} ${doing}`; |
src/web/studio/client_home.ts +8 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts"; | |
| 10 | import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts"; | |
| 11 | 11 | import type { User } from "../../core/users.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | import { mayDirectCrew } from "../../crew/direct.ts"; |
| @@ -31,11 +31,15 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> { | ||
| 31 | 31 | const apps = await timePhaseAsync(ctx.request, "apps", () => clientAppRows(ctx.app, user)); |
| 32 | 32 | const now = new Date(); |
| 33 | 33 | const doings = timePhase(ctx.request, "doing", () => |
| 34 | clientDoings(ctx.app.db, apps.map((a) => a.repo.id), now), | |
| 34 | clientDoings(ctx.app.db, apps.map((a) => a.repo.id)), | |
| 35 | ); | |
| 36 | const finished = timePhase(ctx.request, "finished", () => | |
| 37 | clientFinished(ctx.app.db, apps.map((a) => a.repo.id)), | |
| 35 | 38 | ); |
| 36 | 39 | const rendering = performance.now(); |
| 37 | 40 | const rows = apps.map(({ repo, working, queued, live }) => { |
| 38 | const doing = doingLine(doings.get(repo.id), now.getTime()); | |
| 41 | const doing = doingLine(doings.get(repo.id)); | |
| 42 | const done = finishedLine(finished.get(repo.id), now.getTime()); | |
| 39 | 43 | const about = repo.description !== "" ? repo.description : "An app of yours."; |
| 40 | 44 | // The tell form with this app chosen; it sends the person back to `/`. |
| 41 | 45 | const ask = mayDirectCrew(ctx.app.db, repo, user) |
| @@ -57,6 +61,7 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> { | ||
| 57 | 61 | <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}" |
| 58 | 62 | >${agentsLine(working, queued)}</span |
| 59 | 63 | > |
| 64 | ${done !== "" ? html`<span class="client-app-finished muted">${done}</span>` : ""} | |
| 60 | 65 | ${doing !== "" ? html`<span class="client-app-doing muted">${doing}</span>` : ""} |
outdated
Duplicates the "Last change" line below for merges in the last 7 days. Fold them into one line. Also |
||
| 61 | 66 | </li>`; |
| 62 | 67 | }); |
test/client-apps.test.ts +39 −29
| @@ -1,7 +1,7 @@ | ||
| 1 | /** What a client's apps are doing: the work running, else the last change landed this week (#4007). */ | |
| 1 | /** What a client's apps are doing: the work running, else what was last finished (#4007, #6091). */ | |
| 2 | 2 | import { beforeEach, describe, expect, test } from "bun:test"; |
| 3 | 3 | import type { Database } from "bun:sqlite"; |
| 4 | import { clientDoings } from "../src/core/client-apps.ts"; | |
| 4 | import { clientDoings, clientFinished, doingLine, finishedLine } from "../src/core/client-apps.ts"; | |
| 5 | 5 | import { migrations } from "../src/core/schema.ts"; |
| 6 | 6 | import { openDb } from "../src/kernel/db.ts"; |
| 7 | 7 | |
| @@ -33,41 +33,51 @@ function running(repo: string, issueId: string | null): void { | ||
| 33 | 33 | describe("clientDoings", () => { |
| 34 | 34 | test("a running run names its issue", () => { |
| 35 | 35 | running("a", issue("a", "Add a contact form")); |
| 36 | expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "working", title: "Add a contact form" }); | |
| 36 | expect(clientDoings(db, ["a"]).get("a")).toEqual({ title: "Add a contact form" }); | |
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | test("a change landed inside the week is reported with when", () => { | |
| 40 | landed("a", "Fix the footer", "2026-10-15T13:00:00.000Z"); | |
| 41 | expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "landed", title: "Fix the footer", at: "2026-10-15T13:00:00.000Z" }); | |
| 42 | }); | |
| 43 | ||
| 44 | test("a change landed more than 7 days ago is ignored", () => { | |
| 45 | landed("a", "Old", "2026-10-14T11:59:59.000Z"); | |
| 46 | expect(clientDoings(db, ["a"], NOW).size).toBe(0); | |
| 47 | }); | |
| 48 | ||
| 49 | test("the latest landed change wins", () => { | |
| 50 | landed("a", "First", "2026-10-16T10:00:00.000Z"); | |
| 51 | landed("a", "Second", "2026-10-18T10:00:00.000Z"); | |
| 52 | expect(clientDoings(db, ["a"], NOW).get("a")).toMatchObject({ title: "Second" }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("working beats landed", () => { | |
| 56 | landed("a", "Done", "2026-10-20T10:00:00.000Z"); | |
| 57 | running("a", issue("a", "Doing")); | |
| 58 | expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "working", title: "Doing" }); | |
| 39 | test("a change that landed is not work in progress", () => { | |
| 40 | landed("a", "Fix the footer", "2026-10-20T10:00:00.000Z"); | |
| 41 | expect(clientDoings(db, ["a"]).size).toBe(0); | |
| 59 | 42 | }); |
| 60 | 43 | |
| 61 | 44 | test("a running run with no issue yields nothing", () => { |
| 62 | 45 | running("a", null); |
| 63 | expect(clientDoings(db, ["a"], NOW).size).toBe(0); | |
| 46 | expect(clientDoings(db, ["a"]).size).toBe(0); | |
| 64 | 47 | }); |
| 65 | 48 | |
| 66 | 49 | test("a quiet app, an unlisted app and no apps yield nothing; apps are kept apart", () => { |
| 67 | landed("b", "Mine", "2026-10-20T10:00:00.000Z"); | |
| 68 | landed("c", "Not asked", "2026-10-20T10:00:00.000Z"); | |
| 69 | const got = clientDoings(db, ["a", "b"], NOW); | |
| 70 | expect([...got.keys()]).toEqual(["b"]); | |
| 71 | expect(clientDoings(db, [], NOW).size).toBe(0); | |
| 50 | running("b", issue("b", "Mine")); | |
| 51 | running("c", issue("c", "Not asked")); | |
| 52 | expect([...clientDoings(db, ["a", "b"]).keys()]).toEqual(["b"]); | |
| 53 | expect(clientDoings(db, []).size).toBe(0); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("clientFinished", () => { | |
| 58 | test("the newest merged pull wins, however old", () => { | |
| 59 | landed("a", "First", "2025-01-16T10:00:00.000Z"); | |
| 60 | landed("a", "Second", "2025-02-18T10:00:00.000Z"); | |
| 61 | expect(clientFinished(db, ["a"]).get("a")).toEqual({ title: "Second", at: "2025-02-18T10:00:00.000Z" }); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a quiet app, an unlisted app and no apps yield nothing", () => { | |
| 65 | landed("b", "Mine", "2026-10-20T10:00:00.000Z"); | |
| 66 | landed("c", "Not asked", "2026-10-20T10:00:00.000Z"); | |
| 67 | expect([...clientFinished(db, ["a", "b"]).keys()]).toEqual(["b"]); | |
| 68 | expect(clientFinished(db, []).size).toBe(0); | |
| 69 | }); | |
| 70 | }); | |
| 71 | ||
| 72 | describe("the lines", () => { | |
| 73 | test("finishedLine spells the age out and is empty with nothing finished", () => { | |
| 74 | const at = new Date(NOW.getTime() - 3 * 86_400_000).toISOString(); | |
| 75 | expect(finishedLine({ title: "Fix the menu", at }, NOW.getTime())).toBe("Finished 3 days ago: Fix the menu"); | |
| 76 | expect(finishedLine(undefined, NOW.getTime())).toBe(""); | |
| 77 | }); | |
| 78 | ||
| 79 | test("doingLine says Working on, or nothing", () => { | |
| 80 | expect(doingLine({ title: "Add rye" })).toBe("Working on: Add rye"); | |
| 81 | expect(doingLine(undefined)).toBe(""); | |
| 72 | 82 | }); |
| 73 | 83 | }); |
test/client-home.test.ts +59 −3
| @@ -506,13 +506,48 @@ describe("a client's first screen", () => { | ||
| 506 | 506 | clean(); |
| 507 | 507 | }); |
| 508 | 508 | |
| 509 | test("a change landed this week reads Last change with its age", async () => { | |
| 509 | test("a change landed this week reads Finished once, not Last change too", async () => { | |
| 510 | 510 | clean(); |
| 511 | 511 | seedIssue("i-doing-2", 9002, "pull", "Fix the menu", "closed"); |
| 512 | 512 | child.db |
| 513 | 513 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES ('i-doing-2', 'main', 'x', ?1, ?1, ?2)") |
| 514 | 514 | .run("0".repeat(40), new Date(Date.now() - 3 * 86_400_000).toISOString()); |
| 515 | expect(await row()).toContain("Last change: Fix the menu, 3 days ago"); | |
| 515 | const r = await row(); | |
| 516 | expect(r).toContain("Finished"); | |
| 517 | expect(r).toContain("3 days ago"); | |
| 518 | expect(r).toContain("Fix the menu"); | |
| 519 | expect(r).not.toContain("Last change"); | |
| 520 | expect(r).not.toContain("client-app-doing"); | |
| 521 | clean(); | |
| 522 | }); | |
| 523 | ||
| 524 | test("the newest merged pull reads Finished, on that app only, escaped, with its own phase", async () => { | |
| 525 | clean(); | |
| 526 | const maria = ids().maria; | |
| 527 | const other = await createRepo(child, maria, { name: "cafe2", owner: "marias" }); | |
| 528 | if (!other.ok) throw new Error(JSON.stringify(other.error)); | |
| 529 | const none = main(await page(owner)); | |
| 530 | expect(none).not.toContain("client-app-finished"); | |
| 531 | seedIssue("i-doing-4", 9004, "pull", "Older <i>fix</i>", "closed"); | |
| 532 | seedIssue("i-doing-5", 9005, "pull", "Add <b>rye</b> bread", "closed"); | |
| 533 | const ins = child.db.query( | |
| 534 | "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'x', ?2, ?2, ?3)", | |
| 535 | ); | |
| 536 | ins.run("i-doing-4", "0".repeat(40), new Date(Date.now() - 90 * 86_400_000).toISOString()); | |
| 537 | ins.run("i-doing-5", "0".repeat(40), new Date(Date.now() - 40 * 86_400_000).toISOString()); | |
| 538 | const home = main(await page(owner)); | |
| 539 | const line = home.match(/client-app-finished[^>]*>([^]*?)<\/span>/)?.[1] ?? ""; | |
| 540 | expect(line).toContain("Finished"); | |
| 541 | expect(line).toContain("Add <b>rye</b> bread"); | |
| 542 | expect(line).not.toContain("Older"); | |
| 543 | for (const word of ["pull", "branch", "commit", "merge", "CI", "repository", "repositories"]) | |
| 544 | expect(words(line)).not.toMatch(new RegExp(`\\b${word}`, "i")); | |
| 545 | expect(home.match(/client-app-finished/g)?.length).toBe(1); | |
| 546 | const cafe = home.slice(home.indexOf('href="/marias/cafe2"')); | |
| 547 | expect(cafe.slice(0, cafe.indexOf("</li>"))).not.toContain("Finished"); | |
| 548 | const res = await fetch(url("/"), { headers: { cookie: owner } }); | |
| 549 | expect(res.headers.get("server-timing") ?? "").toMatch(/\bfinished;/); | |
| 550 | child.db.query("DELETE FROM repo WHERE id = ?1").run(other.value.id); | |
| 516 | 551 | clean(); |
| 517 | 552 | }); |
| 518 | 553 | |
| @@ -542,6 +577,27 @@ describe("a client's first screen", () => { | ||
| 542 | 577 | clean(); |
| 543 | 578 | }); |
| 544 | 579 | |
| 580 | test("the terminal's line for the app says what was finished too", async () => { | |
| 581 | clean(); | |
| 582 | seedIssue("i-doing-6", 9006, "pull", "Fix the menu", "closed"); | |
| 583 | child.db | |
| 584 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES ('i-doing-6', 'main', 'x', ?1, ?1, ?2)") | |
| 585 | .run("0".repeat(40), new Date(Date.now() - 3 * 86_400_000).toISOString()); | |
| 586 | const terminal = headlessTerminal(160, 30); | |
| 587 | const { app } = createForgeApp( | |
| 588 | child, | |
| 589 | { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: ids().maria }, | |
| 590 | { terminal, exitHooks: false }, | |
| 591 | ); | |
| 592 | app.start(); | |
| 593 | await settleApp(app); | |
| 594 | const text = app.screen.toText(); | |
| 595 | expect(text).toContain("Finished 3 days ago: Fix the menu"); | |
| 596 | expect(text).not.toContain("Last change"); | |
| 597 | app.stop(); | |
| 598 | clean(); | |
| 599 | }); | |
| 600 | ||
| 545 | 601 | test("titles are cut to 80 characters at a word boundary", () => { |
| 546 | 602 | const long = "word ".repeat(30); |
| 547 | 603 | const cut = cutTitle(long); |
| @@ -549,7 +605,7 @@ describe("a client's first screen", () => { | ||
| 549 | 605 | expect(cut.endsWith("word…")).toBe(true); |
| 550 | 606 | expect(cutTitle("short title")).toBe("short title"); |
| 551 | 607 | expect(cutTitle("x".repeat(200))).toBe(`${"x".repeat(79)}…`); |
| 552 | expect(doingLine(undefined, 0)).toBe(""); | |
| 608 | expect(doingLine(undefined)).toBe(""); | |
| 553 | 609 | }); |
| 554 | 610 | }); |
| 555 | 611 | }); |
new above
offline
Context: the recent-merge "Last change" wording lives here. It should yield to the new Finished line so one merge reads once.