Public: anyone can read this; the crew can change it.
/search answers a warm query under 50 ms and searches with JavaScript off, and a test holds both #9715
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9705-search-answers-a-warm-query-under-50-ms → main 4 commits fork
1 file
changed +108
−0
test/search-page.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * /search is fast and works with no script (#9705). Speed is held as | |
| 3 | * work (greps run, SQL statements), never a wall clock (#4429). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp } from "../src/app.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { grepCount } from "../src/core/search.ts"; | |
| 12 | import { serve } from "../src/http/server.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 15 | ||
| 16 | const STATEMENT_CEILING = 40; | |
| 17 | let dir = ""; | |
| 18 | let app: ReturnType<typeof createApp>; | |
| 19 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 20 | let bearer = ""; | |
| 21 | ||
| 22 | const get = (path: string) => | |
| 23 | fetch(servers.url + path, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 24 | const post = (path: string, body: unknown) => | |
| 25 | fetch(servers.url + path, { | |
| 26 | method: "POST", | |
| 27 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, | |
| 28 | body: JSON.stringify(body), | |
| 29 | }); | |
| 30 | ||
| 31 | /** The `app` phase of Server-Timing (the handler's own time, not the proxy's), or NaN. Reported, never judged (#4429). */ | |
| 32 | function appMs(res: Response): number { | |
| 33 | const part = (res.headers.get("server-timing") ?? "").split(",").map((p) => p.trim()).find((p) => p.startsWith("app;")); | |
| 34 | return Number(/dur=([\d.]+)/.exec(part ?? "")?.[1] ?? "NaN"); | |
| 35 | } | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "forge-search-page-")); | |
| 39 | app = createApp({ dataDir: dir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | const made = await seedAdmin(app, { | |
| 42 | username: "alice", email: "[email protected]", password: "a-good-passphrase", | |
| 43 | }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | bearer = createToken(app.db, made.value.id, "t", "admin").token; | |
| 46 | // Every repo has a commit: an empty repo has no oid to key the grep cache on. | |
| 47 | for (const name of ["flux-engine", "flux-notes", "plain-repo"]) { | |
| 48 | await post("/api/v1/repos", { | |
| 49 | name, visibility: "public", description: name === "plain-repo" ? "nothing here" : "a flux thing", | |
| 50 | }); | |
| 51 | await post(`/api/v1/repos/alice/${name}/contents`, { | |
| 52 | branch: "main", | |
| 53 | message: "first", | |
| 54 | files: [{ path: "src/flux.ts", content: "export const flux = 1;\n" }], | |
| 55 | }); | |
| 56 | } | |
| 57 | for (const title of ["flux capacitor overheats", "flux docs are thin", "unrelated"]) { | |
| 58 | await post("/api/v1/repos/alice/flux-engine/issues", { title, body: "" }); | |
| 59 | } | |
| 60 | }); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a warm query is cache-shaped: no grep, and a bounded number of statements", async () => { | |
| 69 | const first = await get("/search?q=flux"); | |
| 70 | expect(first.status).toBe(200); | |
| 71 | await first.text(); | |
| 72 | const ran = grepCount(); | |
| 73 | const second = await get("/search?q=flux"); | |
| 74 | const body = await second.text(); | |
| 75 | expect(second.status).toBe(200); | |
| 76 | expect(body).toContain("flux-engine"); | |
| 77 | // The 50 ms bound, as work (#4429): a lost cache re-spawns git grep, and a | |
| 78 | // page that goes N+1 raises the statement count; both fail on any runner. | |
| 79 | expectWork(grepCount() - ran, { max: 0 }); | |
| 80 | expectWork(await requestStatements(app.db, servers.url + "/search?q=flux", { headers: { authorization: `Bearer ${bearer}` } }), { max: STATEMENT_CEILING }); | |
| 81 | expectWork(grepCount() - ran, { max: 0 }); | |
| 82 | // The app phase is sent (and is the one to read, not a sum with the proxy's). | |
| 83 | expect(appMs(second)).not.toBeNaN(); | |
| 84 | }); | |
| 85 | ||
| 86 | test("the form is a plain GET form", async () => { | |
| 87 | const html = await (await get("/search?q=flux")).text(); | |
| 88 | const form = /<form\b[^>]*role="search"[^>]*>/.exec(html)?.[0] ?? ""; | |
| 89 | expect(form).toMatch(/method="get"/i); | |
| 90 | expect(form).toMatch(/action="\/search"/); | |
| 91 | expect(html).toMatch(/<input[^>]*type="search"[^>]*name="q"|<input[^>]*name="q"[^>]*type="search"/); | |
| 92 | expect(html).toMatch(/<button[^>]*type="submit"/); | |
| 93 | }); | |
| 94 | ||
| 95 | test("results are in the server's HTML with every script stripped", async () => { | |
| 96 | const html = (await (await get("/search?q=flux")).text()).replace(/<script\b[\s\S]*?<\/script>/gi, ""); | |
| 97 | expect(html).not.toMatch(/<script\b/i); | |
| 98 | const hrefs = [...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1] ?? ""); | |
| 99 | // A result row is a link to the thing itself: repos, issues and code. | |
| 100 | expect(hrefs).toContain("/alice/flux-engine"); | |
| 101 | expect(hrefs).toContain("/alice/flux-notes"); | |
| 102 | expect(hrefs.some((h) => h.startsWith("/alice/flux-engine/issues/"))).toBe(true); | |
| 103 | expect(hrefs.some((h) => h.startsWith("/alice/flux-engine/") && h.includes("src/flux.ts"))).toBe(true); | |
| 104 | expect(html).toContain("flux capacitor overheats"); | |
| 105 | // plain-repo matches by its code only: it has a code row, and no repo row. | |
| 106 | expect(hrefs).not.toContain("/alice/plain-repo"); | |
| 107 | expect(hrefs.some((h) => h.startsWith("/alice/plain-repo/blob/"))).toBe(true); | |
| 108 | }); |