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

Merged opened this

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

Merged

scout/forge:scout/9075-explore-says-when-an-updated-archived-or → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/9075-explore-says-when-an-updated-archived-or was merged into main as 7f48b7b.

Review

Sign in to review this change.
3 files changed +80 −11
src/actions/explore.ts +17 −9
@@ -101,7 +101,7 @@ export function isExploreLine(line: string): boolean {
101101 return EXPLORE_KEYS.test(line);
102102 }
103103
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. */
105105 export interface ExploreRead {
106106 readonly query: ExploreQuery;
107107 readonly unknown: readonly string[];
@@ -122,19 +122,23 @@ export function readExploreLine(line: string): ExploreRead {
122122 const [key, value = ""] = word.split(/:(.*)/s);
123123 const k = (key ?? "").toLowerCase();
124124 if (k === "kind" && value !== "") {
125 const known = KINDS[value.toLowerCase()];
125 const known = Object.hasOwn(KINDS, value.toLowerCase()) ? KINDS[value.toLowerCase()] : undefined;
126126 if (known === undefined) unknown.push(word);
127127 else kind = known;
128128 } else if (k === "sort" && value !== "") {
129129 if ((SORTS as readonly string[]).includes(value.toLowerCase()))
130130 sort = value.toLowerCase() as ExploreSort;
131131 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);
138142 else words.push(word);
139143 }
140144 return {
@@ -160,8 +164,12 @@ const KIND_WORDS: readonly ExploreKind[] = [...new Set(Object.values(KINDS))];
160164 export function exploreNote(unknown: readonly string[]): string {
161165 return unknown
162166 .map((word) => {
163 const sort = word.toLowerCase().startsWith("sort:");
167 const key = word.slice(0, word.indexOf(":")).toLowerCase();
164168 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";
165173 return `${shown} is not a ${sort ? "sort" : "kind"} — use ${(sort ? SORTS : KIND_WORDS).join(", ")}`;
166174 })
167175 .join("; ");
test/explore-unknown-words.test.ts +59
@@ -25,6 +25,53 @@ describe("readExploreLine", () => {
2525 expect(exploreLine(read.query)).toBe("garden");
2626 });
2727
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");

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.

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
2875 test("known words yield none", () => {
2976 expect(readExploreLine("sort:forks kind:apps garden").unknown).toEqual([]);
3077 expect(exploreNote([])).toBe("");
@@ -60,6 +107,15 @@ describe("the page and the API", () => {
60107 expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app garden")}"`);
61108 });
62109
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:&lt;b&gt; is not a name");
116 expect(out).not.toContain("<b>");
117 });
118
63119 test("a known word draws no note", async () => {
64120 const out = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent("sort:forks")}`)).text();
65121 expect(out).not.toContain("is not a");
@@ -100,5 +156,8 @@ describe("the page and the API", () => {
100156 };
101157 expect((await call("sort:bogus")).note).toContain("sort:bogus is not a sort");
102158 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");
103162 });
104163 });
test/explore.test.ts +4 −2
@@ -54,7 +54,8 @@ describe("the one line", () => {
5454 );
5555 expect(parseExploreQuery("updated:7d").updated).toBe("7d");
5656 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:");
5859 expect(exploreLine(parseExploreQuery("updated:30d kind:app garden"))).toBe(
5960 "kind:app updated:30d garden",
6061 );
@@ -263,7 +264,8 @@ describe("explore", () => {
263264 expect(names((await api("kind:app owner:alice")).items).sort()).toEqual(["alice/gated", "alice/loved", "alice/quiet"]);
264265 expect(names((await api("kind:app owner:bob")).items)).toEqual(["bob/busy"]);
265266 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");
267269 const page = await (await fetch(`${base}/explore?q=kind:person`)).text();
268270 expect(page).toContain(`href="/explore?q=${encodeURIComponent("kind:app owner:bob")}">1 app</a>`);
269271 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs