Public: anyone can read this; the crew can change it.
Finding things in the studio: the search box and the palette look for apps, changes and tells #131
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/87-finding-things-in-the-studio-the-search → main 1 commit fork
6 files
changed +419
−6
src/web/layout.ts +28 −4
| @@ -252,7 +252,7 @@ function helpButton(): Html { | ||
| 252 | 252 | |
| 253 | 253 | // ---- palette index ---- |
| 254 | 254 | |
| 255 | const STATIC_PALETTE: PaletteItem[] = [ | |
| 255 | const STATIC_PALETTE_DEV: PaletteItem[] = [ | |
| 256 | 256 | { t: "Home", h: "/", c: "Go to" }, |
| 257 | 257 | { t: "Apps", h: "/apps", c: "Go to" }, |
| 258 | 258 | { t: "Map", h: "/map", c: "Go to" }, |
| @@ -266,22 +266,46 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 266 | 266 | { t: "Origins", h: "/settings/origins", c: "Settings" }, |
| 267 | 267 | ]; |
| 268 | 268 | |
| 269 | /** | |
| 270 | * The studio's own verbs: no "New repository" or "New organization" — dev's | |
| 271 | * own way of making one, which would land a studio reader on dev's own form | |
| 272 | * unannounced. "Asks you" goes to the home, where the asks panel lives. | |
| 273 | */ | |
| 274 | const STATIC_PALETTE_STUDIO: PaletteItem[] = [ | |
| 275 | { t: "Home", h: "/", c: "Go to" }, | |
| 276 | { t: "Apps", h: "/apps", c: "Go to" }, | |
| 277 | { t: "Map", h: "/map", c: "Go to" }, | |
| 278 | { t: "Asks you", h: "/", c: "Go to" }, | |
| 279 | { t: "New app", h: "/apps/new", c: "Create" }, | |
| 280 | { t: "Search", h: "/search", c: "Go to" }, | |
| 281 | { t: "Account settings", h: "/settings", c: "Settings" }, | |
| 282 | { t: "Access tokens", h: "/settings/tokens", c: "Settings" }, | |
| 283 | { t: "SSH keys", h: "/settings/keys", c: "Settings" }, | |
| 284 | { t: "Origins", h: "/settings/origins", c: "Settings" }, | |
| 285 | ]; | |
| 286 | ||
| 269 | 287 | /** |
| 270 | 288 | * The palette index rides as a `<script type="application/json">` data block. |
| 271 | 289 | * The HTML spec stops parsing such a block as script, so the CSP never |
| 272 | 290 | * applies and no nonce is needed — but `</script` must not appear inside it, |
| 273 | 291 | * which escaping `<` guarantees. |
| 292 | * | |
| 293 | * In the studio the by-name rows are apps, not every repository — the same | |
| 294 | * visibility (`appsOf` is `listReposVisibleTo` underneath), just apps first. | |
| 274 | 295 | */ |
| 275 | 296 | function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { |
| 276 | const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 30 }); | |
| 297 | const studio = ctx.mode === "studio"; | |
| 298 | const repos = studio | |
| 299 | ? appsOf(ctx, ctx.auth.user).repos.slice(0, 30) | |
| 300 | : listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 30 }); | |
| 277 | 301 | const items: PaletteItem[] = [ |
| 278 | 302 | ...extra, |
| 279 | 303 | ...repos.map((r) => ({ |
| 280 | 304 | t: `${r.ownerName}/${r.name}`, |
| 281 | 305 | h: `/${r.ownerName}/${r.name}`, |
| 282 | c: "Repositories", | |
| 306 | c: studio ? "Apps" : "Repositories", | |
| 283 | 307 | })), |
| 284 | ...STATIC_PALETTE, | |
| 308 | ...(studio ? STATIC_PALETTE_STUDIO : STATIC_PALETTE_DEV), | |
| 285 | 309 | ]; |
| 286 | 310 | const json = JSON.stringify({ items }).replaceAll("<", "\\u003c"); |
| 287 | 311 | return raw( |
src/web/pages/search.ts +8
| @@ -24,6 +24,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 24 | 24 | import { icon } from "../icons.ts"; |
| 25 | 25 | import { page } from "../layout.ts"; |
| 26 | 26 | import { browseBase } from "../scope.ts"; |
| 27 | import { studioSearchPage } from "../studio/search.ts"; | |
| 27 | 28 | import { |
| 28 | 29 | badge, |
| 29 | 30 | btn, |
| @@ -229,6 +230,13 @@ function section( | ||
| 229 | 230 | * connected origin, which is not a thing to do on every keystroke. |
| 230 | 231 | */ |
| 231 | 232 | export async function searchPage(ctx: Ctx): Promise<Response> { |
| 233 | // In the studio a search is over apps, changes and tells, in the studio's | |
| 234 | // own words — the same rule every other studio page follows. `?mode=dev` | |
| 235 | // is the one-off way to dev's box without switching the browser. | |
| 236 | const studio = | |
| 237 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 238 | if (studio) return studioSearchPage(ctx); | |
| 239 | ||
| 232 | 240 | const params = ctx.url.searchParams; |
| 233 | 241 | const query = (params.get("q") ?? "").trim(); |
| 234 | 242 | const kind = searchKind(params.get("kind")); |
src/web/studio/asks.ts +3 −2
| @@ -35,8 +35,9 @@ import { btn, card, icon, row } from "../ui.ts"; | ||
| 35 | 35 | import { lookThumb, lookUrl } from "../pages/looks.ts"; |
| 36 | 36 | import { leanNote, leanVariant } from "./leans.ts"; |
| 37 | 37 | |
| 38 | /** The apps a person can see, as repositories, with their records. */ | |
| 39 | export function appsOf(ctx: Ctx, user: User) { | |
| 38 | /** The apps a person can see, as repositories, with their records — the | |
| 39 | * same visibility a signed-out reader gets from `listReposVisibleTo` too. */ | |
| 40 | export function appsOf(ctx: Ctx, user: User | undefined) { | |
| 40 | 41 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 }); |
| 41 | 42 | const records = appsForRepos( |
| 42 | 43 | ctx.app.db, |
src/web/studio/search.ts added +208
| @@ -0,0 +1,208 @@ | ||
| 1 | /** | |
| 2 | * Finding things in the studio: apps first, then the changes and tells that | |
| 3 | * mention the query. The same route as dev's own `/search` (`pages/search.ts` | |
| 4 | * delegates here in studio mode) and the same visibility — `appsOf` is | |
| 5 | * `listReposVisibleTo` underneath, so a signed-out reader sees exactly the | |
| 6 | * public apps a signed-out reader of dev's search would. | |
| 7 | * | |
| 8 | * Bounded, on purpose: one read for the apps (`appsOf`'s own 200-row cap, | |
| 9 | * matched in memory — no per-row query) and one for the changes and tells | |
| 10 | * together (`searchLocalIssues`, one `repo_id IN (...)` query), split by | |
| 11 | * kind after the fact. Nothing here does a per-row query or a git read — | |
| 12 | * that is what keeps a keystroke here as cheap as one in dev's own box. | |
| 13 | */ | |
| 14 | ||
| 15 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 16 | import type { AppRecord } from "../../core/apps.ts"; | |
| 17 | import type { Repo } from "../../core/repos.ts"; | |
| 18 | import { cleanQuery, searchLocalIssues, type IssueHit } from "../../core/search.ts"; | |
| 19 | import type { Ctx } from "../../http/context.ts"; | |
| 20 | import { pageHead, repoBase } from "../chrome.ts"; | |
| 21 | import { html, when, type Html } from "../html.ts"; | |
| 22 | import { page } from "../layout.ts"; | |
| 23 | import { markMatch } from "../pages/search.ts"; | |
| 24 | import { | |
| 25 | badge, | |
| 26 | dot, | |
| 27 | empty, | |
| 28 | icon, | |
| 29 | listCard, | |
| 30 | row, | |
| 31 | stateBadge, | |
| 32 | time, | |
| 33 | type Tone, | |
| 34 | } from "../ui.ts"; | |
| 35 | import { appsOf } from "./asks.ts"; | |
| 36 | import { studioCss } from "./style.ts"; | |
| 37 | ||
| 38 | const APP_LIMIT = 20; | |
| 39 | const CHANGE_LIMIT = 20; | |
| 40 | const TELL_LIMIT = 20; | |
| 41 | /** The raw read behind both: split by kind after, not asked for twice. */ | |
| 42 | const ISSUE_READ_LIMIT = CHANGE_LIMIT + TELL_LIMIT; | |
| 43 | ||
| 44 | function matchesApp(repo: Repo, needle: string): boolean { | |
| 45 | const q = needle.toLowerCase(); | |
| 46 | return ( | |
| 47 | repo.name.toLowerCase().includes(q) || | |
| 48 | repo.ownerName.toLowerCase().includes(q) || | |
| 49 | repo.description.toLowerCase().includes(q) | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| 53 | /** One app row: its name, one clamped line, and whether it is live. No | |
| 54 | * per-row query — `runtime.status` reads what is already in memory. */ | |
| 55 | function appRow(ctx: Ctx, repo: Repo, record: AppRecord, query: string): Html { | |
| 56 | const runtime = appsRuntime(ctx.app); | |
| 57 | const envs = runtime === undefined ? [] : runtime.status(record); | |
| 58 | const production = envs.find((e) => e.env === "production"); | |
| 59 | const state = production?.state ?? "stopped"; | |
| 60 | const tone: Tone = | |
| 61 | state === "running" ? "success" : state === "failed" ? "danger" : "default"; | |
| 62 | return row({ | |
| 63 | href: repoBase(repo), | |
| 64 | nav: true, | |
| 65 | lead: dot(tone), | |
| 66 | title: markMatch(repo.name, query), | |
| 67 | titleAttr: repo.description || repo.name, | |
| 68 | meta: repo.description | |
| 69 | ? markMatch(repo.description, query) | |
| 70 | : html`<span class="muted">No description</span>`, | |
| 71 | trail: badge( | |
| 72 | state === "running" ? "live" : state === "stopped" ? "not running" : state, | |
| 73 | { tone }, | |
| 74 | ), | |
| 75 | }); | |
| 76 | } | |
| 77 | ||
| 78 | type LocalIssueHit = IssueHit & { source: "local" }; | |
| 79 | ||
| 80 | function issueHitHref(hit: LocalIssueHit): string { | |
| 81 | const segment = hit.kind === "issue" ? "issues" : "pulls"; | |
| 82 | return `/${hit.repo.owner}/${hit.repo.name}/${segment}/${hit.number}`; | |
| 83 | } | |
| 84 | ||
| 85 | /** A change or a tell: which app it belongs to, and its own studio link — | |
| 86 | * never the five-word badge (`pullWord`/`tellWord`), which reads git and a | |
| 87 | * run per row and would make a keystroke here as slow as a page of them. */ | |
| 88 | function issueHitRow(hit: LocalIssueHit, query: string): Html { | |
| 89 | return row({ | |
| 90 | href: issueHitHref(hit), | |
| 91 | nav: true, | |
| 92 | lead: icon(hit.kind === "issue" ? "issue" : "pull"), | |
| 93 | title: markMatch(hit.title, query), | |
| 94 | titleAttr: hit.title, | |
| 95 | meta: html`${hit.repo.owner}/${hit.repo.name} · updated | |
| 96 | ${time(hit.updatedAt)}`, | |
| 97 | trail: stateBadge(hit.state), | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | function section( | |
| 102 | title: string, | |
| 103 | emptyIcon: Parameters<typeof icon>[0], | |
| 104 | rows: Html[], | |
| 105 | emptyText: string, | |
| 106 | ): Html { | |
| 107 | return html`<h2 class="mt">${title}</h2> | |
| 108 | ${rows.length === 0 | |
| 109 | ? html`<p class="no-hits muted small"> | |
| 110 | ${icon(emptyIcon)}<span>${emptyText}</span> | |
| 111 | </p>` | |
| 112 | : listCard(rows)}`; | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * GET /search in the studio: apps first, then every change and tell whose | |
| 117 | * title mentions the query, each saying which app it belongs to. An empty | |
| 118 | * or overlong query is simply an empty result — `cleanQuery` is the same | |
| 119 | * boundary dev's search enforces. | |
| 120 | */ | |
| 121 | export function studioSearchPage(ctx: Ctx): Response { | |
| 122 | const query = cleanQuery(ctx.url.searchParams.get("q") ?? ""); | |
| 123 | ||
| 124 | const form = html`<form | |
| 125 | class="filters" | |
| 126 | method="get" | |
| 127 | action="/search" | |
| 128 | role="search" | |
| 129 | > | |
| 130 | <input | |
| 131 | class="input search" | |
| 132 | type="search" | |
| 133 | name="q" | |
| 134 | value="${query}" | |
| 135 | placeholder="Search your apps, changes and tells" | |
| 136 | aria-label="Search" | |
| 137 | autofocus | |
| 138 | data-search-input | |
| 139 | /> | |
| 140 | <button class="btn" data-variant="primary" data-size="sm" type="submit"> | |
| 141 | Search | |
| 142 | </button> | |
| 143 | </form>`; | |
| 144 | ||
| 145 | if (query === "") { | |
| 146 | return page({ | |
| 147 | ctx, | |
| 148 | title: "Studio search", | |
| 149 | styleCss: studioCss, | |
| 150 | body: html`${pageHead("Search")} ${form} | |
| 151 | ${empty({ | |
| 152 | icon: "search", | |
| 153 | title: "Search your studio", | |
| 154 | note: "An app's name or description, or a word from what was told or changed.", | |
| 155 | })}`, | |
| 156 | }); | |
| 157 | } | |
| 158 | ||
| 159 | const { repos, records } = appsOf(ctx, ctx.auth.user); | |
| 160 | const apps = repos.filter((r) => matchesApp(r, query)).slice(0, APP_LIMIT); | |
| 161 | ||
| 162 | const issueHits = searchLocalIssues( | |
| 163 | ctx.app.db, | |
| 164 | repos, | |
| 165 | query, | |
| 166 | ISSUE_READ_LIMIT, | |
| 167 | ).filter((h): h is LocalIssueHit => h.source === "local"); | |
| 168 | const changes = issueHits | |
| 169 | .filter((h) => h.kind === "pull") | |
| 170 | .slice(0, CHANGE_LIMIT); | |
| 171 | const tells = issueHits | |
| 172 | .filter((h) => h.kind === "issue") | |
| 173 | .slice(0, TELL_LIMIT); | |
| 174 | ||
| 175 | const body = html`${pageHead(html`Results for “${query}”`)} ${form} | |
| 176 | ${section( | |
| 177 | "Apps", | |
| 178 | "rocket", | |
| 179 | apps.map((r) => appRow(ctx, r, records.get(r.id)!, query)), | |
| 180 | "No app matches", | |
| 181 | )} | |
| 182 | ${section( | |
| 183 | "Changes", | |
| 184 | "pull", | |
| 185 | changes.map((h) => issueHitRow(h, query)), | |
| 186 | "No change matches", | |
| 187 | )} | |
| 188 | ${section( | |
| 189 | "Tells", | |
| 190 | "issue", | |
| 191 | tells.map((h) => issueHitRow(h, query)), | |
| 192 | "No tell matches", | |
| 193 | )} | |
| 194 | ${when( | |
| 195 | apps.length === 0 && changes.length === 0 && tells.length === 0, | |
| 196 | () => | |
| 197 | html`<p class="muted small mt"> | |
| 198 | Nothing matched — try a different word. | |
| 199 | </p>`, | |
| 200 | )}`; | |
| 201 | ||
| 202 | return page({ | |
| 203 | ctx, | |
| 204 | title: `Studio search · ${query}`, | |
| 205 | styleCss: studioCss, | |
| 206 | body, | |
| 207 | }); | |
| 208 | } |
test/studio-words.test.ts +6
| @@ -333,6 +333,9 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 333 | 333 | { word: "working", path: `/alice/vocab/runs/${runId}` }, |
| 334 | 334 | { word: "look", path: `/alice/vocab/pulls/${openPullNumber}` }, |
| 335 | 335 | { word: "shipped", path: `/alice/vocab/pulls/${mergedPullNumber}` }, |
| 336 | // A word from the app's own description, not its name — proving the | |
| 337 | // search reaches the description, not just a name match. | |
| 338 | { word: "search", path: "/search?q=catch" }, | |
| 336 | 339 | ]; |
| 337 | 340 | |
| 338 | 341 | for (const p of pages) { |
| @@ -377,6 +380,9 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 377 | 380 | visible, |
| 378 | 381 | `${p.word} (${p.path}) named the merged branch "${mergedBranch}"`, |
| 379 | 382 | ).not.toContain(mergedBranch); |
| 383 | // The search itself: "catch" is only in the app's description, never | |
| 384 | // its name, so finding it here proves the description is reached. | |
| 385 | if (p.word === "search") expect(visible).toContain("vocab"); | |
| 380 | 386 | } |
| 381 | 387 | |
| 382 | 388 | // A run id is a key, not a headline: it rides hrefs (the dev link, the |
test/studio.test.ts +166
| @@ -3450,3 +3450,169 @@ describe("this week's runs and spend are counted by the database, not read off a | ||
| 3450 | 3450 | expect(page).toContain("$0.00"); |
| 3451 | 3451 | }); |
| 3452 | 3452 | }); |
| 3453 | ||
| 3454 | describe("finding things in the studio: /search", () => { | |
| 3455 | let dataDir: string; | |
| 3456 | let app: App; | |
| 3457 | let servers: Servers; | |
| 3458 | let base: string; | |
| 3459 | let alice: User; | |
| 3460 | let aliceCookie: string; | |
| 3461 | let widgets: Repo; | |
| 3462 | let tellNumber: number; | |
| 3463 | let changeNumber: number; | |
| 3464 | ||
| 3465 | beforeAll(async () => { | |
| 3466 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-search-")); | |
| 3467 | app = createApp({ dataDir, port: 0 }); | |
| 3468 | servers = await serve(app); | |
| 3469 | base = servers.url; | |
| 3470 | ||
| 3471 | const a = await createUser(app.db, { | |
| 3472 | username: "alice", | |
| 3473 | email: "[email protected]", | |
| 3474 | password: "a-good-passphrase", | |
| 3475 | }); | |
| 3476 | if (!a.ok) throw new Error(a.error); | |
| 3477 | alice = a.value; | |
| 3478 | aliceCookie = ( | |
| 3479 | ( | |
| 3480 | await fetch(`${base}/signin`, { | |
| 3481 | method: "POST", | |
| 3482 | body: new URLSearchParams({ | |
| 3483 | username: "alice", | |
| 3484 | password: "a-good-passphrase", | |
| 3485 | }), | |
| 3486 | redirect: "manual", | |
| 3487 | }) | |
| 3488 | ).headers.get("set-cookie") ?? "" | |
| 3489 | ).split(";")[0]!; | |
| 3490 | ||
| 3491 | const made = await createRepo(app, alice, { | |
| 3492 | name: "widgets", | |
| 3493 | visibility: "public", | |
| 3494 | description: "Sell handmade wooden chess sets", | |
| 3495 | }); | |
| 3496 | if (!made.ok) throw new Error(String(made.error)); | |
| 3497 | widgets = made.value; | |
| 3498 | const seeded = await commitToBranch(app, alice, widgets, { | |
| 3499 | message: "Start", | |
| 3500 | files: [{ path: "README.md", content: "start\n" }], | |
| 3501 | }); | |
| 3502 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 3503 | const enabled = await enableApp(app, alice, widgets, { slug: "widgets" }); | |
| 3504 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 3505 | ||
| 3506 | const told = openIssue(app, alice, widgets, { | |
| 3507 | title: "Add a chess clock timer", | |
| 3508 | body: "Players want a clock.", | |
| 3509 | }); | |
| 3510 | if (!told.ok) throw new Error(String(told.error)); | |
| 3511 | tellNumber = told.value.number; | |
| 3512 | ||
| 3513 | const branchMade = await commitToBranch(app, alice, widgets, { | |
| 3514 | branch: "chess-clock", | |
| 3515 | base: widgets.defaultBranch, | |
| 3516 | message: "Add clock", | |
| 3517 | files: [{ path: "clock.ts", content: "export {};\n" }], | |
| 3518 | }); | |
| 3519 | if (!branchMade.ok) throw new Error(JSON.stringify(branchMade.error)); | |
| 3520 | const opened = await openPull(app, alice, widgets, { | |
| 3521 | title: "Wire up the chess clock", | |
| 3522 | body: "", | |
| 3523 | baseBranch: widgets.defaultBranch, | |
| 3524 | headBranch: "chess-clock", | |
| 3525 | }); | |
| 3526 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 3527 | changeNumber = opened.value.number; | |
| 3528 | ||
| 3529 | // A private app, another owner's — never in alice's or a stranger's | |
| 3530 | // results, however much its description matches. | |
| 3531 | const bob = await createUser(app.db, { | |
| 3532 | username: "bob", | |
| 3533 | email: "[email protected]", | |
| 3534 | password: "a-good-passphrase", | |
| 3535 | }); | |
| 3536 | if (!bob.ok) throw new Error(bob.error); | |
| 3537 | const secret = await createRepo(app, bob.value, { | |
| 3538 | name: "secret", | |
| 3539 | visibility: "private", | |
| 3540 | description: "A private chess engine", | |
| 3541 | }); | |
| 3542 | if (!secret.ok) throw new Error(String(secret.error)); | |
| 3543 | const secretSeeded = await commitToBranch(app, bob.value, secret.value, { | |
| 3544 | message: "Start", | |
| 3545 | files: [{ path: "README.md", content: "start\n" }], | |
| 3546 | }); | |
| 3547 | if (!secretSeeded.ok) throw new Error(JSON.stringify(secretSeeded.error)); | |
| 3548 | const secretEnabled = await enableApp(app, bob.value, secret.value, { | |
| 3549 | slug: "secret", | |
| 3550 | }); | |
| 3551 | if (!secretEnabled.ok) throw new Error(String(secretEnabled.error)); | |
| 3552 | }); | |
| 3553 | ||
| 3554 | afterAll(async () => { | |
| 3555 | servers.stop(); | |
| 3556 | app.close(); | |
| 3557 | await rm(dataDir, { recursive: true, force: true }); | |
| 3558 | }); | |
| 3559 | ||
| 3560 | const asAlice = (path: string) => | |
| 3561 | fetch(`${base}${path}`, { | |
| 3562 | headers: { cookie: `${aliceCookie}; forge_mode=studio` }, | |
| 3563 | redirect: "manual", | |
| 3564 | }); | |
| 3565 | const anonymous = (path: string) => | |
| 3566 | fetch(`${base}${path}`, { | |
| 3567 | headers: { cookie: "forge_mode=studio" }, | |
| 3568 | redirect: "manual", | |
| 3569 | }); | |
| 3570 | // The topbar carries the palette's own list of apps on every page, so an | |
| 3571 | // assertion about the *results* reads the main content, not the whole | |
| 3572 | // document. | |
| 3573 | const mainOf = (html: string): string => | |
| 3574 | html.match(/<main[^>]*id="main"[\s\S]*?<\/main>/)?.[0] ?? html; | |
| 3575 | ||
| 3576 | test("an app is found by a word from its own description", async () => { | |
| 3577 | const res = await asAlice("/search?q=chess"); | |
| 3578 | expect(res.status).toBe(200); | |
| 3579 | const html = await res.text(); | |
| 3580 | expect(html).toContain("widgets"); | |
| 3581 | expect(html).toContain("chess sets"); | |
| 3582 | // Bob's private app matches the same word but is never the viewer's to see. | |
| 3583 | expect(html).not.toContain("secret"); | |
| 3584 | }); | |
| 3585 | ||
| 3586 | test("a change and a tell are found too, each saying which app they belong to", async () => { | |
| 3587 | const html = await (await asAlice("/search?q=chess")).text(); | |
| 3588 | expect(html).toContain("Add a chess clock timer"); | |
| 3589 | expect(html).toContain(`/alice/widgets/issues/${tellNumber}`); | |
| 3590 | expect(html).toContain("Wire up the chess clock"); | |
| 3591 | expect(html).toContain(`/alice/widgets/pulls/${changeNumber}`); | |
| 3592 | expect(html).toContain("alice/widgets"); | |
| 3593 | }); | |
| 3594 | ||
| 3595 | test("an empty query is an empty result, not an error page", async () => { | |
| 3596 | const res = await asAlice("/search"); | |
| 3597 | expect(res.status).toBe(200); | |
| 3598 | const html = await res.text(); | |
| 3599 | expect(mainOf(html)).not.toContain("widgets"); | |
| 3600 | }); | |
| 3601 | ||
| 3602 | test("an overlong query is bounded, not a crash", async () => { | |
| 3603 | const res = await asAlice(`/search?q=${"chess ".repeat(100)}`); | |
| 3604 | expect(res.status).toBe(200); | |
| 3605 | }); | |
| 3606 | ||
| 3607 | test("signed out, the same public visibility as dev's own search", async () => { | |
| 3608 | const html = await (await anonymous("/search?q=chess")).text(); | |
| 3609 | expect(html).toContain("widgets"); | |
| 3610 | expect(html).not.toContain("secret"); | |
| 3611 | }); | |
| 3612 | ||
| 3613 | test("?mode=dev on the same route still answers in dev's own words", async () => { | |
| 3614 | const html = await (await asAlice("/search?q=chess&mode=dev")).text(); | |
| 3615 | expect(html).toContain("Repositories"); | |
| 3616 | expect(html).toContain("Issues and pull requests"); | |
| 3617 | }); | |
| 3618 | }); |
new above
offline