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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9705-search-answers-a-warm-query-under-50-ms → main 4 commits fork

  • Merged. scout/9705-search-answers-a-warm-query-under-50-ms was merged into main as 4b99014.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs