diff --git a/.agents/skills/jobbank-search/cli/src/commands/search.ts b/.agents/skills/jobbank-search/cli/src/commands/search.ts index cfc064bf..1b1eb89f 100644 --- a/.agents/skills/jobbank-search/cli/src/commands/search.ts +++ b/.agents/skills/jobbank-search/cli/src/commands/search.ts @@ -37,21 +37,27 @@ export const search = defineCommand({ description: "Exclude keywords (antikey)", }), type: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Job type code (cvtype). Repeatable: --type 3 --type 6", }), education: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Education field code (udd). Repeatable.", }), location: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Region code (amt). Repeatable.", }), "work-area": option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Work area / function code (erf). Repeatable.", }), industry: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Industry code (branche). Repeatable.", }), "suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Suitable-for code (andet). Repeatable.", }), company: option(z.coerce.number().int().min(1).optional(), { diff --git a/.agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts b/.agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts new file mode 100644 index 00000000..0245c4b4 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +async function search(args: string[]): Promise { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url); + // Empty protocol fixtures isolate option serialization, not HTML parsing. + return new Response(url.pathname === "/job/rss" + ? '' + : '0 relevante job'); + }, + }); + const dir = mkdtempSync(join(tmpdir(), "jobbank-repeatable-")); + const preload = join(dir, "fetch.ts"); + writeFileSync(preload, ` + const originalFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + // Bunli's bundled layout engine may load its local WASM as a data URL. + if (url.protocol === "data:") return originalFetch(input, init); + if (url.origin !== "https://jobbank.dk" || !["/job/rss", "/job/"].includes(url.pathname)) { + throw new Error("Unexpected fixture request"); + } + return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init); + }; + `); + try { + const proc = Bun.spawn([ + process.execPath, "run", "--preload", preload, + join(import.meta.dir, "../src/cli.ts"), "search", ...args, + ], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ meta: { total: 0 }, results: [] }); + expect(requests.map((url) => url.pathname)).toEqual(["/job/rss", "/job/"]); + return requests; + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + } +} + +// SKILL.md documents repeated values as OR filters, including the concrete +// industry/location example. Assert the actual CLI's RSS and count requests; +// rssFetch({ key: [a, b] }) alone bypasses the argument parser that lost a. +const filters = [ + ["industry", "branche", "10331", "10358"], + ["location", "amt", "2", "8"], + ["type", "cvtype", "3", "6"], + ["education", "udd", "24", "21"], + ["work-area", "erf", "31", "43"], + ["suitable-for", "andet", "2", "4"], +]; + +for (const [flag, parameter, first, second] of filters) { + test(`search preserves repeated --${flag} values in both portal requests`, async () => { + for (const url of await search([`--${flag}`, first, `--${flag}`, second])) { + expect(url.searchParams.getAll(parameter)).toEqual([first, second]); + } + }); +} + +test("mixed equals-form and comma-separated repeated values are retained", async () => { + for (const url of await search(["--type=3,6", "--type=13"])) { + expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6", "13"]); + } +}); + +test("a single value and a comma list keep their existing request shape", async () => { + for (const url of await search(["--industry", "10331", "--location=2,8"])) { + expect(url.searchParams.getAll("branche")).toEqual(["10331"]); + expect(url.searchParams.getAll("amt")).toEqual(["2", "8"]); + } +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index f21c2a5a..cc0fd264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,8 @@ per-file diff commands. a longer number is still caught. Two new `test_verify_pdf.py` cases; the numeric one fails on the unbounded pattern. +- **`jobbank-search` preserves repeated filter values** (#577) - repeated documented filter options now retain all supplied values instead of only the last value. + - **`check_framework_version.py`'s git diff read no longer crashes on non-ASCII framework-file content** (`tools/check_framework_version.py`) - `run_git()` called `subprocess.run(text=True)` without an explicit `encoding`, so output decoded via the host