Public: anyone can read this; the crew can change it.
Explore says when an updated:, archived: or owner: word is not one it takes, instead of matching nothing as text #9131
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9075-explore-says-when-an-updated-archived-or → main 4 commits fork 1 thread · 1 unresolved
3 files
changed +80
−11
src/actions/explore.ts +17 −9
| @@ -101,7 +101,7 @@ export function isExploreLine(line: string): boolean { | ||
| 101 | 101 | return EXPLORE_KEYS.test(line); |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | /** The query and the `kind:`/`sort:` words it did not know, as typed. */ | |
| 104 | /** The query and the `kind:`, `sort:`, `updated:`, `archived:` and `owner:` words it did not take, as typed. */ | |
| 105 | 105 | export interface ExploreRead { |
| 106 | 106 | readonly query: ExploreQuery; |
| 107 | 107 | readonly unknown: readonly string[]; |
| @@ -122,19 +122,23 @@ export function readExploreLine(line: string): ExploreRead { | ||
| 122 | 122 | const [key, value = ""] = word.split(/:(.*)/s); |
| 123 | 123 | const k = (key ?? "").toLowerCase(); |
| 124 | 124 | if (k === "kind" && value !== "") { |
| 125 | const known = KINDS[value.toLowerCase()]; | |
| 125 | const known = Object.hasOwn(KINDS, value.toLowerCase()) ? KINDS[value.toLowerCase()] : undefined; | |
| 126 | 126 | if (known === undefined) unknown.push(word); |
| 127 | 127 | else kind = known; |
| 128 | 128 | } else if (k === "sort" && value !== "") { |
| 129 | 129 | if ((SORTS as readonly string[]).includes(value.toLowerCase())) |
| 130 | 130 | sort = value.toLowerCase() as ExploreSort; |
| 131 | 131 | else unknown.push(word); |
| 132 | } else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase())) | |
| 133 | archived = value.toLowerCase() === "yes"; | |
| 134 | else if (k === "updated" && value.toLowerCase() in WINDOWS) | |
| 135 | updated = value.toLowerCase() as ExploreWindow; | |
| 136 | else if (k === "owner" && isSafeName(value)) owner = value; | |
| 137 | else if (k === "q" && value !== "") words.push(value); | |
| 132 | } else if (k === "archived" && value !== "") { | |
| 133 | if (["yes", "no"].includes(value.toLowerCase())) archived = value.toLowerCase() === "yes"; | |
| 134 | else unknown.push(word); | |
| 135 | } else if (k === "updated" && value !== "") { | |
| 136 | if (Object.hasOwn(WINDOWS, value.toLowerCase())) updated = value.toLowerCase() as ExploreWindow; | |
| 137 | else unknown.push(word); | |
| 138 | } else if (k === "owner" && value !== "") { | |
| 139 | if (isSafeName(value)) owner = value; | |
| 140 | else unknown.push(word); | |
| 141 | } else if (k === "q" && value !== "") words.push(value); | |
| 138 | 142 | else words.push(word); |
| 139 | 143 | } |
| 140 | 144 | return { |
| @@ -160,8 +164,12 @@ const KIND_WORDS: readonly ExploreKind[] = [...new Set(Object.values(KINDS))]; | ||
| 160 | 164 | export function exploreNote(unknown: readonly string[]): string { |
| 161 | 165 | return unknown |
| 162 | 166 | .map((word) => { |
| 163 | const sort = word.toLowerCase().startsWith("sort:"); | |
| 167 | const key = word.slice(0, word.indexOf(":")).toLowerCase(); | |
| 164 | 168 | const shown = word.length > 40 ? `${word.slice(0, 39)}…` : word; |
| 169 | if (key === "owner") return `${shown} is not a name`; | |
| 170 | if (key === "updated") return `${shown} is not a window — use ${Object.keys(WINDOWS).join(", ")}`; | |
| 171 | if (key === "archived") return `${shown} is not an answer — use yes, no`; | |
| 172 | const sort = key === "sort"; | |
| 165 | 173 | return `${shown} is not a ${sort ? "sort" : "kind"} — use ${(sort ? SORTS : KIND_WORDS).join(", ")}`; |
| 166 | 174 | }) |
| 167 | 175 | .join("; "); |
test/explore-unknown-words.test.ts +59
| @@ -25,6 +25,53 @@ describe("readExploreLine", () => { | ||
| 25 | 25 | expect(exploreLine(read.query)).toBe("garden"); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("an unknown updated: is returned, left out, and worded as a window", () => { | |
| 29 | const read = readExploreLine("updated:2y garden"); | |
| 30 | expect(read.unknown).toEqual(["updated:2y"]); | |
| 31 | expect(read.query.updated).toBe("all"); | |
| 32 | expect(exploreLine(read.query)).toBe("garden"); | |
| 33 | expect(exploreNote(read.unknown)).toBe("updated:2y is not a window — use 1d, 7d, 30d"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("an unknown archived: is returned, left out, and worded", () => { | |
| 37 | const read = readExploreLine("archived:maybe"); | |
| 38 | expect(read.unknown).toEqual(["archived:maybe"]); | |
| 39 | expect(read.query.archived).toBe(false); | |
| 40 | expect(exploreLine(read.query)).toBe(""); | |
| 41 | expect(exploreNote(read.unknown)).toBe("archived:maybe is not an answer — use yes, no"); | |
| 42 | }); | |
| 43 | ||
| 44 | test("an unsafe owner: is returned, left out, and worded as a name", () => { | |
| 45 | const read = readExploreLine("owner:a/b owner:trevato"); | |
| 46 | expect(read.unknown).toEqual(["owner:a/b"]); | |
| 47 | expect(read.query.owner).toBe("trevato"); | |
| 48 | expect(exploreNote(["owner:a/b"])).toBe("owner:a/b is not a name"); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a bare key and a word with a colon stay free text", () => { | |
| 52 | for (const w of ["updated:", "archived:", "owner:", "http://x"]) { | |
| 53 | const read = readExploreLine(w); | |
| 54 | expect(read.unknown).toEqual([]); | |
| 55 | expect(read.query.text).toBe(w); | |
| 56 | } | |
| 57 | }); | |
| 58 | ||
| 59 | test("the API carries the note for all three", async () => { | |
| 60 | const dir = await mkdtemp(join(tmpdir(), "forge-explore-keys-")); | |
| 61 | const a = createApp({ dataDir: dir, port: 0 }); | |
| 62 | const s = await serve(a); | |
| 63 | try { | |
| 64 | const q = encodeURIComponent("owner:trevato updated:2y archived:maybe"); | |
| 65 | const body = (await (await fetch(`${s.url}/api/v1/explore?q=${q}`)).json()) as { note?: string; query: string }; | |
| 66 | expect(body.note).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no"); | |
| 67 | expect(body.query).toBe("owner:trevato"); | |
| 68 | } finally { | |
| 69 | await s.stop(); | |
| 70 | a.db.close(); | |
| 71 | await rm(dir, { recursive: true, force: true }); | |
| 72 | } | |
| 73 | }); | |
| 74 | ||
| 28 | 75 | test("known words yield none", () => { |
| 29 | 76 | expect(readExploreLine("sort:forks kind:apps garden").unknown).toEqual([]); |
| 30 | 77 | expect(exploreNote([])).toBe(""); |
| @@ -60,6 +107,15 @@ describe("the page and the API", () => { | ||
| 60 | 107 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app garden")}"`); |
| 61 | 108 | }); |
| 62 | 109 | |
| 110 | test("the page says an updated:, archived: or owner: word once, escaped", async () => { | |
| 111 | const page = async (q: string) => await (await fetch(`${servers.url}/explore?q=${encodeURIComponent(q)}`)).text(); | |
| 112 | expect(await page("updated:2y")).toContain("updated:2y is not a window — use 1d, 7d, 30d"); | |
| 113 | expect(await page("archived:maybe")).toContain("archived:maybe is not an answer — use yes, no"); | |
| 114 | const out = await page("owner:<b>"); | |
| 115 | expect(out).toContain("owner:<b> is not a name"); | |
| 116 | expect(out).not.toContain("<b>"); | |
| 117 | }); | |
| 118 | ||
| 63 | 119 | test("a known word draws no note", async () => { |
| 64 | 120 | const out = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent("sort:forks")}`)).text(); |
| 65 | 121 | expect(out).not.toContain("is not a"); |
| @@ -100,5 +156,8 @@ describe("the page and the API", () => { | ||
| 100 | 156 | }; |
| 101 | 157 | expect((await call("sort:bogus")).note).toContain("sort:bogus is not a sort"); |
| 102 | 158 | expect((await call("sort:forks")).note).toBeUndefined(); |
| 159 | expect((await call("archived:maybe")).note).toBe("archived:maybe is not an answer — use yes, no"); | |
| 160 | expect((await call("updated:2y")).note).toBe("updated:2y is not a window — use 1d, 7d, 30d"); | |
| 161 | expect((await call("owner:a/b")).note).toBe("owner:a/b is not a name"); | |
| 103 | 162 | }); |
| 104 | 163 | }); |
test/explore.test.ts +4 −2
| @@ -54,7 +54,8 @@ describe("the one line", () => { | ||
| 54 | 54 | ); |
| 55 | 55 | expect(parseExploreQuery("updated:7d").updated).toBe("7d"); |
| 56 | 56 | expect(parseExploreQuery("updated:2y").updated).toBe("all"); |
| 57 | expect(parseExploreQuery("updated:2y").text).toBe("updated:2y"); | |
| 57 | expect(parseExploreQuery("updated:2y").text).toBe(""); | |
| 58 | expect(parseExploreQuery("updated:").text).toBe("updated:"); | |
| 58 | 59 | expect(exploreLine(parseExploreQuery("updated:30d kind:app garden"))).toBe( |
| 59 | 60 | "kind:app updated:30d garden", |
| 60 | 61 | ); |
| @@ -263,7 +264,8 @@ describe("explore", () => { | ||
| 263 | 264 | expect(names((await api("kind:app owner:alice")).items).sort()).toEqual(["alice/gated", "alice/loved", "alice/quiet"]); |
| 264 | 265 | expect(names((await api("kind:app owner:bob")).items)).toEqual(["bob/busy"]); |
| 265 | 266 | expect(names((await api("owner:bob")).items).sort()).toEqual(["bob", "bob/busy"]); |
| 266 | expect((await api("kind:app owner:..")).items).toEqual([]); | |
| 267 | const bad = await api("kind:app owner:.."); | |
| 268 | expect((bad as { note?: string }).note).toBe("owner:.. is not a name"); | |
| 267 | 269 | const page = await (await fetch(`${base}/explore?q=kind:person`)).text(); |
| 268 | 270 | expect(page).toContain(`href="/explore?q=${encodeURIComponent("kind:app owner:bob")}">1 app</a>`); |
| 269 | 271 | }); |
new above
offline
Only the API is asserted. Add the same for the /explore page (note present, HTML-escaped) and for the MCP explore tool's note field, so box 4 is held by tests rather than by shared code alone.