diff --git a/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts index a84e8eaa..f296e160 100644 --- a/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts @@ -1,9 +1,8 @@ import { describe, test, expect } from "bun:test"; import { runCLI } from "./helpers"; -// These assert on validation error codes that are emitted BEFORE any network -// call (or independently of it), so the suite is network-free: a valid-flag case -// still runs offline because it only checks the ABSENCE of a validation error. +// Invalid flags fail before any request. Valid-flag controls run against a +// loopback fixture and assert the requested filters, not just an absent error. function parsedStderr(stderr: string): { error?: string; code?: string } { try { @@ -51,9 +50,30 @@ describe("freehire CLI flag validation", () => { expect(parsedStderr(result.stderr).code).toBe("BAD_ARG"); }); - test("valid integers produce no BAD_ARG", async () => { - const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]); - expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG"); + test("valid integers construct the requested search offline", async () => { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url)); + return Response.json({ data: [], meta: { total: 0 } }); + }, + }); + try { + const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"], + { FREEHIRE_API_URL: server.url.toString() }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + expect(requests[0].pathname).toBe("/api/v1/agent/jobs/search"); + expect(requests[0].searchParams.get("posted_within_days")).toBe("7"); + expect(requests[0].searchParams.get("limit")).toBe("1"); + expect(requests[0].searchParams.get("offset")).toBe("0"); + expect(JSON.parse(result.stdout)).toEqual({ meta: { count: 0, page: 1, total: 0 }, results: [] }); + } finally { + server.stop(true); + } }); }); diff --git a/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts index af96989f..b7de7894 100644 --- a/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { runCLI } from "./helpers"; +import { runCLI, runSearchFixture } from "./helpers"; const LOCATION = "Copenhagen, Denmark"; @@ -28,9 +28,15 @@ describe("LinkedIn CLI flag validation", () => { }); test("valid integer passes validation", async () => { - const result = await runCLI(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]); - const err = parsedStderr(result.stderr); - expect(err.code).not.toBe("BAD_ARG"); + const { result, requests } = await runSearchFixture(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get("location")).toBe(LOCATION); + expect(requests[0].searchParams.get("f_TPR")).toBe("r604800"); + const output = JSON.parse(result.stdout); + expect(output.meta.count).toBe(1); + expect(output.results.map((job: { id: string }) => job.id)).toEqual(["4425498329"]); }); // Fractional values must be rejected, not truncated: parseInt("0.5") is 0, @@ -126,12 +132,18 @@ describe("LinkedIn CLI flag validation", () => { expect(err.code).toBe("NO_LOCATION"); }); - test("all valid flags produce no BAD_ARG", async () => { - const result = await runCLI([ + test("all valid flags construct the requested search offline", async () => { + const { result, requests } = await runSearchFixture([ "search", "-l", LOCATION, "--jobage", "7", "--page", "1", "--limit", "5", ]); - const err = parsedStderr(result.stderr); - expect(err.code).not.toBe("BAD_ARG"); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get("f_TPR")).toBe("r604800"); + expect(requests[0].searchParams.get("start")).toBe("0"); + const output = JSON.parse(result.stdout); + expect(output.meta).toEqual({ count: 2, page: 1 }); + expect(output.results.map((job: { id: string }) => job.id)).toEqual(["4425498329", "4425494976"]); }); }); diff --git a/.agents/skills/linkedin-search/cli/tests/fixtures/search-cards.json b/.agents/skills/linkedin-search/cli/tests/fixtures/search-cards.json new file mode 100644 index 00000000..04f2848d --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/fixtures/search-cards.json @@ -0,0 +1 @@ +"
  • \n \n \n\n \n \n \n
    \n \n\n \n \n \n \n \n Artificial Intelligence Engineer\n \n \n \n \n\n \n \n
    \n \n \n \n
    \n \n\n
    \n

    \n \n Artificial Intelligence Engineer\n \n

    \n\n

    \n \n \n Humans.tech\n \n \n

    \n\n\n
    \n \n \n Italy\n \n\n \n \n \n \n \n\n
    \n \n \n Actively Hiring\n \n
    \n \n\n \n\n \n
    \n
    \n \n \n
    \n \n \n \n \n
  • \n
  • \n \n \n\n \n \n \n
    \n \n\n \n \n \n \n \n Machine Learning Engineer\n \n \n \n \n\n \n \n
    \n \n \n \n
    \n \n\n
    \n

    \n \n Machine Learning Engineer\n \n

    \n\n

    \n \n \n Humans.tech\n \n \n

    \n\n\n
    \n \n \n Italy\n \n\n \n \n \n \n \n\n
    \n \n \n Actively Hiring\n \n
    \n \n\n \n\n \n
    \n
    \n \n \n
    \n \n \n \n \n
  • \n" diff --git a/.agents/skills/linkedin-search/cli/tests/fixtures/search-cards.provenance.md b/.agents/skills/linkedin-search/cli/tests/fixtures/search-cards.provenance.md new file mode 100644 index 00000000..a9e4345a --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/fixtures/search-cards.provenance.md @@ -0,0 +1,20 @@ +# Offline search cards + +`search-cards.json` stores the HTML string for the first two verbatim `
  • ` cards from a real +LinkedIn guest search response captured by **portapulito** through the CLI's +`htmlFetch` on 2026-07-14 and published in [PR #153](https://github.com/MadsLorentzen/ai-job-search/pull/153). +That PR was closed as an accidental personalized-fork submission, not merged. + +Source: `.agents/skills/linkedin-search/cli/test/__fixtures__/search-list.html` +at commit `5bc3afb94d19b670ecf09287c20d0c95e5d13ba0`. +Original response SHA-256: `ff5fb9009f26c8f602b1ac4b018f471e76784300723b22db64d9d19c2b7f557c`. +Two-card excerpt SHA-256: `1ab95e6ccb38b50d922e4d714ff95bb58219c012489a3a94aca5e0adb7b552fb`. + +The JSON string encoding preserves the original HTML whitespace without adding +trailing whitespace to source lines. The excerpt hash above is of the decoded HTML. + +The valid-flag controls serve these saved cards over loopback HTTP. Their child +CLI preload accepts only the expected search endpoint and redirects that request +to loopback. Tests assert the actual URL parameters, successful output, IDs and +client-side result limits. No fixture regeneration or portal request occurs. +This isolates these controls; it is not an OS-level egress policy for the entire CI. diff --git a/.agents/skills/linkedin-search/cli/tests/helpers.ts b/.agents/skills/linkedin-search/cli/tests/helpers.ts index 75b82b9d..dabe96b9 100644 --- a/.agents/skills/linkedin-search/cli/tests/helpers.ts +++ b/.agents/skills/linkedin-search/cli/tests/helpers.ts @@ -1,4 +1,6 @@ import { join } from "path"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); @@ -8,8 +10,8 @@ export interface CLIResult { exitCode: number; } -export async function runCLI(args: string[]): Promise { - const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { +export async function runCLI(args: string[], preload?: string): Promise { + const proc = Bun.spawn(["bun", "run", ...(preload ? ["--preload", preload] : []), CLI_PATH, ...args], { stdout: "pipe", stderr: "pipe", }); @@ -23,6 +25,37 @@ export async function runCLI(args: string[]): Promise { return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; } +/** Run valid search controls through the real CLI with a saved, offline response. */ +export async function runSearchFixture(args: string[]): Promise<{ result: CLIResult; requests: URL[] }> { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + requests.push(new URL(request.url)); + return new Response(await Bun.file(join(import.meta.dir, "fixtures/search-cards.json")).json()); + }, + }); + const dir = mkdtempSync(join(tmpdir(), "linkedin-valid-flags-")); + 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)); + if (url.origin !== "https://www.linkedin.com" || url.pathname !== "/jobs-guest/jobs/api/seeMoreJobPostings/search") { + throw new Error("Unexpected search fixture request"); + } + return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init); + }; + `); + try { + return { result: await runCLI(args, preload), requests }; + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + } +} + export function parseJSON(result: CLIResult): T { if (result.exitCode !== 0) { throw new Error( diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3138eb..bc53e744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -324,6 +324,8 @@ per-file diff commands. unchanged, an absent one stays `""`. The JSON-LD test that pinned the raw `

    ...

    ` now expects the text, plus two new cases; the HTML case fails on master. +- **Valid portal flag tests stay offline** (#581) - success controls use local fixtures instead of depending on live job portals or their credentials. + - **`job_key.py` no longer gives two postings one key when a title or company is partly non-Latin** (`tools/job_key.py`, `tests/test_job_key.py`) - the hash fallbacks from #487 and #502 fired only when the slug was completely empty, so a mixed-script name kept its