Public: anyone can read this; the crew can change it.
/:owner/:name/waves/<version> renders one wave as a note: headline, highlights with their why, merged pulls as links #2091
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1856-owner-name-waves-version-renders-one-wav → main 4 commits fork 3 threads · 3 unresolved
8 files
changed +219
−26
README.md +4 −3
| @@ -980,9 +980,10 @@ work here. | ||
| 980 | 980 | placeholders; no model is called. It refuses, writing nothing, when an |
| 981 | 981 | earlier wave does not parse (naming the file) or nothing new has merged. |
| 982 | 982 | `/:owner/:name/waves` lists the waves on the default branch, newest version |
| 983 | first, each linking to its file (linked from that settings card); in the | |
| 984 | terminal, `V` in a repository opens the same list, and Enter on a row opens | |
| 985 | its `waves/<file>` in the file viewer (a live browse of another forge says | |
| 983 | first, each line opening `/:owner/:name/waves/<version>` (headline, date, | |
| 984 | highlights with why, pulls as links; linked from that settings card); in the | |
| 985 | terminal, `V` in a repository opens the same list and Enter (or a click) on | |
| 986 | a row opens the same wave as plain text (a live browse of another forge says | |
| 986 | 987 | waves are only read on this forge). |
| 987 | 988 | |
| 988 | 989 | - **Accounts & access** — register/sign in, personal access tokens with coarse |
src/http/server.ts +2 −1
| @@ -169,7 +169,7 @@ import { | ||
| 169 | 169 | branchDelete, |
| 170 | 170 | branchesPage, |
| 171 | 171 | } from "../web/pages/branches.ts"; |
| 172 | import { wavesPage } from "../web/pages/waves.ts"; | |
| 172 | import { wavePage, wavesPage } from "../web/pages/waves.ts"; | |
| 173 | 173 | import { |
| 174 | 174 | contributeSubmit, |
| 175 | 175 | deleteFilePage, |
| @@ -1152,6 +1152,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1152 | 1152 | router.add("GET", "/:owner/:name/activity.atom", activityAtom); |
| 1153 | 1153 | router.add("GET", "/:owner/:name/branches", branchesPage); |
| 1154 | 1154 | router.add("GET", "/:owner/:name/waves", wavesPage); |
| 1155 | router.add("GET", "/:owner/:name/waves/:version", wavePage); | |
| 1155 | 1156 | router.add("POST", "/:owner/:name/branches", branchCreate); |
| 1156 | 1157 | router.add("POST", "/:owner/:name/branches/:branch/delete", branchDelete); |
| 1157 | 1158 |
src/story/wave.ts +2 −1
| @@ -40,7 +40,8 @@ export type WaveResult<T> = | ||
| 40 | 40 | export const MIN_HIGHLIGHTS = 3; |
| 41 | 41 | export const MAX_HIGHLIGHTS = 5; |
| 42 | 42 | |
| 43 | const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; | |
| 43 | /** The shape of a wave's version, and of its file name before `.md`. */ | |
| 44 | export const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; | |
| 44 | 45 | const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; |
| 45 | 46 | const SEP = " — "; |
| 46 | 47 |
src/story/waves.ts +23 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { readerFor, listTree } from "../git/read.ts"; |
| 9 | import { parseWave, type Wave } from "./wave.ts"; | |
| 9 | import { parseWave, VERSION as WAVE_VERSION, type Wave } from "./wave.ts"; | |
| 10 | 10 | |
| 11 | 11 | /** A page, not an archive: more files than this are not read. */ |
| 12 | 12 | const MAX_FILES = 300; |
| @@ -62,6 +62,28 @@ export function byVersionDesc(a: string, b: string): number { | ||
| 62 | 62 | return y.pre.length - x.pre.length; |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | /** One wave file by version, or null when the version is malformed or absent. */ | |
| 66 | export async function readWave( | |
| 67 | dir: string, | |
| 68 | branch: string, | |
| 69 | version: string, | |
| 70 | ): Promise<WaveEntry | null> { | |
| 71 | if (!WAVE_VERSION.test(version)) return null; | |
| 72 | const file = `${version}.md`; | |
| 73 | const path = `waves/${file}`; | |
| 74 | const tree = await listTree(dir, branch, "waves"); | |
| 75 | if (!tree.ok) return null; | |
| 76 | const f = tree.value.find((e) => e.type === "blob" && e.name === file); | |
| 77 | if (!f) return null; | |
| 78 | if (f.size > MAX_BYTES) return { ok: false, file, reason: `${path}: file is too large` }; | |
| 79 | const obj = await readerFor(dir).object(f.oid); | |
| 80 | if (!obj.ok) return { ok: false, file, reason: `${path}: unreadable` }; | |
| 81 | const parsed = parseWave(path, new TextDecoder().decode(obj.value.body)); | |
| 82 | return parsed.ok | |
| 83 | ? { ok: true, file, wave: parsed.value } | |
| 84 | : { ok: false, file, reason: parsed.error.message }; | |
| 85 | } | |
| 86 | ||
| 65 | 87 | /** Every wave file on `branch`, parsed; good ones newest first, then the bad. */ |
| 66 | 88 | export async function readWaves( |
| 67 | 89 | dir: string, |
src/tui/screens/waves.ts +54 −7
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | * A repository's waves: the same rows, in the same order, as the web page, |
| 3 | 3 | * read through `story/waves.ts`. Local repositories only — a live browse of |
| 4 | 4 | * another forge has no `waves/` we can read here, and says so. Enter on a row |
| 5 | * opens its file in the workspace viewer on the default branch. | |
| 5 | * opens that wave as plain text. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { canRead } from "../../core/repos.ts"; |
| 9 | import { readWaves, type WaveEntry } from "../../story/waves.ts"; | |
| 9 | import { VERSION } from "../../story/wave.ts"; | |
| 10 | import { readWave, readWaves, type WaveEntry } from "../../story/waves.ts"; | |
| 10 | 11 | import type { RepoSource } from "../../view/source.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding } from "../app/keys.ts"; |
| @@ -15,8 +16,7 @@ import { truncate } from "../term/width.ts"; | ||
| 15 | 16 | import { fill, len, split } from "../ui/layout.ts"; |
| 16 | 17 | import { emptyState } from "../ui/widgets.ts"; |
| 17 | 18 | import type { Session } from "../session.ts"; |
| 18 | import { ListState, drawList, hitList, plural, runAsync } from "./common.ts"; | |
| 19 | import { blobScreen } from "./workspace.ts"; | |
| 19 | import { ListState, Scroll, drawList, drawLines, hitList, plural, runAsync } from "./common.ts"; | |
| 20 | 20 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; |
| 21 | 21 | |
| 22 | 22 | /** One line per wave, as the screen draws it. */ |
| @@ -32,6 +32,47 @@ export function waveRows(entries: WaveEntry[]) { | ||
| 32 | 32 | ); |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | /** Wave text is someone's prose: no escape, control or newline survives into the terminal. */ | |
| 36 | const plain = (s: string): string => | |
| 37 | // eslint-disable-next-line no-control-regex | |
| 38 | s.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]+/g, " "); | |
| 39 | ||
| 40 | /** One wave as lines of plain text: headline, version and date, highlights with why, pulls as title and URL. */ | |
| 41 | export function waveLines(entry: WaveEntry): string[] { | |
| 42 | if (!entry.ok) return [plain(entry.file), "", `Not a wave: ${plain(entry.reason)}`]; | |
| 43 | const w = entry.wave; | |
| 44 | return [ | |
| 45 | plain(w.headline), | |
| 46 | `${plain(w.version)} ${plain(w.date)}`, | |
| 47 | "", | |
| 48 | "Highlights", | |
| 49 | ...w.highlights.map((h) => ` ${plain(h.title)} - ${plain(h.why)}`), | |
| 50 | "", | |
| 51 | "Pulls", | |
| 52 | ...w.pulls.flatMap((p) => [` ${plain(p.title)}`, ` ${plain(p.url)}`]), | |
| 53 | ]; | |
| 54 | } | |
| 55 | ||
| 56 | function waveScreen(ctx: Ctx, entry: WaveEntry): Screen { | |
| 57 | const lines = waveLines(entry); | |
| 58 | const scroll = new Scroll(); | |
| 59 | return { | |
| 60 | title: entry.ok ? entry.wave.version : entry.file, | |
| 61 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], | |
| 62 | render(area, s) { | |
| 63 | const c = ctx.theme.c; | |
| 64 | drawLines( | |
| 65 | s, | |
| 66 | area, | |
| 67 | c, | |
| 68 | scroll, | |
| 69 | lines.map((text) => [{ text, style: style(c.fg, c.bg) }]), | |
| 70 | ); | |
| 71 | }, | |
| 72 | key: (e) => scroll.key(e, lines.length), | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 35 | 76 | export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { |
| 36 | 77 | const repo = localRepo(src); |
| 37 | 78 | let entries: WaveEntry[] = []; |
| @@ -41,9 +82,15 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 41 | 82 | const openAt = (i: number): void => { |
| 42 | 83 | const entry = entries[i]; |
| 43 | 84 | if (entry === undefined || repo === undefined) return; |
| 44 | ctx.push( | |
| 45 | blobScreen(f, ctx, src, repo.defaultBranch, `waves/${entry.file}`), | |
| 46 | ); | |
| 85 | runAsync(ctx, async () => { | |
| 86 | // Same gate as the list, re-checked: the screen outlives a change of access. | |
| 87 | if (!canRead(f.db, repo, f.user)) return; | |
| 88 | const version = entry.file.slice(0, -3); | |
| 89 | const one = VERSION.test(version) | |
| 90 | ? await readWave(f.dir(repo), repo.defaultBranch, version) | |
| 91 | : entry; | |
| 92 | if (one) ctx.push(waveScreen(ctx, one)); | |
| 93 | }); | |
| 47 | 94 | }; |
| 48 | 95 | |
| 49 | 96 | const reload = async (): Promise<void> => { |
src/web/pages/waves.ts +53 −2
| @@ -8,7 +8,8 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { repoDir } from "../../git/repos.ts"; |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | import { readWaves } from "../../story/waves.ts"; | |
| 11 | import { VERSION } from "../../story/wave.ts"; | |
| 12 | import { readWave, readWaves } from "../../story/waves.ts"; | |
| 12 | 13 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 13 | 14 | import { html } from "../html.ts"; |
| 14 | 15 | import { page } from "../layout.ts"; |
| @@ -26,7 +27,9 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 26 | 27 | branch, |
| 27 | 28 | ); |
| 28 | 29 | const fileHref = (file: string): string => |
| 29 | `${repoBase(repo)}/blob/${encodeURIComponent(branch)}/waves/${encodeURIComponent(file)}`; | |
| 30 | VERSION.test(file.slice(0, -3)) | |
| 31 | ? `${repoBase(repo)}/waves/${encodeURIComponent(file.slice(0, -3))}` | |
| 32 | : `${repoBase(repo)}/blob/${encodeURIComponent(branch)}/waves/${encodeURIComponent(file)}`; | |
| 30 | 33 | |
| 31 | 34 | const rows = entries.map((e) => |
| 32 | 35 | e.ok |
| @@ -57,3 +60,51 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 57 | 60 | body, |
| 58 | 61 | }); |
| 59 | 62 | } |
| 63 | ||
| 64 | /** Only http(s) becomes a link; anything else is shown as plain text. */ | |
| 65 | export const webUrl = (s: string): boolean => /^https?:\/\//i.test(s); | |
| 66 | ||
| 67 | // GET /:owner/:name/waves/:version | |
The terminal needs the same view: |
||
| 68 | export async function wavePage(ctx: Ctx): Promise<Response> { | |
| 69 | const repo = loadRepo(ctx); | |
| 70 | if (!repo) return notFoundPage(ctx); | |
| 71 | const entry = await readWave( | |
| 72 | repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 73 | repo.defaultBranch, | |
| 74 | ctx.params.version ?? "", | |
| 75 | ); | |
| 76 | if (!entry) return notFoundPage(ctx); | |
| 77 | const scope = localScope(ctx, repo); | |
| 78 | const back = html`<a href="${repoBase(repo)}/waves">All waves</a>`; | |
| 79 | ||
| 80 | const content = entry.ok | |
| 81 | ? html`<h1>${entry.wave.headline}</h1> | |
| 82 | <p class="muted">${entry.wave.version} · ${entry.wave.date}</p> | |
| 83 | <h2>Highlights</h2> | |
| 84 | <ul> | |
| 85 | ${entry.wave.highlights.map( | |
| 86 | (h) => html`<li><strong>${h.title}</strong> — ${h.why}</li>`, | |
| 87 | )} | |
| 88 | </ul> | |
| 89 | <h2>Pulls</h2> | |
| 90 | <ul> | |
| 91 | ${entry.wave.pulls.map((p) => | |
| 92 | webUrl(p.url) | |
| 93 | ? html`<li><a href="${p.url}" rel="noopener noreferrer">${p.title}</a></li>` | |
Good: only http(s) becomes an |
||
| 94 | : html`<li>${p.title}</li>`, | |
| 95 | )} | |
| 96 | </ul>` | |
| 97 | : html`<h1>${entry.file}</h1> | |
| 98 | <p class="muted">Not a wave: ${entry.reason}</p>`; | |
| 99 | ||
| 100 | const body = html`${scopeHeader(ctx, scope, "code", { compact: true })} | |
| 101 | <div class="mt">${content}<p>${back}</p></div>`; | |
| 102 | ||
| 103 | return page({ | |
| 104 | ctx, | |
| 105 | title: `${entry.ok ? entry.wave.version : entry.file} · Waves · ${scope.facts.name}`, | |
| 106 | crumbs: scope.crumbs, | |
| 107 | palette: scope.palette, | |
| 108 | body, | |
| 109 | }); | |
| 110 | } |
test/tui-waves.test.ts +38 −10
| @@ -9,7 +9,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 9 | 9 | import type { User } from "../src/core/users.ts"; |
| 10 | 10 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 11 | 11 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 12 | import { wavesScreen } from "../src/tui/screens/waves.ts"; | |
| 12 | import { waveLines, wavesScreen } from "../src/tui/screens/waves.ts"; | |
| 13 | 13 | import type { RepoSource } from "../src/view/source.ts"; |
| 14 | 14 | import { local } from "../src/tui/screens/where.ts"; |
| 15 | 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -95,8 +95,9 @@ describe("waves in the terminal", () => { | ||
| 95 | 95 | const open = async ( |
| 96 | 96 | user: User, |
| 97 | 97 | repo: Repo, |
| 98 | keys: string[] = [], | |
| 98 | 99 | remote = false, |
| 99 | ): Promise<{ text: string; stop(): void; app: ReturnType<typeof createForgeApp>["app"] }> => { | |
| 100 | ): Promise<{ text: string; stop(): void }> => { | |
| 100 | 101 | const terminal = headlessTerminal(120, 40); |
| 101 | 102 | const { app, session } = createForgeApp( |
| 102 | 103 | forge, |
| @@ -109,7 +110,11 @@ describe("waves in the terminal", () => { | ||
| 109 | 110 | const src = remote ? remoteOf(here) : here; |
| 110 | 111 | app.push(wavesScreen(session, app.ctx, src)); |
| 111 | 112 | await settleApp(app); |
| 112 | return { text: app.screen.toText(), stop: () => app.stop(), app }; | |
| 113 | for (const k of keys) { | |
| 114 | terminal.feed(k === "enter" ? "\r" : k); | |
| 115 | await settleApp(app); | |
| 116 | } | |
| 117 | return { text: app.screen.toText(), stop: () => app.stop() }; | |
| 113 | 118 | }; |
| 114 | 119 | |
| 115 | 120 | test("lists the web page's rows in the same order, bad files last", async () => { |
| @@ -129,17 +134,40 @@ describe("waves in the terminal", () => { | ||
| 129 | 134 | expect(h.text).toContain("No waves yet"); |
| 130 | 135 | }); |
| 131 | 136 | |
| 132 | test("Enter on a wave row opens its file in the viewer", async () => { | |
| 133 | const h = await open(alice, pub); | |
| 134 | h.app.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 135 | await settleApp(h.app); | |
| 136 | const after = h.app.screen.toText(); | |
| 137 | test("enter opens one wave: headline, version, date, highlights with why, pulls", async () => { | |
| 138 | const h = await open(alice, pub, ["enter"]); | |
| 137 | 139 | h.stop(); |
| 138 | expect(after).toContain("0.10.0.md"); | |
| 140 | expect(h.text).toContain("Tenth"); | |
| 141 | expect(h.text).toContain("0.10.0 2026-02-01"); | |
| 142 | expect(h.text).toContain("Thing 1 - because"); | |
| 143 | expect(h.text).toContain("#1 A pull"); | |
| 144 | expect(h.text).toContain("https://forge.example/o/r/pulls/1"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("enter on an unparsable row shows its reason", async () => { | |
| 148 | const h = await open(alice, pub, ["j", "j", "enter"]); | |
| 149 | h.stop(); | |
| 150 | expect(h.text).toContain("broken.md"); | |
| 151 | expect(h.text).toContain("Not a wave:"); | |
| 152 | }); | |
| 153 | ||
| 154 | test("wave text carries no escape or control characters", () => { | |
| 155 | const lines = waveLines({ | |
| 156 | ok: true, | |
| 157 | file: "1.0.0.md", | |
| 158 | wave: { | |
| 159 | version: "1.0.0", | |
| 160 | date: "2026-01-01", | |
| 161 | headline: "Hi\u001b[31m red\u0007\u202e", | |
| 162 | highlights: [{ title: "T\u001b]0;x\u0007", why: "w\nz" }], | |
| 163 | pulls: [{ title: "P", url: "https://x.example/\u001b[2J" }], | |
| 164 | }, | |
| 165 | }); | |
| 166 | expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/); | |
| 139 | 167 | }); |
| 140 | 168 | |
| 141 | 169 | test("a live browse says waves are only read on this forge", async () => { |
| 142 | const h = await open(alice, pub, true); | |
| 170 | const h = await open(alice, pub, [], true); | |
| 143 | 171 | h.stop(); |
| 144 | 172 | expect(h.text).toContain("only read on this forge"); |
| 145 | 173 | expect(h.text).not.toContain("no waves/ directory"); |
test/waves-page.test.ts +43 −1
| @@ -8,6 +8,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 8 | 8 | import { createSession } from "../src/core/sessions.ts"; |
| 9 | 9 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { webUrl } from "../src/web/pages/waves.ts"; | |
| 11 | 12 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 12 | 13 | |
| 13 | 14 | const PASSWORD = "a-good-passphrase"; |
| @@ -68,6 +69,9 @@ describe("waves page", () => { | ||
| 68 | 69 | { path: "waves/0.9.0.md", content: text(wave("0.9.0", "2026-01-01", "Ninth")) }, |
| 69 | 70 | { path: "waves/0.10.0.md", content: text(wave("0.10.0", "2026-02-01", "Tenth")) }, |
| 70 | 71 | { path: "waves/0.2.0.md", content: text(wave("0.2.0", "2025-12-01", "<script>alert(1)</script> & Zeta")) }, |
| 72 | { path: "waves/0.3.0.md", content: text({ ...wave("0.3.0", "2025-12-15", "Evil <b>links</b>"), highlights: [{ title: "T<i>1</i>", why: "why <u>one</u>" }, { title: "Two", why: "w" }, { title: "Three", why: "w" }] }) }, | |
| 73 | { path: "waves/0.5.0.md", content: text(wave("0.5.0", "2025-12-16", "Js")).replace("https://forge.example/o/r/pulls/1", "javascript:alert(1)") }, | |
| 74 | { path: "waves/0.4.0.md", content: "no front matter here\n" }, | |
| 71 | 75 | { path: "waves/broken.md", content: "no front matter here\n" }, |
| 72 | 76 | ]); |
| 73 | 77 | await put(priv, [ |
| @@ -91,7 +95,7 @@ describe("waves page", () => { | ||
| 91 | 95 | expect(at("Ninth")).toBeLessThan(at("Zeta")); |
| 92 | 96 | expect(body).toContain("0.10.0"); |
| 93 | 97 | expect(body).toContain("2026-02-01"); |
| 94 | expect(body).toContain("/alice/pub/blob/main/waves/0.10.0.md"); | |
| 98 | expect(body).toContain('href="/alice/pub/waves/0.10.0"'); | |
| 95 | 99 | }); |
| 96 | 100 | |
| 97 | 101 | test("a headline with markup comes out inert", async () => { |
| @@ -127,4 +131,42 @@ describe("waves page", () => { | ||
| 127 | 131 | const owner = await get("/alice/priv/waves"); |
| 128 | 132 | expect(await owner.text()).toContain("Secret launch"); |
| 129 | 133 | }); |
| 134 | ||
| 135 | test("one wave renders headline, date, highlights with why, pulls as links", async () => { | |
| 136 | const res = await get("/alice/pub/waves/0.10.0"); | |
| 137 | expect(res.status).toBe(200); | |
| 138 | const body = await res.text(); | |
| 139 | expect(body).toContain("Tenth"); | |
| 140 | expect(body).toContain("2026-02-01"); | |
| 141 | expect(body).toContain("Thing 1"); | |
| 142 | expect(body).toContain("because"); | |
| 143 | expect(body).toContain('href="https://forge.example/o/r/pulls/1"'); | |
| 144 | }); | |
| 145 | ||
| 146 | test("values are escaped and a javascript: pull is not a link", async () => { | |
| 147 | const body = await (await get("/alice/pub/waves/0.3.0")).text(); | |
| 148 | expect(body).toContain("Evil <b>links</b>"); | |
| 149 | expect(body).toContain("why <u>one</u>"); | |
| 150 | expect(body).not.toContain("<b>links"); | |
| 151 | const js = await (await get("/alice/pub/waves/0.5.0")).text(); | |
| 152 | expect(js).not.toContain('href="javascript:'); | |
| 153 | expect(webUrl("javascript:alert(1)")).toBe(false); | |
| 154 | expect(webUrl("https://x.example/p/1")).toBe(true); | |
| 155 | }); | |
| 156 | ||
| 157 | test("an unknown or malformed version is a 404; an unparsable file shows its reason", async () => { | |
| 158 | expect((await get("/alice/pub/waves/9.9.9")).status).toBe(404); | |
| 159 | expect((await get("/alice/pub/waves/nope")).status).toBe(404); | |
| 160 | expect((await get("/alice/pub/waves/..%2Fx")).status).toBe(404); | |
| 161 | const bad = await get("/alice/pub/waves/0.4.0"); | |
| 162 | expect(bad.status).toBe(200); | |
| 163 | expect(await bad.text()).toContain("missing front matter"); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a private repository's wave is refused to an outsider", async () => { | |
| 167 | const out = await get("/alice/priv/waves/1.0.0", false); | |
| 168 | expect(out.status).toBe(404); | |
| 169 | expect(await out.text()).not.toContain("Secret launch"); | |
| 170 | expect(await (await get("/alice/priv/waves/1.0.0")).text()).toContain("Secret launch"); | |
| 171 | }); | |
| 130 | 172 | }); |
new above
offline
Nit: consider adding the bidi controls (\u202a-\u202e, \u2066-\u2069) to this class so prose cannot reorder what is shown on the line. Not blocking.