Public: anyone can read this; the crew can change it.
The explorer keeps its filters in the URL, so a filtered view is a link that reloads the same #10732
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10727-the-explorer-keeps-its-filters-in-the-ur → main 5 commits fork 3 threads · 3 unresolved
10 files
changed +225
−31
docs/MCP.md +1 −1
| @@ -119,7 +119,7 @@ longer sentences: | ||
| 119 | 119 | - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder. |
| 120 | 120 | - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed. |
| 121 | 121 | - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`. |
| 122 | - `explore.q`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window), `owner:name` and `archived:yes`. `query` is the same argument; `q` wins when both are given. | |
| 122 | - `explore.q`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window), `owner:name` and `archived:yes`. `query` is the same argument; `q` wins when both are given. Each of `kind`, `sort`, `updated`, `owner`, `archived` is also its own argument (and a query parameter on `/explore` and `/api/v1/explore`); it wins over the same key in `q`. | |
| 123 | 123 | - `archive.repos`: several repositories, as `owner/name`, to archive in one operation. |
| 124 | 124 | - `milestones.issue`: the number of an issue that waits for others; the limbs when absent. `watch.issue` is the same number; `watch.watch` false stops watching, anything else watches. |
| 125 | 125 | - `limb.reason` (close): why it is not being done, in one sentence. `limb.pillar` (resume): the pillar to return it under. `limb.under` (raise, lower): `top` or a milestone number, only when the limb sits in several places. |
docs/features/explore.md +12 −9
| @@ -24,20 +24,23 @@ In `score`, likes add `0.5 × ln(1 + likes)`, small next to forks (3) and ships | ||
| 24 | 24 | |
| 25 | 25 | An unknown `sort:` word is a 422 naming the sorts above (see the end of this page). |
| 26 | 26 | |
| 27 | When an unknown `kind:`, `sort:`, `updated:` or `archived:` value is within edit distance 2 of a known one (and nearer than the value is long, so one or two characters of junk suggest nothing), the note ends with `Did you mean <a>sort:forks</a>?`, a link to the whole line with that word mended. Only the first such word is mended per line; a word repeated is mended everywhere. `GET /api/v1/explore` and the MCP `explore` tool carry the same as `suggest` (`"garden sort:forks"`, the whole corrected line), absent when there is none. The terminal shows it as plain text under "Read as:". It is a pure function of the line: nothing is looked up. | |
| 27 | When an unknown `kind:`, `sort:`, `updated:` or `archived:` value is within edit distance 2 of a known one (and nearer than the value is long, so one or two characters of junk suggest nothing), the note ends with `Did you mean <a>sort:forks</a>?`, a link to the same view with that word mended (in the parameter form below, `/explore?sort=forks`). Only the first such word is mended per line; a word repeated is mended everywhere. `GET /api/v1/explore` and the MCP `explore` tool carry the same as `suggest` (`"garden sort:forks"`, the whole corrected line), absent when there is none. The terminal shows it as plain text under "Read as:". It is a pure function of the line: nothing is looked up. | |
| 28 | 28 | |
| 29 | 29 | ## One word on every surface (#10387) |
| 30 | 30 | |
| 31 | What a person filters on `/explore` an agent filters with the same words: the `q` line. The page's chips are plain links to `/explore?q=<line>`; the API takes the same line as `?q=`, the MCP tool as the argument `q`. | |
| 31 | What a person filters on `/explore` an agent filters with the same words. Each filter is its own parameter: the page's chips are plain links like `/explore?kind=app&sort=forks` (the default view is bare `/explore`, and only the free words stay in `q`), so a filtered view is a link that reloads the same (#10727). The API takes the same parameters, and the MCP tool the same names as arguments. | |
| 32 | 32 | |
| 33 | | the page offers | the line | API `GET /api/v1/explore` | MCP `explore` | | |
| 33 | | the page offers | the parameter | API `GET /api/v1/explore` | MCP `explore` | | |
| 34 | 34 | |---|---|---|---| |
| 35 | | Best / Forks / Shipped / Liked / Use / Fresh | `sort:score` `forks` `ships` `likes` `use` `fresh` (`name` by hand) | `?q=sort:forks` | `{ q: "sort:forks" }` | | |
| 36 | | All / Apps / People / Forges (admin) | `kind:app` `kind:person` `kind:forge` | `?q=kind:app` | `{ q: "kind:app" }` | | |
| 37 | | Any time / Today / This week / This month | `updated:1d` `7d` `30d` | `?q=updated:7d` | `{ q: "updated:7d" }` | | |
| 38 | | Show archived | `archived:yes` | `?q=archived:yes` | `{ q: "archived:yes" }` | | |
| 39 | | (owner link on a row) | `owner:name` | `?q=owner:name` | `{ q: "owner:name" }` | | |
| 35 | | Best / Forks / Shipped / Liked / Use / Fresh | `sort=score` `forks` `ships` `likes` `use` `fresh` (`name` by hand) | `?sort=forks` | `{ sort: "forks" }` | | |
| 36 | | All / Apps / People / Forges (admin) | `kind=app` `person` `forge` | `?kind=app` | `{ kind: "app" }` | | |
| 37 | | Any time / Today / This week / This month | `updated=1d` `7d` `30d` | `?updated=7d` | `{ updated: "7d" }` | | |
| 38 | | Show archived | `archived=yes` | `?archived=yes` | `{ archived: "yes" }` | | |
| 39 | | (owner link on a row) | `owner=name` | `?owner=name` | `{ owner: "name" }` | | |
| 40 | | the search box | `q=words` | `?q=words` | `{ q: "words" }` | | |
| 40 | 41 | |
| 41 | The three return the same ordered rows (`test/explore-parity.test.ts`). | |
| 42 | The one line still works everywhere: `?q=kind:app sort:forks garden` and `{ q: "kind:app sort:forks garden" }` read the same as the separate parameters, and old `/explore?q=…` links keep working. When a parameter and the same key in `q` are both given, the parameter wins (`?q=sort:name&sort=forks` sorts by forks). A parameter's value is one word; one the explorer does not take is an unknown word like any typed in `q`, and is refused the same way (below). The terminal's search line takes only the line. | |
| 43 | ||
| 44 | The three return the same ordered rows (`test/explore-parity.test.ts`; the URL form in `test/explore-url.test.ts`). | |
| 42 | 45 | |
| 43 | 46 | An unknown filter word is refused the same way everywhere: `422` from the API (`{ "error": "unknown_filter", "message": "sort:bogus is not a sort — use score, forks, …", "suggest"? }`), an `unknown_filter` error from the MCP tool, and `422` on the page, which still draws the note, the did-you-mean link and the results. This replaces the earlier note-only answer (#7595). |
src/actions/explore.ts +35
| @@ -94,6 +94,41 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 94 | 94 | return readExploreLine(line).query; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | /** The filter names a request may carry as separate parameters (`?kind=app&sort=forks`), beside `q`. */ | |
| 98 | export const EXPLORE_PARAMS = ["kind", "sort", "updated", "owner", "archived"] as const; | |
| 99 | ||
| 100 | /** | |
| 101 | * The one line a request asks for: `q`, then each separate parameter as its | |
| 102 | * `key:value` word. A value is one word (whitespace removed, so it cannot | |
| 103 | * smuggle in text or another key), and one the line does not take is an | |
| 104 | * unknown word like any other. Parameters win over the same key in `q`. | |
| 105 | */ | |
| 106 | export function exploreLineFrom(get: (name: string) => string | null | undefined): string { | |
| 107 | const given = new Map<string, string>(); | |
| 108 | for (const key of EXPLORE_PARAMS) { | |
| 109 | const value = (get(key) ?? "").replace(/\s+/g, "").slice(0, 100); | |
| 110 | if (value !== "") given.set(key, value); | |
| 111 | } | |
| 112 | // A parameter wins: the same key typed in `q` is dropped, not left to the reader's order. | |
outdated
Docs promise the parameter wins over the same key in q, but the line just appends it. Prove with a test (page, API, MCP) or make the code drop the q key. |
||
| 113 | const typed = (get("q") ?? "") | |
| 114 | .trim() | |
| 115 | .split(/\s+/) | |
| 116 | .filter((w) => w !== "" && !(w.includes(":") && given.has(w.slice(0, w.indexOf(":")).toLowerCase()))); | |
| 117 | return [...typed, ...[...given].map(([k, v]) => `${k}:${v}`)].join(" "); | |
| 118 | } | |
| 119 | ||
| 120 | /** A query as separate URL parameters, the form the page links to; "" for the default view. */ | |
| 121 | export function exploreParams(query: ExploreQuery): string { | |
| 122 | const p = new URLSearchParams(); | |
| 123 | if (query.kind !== "all") p.set("kind", query.kind); | |
| 124 | if (query.sort !== "score") p.set("sort", query.sort); | |
| 125 | if (query.updated !== "all") p.set("updated", query.updated); | |
| 126 | if (query.archived) p.set("archived", "yes"); | |
| 127 | if (query.owner !== undefined) p.set("owner", query.owner); | |
| 128 | if (query.text !== "") p.set("q", query.text); | |
| 129 | return p.toString(); | |
| 130 | } | |
| 131 | ||
| 97 | 132 | const EXPLORE_KEYS = /(^|\s)(kind|sort|updated|owner|archived):/i; |
| 98 | 133 | |
| 99 | 134 | /** Whether a line has any word the explorer reads as a key, so every surface takes it as the explorer's. */ |
src/http/api.ts +2 −1
| @@ -335,6 +335,7 @@ import { | ||
| 335 | 335 | exploreNote, |
| 336 | 336 | exploreSuggestion, |
| 337 | 337 | readExploreLine, |
| 338 | exploreLineFrom, | |
| 338 | 339 | } from "../actions/explore.ts"; |
| 339 | 340 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 340 | 341 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| @@ -3393,7 +3394,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3393 | 3394 | // Explore (#607): the rows /explore shows, for the same one line. |
| 3394 | 3395 | router.add("GET", "/api/v1/explore", async (ctx) => { |
| 3395 | 3396 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3396 | const line = ctx.url.searchParams.get("q") ?? ""; | |
| 3397 | const line = exploreLineFrom((name) => ctx.url.searchParams.get(name)); | |
| 3397 | 3398 | const { query, unknown } = readExploreLine(line); |
| 3398 | 3399 | const suggest = exploreSuggestion(line); |
| 3399 | 3400 | if (unknown.length > 0) |
src/http/mcp/tools/explore.ts +4 −3
| @@ -1,9 +1,9 @@ | ||
| 1 | import { explorePage, exploreLine, exploreNote, exploreSuggestion, readExploreLine } from "../../../actions/explore.ts"; | |
| 1 | import { explorePage, exploreLine, exploreLineFrom, EXPLORE_PARAMS, exploreNote, exploreSuggestion, readExploreLine } from "../../../actions/explore.ts"; | |
| 2 | 2 | import { bad, good, type Tool, str } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "explore", |
| 6 | doc: "`{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; page on with `cursor`", | |
| 6 | doc: "`{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; each also as its own argument; page on with `cursor`", | |
| 7 | 7 | description: |
| 8 | 8 | "The apps, people (and, for an admin, forges) you can see, ranked.", |
| 9 | 9 | inputSchema: { |
| @@ -19,6 +19,7 @@ export const tool: Tool = { | ||
| 19 | 19 | maxLength: 200, |
| 20 | 20 | description: "Alias of q", |
| 21 | 21 | }, |
| 22 | ...Object.fromEntries(EXPLORE_PARAMS.map((k) => [k, { type: "string", maxLength: 100 }])), | |
outdated
New MCP arguments are untested; add a test that calls the tool with these as arguments and compares with the API. Also give them enum/maxLength in the schema rather than bare strings. |
||
| 22 | 23 | cursor: { |
| 23 | 24 | type: "string", |
| 24 | 25 | }, |
| @@ -27,7 +28,7 @@ export const tool: Tool = { | ||
| 27 | 28 | writes: false, |
| 28 | 29 | async run(ctx, user, args) { |
| 29 | 30 | const q = str(args["q"]); |
| 30 | const line = q !== "" ? q : str(args["query"]); | |
| 31 | const line = exploreLineFrom((name) => (name === "q" ? (q !== "" ? q : str(args["query"])) : str(args[name]))); | |
| 31 | 32 | const { query, unknown } = readExploreLine(line); |
| 32 | 33 | const suggest = exploreSuggestion(line); |
| 33 | 34 | if (unknown.length > 0) return bad("unknown_filter", exploreNote(unknown) + (suggest === undefined ? "" : `; did you mean "${suggest.line}"?`)); |
src/web/studio/explore.ts +6 −4
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | import { |
| 10 | 10 | explore, |
| 11 | 11 | exploreLine, |
| 12 | exploreLineFrom, | |
| 13 | exploreParams, | |
| 12 | 14 | exploreNote, |
| 13 | 15 | exploreSuggestion, |
| 14 | 16 | readExploreLine, |
| @@ -24,8 +26,8 @@ import { page } from "../layout.ts"; | ||
| 24 | 26 | import { btn, card, empty, nameMark, row as rowOf, seg, tabs, timeAgo } from "../ui.ts"; |
| 25 | 27 | |
| 26 | 28 | const href = (query: ExploreQuery): string => { |
| 27 | const line = exploreLine(query); | |
| 28 | return line === "" ? "/explore" : `/explore?q=${encodeURIComponent(line)}`; | |
| 29 | const params = exploreParams(query); | |
| 30 | return params === "" ? "/explore" : `/explore?${params}`; | |
| 29 | 31 | }; |
| 30 | 32 | |
| 31 | 33 | /** |
| @@ -131,7 +133,7 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html { | ||
| 131 | 133 | // GET /explore |
| 132 | 134 | export async function explorePage(ctx: Ctx): Promise<Response> { |
| 133 | 135 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 134 | const line = ctx.url.searchParams.get("q") ?? ""; | |
| 136 | const line = exploreLineFrom((name) => ctx.url.searchParams.get(name)); | |
| 135 | 137 | const { query, unknown } = readExploreLine(line); |
| 136 | 138 | const note = exploreNote(unknown); |
| 137 | 139 | const suggestion = exploreSuggestion(line); |
| @@ -181,7 +183,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 181 | 183 | ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${ |
| 182 | 184 | suggestion === undefined |
| 183 | 185 | ? html`` |
| 184 | : html` Did you mean <a href="/explore?q=${encodeURIComponent(suggestion.line)}">${suggestion.fix}</a>?` | |
| 186 | : html` Did you mean <a href="${href(readExploreLine(suggestion.line).query)}">${suggestion.fix}</a>?` | |
| 185 | 187 | }</p>`} |
| 186 | 188 | <div class="mb"> |
| 187 | 189 | ${tabs( |
test/explore-parity.test.ts +55 −1
| @@ -13,6 +13,9 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 13 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | 14 | import { proveAll } from "./helpers/verified.ts"; |
| 15 | 15 | import { promote } from "./helpers/members.ts"; |
| 16 | import { exploreParams, readExploreLine } from "../src/actions/explore.ts"; | |
| 17 | /** The href the page gives a line: separate parameters, `&` escaped as in markup. */ | |
| 18 | const linkOf = (line: string): string => `/explore?${exploreParams(readExploreLine(line).query)}`.replace(/&/g, "&"); | |
| 16 | 19 | |
| 17 | 20 | quietLogs(); |
| 18 | 21 | |
| @@ -184,7 +187,7 @@ describe("one filter, three surfaces", () => { | ||
| 184 | 187 | test("the page's chips are links whose q is a line the API takes", async () => { |
| 185 | 188 | const html = await (await fetch(`${servers.url}/explore`)).text(); |
| 186 | 189 | for (const line of ["kind:app", "sort:forks", "sort:ships", "sort:likes", "sort:use", "sort:fresh", "updated:7d", "kind:person"]) { |
| 187 | expect(html).toContain(`href="/explore?q=${encodeURIComponent(line)}"`); | |
| 190 | expect(html).toContain(`href="${linkOf(line)}"`); | |
| 188 | 191 | expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).status).toBe(200); |
| 189 | 192 | } |
| 190 | 193 | }); |
| @@ -207,3 +210,54 @@ describe("one filter, three surfaces", () => { | ||
| 207 | 210 | } |
| 208 | 211 | }); |
| 209 | 212 | }); |
| 213 | ||
| 214 | describe("separate parameters", () => { | |
| 215 | const PARAMS: Record<string, string>[] = [ | |
| 216 | { kind: "app", sort: "forks" }, | |
| 217 | { kind: "app", sort: "name", owner: "pine" }, | |
| 218 | { kind: "app", updated: "7d" }, | |
| 219 | { kind: "app", archived: "yes", sort: "name" }, | |
| 220 | ]; | |
| 221 | async function mcpArgs(args: Record<string, string>): Promise<{ ok: boolean; text: string }> { | |
| 222 | const res = await fetch(`${servers.url}/mcp`, { | |
| 223 | method: "POST", | |
| 224 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 225 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: args } }), | |
| 226 | }); | |
| 227 | const out = ((await res.json()) as any).result; | |
| 228 | return { ok: out.isError !== true, text: out.content[0].text }; | |
| 229 | } | |
| 230 | const names = async (qs: string) => | |
| 231 | (((await (await fetch(`${servers.url}/api/v1/explore?${qs}`)).json()) as { items: { kind: string; name: string }[] }).items); | |
| 232 | ||
| 233 | for (const params of PARAMS) { | |
| 234 | test(`${JSON.stringify(params)} gives the same rows on the API, the MCP and the page`, async () => { | |
| 235 | const qs = new URLSearchParams(params).toString(); | |
| 236 | const api = await names(qs); | |
| 237 | expect(api.length).toBeGreaterThan(0); | |
| 238 | const tool = await mcpArgs(params); | |
| 239 | expect(tool.ok).toBe(true); | |
| 240 | expect(ids((JSON.parse(tool.text) as { items: typeof api }).items)).toEqual(ids(api)); | |
| 241 | // The same as the one-line form. | |
| 242 | const line = Object.entries(params).map(([k, v]) => `${k}:${v}`).join(" "); | |
| 243 | expect(ids(await names(`q=${encodeURIComponent(line)}`))).toEqual(ids(api)); | |
| 244 | const html = await (await fetch(`${servers.url}/explore?${qs}`)).text(); | |
| 245 | expect(pageOrder(html)).toEqual( | |
| 246 | ((await (await fetch(`${servers.url}/api/v1/explore?${qs}`)).json()) as { items: { url: string }[] }).items.map((i) => i.url), | |
| 247 | ); | |
| 248 | }); | |
| 249 | } | |
| 250 | ||
| 251 | test("a parameter wins over the same key in q, on the API, the MCP and the page", async () => { | |
| 252 | const want = ids(await names("kind=app&sort=name")); | |
| 253 | expect(want.every((w) => w.startsWith("app:"))).toBe(true); | |
| 254 | const q = encodeURIComponent("kind:person sort:forks"); | |
| 255 | expect(ids(await names(`q=${q}&kind=app&sort=name`))).toEqual(want); | |
| 256 | const tool = await mcpArgs({ q: "kind:person sort:forks", kind: "app", sort: "name" }); | |
| 257 | expect(ids((JSON.parse(tool.text) as { items: { kind: string; name: string }[] }).items)).toEqual(want); | |
| 258 | const html = await (await fetch(`${servers.url}/explore?q=${q}&kind=app&sort=name`)).text(); | |
| 259 | const urls = (await names("kind=app&sort=name")).map((_, i) => i); | |
| 260 | expect(pageOrder(html).length).toBe(urls.length); | |
| 261 | expect(pageOrder(html).every((u) => u.split("/").length === 3)).toBe(true); | |
| 262 | }); | |
| 263 | }); |
test/explore-unknown-words.test.ts +5 −2
| @@ -13,6 +13,9 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 16 | import { exploreParams } from "../src/actions/explore.ts"; | |
| 17 | /** The href the page gives a line: separate parameters, `&` escaped as in markup. */ | |
| 18 | const linkOf = (line: string): string => `/explore?${exploreParams(readExploreLine(line).query)}`.replace(/&/g, "&"); | |
| 16 | 19 | |
| 17 | 20 | quietLogs(); |
| 18 | 21 | |
| @@ -105,7 +108,7 @@ describe("the page and the API", () => { | ||
| 105 | 108 | expect(out.split("is not a sort").length).toBe(2); |
| 106 | 109 | expect(out).toContain("sort:bogus is not a sort — use score, forks, ships, likes, use, fresh, name"); |
| 107 | 110 | expect(out).not.toContain(encodeURIComponent("sort:bogus")); |
| 108 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app garden")}"`); | |
| 111 | expect(out).toContain(`href="${linkOf("kind:app garden")}"`); | |
| 109 | 112 | }); |
| 110 | 113 | |
| 111 | 114 | test("the page says an updated:, archived: or owner: word once, escaped", async () => { |
| @@ -197,7 +200,7 @@ describe("did you mean", () => { | ||
| 197 | 200 | try { |
| 198 | 201 | const q = encodeURIComponent("garden sort:froks"); |
| 199 | 202 | const html = await (await fetch(`${s.url}/explore?q=${q}`)).text(); |
| 200 | expect(html).toContain(`Did you mean <a href="/explore?q=${encodeURIComponent("garden sort:forks")}">sort:forks</a>?`); | |
| 203 | expect(html).toContain(`Did you mean <a href="${linkOf("garden sort:forks")}">sort:forks</a>?`); | |
| 201 | 204 | const api = (await (await fetch(`${s.url}/api/v1/explore?q=${q}`)).json()) as { suggest?: string }; |
| 202 | 205 | expect(api.suggest).toBe("garden sort:forks"); |
| 203 | 206 | const none = (await (await fetch(`${s.url}/api/v1/explore?q=sort:zzzzzzzz`)).json()) as { suggest?: string }; |
test/explore-url.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** The explorer's filters are URL parameters (#10727): a filtered view is a link that reloads the same. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { exploreLineFrom, exploreParams, readExploreLine } from "../src/actions/explore.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { proveAll } from "./helpers/verified.ts"; | |
| 14 | import { promote } from "./helpers/members.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | let dir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dir = await mkdtemp(join(tmpdir(), "forge-explore-url-")); | |
| 23 | app = createApp({ dataDir: dir, port: 0 }); | |
| 24 | for (const [owner, names] of [["olive", ["garden", "kiln"]], ["pine", ["loom"]]] as const) { | |
| 25 | const made = await createUser(app.db, { origin: "seed", username: owner, email: `${owner}@forge.local`, password: "a-good-passphrase", adminIfFirst: false }); | |
| 26 | if (!made.ok) throw new Error(made.error); | |
| 27 | proveAll(app); | |
| 28 | promote(app.db, made.value); | |
| 29 | for (const n of names) { | |
| 30 | const repo = await createRepo(app, made.value, { name: n, visibility: "public" }); | |
| 31 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 32 | const rec = insertApp(app.db, { repoId: repo.value.id, slug: `${owner}-${n}`, access: "public" }); | |
| 33 | if (!rec.ok) throw new Error(rec.error); | |
| 34 | } | |
| 35 | } | |
| 36 | servers = await serve(app); | |
| 37 | }); | |
| 38 | afterAll(async () => { | |
| 39 | await servers.stop(); | |
| 40 | app.db.close(); | |
| 41 | await rm(dir, { recursive: true, force: true }); | |
| 42 | }); | |
| 43 | ||
| 44 | const page = async (qs: string) => { | |
| 45 | const res = await fetch(`${servers.url}/explore${qs}`); | |
| 46 | return { status: res.status, html: await res.text() }; | |
| 47 | }; | |
| 48 | const listed = (html: string): string[] => { | |
| 49 | const list = /<ul class="list">([\s\S]*?)<\/ul>/.exec(html); | |
| 50 | return list === null ? [] : [...list[1]!.matchAll(/<li[^>]*>[\s\S]*?<a href="(\/[^"?]*)">/g)].map((m) => m[1]!); | |
| 51 | }; | |
| 52 | ||
| 53 | test("two filtered URLs give different, correct result sets, and a reload equals the first load", async () => { | |
| 54 | const mine = await page("?kind=app&owner=olive&sort=name"); | |
| 55 | const theirs = await page("?kind=app&owner=pine"); | |
| 56 | expect(listed(mine.html)).toEqual(["/olive/garden", "/olive/kiln"]); | |
| 57 | expect(listed(theirs.html)).toEqual(["/pine/loom"]); | |
| 58 | expect((await page("?kind=app&owner=olive&sort=name")).html).toBe(mine.html); | |
| 59 | const people = await page("?kind=person"); | |
| 60 | expect(listed(people.html).sort()).toEqual(["/olive", "/pine"]); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the API and the page answer the same for separate parameters and for the line", async () => { | |
| 64 | const a = (await (await fetch(`${servers.url}/api/v1/explore?kind=app&owner=pine`)).json()) as { items: { url: string }[] }; | |
| 65 | const b = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent("kind:app owner:pine")}`)).json()) as { items: { url: string }[] }; | |
| 66 | expect(a.items.map((i) => i.url)).toEqual(["/pine/loom"]); | |
| 67 | expect(b.items).toEqual(a.items); | |
| 68 | }); | |
| 69 | ||
| 70 | test("an unknown parameter is ignored; an invalid value is named and the default shown", async () => { | |
| 71 | const plain = await page(""); | |
| 72 | const extra = await page("?utm=1&zzz=2"); | |
| 73 | expect(extra.status).toBe(200); | |
| 74 | expect(listed(extra.html)).toEqual(listed(plain.html)); | |
| 75 | const bad = await page("?sort=bogus"); | |
| 76 | expect(bad.status).toBe(422); | |
| 77 | expect(listed(bad.html)).toEqual(listed(plain.html)); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a value is one word and is escaped", async () => { | |
| 81 | expect(exploreLineFrom((n) => (n === "kind" ? "app garden" : undefined))).toBe("kind:appgarden"); | |
| 82 | expect(readExploreLine(exploreLineFrom((n) => (n === "owner" ? "<script>" : undefined))).unknown).toEqual(["owner:<script>"]); | |
| 83 | const res = await page(`?owner=${encodeURIComponent('"><script>x</script>')}`); | |
| 84 | expect(res.html).not.toContain("<script>x</script>"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the chips link with separate parameters", async () => { | |
| 88 | const { html } = await page(""); | |
| 89 | expect(html).toContain('href="/explore?kind=app"'); | |
| 90 | expect(html).toContain('href="/explore?sort=forks"'); | |
| 91 | expect(exploreParams(readExploreLine("kind:app sort:forks owner:olive garden").query)).toBe("kind=app&sort=forks&owner=olive&q=garden"); | |
| 92 | }); |
test/explore.test.ts +13 −10
| @@ -22,6 +22,9 @@ import { forgeHttp, serve, type Servers } from "../src/http/server.ts"; | ||
| 22 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 23 | 23 | import { proveAll } from "./helpers/verified.ts"; |
| 24 | 24 | import { promote } from "./helpers/members.ts"; |
| 25 | import { exploreParams, readExploreLine } from "../src/actions/explore.ts"; | |
| 26 | /** The href the page gives a line: separate parameters, `&` escaped as in markup. */ | |
| 27 | const linkOf = (line: string): string => `/explore?${exploreParams(readExploreLine(line).query)}`.replace(/&/g, "&"); | |
| 25 | 28 | |
| 26 | 29 | quietLogs(); |
| 27 | 30 | |
| @@ -267,7 +270,7 @@ describe("explore", () => { | ||
| 267 | 270 | const bad = await api("kind:app owner:.."); |
| 268 | 271 | expect((bad as { message?: string }).message).toBe("owner:.. is not a name"); |
| 269 | 272 | const page = await (await fetch(`${base}/explore?q=kind:person`)).text(); |
| 270 | expect(page).toContain(`href="/explore?q=${encodeURIComponent("kind:app owner:bob")}">1 app</a>`); | |
| 273 | expect(page).toContain(`href="${linkOf("kind:app owner:bob")}">1 app</a>`); | |
| 271 | 274 | }); |
| 272 | 275 | |
| 273 | 276 | test("a hostile owner stays escaped text on the page", async () => { |
| @@ -380,7 +383,7 @@ describe("explore", () => { | ||
| 380 | 383 | |
| 381 | 384 | const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("updated:7d")}`)).text(); |
| 382 | 385 | for (const [label, w] of [["Today", "1d"], ["This week", "7d"], ["This month", "30d"]] as const) { |
| 383 | expect(page).toContain(`href="/explore?q=updated%3A${w}"`); | |
| 386 | expect(page).toContain(`href="${linkOf(`updated:${w}`)}"`); | |
| 384 | 387 | expect(page).toContain(label); |
| 385 | 388 | } |
| 386 | 389 | expect(page).toMatch(/aria-current="page"\s*>This week/); |
| @@ -423,11 +426,11 @@ describe("explore on a forge with nothing public", () => { | ||
| 423 | 426 | ).text(); |
| 424 | 427 | |
| 425 | 428 | test("the Forges chip is a site admin's: current on kind:forge, sort kept, absent for a guest", async () => { |
| 426 | const chip = (q: string) => new RegExp(`<a[^>]*href="/explore\\?q=${q}"[^>]*>\\s*Forges</a\\s*>`); | |
| 429 | const chip = (q: string) => new RegExp(`<a[^>]*href="/explore\\?${q}"[^>]*>\\s*Forges</a\\s*>`); | |
| 427 | 430 | const sorted = await get(`/explore?q=${encodeURIComponent("sort:forks")}`, true); |
| 428 | expect(sorted).toContain(`href="/explore?q=${encodeURIComponent("kind:forge sort:forks")}"`); | |
| 431 | expect(sorted).toContain(`href="${linkOf("kind:forge sort:forks")}"`); | |
| 429 | 432 | const page = await get(`/explore?q=${encodeURIComponent("kind:forge")}`, true); |
| 430 | const tag = page.match(chip("kind%3Aforge"))?.[0] ?? ""; | |
| 433 | const tag = page.match(chip("kind=forge"))?.[0] ?? ""; | |
| 431 | 434 | expect(tag).toContain("aria-current"); |
| 432 | 435 | const guest = await get(`/explore?q=${encodeURIComponent("kind:forge")}`, false); |
| 433 | 436 | expect(guest).not.toMatch(/>\s*Forges</); |
| @@ -460,17 +463,17 @@ describe("explore on a forge with nothing public", () => { | ||
| 460 | 463 | test("a miss names the line and offers each term as a link that drops it", async () => { |
| 461 | 464 | const out = await get(`/explore?q=${encodeURIComponent("kind:app zzz-no-such")}`, false); |
| 462 | 465 | expect(out).toContain("Nothing matches kind:app zzz-no-such"); |
| 463 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("zzz-no-such")}"`); | |
| 464 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app")}"`); | |
| 466 | expect(out).toContain(`href="${linkOf("zzz-no-such")}"`); | |
| 467 | expect(out).toContain(`href="${linkOf("kind:app")}"`); | |
| 465 | 468 | expect(out).toContain('href="/explore"'); |
| 466 | 469 | }); |
| 467 | 470 | |
| 468 | 471 | test("a window with no hits offers to drop it, and the seg has an Any time chip", async () => { |
| 469 | 472 | const out = await get(`/explore?q=${encodeURIComponent("updated:1d zzz-no-such")}`, false); |
| 470 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("zzz-no-such")}"`); | |
| 471 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("updated:1d")}"`); | |
| 473 | expect(out).toContain(`href="${linkOf("zzz-no-such")}"`); | |
| 474 | expect(out).toContain(`href="${linkOf("updated:1d")}"`); | |
| 472 | 475 | const only = await get(`/explore?q=${encodeURIComponent("updated:1d kind:person zzz")}`, false); |
| 473 | expect(only).toContain(`href="/explore?q=${encodeURIComponent("kind:person zzz")}"`); | |
| 476 | expect(only).toContain(`href="${linkOf("kind:person zzz")}"`); | |
| 474 | 477 | expect(only).toContain("Any time"); |
| 475 | 478 | expect(only).toMatch(/aria-current="page"\s*>Today/); |
| 476 | 479 | const none = await get(`/explore?q=${encodeURIComponent("zzz")}`, false); |
Mirror this in docs/features/explore.md (lines 31-39), which still documents the chips and the MCP argument as the ?q= line only.