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

Merged opened this

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

Merged

mason/forge:mason/10727-the-explorer-keeps-its-filters-in-the-ur → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/10727-the-explorer-keeps-its-filters-in-the-ur was merged into main as 4b17303.

Review

Sign in to review this change.
10 files changed +225 −31
docs/MCP.md +1 −1
@@ -119,7 +119,7 @@ longer sentences:
119119 - `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.
120120 - `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.
121121 - `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`.

Mirror this in docs/features/explore.md (lines 31-39), which still documents the chips and the MCP argument as the ?q= line only.

123123 - `archive.repos`: several repositories, as `owner/name`, to archive in one operation.
124124 - `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.
125125 - `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
2424
2525 An unknown `sort:` word is a 422 naming the sorts above (see the end of this page).
2626
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.
2828
2929 ## One word on every surface (#10387)
3030
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.
3232
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` |
3434 |---|---|---|---|
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" }` |
4041
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`).
4245
4346 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 {
9494 return readExploreLine(line).query;
9595 }
9696
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
97132 const EXPLORE_KEYS = /(^|\s)(kind|sort|updated|owner|archived):/i;
98133
99134 /** 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 {
335335 exploreNote,
336336 exploreSuggestion,
337337 readExploreLine,
338 exploreLineFrom,
338339 } from "../actions/explore.ts";
339340 import { frontInput, frontJson, setFront } from "../actions/front.ts";
340341 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
@@ -3393,7 +3394,7 @@ export function apiRoutes(router: Router<Handler>): void {
33933394 // Explore (#607): the rows /explore shows, for the same one line.
33943395 router.add("GET", "/api/v1/explore", async (ctx) => {
33953396 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));
33973398 const { query, unknown } = readExploreLine(line);
33983399 const suggest = exploreSuggestion(line);
33993400 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";
22 import { bad, good, type Tool, str } from "../shared.ts";
33
44 export const tool: Tool = {
55 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`",
77 description:
88 "The apps, people (and, for an admin, forges) you can see, ranked.",
99 inputSchema: {
@@ -19,6 +19,7 @@ export const tool: Tool = {
1919 maxLength: 200,
2020 description: "Alias of q",
2121 },
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.

2223 cursor: {
2324 type: "string",
2425 },
@@ -27,7 +28,7 @@ export const tool: Tool = {
2728 writes: false,
2829 async run(ctx, user, args) {
2930 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])));
3132 const { query, unknown } = readExploreLine(line);
3233 const suggest = exploreSuggestion(line);
3334 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 @@
99 import {
1010 explore,
1111 exploreLine,
12 exploreLineFrom,
13 exploreParams,
1214 exploreNote,
1315 exploreSuggestion,
1416 readExploreLine,
@@ -24,8 +26,8 @@ import { page } from "../layout.ts";
2426 import { btn, card, empty, nameMark, row as rowOf, seg, tabs, timeAgo } from "../ui.ts";
2527
2628 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}`;
2931 };
3032
3133 /**
@@ -131,7 +133,7 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html {
131133 // GET /explore
132134 export async function explorePage(ctx: Ctx): Promise<Response> {
133135 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));
135137 const { query, unknown } = readExploreLine(line);
136138 const note = exploreNote(unknown);
137139 const suggestion = exploreSuggestion(line);
@@ -181,7 +183,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
181183 ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${
182184 suggestion === undefined
183185 ? 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>?`
185187 }</p>`}
186188 <div class="mb">
187189 ${tabs(
test/explore-parity.test.ts +55 −1
@@ -13,6 +13,9 @@ import { serve, type Servers } from "../src/http/server.ts";
1313 import { quietLogs } from "./helpers/globals.ts";
1414 import { proveAll } from "./helpers/verified.ts";
1515 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, "&amp;");
1619
1720 quietLogs();
1821
@@ -184,7 +187,7 @@ describe("one filter, three surfaces", () => {
184187 test("the page's chips are links whose q is a line the API takes", async () => {
185188 const html = await (await fetch(`${servers.url}/explore`)).text();
186189 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)}"`);
188191 expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).status).toBe(200);
189192 }
190193 });
@@ -207,3 +210,54 @@ describe("one filter, three surfaces", () => {
207210 }
208211 });
209212 });
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";
1313 import { createUser } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
1515 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, "&amp;");
1619
1720 quietLogs();
1821
@@ -105,7 +108,7 @@ describe("the page and the API", () => {
105108 expect(out.split("is not a sort").length).toBe(2);
106109 expect(out).toContain("sort:bogus is not a sort — use score, forks, ships, likes, use, fresh, name");
107110 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")}"`);
109112 });
110113
111114 test("the page says an updated:, archived: or owner: word once, escaped", async () => {
@@ -197,7 +200,7 @@ describe("did you mean", () => {
197200 try {
198201 const q = encodeURIComponent("garden sort:froks");
199202 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>?`);
201204 const api = (await (await fetch(`${s.url}/api/v1/explore?q=${q}`)).json()) as { suggest?: string };
202205 expect(api.suggest).toBe("garden sort:forks");
203206 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";
2222 import { quietLogs } from "./helpers/globals.ts";
2323 import { proveAll } from "./helpers/verified.ts";
2424 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, "&amp;");
2528
2629 quietLogs();
2730
@@ -267,7 +270,7 @@ describe("explore", () => {
267270 const bad = await api("kind:app owner:..");
268271 expect((bad as { message?: string }).message).toBe("owner:.. is not a name");
269272 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>`);
271274 });
272275
273276 test("a hostile owner stays escaped text on the page", async () => {
@@ -380,7 +383,7 @@ describe("explore", () => {
380383
381384 const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("updated:7d")}`)).text();
382385 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}`)}"`);
384387 expect(page).toContain(label);
385388 }
386389 expect(page).toMatch(/aria-current="page"\s*>This week/);
@@ -423,11 +426,11 @@ describe("explore on a forge with nothing public", () => {
423426 ).text();
424427
425428 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*>`);
427430 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")}"`);
429432 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] ?? "";
431434 expect(tag).toContain("aria-current");
432435 const guest = await get(`/explore?q=${encodeURIComponent("kind:forge")}`, false);
433436 expect(guest).not.toMatch(/>\s*Forges</);
@@ -460,17 +463,17 @@ describe("explore on a forge with nothing public", () => {
460463 test("a miss names the line and offers each term as a link that drops it", async () => {
461464 const out = await get(`/explore?q=${encodeURIComponent("kind:app zzz-no-such")}`, false);
462465 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")}"`);
465468 expect(out).toContain('href="/explore"');
466469 });
467470
468471 test("a window with no hits offers to drop it, and the seg has an Any time chip", async () => {
469472 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")}"`);
472475 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")}"`);
474477 expect(only).toContain("Any time");
475478 expect(only).toMatch(/aria-current="page"\s*>Today/);
476479 const none = await get(`/explore?q=${encodeURIComponent("zzz")}`, false);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs