Public: anyone can read this; the crew can change it.
The story opens wave to changes to pulls to conversation, one tap each, and a test walks it #10706
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10702-the-story-opens-wave-to-changes-to-pulls → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +243
−15
docs/features/wave-walk.md added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | # Walking a wave | |
| 2 | ||
| 3 | A wave names the pulls it carries. You can go from the wave to a pull, to its conversation, and back, on the page and in the terminal. | |
| 4 | ||
| 5 | ## On the page | |
| 6 | ||
| 7 | 1. Open `/<owner>/<repo>/waves` and pick a wave, or go to `/<owner>/<repo>/waves/<version>`. | |
| 8 | 2. Tap a pull in its list. The link carries `?from=<owner>/<repo>@<version>`, so the pull page knows where you came from. | |
| 9 | 3. The pull page opens on its verdict and the conversation. Above the verdict a breadcrumb reads `Wave <version> / This pull`. | |
| 10 | 4. Tap `Wave <version>` to return to the wave. | |
| 11 | ||
| 12 | The `from` value is checked against the shape of a wave address before it is used; anything else shows no breadcrumb. | |
| 13 | ||
| 14 | ## In the terminal | |
| 15 | ||
| 16 | 1. Open a repository's Waves screen and press Enter on a wave. | |
| 17 | 2. `n` (or Tab) and `p` move the `>` mark among the wave's pulls; Enter opens the marked pull. | |
| 18 | 3. Esc (or `q`) on the pull returns to the wave, and again to the list. | |
| 19 | ||
| 20 | Only pulls the viewer may read are listed, and Enter re-checks that before opening one. The walk is tested in `test/story-walk.test.ts` and `test/tui-waves.test.ts`. |
src/story/waves.ts +20
| @@ -188,3 +188,23 @@ export async function latestWave(dir: string, branch: string): Promise<Wave | nu | ||
| 188 | 188 | } |
| 189 | 189 | return null; |
| 190 | 190 | } |
| 191 | ||
| 192 | /** A wave's page on this forge. */ | |
| 193 | export const wavePath = (owner: string, name: string, version: string): string => | |
| 194 | `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/waves/${encodeURIComponent(version)}`; | |
| 195 | ||
| 196 | /** The query a pull link carries so the pull can lead back to the wave it was opened from. */ | |
| 197 | export const fromWave = (owner: string, name: string, version: string): string => | |
| 198 | `?from=${encodeURIComponent(`${owner}/${name}@${version}`)}`; | |
| 199 | ||
| 200 | const FROM = /^([A-Za-z0-9._-]{1,100})\/([A-Za-z0-9._-]{1,100})@(.{1,64})$/; | |
| 201 | ||
| 202 | /** | |
| 203 | * The wave a `?from=` names, as its page path, or null when the value is not | |
| 204 | * a well-formed wave address. Pure text: nothing is read, nothing echoed raw. | |
| 205 | */ | |
| 206 | export function backToWave(from: string | null): { href: string; version: string } | null { | |
| 207 | const m = from === null ? null : FROM.exec(from); | |
| 208 | if (!m || !WAVE_VERSION.test(m[3]!)) return null; | |
| 209 | return { href: wavePath(m[1]!, m[2]!, m[3]!), version: m[3]! }; | |
| 210 | } |
src/tui/screens/waves.ts +56 −9
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { cutWaveFor, planCutFor } from "../../actions/cut_wave.ts"; |
| 9 | import { canRead } from "../../core/repos.ts"; | |
| 9 | import { canRead, getRepo } from "../../core/repos.ts"; | |
| 10 | 10 | import { VERSION } from "../../story/wave.ts"; |
| 11 | 11 | import { movedForViewer, type MovedMilestone } from "../../story/wave_moved.ts"; |
| 12 | 12 | import { milestonePath, planNow } from "../../story/wave_plan.ts"; |
| @@ -24,7 +24,7 @@ import { emptyState } from "../ui/widgets.ts"; | ||
| 24 | 24 | import type { Session } from "../session.ts"; |
| 25 | 25 | import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural, runAsync, yesNo } from "./common.ts"; |
| 26 | 26 | import { pullScreen } from "./pull.ts"; |
| 27 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; | |
| 27 | import { local, localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; | |
| 28 | 28 | |
| 29 | 29 | /** One line per wave, as the screen draws it. */ |
| 30 | 30 | export function waveRows(entries: WaveEntry[]) { |
| @@ -52,6 +52,8 @@ export function waveLines( | ||
| 52 | 52 | entry: WaveEntry, |
| 53 | 53 | pulls: CarriedPull[], |
| 54 | 54 | moved: MovedLine[] = [], |
| 55 | /** Which pull the cursor is on, marked with `>`; none when null. */ | |
| 56 | picked: number | null = null, | |
| 55 | 57 | ): string[] { |
| 56 | 58 | if (!entry.ok) return [plain(entry.file), "", `Not a wave: ${plain(entry.reason)}`]; |
| 57 | 59 | const w = entry.wave; |
| @@ -75,21 +77,48 @@ export function waveLines( | ||
| 75 | 77 | "Pulls", |
| 76 | 78 | ...(pulls.length === 0 |
| 77 | 79 | ? [" No pulls to show here."] |
| 78 | : pulls.flatMap((p) => [ | |
| 79 | ` #${p.number} ${plain(p.title)} ${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`, | |
| 80 | : pulls.flatMap((p, i) => [ | |
| 81 | `${picked === i ? "> " : " "}#${p.number} ${plain(p.title)} ${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`, | |
| 80 | 82 | ` ${plain(pullPath(p))}`, |
| 81 | 83 | ])), |
| 82 | 84 | ]; |
| 83 | 85 | } |
| 84 | 86 | |
| 85 | function waveScreen(ctx: Ctx, entry: WaveEntry, pulls: CarriedPull[], moved: MovedLine[]): Screen { | |
| 86 | const lines = waveLines(entry, pulls, moved); | |
| 87 | function waveScreen( | |
| 88 | f: Session, | |
| 89 | ctx: Ctx, | |
| 90 | entry: WaveEntry, | |
| 91 | pulls: CarriedPull[], | |
| 92 | moved: MovedLine[], | |
| 93 | src: RepoSource, | |
| 94 | ): Screen { | |
| 87 | 95 | const scroll = new Scroll(); |
| 96 | let picked = 0; | |
| 97 | /** Where the wave's pulls start in the text, so the pick is scrolled into view. */ | |
| 98 | const linesFor = (): string[] => waveLines(entry, pulls, moved, pulls.length === 0 ? null : picked); | |
| 99 | const open = (): void => { | |
| 100 | const p = pulls[picked]; | |
Non-blocking: this comment says the pick is scrolled into view, but linesFor only builds the lines. Make it true or reword it. |
||
| 101 | if (p === undefined) return; | |
| 102 | const here = localRepo(src); | |
| 103 | // The pull's own repository, read again through the gate: a wave may name pulls of other repositories. | |
| 104 | const repo = | |
| 105 | here !== undefined && here.ownerName === p.owner && here.name === p.name | |
| 106 | ? here | |
| 107 | : getRepo(f.db, p.owner, p.name); | |
| 108 | if (repo === undefined || !canRead(f.db, repo, f.user)) return; | |
| 109 | // Esc on the pull pops back to this screen: the wave is where the walk began. | |
| 110 | ctx.push(pullScreen(f, ctx, local(f, repo), p.number)); | |
| 111 | }; | |
| 88 | 112 | return { |
| 89 | 113 | title: entry.ok ? entry.wave.version : entry.file, |
| 90 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], | |
| 114 | keys: [ | |
| 115 | binding(["j", "k"], "scroll"), | |
| 116 | ...(pulls.length > 0 ? [binding(["n", "p"], "pick a pull"), binding(["enter"], "open pull")] : []), | |
| 117 | binding(["esc"], "back"), | |
| 118 | ], | |
| 91 | 119 | render(area, s) { |
| 92 | 120 | const c = ctx.theme.c; |
| 121 | const lines = linesFor(); | |
| 93 | 122 | drawLines( |
| 94 | 123 | s, |
| 95 | 124 | area, |
| @@ -98,7 +127,25 @@ function waveScreen(ctx: Ctx, entry: WaveEntry, pulls: CarriedPull[], moved: Mov | ||
| 98 | 127 | lines.map((text) => [{ text, style: style(c.fg, c.bg) }]), |
| 99 | 128 | ); |
| 100 | 129 | }, |
| 101 | key: (e) => scroll.key(e, lines.length), | |
| 130 | key(e) { | |
| 131 | if (pulls.length > 0 && !e.ctrl && !e.alt) { | |
| 132 | if (e.key === "n" || e.key === "tab") { | |
| 133 | picked = (picked + 1) % pulls.length; | |
| 134 | ctx.invalidate(); | |
| 135 | return true; | |
| 136 | } | |
| 137 | if (e.key === "p") { | |
| 138 | picked = (picked + pulls.length - 1) % pulls.length; | |
| 139 | ctx.invalidate(); | |
| 140 | return true; | |
| 141 | } | |
| 142 | if (e.key === "enter") { | |
| 143 | open(); | |
| 144 | return true; | |
| 145 | } | |
| 146 | } | |
| 147 | return scroll.key(e, linesFor().length); | |
| 148 | }, | |
| 102 | 149 | }; |
| 103 | 150 | } |
| 104 | 151 | |
| @@ -131,7 +178,7 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 131 | 178 | plan: () => planNow(f.db, repo, new Date()), |
| 132 | 179 | }).map((m) => ({ ...m, path: milestonePath(repoBase(repo), m.number) })) |
| 133 | 180 | : []; |
| 134 | ctx.push(waveScreen(ctx, one, pulls, moved)); | |
| 181 | ctx.push(waveScreen(f, ctx, one, pulls, moved, src)); | |
| 135 | 182 | }); |
| 136 | 183 | }; |
| 137 | 184 |
src/web/pages/waves.ts +2 −2
| @@ -16,7 +16,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 16 | 16 | import { VERSION, type Wave } from "../../story/wave.ts"; |
| 17 | 17 | import { movedForViewer } from "../../story/wave_moved.ts"; |
| 18 | 18 | import { carriedPulls, pullPath } from "../../story/wave_pulls.ts"; |
| 19 | import { pullRefs, readWave, readWaves } from "../../story/waves.ts"; | |
| 19 | import { fromWave, pullRefs, readWave, readWaves } from "../../story/waves.ts"; | |
| 20 | 20 | import { NO_WAVES } from "../../story/words.ts"; |
| 21 | 21 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 22 | 22 | import { escapeHtml, html } from "../html.ts"; |
| @@ -244,7 +244,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> { | ||
| 244 | 244 | : listCard( |
| 245 | 245 | carried.map((p) => |
| 246 | 246 | row({ |
| 247 | href: pullPath(p), | |
| 247 | href: `${pullPath(p)}${fromWave(repo.ownerName, repo.name, entry.wave.version)}`, | |
| 248 | 248 | title: html`#${p.number} ${p.title}`, |
| 249 | 249 | meta: html`${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`, |
| 250 | 250 | state: p.mergedAt === null ? p.state : ("merged" as const), |
src/web/studio/change.ts +4
| @@ -51,6 +51,7 @@ import { liveClientJs, liveMount } from "../live.ts"; | ||
| 51 | 51 | import { |
| 52 | 52 | btn, |
| 53 | 53 | card, |
| 54 | crumbs, | |
| 54 | 55 | confirmAction, |
| 55 | 56 | badge, |
| 56 | 57 | icon, |
| @@ -92,6 +93,7 @@ import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../fl | ||
| 92 | 93 | import { changeHref } from "./nouns.ts"; |
| 93 | 94 | import { pullWord, servingHistory, stateDot, wordDot, type WordCtx } from "./words.ts"; |
| 94 | 95 | import { getHold } from "../../core/holds.ts"; |
| 96 | import { backToWave } from "../../story/waves.ts"; | |
| 95 | 97 | import { pullRoster } from "./pull_roster.ts"; |
| 96 | 98 | |
| 97 | 99 | /** The pull and its repo, or the response that refuses to say why not. */ |
| @@ -737,7 +739,9 @@ async function changeConversation( | ||
| 737 | 739 | const totals = |
| 738 | 740 | status === undefined ? null : (await lap(spent, "diff", () => keptChange(wordCtx.dir, status))).diff; |
| 739 | 741 | const verdict = verdictOf(verdictFacts(ctx, repo, pull, status, { mayMerge: writer }), thread, files); |
| 742 | const wave = backToWave(ctx.url.searchParams.get("from")); | |
| 740 | 743 | const body = html`${pageHead.top} |
outdated
Principle 3/7: hand-rolled |
||
| 744 | ${when(wave, (w) => crumbs([{ label: `Wave ${w.version}`, href: w.href }, { label: "This pull" }]))} | |
| 741 | 745 | ${verdictLine(verdict, { action: false })} |
| 742 | 746 | ${changeSummary(pull.body, totals, files)} |
| 743 | 747 | ${when(siblings.length > 0, () => |
test/story-walk.test.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** The story opens wave → pull → conversation, one tap each, and back to the same wave (#10702). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { backToWave } from "../src/story/waves.ts"; | |
| 12 | import { writeWave } from "../src/story/wave.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { mergedPull } from "./helpers/wave-pulls.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let root = ""; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let cookie = ""; | |
| 22 | ||
| 23 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 24 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 25 | return r.value; | |
| 26 | }; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | root = mkdtempSync(join(tmpdir(), "forge-story-walk-")); | |
| 30 | app = createApp({ dataDir: root, port: 0 }); | |
| 31 | servers = await serve(app); | |
| 32 | const u = must( | |
| 33 | await createUser(app.db, { | |
| 34 | origin: "seed", | |
| 35 | trusted: true, | |
| 36 | username: "keeper", | |
| 37 | email: "[email protected]", | |
| 38 | password: "a-good-passphrase", | |
| 39 | adminIfFirst: true, | |
| 40 | }), | |
| 41 | ); | |
| 42 | cookie = `forge_session=${createSession(app.db, u.id).token}; forge_mode=dev`; | |
| 43 | const repo = must(await createRepo(app, u, { name: "alpha", visibility: "public" })); | |
| 44 | must(await commitToBranch(app, u, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] })); | |
| 45 | const n = await mergedPull(app, u, repo, "Add the thing"); | |
| 46 | const wave = (version: string, pulls: { title: string; url: string }[]) => { | |
| 47 | const w = writeWave({ | |
| 48 | version, | |
| 49 | date: "2026-03-05", | |
| 50 | headline: `Wave ${version}`, | |
| 51 | highlights: [1, 2, 3].map((k) => ({ title: `Thing ${k}`, why: "because" })), | |
| 52 | pulls, | |
| 53 | }); | |
| 54 | if (!w.ok) throw w.error; | |
| 55 | return { path: `waves/${version}.md`, content: w.value }; | |
| 56 | }; | |
| 57 | must( | |
| 58 | await commitToBranch(app, u, repo, { | |
| 59 | message: "waves", | |
| 60 | files: [ | |
| 61 | wave("0.1.0", []), | |
| 62 | wave("0.2.0", [{ title: `#${n} Add the thing`, url: `https://forge.example/keeper/alpha/pulls/${n}` }]), | |
| 63 | ], | |
| 64 | }), | |
| 65 | ); | |
| 66 | }, 30_000); | |
| 67 | afterAll(() => { | |
| 68 | servers.stop(); | |
| 69 | app.close(); | |
| 70 | rmSync(root, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | const get = async (path: string) => { | |
| 74 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 75 | return { res, body: await res.text() }; | |
| 76 | }; | |
| 77 | const href = (body: string, re: RegExp): string => { | |
| 78 | const m = re.exec(body); | |
| 79 | if (!m) throw new Error(`no link matching ${re}`); | |
| 80 | return m[1]!.replaceAll("&", "&"); | |
| 81 | }; | |
| 82 | const fast = (res: Response) => { | |
| 83 | const app = /app;dur=([\d.]+)/.exec(res.headers.get("server-timing") ?? ""); | |
| 84 | expect(app).not.toBeNull(); | |
| 85 | expect(Number(app![1])).toBeLessThan(500); // loose: a slow machine must not flake it | |
| 86 | }; | |
| 87 | ||
| 88 | test("waves → newest wave → its pull → the conversation, and back to the same wave", async () => { | |
| 89 | const list = await get("/keeper/alpha/waves"); | |
| 90 | expect(list.res.status).toBe(200); | |
| 91 | const waveHref = href(list.body, /href="(\/keeper\/alpha\/waves\/0\.2\.0)"/); | |
| 92 | ||
| 93 | const wave = await get(waveHref); | |
| 94 | expect(wave.res.status).toBe(200); | |
| 95 | await get(waveHref); // warm | |
| 96 | fast((await get(waveHref)).res); | |
| 97 | const pullHref = href(wave.body, /href="(\/keeper\/alpha\/pulls\/\d+\?from=[^"]+)"/); | |
| 98 | ||
| 99 | const pull = await get(pullHref); | |
| 100 | expect(pull.res.status).toBe(200); | |
| 101 | expect(pull.body).toContain("Add the thing"); | |
| 102 | expect(pull.body).toContain('id="pull-check"'); | |
| 103 | fast((await get(pullHref)).res); | |
| 104 | ||
| 105 | expect(href(pull.body, /href="([^"]*)">Wave 0\.2\.0/)).toBe(waveHref); | |
| 106 | expect((await get(waveHref)).res.status).toBe(200); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a level with nothing below it shows no link", async () => { | |
| 110 | const empty = await get("/keeper/alpha/waves/0.1.0"); | |
| 111 | expect(empty.res.status).toBe(200); | |
| 112 | expect(empty.body).toContain("No pulls to show here."); | |
| 113 | expect(empty.body).not.toMatch(/\/pulls\/\d+/); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a pull without a wave, or with a malformed one, shows no back link", () => { | |
| 117 | expect(backToWave(null)).toBeNull(); | |
| 118 | expect(backToWave("../../[email protected]")).toBeNull(); | |
| 119 | expect(backToWave("a/b@<script>")).toBeNull(); | |
| 120 | expect(backToWave("a/[email protected]")).toEqual({ href: "/a/b/waves/1.0.0", version: "1.0.0" }); | |
| 121 | }); |
test/tui-waves.test.ts +19 −3
| @@ -127,7 +127,7 @@ describe("waves in the terminal", () => { | ||
| 127 | 127 | repo: Repo, |
| 128 | 128 | keys: string[] = [], |
| 129 | 129 | remote = false, |
| 130 | ): Promise<{ text: string; stop(): void }> => { | |
| 130 | ): Promise<{ text: string; after: string[]; stop(): void }> => { | |
| 131 | 131 | const terminal = headlessTerminal(120, 40); |
| 132 | 132 | const { app, session } = createForgeApp( |
| 133 | 133 | forge, |
| @@ -140,11 +140,13 @@ describe("waves in the terminal", () => { | ||
| 140 | 140 | const src = remote ? remoteOf(here) : here; |
| 141 | 141 | app.push(wavesScreen(session, app.ctx, src)); |
| 142 | 142 | await settleApp(app); |
| 143 | const after: string[] = []; | |
| 143 | 144 | for (const k of keys) { |
| 144 | terminal.feed(k === "enter" ? "\r" : k); | |
| 145 | terminal.feed(k === "enter" ? "\r" : k === "esc" ? "\x1b" : k); | |
| 145 | 146 | await settleApp(app); |
| 147 | after.push(app.screen.toText()); | |
| 146 | 148 | } |
| 147 | return { text: app.screen.toText(), stop: () => app.stop() }; | |
| 149 | return { text: app.screen.toText(), after, stop: () => app.stop() }; | |
| 148 | 150 | }; |
| 149 | 151 | |
| 150 | 152 | test("lists the web page's rows in the same order, bad files last", async () => { |
| @@ -186,6 +188,20 @@ describe("waves in the terminal", () => { | ||
| 186 | 188 | expect(outsider.text).not.toContain("/alice/priv/pulls/"); |
| 187 | 189 | }); |
| 188 | 190 | |
| 191 | test("the wave leads to its pulls and back: pick, open the pull, back returns to the wave", async () => { | |
| 192 | const h = await open(alice, mix, ["enter", "n", "enter", "q"]); | |
| 193 | h.stop(); | |
| 194 | const [wave, picked, pull, back] = h.after; | |
| 195 | expect(wave).toContain("> #"); | |
| 196 | expect(picked).toContain("> #"); | |
| 197 | // The second pull is the private one, picked by `n`; the pull screen names it. | |
| 198 | expect(picked).not.toEqual(wave); | |
| 199 | expect(pull).toContain("Secret work"); | |
| 200 | expect(pull).not.toContain("Highlights"); | |
| 201 | expect(back).toContain("Highlights"); | |
| 202 | expect(back).toContain("Shown work"); | |
| 203 | }); | |
| 204 | ||
| 189 | 205 | test("the wave screen names the milestones it moved, as the page does, and not one the viewer may not see", async () => { |
| 190 | 206 | const r = await createRepo(forge, alice, { name: "ms", visibility: "public" }); |
| 191 | 207 | if (!r.ok) throw new Error("repo failed"); |
test/wave-pulls.test.ts +1 −1
| @@ -103,7 +103,7 @@ test("rows give #N title · merged date, link to the pull, and leave out the uns | ||
| 103 | 103 | expect(body).not.toContain("<b>thing"); |
| 104 | 104 | expect(body).toContain("Fix the other"); |
| 105 | 105 | expect(body).toContain("merged 2026-03-04"); |
| 106 | expect(body).toMatch(/href="\/alice\/pub\/pulls\/\d+"[^>]*class="row"|class="row"[^>]*href="\/alice\/pub\/pulls\/\d+"/); | |
| 106 | expect(body).toMatch(/href="\/alice\/pub\/pulls\/\d+(?:\?from=[^"]*)?"[^>]*class="row"|class="row"[^>]*href="\/alice\/pub\/pulls\/\d+(?:\?from=[^"]*)?"/); | |
| 107 | 107 | expect(body).not.toContain("Secret work"); |
| 108 | 108 | expect(body).not.toContain("/alice/priv/pulls/"); |
| 109 | 109 | expect(body).not.toContain("#999"); |
Both doors: the terminal wave screen needs the same wave -> pull -> back walk, built on this helper, with a test.