Public: anyone can read this; the crew can change it.
A repository's builds page speaks the home page's language #9795
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9794-a-repository-s-builds-page-speaks-the-ho → main 3 commits fork 4 threads · 4 unresolved
docs/design/areas/builds.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Builds | |
| 2 | ||
| 3 | The builds page (`src/web/pages/builds.ts`) is an `<h2>` and a plain `.list` of rows, indented by `data-depth` under the pin each grew from. The label is the row's one accent link (to its commit); Screenshots, the cart and Preview are quiet links in the trail, and `.build-del` is the Delete link, muted until hover or focus turns it `--danger`. With no builds, one sentence says how to make one. The pin form is the new issue form's markup: `<h1>`, `.narrow-wide`, `.form-actions`, Cancel a link. On a phone the trail drops under the row's text and wraps. The shots and cart pages under `/builds/:label` are separate. |
src/web/pages/builds.ts +81 −112
| @@ -27,7 +27,7 @@ import { backTo, field as formValue, readForm } from "../form.ts"; | ||
| 27 | 27 | import { isLocalPath } from "../flash.ts"; |
| 28 | 28 | import { html, when, type Html } from "../html.ts"; |
| 29 | 29 | import { flashTo, page } from "../layout.ts"; |
| 30 | import { btn, card, field, icon, selectField, sha, time } from "../ui.ts"; | |
| 30 | import { btn, field, row, selectField, time } from "../ui.ts"; | |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | 33 | * The label a new pin is offered. An iteration branch (`iter-3`) pinned |
| @@ -53,7 +53,6 @@ export function nextLabel(pins: readonly BuildPin[]): string { | ||
| 53 | 53 | return `${last}-2`; |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | /** The lineage as a card: one line per pin, indented under its parent. */ | |
| 57 | 56 | const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden"; |
| 58 | 57 | |
| 59 | 58 | // GET /:owner/:name/builds — the pins as a lineage, each under the one it grew from. |
| @@ -66,7 +65,7 @@ export async function buildsPage(ctx: Ctx): Promise<Response> { | ||
| 66 | 65 | const writer = ctx.auth.user !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user); |
| 67 | 66 | const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined; |
| 68 | 67 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} |
| 69 | <div class="mt">${buildsCard(base, nodes, writer, writer && isApp, writer)}</div>`; | |
| 68 | ${buildsList(base, nodes, writer, writer && isApp, writer)}`; | |
| 70 | 69 | return timePhase(ctx.request, "render", () => |
| 71 | 70 | page({ ctx, title: `Builds · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body, styleCss: BUILDS_CSS }), |
| 72 | 71 | ); |
| @@ -85,7 +84,7 @@ export async function deletePinSubmit(ctx: Ctx): Promise<Response> { | ||
| 85 | 84 | return flashTo(ctx, to, gone.ok ? { message: `Deleted pin ${gone.value.label}.` } : { tone: "danger", message: "Delete failed." }); |
| 86 | 85 | } |
| 87 | 86 | |
| 88 | export function buildsCard( | |
| 87 | export function buildsList( | |
| 89 | 88 | base: string, |
| 90 | 89 | nodes: readonly BuildNode[], |
| 91 | 90 | canPin: boolean, |
| @@ -94,78 +93,57 @@ export function buildsCard( | ||
| 94 | 93 | /** Whether each pin of this repository offers Delete: a writer. */ |
| 95 | 94 | canDelete = false, |
| 96 | 95 | ): Html { |
| 97 | return card( | |
| 98 | nodes.length === 0 | |
| 99 | ? html`<p class="muted small builds-none">No pinned builds</p>` | |
| 100 | : html`<ol class="builds"> | |
| 101 | ${nodes.map( | |
| 102 | (n) => html`<li data-depth="${String(Math.min(n.depth, 8))}" class="build-row"> | |
| 103 | ${when(n.edge === "concept", () => html`<span class="build-concept" title="Concept edge: parent set by hand, not by commit history">┄</span>`)} | |
| 104 | <span class="build-label mono">${n.from === undefined ? null : html`<span class="muted">${n.from}:</span>`}${n.pin.label}</span> | |
| 105 | ${sha(n.pin.oid, `${n.from === undefined ? base : `/${n.from}`}/commit/${n.pin.oid}`)} | |
| 106 | <span class="build-note truncate" title="${n.pin.note}">${n.pin.note === n.pin.label ? "" : n.pin.note}</span> | |
| 107 | <span class="muted small build-meta" | |
| 108 | >${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}</span | |
| 109 | > | |
| 110 | ${when( | |
| 111 | n.from === undefined && n.pin.demo?.launch === "file", | |
| 112 | () => html`<a class="build-cart small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/cart" title="Play the captured page of ${n.pin.demo?.name ?? ""}" | |
| 113 | >▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a | |
| 114 | >`, | |
| 115 | )} | |
| 116 | ${when( | |
| 117 | n.from === undefined, | |
| 118 | () => html`<a class="build-shots small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/shots">Screenshots</a>`, | |
| 119 | )} | |
| 120 | ${when( | |
| 121 | canPreview && n.from === undefined, | |
| 122 | () => html`<form method="post" action="${base}/builds/preview" class="build-preview"> | |
| 123 | <input type="hidden" name="sha" value="${n.pin.oid}" /> | |
| 124 | ${btn("Preview now", { size: "sm", variant: "ghost", icon: "eye" })} | |
| 125 | </form>`, | |
| 126 | )} | |
| 127 | ${when( | |
| 128 | canDelete && n.from === undefined, | |
| 129 | () => btn("Delete", { | |
| 130 | href: `/-/confirm?to=${encodeURIComponent(`${base}/builds/${encodeURIComponent(n.pin.label)}/delete`)}`, | |
| 131 | size: "sm", | |
| 132 | variant: "danger", | |
| 133 | label: `Delete pin ${n.pin.label}`, | |
| 134 | }), | |
| 135 | )} | |
| 136 | </li>`, | |
| 137 | )} | |
| 138 | </ol>`, | |
| 139 | { | |
| 140 | head: html`${icon("pin")} | |
| 141 | <h2>Builds</h2> | |
| 142 | <span class="spacer"></span> | |
| 143 | ${when(nodes.length > 0, () => btn("Iterations", { href: `${base}/iterations?layout=tree`, size: "sm", variant: "ghost", icon: "map" }))} | |
| 144 | ${when(canPin, () => | |
| 145 | html`${btn("Pin a build", { | |
| 146 | href: `${base}/builds/new`, | |
| 147 | size: "sm", | |
| 148 | icon: "plus", | |
| 149 | })}`, | |
| 150 | )}`, | |
| 151 | extra: "builds-card", | |
| 152 | }, | |
| 153 | ); | |
| 96 | return html`<h2 class="mt">Builds</h2> | |
| 97 | <p class="small builds-links"> | |
outdated
|
||
| 98 | ${when(canPin, () => html`<a href="${base}/builds/new">Pin a build</a>`)} | |
| 99 | ${when(nodes.length > 0, () => html`<a href="${base}/iterations?layout=tree">Iterations</a>`)} | |
| 100 | </p> | |
| 101 | ${nodes.length === 0 | |
| 102 | ? html`<p class="muted">No builds yet.${canPin ? html` Pin one from <a href="${base}/branches">a branch</a> to make it.` : null}</p>` | |
| 103 | : html`<ul class="list builds">${nodes.map((n) => buildRow(base, n, canPreview, canDelete))}</ul>`}`; | |
| 104 | } | |
| 105 | ||
| 106 | /** One pin: its label is the one accent link; the rest is quiet, in the trail. */ | |
| 107 | function buildRow(base: string, n: BuildNode, canPreview: boolean, canDelete: boolean): Html { | |
| 108 | const own = n.from === undefined; | |
| 109 | const label = encodeURIComponent(n.pin.label); | |
| 110 | const note = n.pin.note === n.pin.label ? "" : `${n.pin.note} · `; | |
| 111 | return row({ | |
| 112 | title: html`${when(n.edge === "concept", () => html`<span class="build-concept" title="Concept edge: parent set by hand, not by commit history">┄ </span>`)}<a class="mono" href="${own ? base : `/${n.from}`}/commit/${n.pin.oid}">${n.from === undefined ? null : html`<span class="muted">${n.from}:</span>`}${n.pin.label}</a>`, | |
| 113 | meta: html`${note}${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}`, | |
| 114 | data: { depth: String(Math.min(n.depth, 8)) }, | |
| 115 | trail: html`${when( | |
| 116 | own && n.pin.demo?.launch === "file", | |
| 117 | () => html`<a class="small" href="${base}/builds/${label}/cart" title="Play the captured page of ${n.pin.demo?.name ?? ""}">▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a>`, | |
| 118 | )} | |
| 119 | ${when(own, () => html`<a class="small" href="${base}/builds/${label}/shots">Screenshots</a>`)} | |
| 120 | ${when( | |
| 121 | canPreview && own, | |
| 122 | () => html`<form method="post" action="${base}/builds/preview" class="build-preview"> | |
| 123 | <input type="hidden" name="sha" value="${n.pin.oid}" /> | |
| 124 | ${btn("Preview", { size: "sm", variant: "link", label: `Preview build ${n.pin.label}` })} | |
| 125 | </form>`, | |
| 126 | )} | |
| 127 | ${when( | |
| 128 | canDelete && own, | |
| 129 | () => html`<a class="small build-del" href="/-/confirm?to=${encodeURIComponent(`${base}/builds/${label}/delete`)}" aria-label="Delete pin ${n.pin.label}">Delete</a>`, | |
| 130 | )}`, | |
| 131 | }); | |
| 154 | 132 | } |
| 155 | 133 | |
outdated
Screenshots and the cart link are |
||
| 156 | 134 | export const BUILDS_CSS = ` |
| 157 | .builds{list-style:none;margin:0;padding:6px 12px} | |
| 158 | .build-row{display:flex;align-items:center;gap:8px;min-height:30px;min-width:0} | |
| 159 | ${[1, 2, 3, 4, 5, 6, 7, 8].map((d) => `.build-row[data-depth="${d}"]{padding-left:${d * 20}px}`).join("\n")} | |
| 160 | .build-row + .build-row{border-top:1px solid var(--border)} | |
| 161 | .build-label{font-weight:600;flex:none} | |
| 162 | .build-concept{color:var(--muted-fg);flex:none} | |
| 163 | .build-note{min-width:0;flex:1} | |
| 164 | .build-meta{flex:none;margin-left:auto} | |
| 165 | .build-preview{flex:none;margin:0} | |
| 166 | .build-shots{flex:none} | |
| 167 | .builds-none{padding:10px 14px;margin:0} | |
| 168 | (max-width:640px){.build-meta{display:none}} | |
| 135 | ${[1, 2, 3, 4, 5, 6, 7, 8].map((d) => `.builds > li[data-depth="${d}"]{padding-left:${d * 20}px}`).join("\n")} | |
| 136 | .builds .row-meta{white-space:normal;overflow-wrap:anywhere} | |
| 137 | .builds .row-trail{flex:0 1 auto;flex-wrap:wrap;gap:4px 12px;align-items:center} | |
| 138 | .build-concept{color:var(--muted-fg)} | |
| 139 | .build-preview{margin:0} | |
| 140 | .build-preview .btn{color:var(--muted-fg);font-size:inherit} | |
| 141 | .build-preview .btn:hover,.build-preview .btn:focus-visible{color:var(--accent)} | |
| 142 | .builds .row-trail a,.builds-links a{color:var(--muted-fg)} | |
| 143 | .builds .row-trail a:hover,.builds .row-trail a:focus-visible,.builds-links a:hover,.builds-links a:focus-visible{color:var(--accent)} | |
| 144 | .builds .row-trail a.build-del:hover,.builds .row-trail a.build-del:focus-visible{color:var(--danger)} | |
| 145 | .builds-links{display:flex;gap:12px;margin:0 0 8px} | |
| 146 | @media (max-width:719px){.builds .row{flex-wrap:wrap}.builds .row-trail{flex:1 1 100%;justify-content:flex-start}} | |
| 169 | 147 | `; |
| 170 | 148 | |
| 171 | 149 | // GET /:owner/:name/builds/new?sha=&source= — the pin form. |
| @@ -190,38 +168,33 @@ export async function newBuildPage(ctx: Ctx): Promise<Response> { | ||
| 190 | 168 | const askedBack = ctx.url.searchParams.get("back") ?? ""; |
| 191 | 169 | const back = isLocalPath(askedBack) ? askedBack : undefined; |
| 192 | 170 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} |
| 193 | <div class="mt"> | |
| 194 | ${card( | |
| 195 | html`<form method="post" action="${base}/builds" class="stack"> | |
| 196 | ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })} | |
| 197 | <input type="hidden" name="source" value="${source}" /> | |
| 198 | ${when(back, (b) => html`<input type="hidden" name="back" value="${b}" />`)} | |
| 199 | ${field({ label: "Label", name: "label", value: suggestLabel(pins, repo.forkParentId !== null, source), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: iter-3, v3." })} | |
| 200 | ${field({ label: "Note", name: "note", rows: 3, hint: "Optional." })} | |
| 201 | ${selectField({ | |
| 202 | label: "Parent", | |
| 203 | name: "parent", | |
| 204 | hint: "Automatic: the nearest pin in the commit history. A pin in another repository creates a concept edge.", | |
| 205 | options: [ | |
| 206 | { value: "", label: "Automatic", selected: true }, | |
| 207 | // Any pin of the family: one in another repository is a | |
| 208 | // concept edge, an idea shared without history. | |
| 209 | ...family.map((n) => { | |
| 210 | const value = n.from === undefined ? n.pin.label : `${n.from}:${n.pin.label}`; | |
| 211 | return { value, label: value }; | |
| 212 | }), | |
| 213 | ], | |
| 214 | })} | |
| 215 | <div class="bar"> | |
| 216 | ${btn("Pin build", { variant: "primary", icon: "pin" })} | |
| 217 | ${btn("Cancel", { href: back ?? `${base}/branches`, variant: "ghost" })} | |
| 218 | </div> | |
| 219 | </form>`, | |
| 220 | { | |
| 221 | head: html`${icon("pin")} | |
| 222 | <h2>Pin a build</h2>`, | |
| 223 | }, | |
| 224 | )} | |
| 171 | <h1 class="mt">Pin a build</h1> | |
| 172 | <div class="narrow-wide mt"> | |
| 173 | <form method="post" action="${base}/builds"> | |
| 174 | ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })} | |
| 175 | <input type="hidden" name="source" value="${source}" /> | |
| 176 | ${when(back, (b) => html`<input type="hidden" name="back" value="${b}" />`)} | |
| 177 | ${field({ label: "Label", name: "label", value: suggestLabel(pins, repo.forkParentId !== null, source), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: iter-3, v3." })} | |
| 178 | ${field({ label: "Note", name: "note", type: "textarea", rows: 3, hint: "Optional." })} | |
| 179 | ${selectField({ | |
| 180 | label: "Parent", | |
| 181 | name: "parent", | |
| 182 | hint: "Automatic: the nearest pin in the commit history. A pin in another repository creates a concept edge.", | |
| 183 | options: [ | |
| 184 | { value: "", label: "Automatic", selected: true }, | |
| 185 | // Any pin of the family: one in another repository is a | |
| 186 | // concept edge, an idea shared without history. | |
| 187 | ...family.map((n) => { | |
| 188 | const value = n.from === undefined ? n.pin.label : `${n.from}:${n.pin.label}`; | |
| 189 | return { value, label: value }; | |
| 190 | }), | |
| 191 | ], | |
| 192 | })} | |
| 193 | <div class="form-actions"> | |
| 194 | ${btn("Pin build", { variant: "primary", icon: "pin" })} | |
| 195 | ${btn("Cancel", { href: back ?? `${base}/branches`, variant: "link" })} | |
| 196 | </div> | |
| 197 | </form> | |
| 225 | 198 | </div>`; |
| 226 | 199 | return timePhase(ctx.request, "render", () => |
| 227 | 200 | page({ |
| @@ -314,12 +287,8 @@ export async function previewBuildSubmit(ctx: Ctx): Promise<Response> { | ||
| 314 | 287 | title: `Opening ${short} · ${repo.name}`, |
| 315 | 288 | crumbs: crumbsFor(repo), |
| 316 | 289 | scriptJs: `location.replace(${JSON.stringify(url).replace(/</g, "\\u003c")});`, |
| 317 | body: html`<div class="mt"> | |
| 318 | ${card( | |
| 319 | html`<p>Starting build <span class="mono">${short}</span>.</p> | |
| 320 | <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`, | |
| 321 | { head: html`${icon("eye")}<h2>Opening preview</h2>` }, | |
| 322 | )} | |
| 323 | </div>`, | |
| 290 | body: html`<h2 class="mt">Opening preview</h2> | |
| 291 | <p>Starting build <span class="mono">${short}</span>.</p> | |
| 292 | <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`, | |
| 324 | 293 | }); |
| 325 | 294 | } |
test/builds-rows.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { adminCookie } from "./helpers/admin.ts"; | |
| 3 | import { buildsList } from "../src/web/pages/builds.ts"; | |
| 4 | import type { BuildNode } from "../src/core/lineage.ts"; | |
| 5 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | ||
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let token = ""; | |
| 16 | const send = (path: string, method = "GET", body?: unknown) => | |
| 17 | fetch(`${servers.url}${path}`, { | |
| 18 | method, | |
| 19 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 20 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 21 | }); | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dataDir = await mkdtemp(join(tmpdir(), "forge-builds-rows-")); | |
| 25 | app = createApp({ dataDir, port: 0 }); | |
| 26 | servers = await serve(app); | |
| 27 | await seedAdmin(app, { username: "dev", email: "[email protected]", password: "rows-pass-1" }); | |
| 28 | token = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 29 | method: "POST", | |
| 30 | headers: { "content-type": "application/json" }, | |
| 31 | body: JSON.stringify({ adminConfirm: true, username: "dev", password: "rows-pass-1", name: "c", scope: "rw" }), | |
| 32 | }) | |
| 33 | .then((r) => r.json()) | |
| 34 | .then((j) => (j as { token: string }).token); | |
| 35 | await send("/api/v1/repos", "POST", { name: "lib", visibility: "public" }); | |
| 36 | }); | |
| 37 | afterAll(async () => { | |
| 38 | await servers.stop(); | |
| 39 | await rm(dataDir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | test("builds: no builds is a sentence, a pin is a row in a list, and no .card", async () => { | |
| 43 | const empty = await (await fetch(`${servers.url}/dev/lib/builds`)).text(); | |
| 44 | expect(empty).toContain("<h2"); | |
| 45 | expect(empty).toContain("No builds yet."); | |
| 46 | expect(empty).not.toMatch(/class="card/); | |
| 47 | ||
| 48 | const c = await send("/api/v1/repos/dev/lib/contents", "POST", { branch: "main", message: "one", files: [{ path: "a.txt", content: "x\n" }] }); | |
| 49 | const made = (await c.json()) as { commitOid?: string; oid?: string; sha?: string }; | |
| 50 | const oid = made.commitOid ?? made.oid ?? made.sha; | |
| 51 | expect((await send("/api/v1/repos/dev/lib/builds", "POST", { label: "v1", sha: oid })).ok).toBe(true); | |
| 52 | ||
| 53 | const list = await (await fetch(`${servers.url}/dev/lib/builds`)).text(); | |
| 54 | expect(list).toMatch(/<ul class="list builds">/); | |
| 55 | expect(list).toMatch(/<li data-depth="0">[\s\S]*?class="row"[\s\S]*?>v1</); | |
| 56 | expect(list).toContain("Screenshots"); | |
outdated
Only the read-only list is tested. Add the writer rows (Preview form, Delete link to /-/confirm) and the /builds/new markup (h1, .narrow-wide, .form-actions, no .card), so that box 2 and the trail are covered by a test that fails without the change. |
||
| 57 | expect(list).not.toMatch(/class="card/); | |
| 58 | expect(list).not.toContain("build-row"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("builds: a writer's rows carry Preview in a post form and Delete as a link to /-/confirm; the trail is one quiet group", () => { | |
| 62 | const node = { | |
| 63 | pin: { label: "v1", oid: "a".repeat(40), note: "v1", source: "commit", pinnedBy: "dev", pinnedAt: 0 }, | |
| 64 | depth: 0, | |
| 65 | edge: "git", | |
| 66 | } as unknown as BuildNode; | |
| 67 | const out = buildsList("/dev/lib", [node], true, true, true).toString(); | |
| 68 | expect(out).toMatch(/<form method="post" action="\/dev\/lib\/builds\/preview"[\s\S]*?<button[^>]*>[\s\S]*?Preview/); | |
| 69 | expect(out).toMatch(/<a class="small build-del" href="\/-\/confirm\?to=[^"]*delete"/); | |
| 70 | expect(out).not.toMatch(/class="card/); | |
| 71 | const read = buildsList("/dev/lib", [node], false, false, false).toString(); | |
| 72 | expect(read).not.toContain("Preview"); | |
| 73 | expect(read).not.toContain("/-/confirm"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("builds/new: the pin form is the issue form's markup", async () => { | |
| 77 | const cookie = await adminCookie(app, { username: "ed", email: "[email protected]", password: "rows-pass-2" }); | |
| 78 | const form = await (await fetch(`${servers.url}/dev/lib/builds/new`, { headers: { cookie } })).text(); | |
| 79 | expect(form).toContain('<h1 class="mt">Pin a build</h1>'); | |
| 80 | expect(form).toContain("narrow-wide"); | |
| 81 | expect(form).toContain('class="form-actions"'); | |
| 82 | expect(form).toMatch(/<a\s[^>]*data-variant="link"[^>]*>Cancel<\/a/); | |
| 83 | expect(form).not.toMatch(/class="card/); | |
| 84 | }); |
test/iterations.test.ts +2 −2
| @@ -350,8 +350,8 @@ describe("iterations page", () => { | ||
| 350 | 350 | |
| 351 | 351 | test("Builds draws the family's pins as a lineage, each under its parent, and the pin form offers them as Parent", async () => { |
| 352 | 352 | const list = await (await get("/alice/game/builds")).text(); |
| 353 | expect(list).toMatch(/data-depth="0"[^>]*class="build-row">[\s\S]*?>v1</); | |
| 354 | expect(list).toMatch(/data-depth="1"[^>]*class="build-row">[\s\S]*?>v2</); | |
| 353 | expect(list).toMatch(/data-depth="0"[^>]*>[\s\S]*?>v1</); | |
| 354 | expect(list).toMatch(/data-depth="1"[^>]*>[\s\S]*?>v2</); | |
| 355 | 355 | expect(list).toMatch(/<span class="muted">bob\/game:<\/span>b1/); |
| 356 | 356 | const cookie = await signIn("alice"); |
| 357 | 357 | const form = await (await get("/alice/game/builds/new", cookie)).text(); |
The
/** The lineage as a card ... */comment was deleted and left a double blank line. Put the blank line back to one.