feat: add skills

This commit is contained in:
Prad Nukala committed 2026-08-26 10:35:34 -04:00
1 parent 6984212b17
commit c50aa92862
150 files changed
+12702

No files matched your search

@@ -0,0 +1,96 @@
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.
function parsedStderr(stderr: string): { error?: string; code?: string } {
try {
return JSON.parse(stderr);
} catch {
return {};
}
}
describe("freehire CLI flag validation", () => {
describe("numeric flag validation", () => {
for (const name of ["jobage", "page", "limit"]) {
test(`--${name} non-numeric exits 1 with BAD_ARG`, async () => {
const result = await runCLI(["search", `--${name}`, "foo"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(new RegExp(name));
});
}
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");
});
});
describe("--description-format validation", () => {
test("an unsupported format exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "--description-format", "tekst"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/description-format/);
});
});
describe("--facet validation", () => {
test("a facet without '=' exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "--facet", "novalue"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
});
});
describe("detail argument validation", () => {
test("missing slug exits 1 with NO_ID", async () => {
const result = await runCLI(["detail"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("NO_ID");
});
test("an unparseable slug exits 1 with BAD_ID (no network)", async () => {
const result = await runCLI(["detail", "not a slug!"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_ID");
});
});
describe("command dispatch", () => {
test("unknown command exits 1 with BAD_CMD", async () => {
const result = await runCLI(["frobnicate"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_CMD");
});
test("no command prints help and exits 1", async () => {
const result = await runCLI([]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toMatch(/USAGE/);
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--query", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
});
+234
View File
@@ -0,0 +1,234 @@
import { afterEach, describe, expect, test } from "bun:test";
import { runSearch } from "../src/commands/search";
import { runDetail } from "../src/commands/detail";
import type { FreehireJob } from "../src/helpers";
const originalFetch = globalThis.fetch;
const originalStdoutWrite = process.stdout.write;
function captureStdout(): { get: () => string } {
let buf = "";
process.stdout.write = ((chunk: string | Uint8Array) => {
buf += chunk.toString();
return true;
}) as typeof process.stdout.write;
return { get: () => buf };
}
/** Stub fetch with a canned response; the return value exposes the URL it was called with. */
function mockFetch(status: number, body: unknown): { url: () => string } {
let requested = "";
globalThis.fetch = (async (input: string | URL | Request) => {
requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return { url: () => requested };
}
/** The query params of the URL the mocked fetch was called with. */
function requestedParams(mock: { url: () => string }): URLSearchParams {
return new URL(mock.url()).searchParams;
}
function captureStderr(): { get: () => string; restore: () => void } {
let buf = "";
const original = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
buf += chunk.toString();
return true;
}) as typeof process.stderr.write;
return { get: () => buf, restore: () => (process.stderr.write = original) };
}
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
return {
public_slug: "backend-engineer-acme-ab12cd34",
source: "greenhouse",
external_id: "acme:1",
url: "https://boards.greenhouse.io/acme/jobs/1",
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
description: "<p>Build things</p>",
skills: ["go"],
work_mode: "remote",
regions: ["eu"],
countries: ["de"],
cities: ["Berlin"],
posted_at: "2026-07-06T00:00:00Z",
created_at: "2026-07-06T15:00:00Z",
enrichment: {},
...overrides,
};
}
afterEach(() => {
globalThis.fetch = originalFetch;
process.stdout.write = originalStdoutWrite;
});
const searchOpts = {
jobage: 9999,
page: 1,
limit: 25,
format: "json" as const,
descriptionFormat: "markdown" as const,
regions: [] as string[],
countries: [] as string[],
cities: [] as string[],
seniority: [] as string[],
category: [] as string[],
skills: [] as string[],
facets: {} as Record<string, string[]>,
};
describe("runSearch (mocked fetch)", () => {
test("emits the contract envelope with meta.count/page/total", async () => {
mockFetch(200, { data: [job()], meta: { total: 42, limit: 25, offset: 0 } });
const out = captureStdout();
const code = await runSearch({ ...searchOpts, query: "backend" });
expect(code).toBe(0);
const parsed = JSON.parse(out.get());
expect(parsed.meta).toEqual({ count: 1, page: 1, total: 42 });
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].id).toBe("backend-engineer-acme-ab12cd34");
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
});
test("queries the agent endpoint asking for full descriptions", async () => {
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
captureStdout();
await runSearch({ ...searchOpts, query: "backend" });
expect(new URL(mock.url()).pathname).toBe("/api/v1/agent/jobs/search");
expect(requestedParams(mock).get("include_description")).toBe("true");
expect(requestedParams(mock).get("description_format")).toBe("markdown");
});
test("skips description hydration when includeDescription is false", async () => {
// A default search hydrates ~20k tokens of description bodies per query,
// while /scrape's Step 2 says to pre-filter by title/snippet before
// reading bodies. --no-description keeps the discovery pass cheap;
// hydration stays the default (review opportunity O1, 2026-08-19).
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
const out = captureStdout();
await runSearch({ ...searchOpts, query: "backend", includeDescription: false });
expect(requestedParams(mock).get("include_description")).toBe("false");
expect(requestedParams(mock).get("description_format")).toBeNull();
// The live API ignores include_description=false and sends bodies anyway
// (verified 2026-08-19), so the lean guarantee is enforced client-side.
const parsed = JSON.parse(out.get());
expect(parsed.results[0].description).toBeNull();
});
test("asks for the requested description format", async () => {
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
captureStdout();
await runSearch({ ...searchOpts, descriptionFormat: "text", query: "backend" });
expect(requestedParams(mock).get("description_format")).toBe("text");
});
test("carries each hit's description verbatim, in the server's format", async () => {
const markdown = "## About the role\n\n- Write Go\n- Ship things";
mockFetch(200, { data: [job({ description: markdown })], meta: { total: 1 } });
const out = captureStdout();
await runSearch({ ...searchOpts, query: "backend" });
expect(JSON.parse(out.get()).results[0].description).toBe(markdown);
});
test("a hit with no description carries null, not an empty string", async () => {
mockFetch(200, { data: [job({ description: "" })], meta: { total: 1 } });
const out = captureStdout();
await runSearch({ ...searchOpts, query: "backend" });
expect(JSON.parse(out.get()).results[0].description).toBeNull();
});
// A self-hosted freehire predating /agent/jobs/search answers 404, which apiGet
// maps to null. Reporting that as "no results" would hide a broken endpoint
// behind an empty, plausible-looking result set.
test("a 404 from the search endpoint is an error, not an empty result set", async () => {
mockFetch(404, { error: "not found" });
const err = captureStderr();
const out = captureStdout();
const code = await runSearch({ ...searchOpts, query: "backend" });
err.restore();
expect(code).toBe(1);
expect(out.get()).toBe("");
expect(JSON.parse(err.get()).error).toMatch(/agent\/jobs\/search/);
});
test("empty result set yields an empty results array", async () => {
mockFetch(200, { data: [], meta: { total: 0 } });
const out = captureStdout();
const code = await runSearch({ ...searchOpts, query: "nothing-matches-xyz" });
expect(code).toBe(0);
expect(JSON.parse(out.get()).results).toHaveLength(0);
});
test("network failure exits 1 with SEARCH_FAILED", async () => {
globalThis.fetch = (async () => {
throw new Error("ECONNREFUSED");
}) as typeof fetch;
let err = "";
const origErr = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
err += chunk.toString();
return true;
}) as typeof process.stderr.write;
const code = await runSearch({ ...searchOpts, query: "backend" });
process.stderr.write = origErr;
expect(code).toBe(1);
expect(JSON.parse(err).code).toBe("SEARCH_FAILED");
});
});
describe("runDetail (mocked fetch)", () => {
test("prints the reshaped detail with a cleaned description", async () => {
mockFetch(200, { data: job() });
const out = captureStdout();
const code = await runDetail({ id: "backend-engineer-acme-ab12cd34", format: "json" });
expect(code).toBe(0);
const parsed = JSON.parse(out.get());
expect(parsed.id).toBe("backend-engineer-acme-ab12cd34");
expect(parsed.description).toBe("Build things");
expect(parsed.cities).toEqual(["Berlin"]);
});
test("404 exits 1 with NOT_FOUND", async () => {
mockFetch(404, { error: "not found" });
let err = "";
const origErr = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
err += chunk.toString();
return true;
}) as typeof process.stderr.write;
const code = await runDetail({ id: "does-not-exist", format: "json" });
process.stderr.write = origErr;
expect(code).toBe(1);
expect(JSON.parse(err).code).toBe("NOT_FOUND");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[], env: Record<string, string> = {}): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, ...env },
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, test, expect } from "bun:test";
import { cleanHtml, normalizeSlug, toResult, toDetail, type FreehireJob } from "../src/helpers";
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
return {
public_slug: "backend-engineer-acme-ab12cd34",
source: "greenhouse",
external_id: "acme:1",
url: "https://boards.greenhouse.io/acme/jobs/1",
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
description: "<ul><li>Build APIs</li><li>Ship &amp; iterate</li></ul>",
skills: ["go", "kubernetes"],
work_mode: "remote",
regions: ["eu"],
countries: ["de"],
cities: ["Berlin"],
posted_at: "2026-07-06T00:00:00Z",
created_at: "2026-07-06T15:00:00Z",
enrichment: {},
...overrides,
};
}
describe("toResult — reshape into the portal-skill contract", () => {
test("maps public_slug -> id and posted_at -> date", () => {
const r = toResult(job());
expect(r.id).toBe("backend-engineer-acme-ab12cd34");
expect(r.date).toBe("2026-07-06T00:00:00Z");
});
test("carries the required contract fields", () => {
const r = toResult(job());
expect(r).toMatchObject({
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
url: "https://boards.greenhouse.io/acme/jobs/1",
});
});
test("missing values are null, not omitted", () => {
const r = toResult(job({ company: "", location: "", posted_at: null, work_mode: undefined }));
expect(r.company).toBeNull();
expect(r.location).toBeNull();
expect(r.date).toBeNull();
expect(r.work_mode).toBeNull();
});
});
describe("toDetail — adds cleaned description + enrichment", () => {
test("strips HTML and decodes entities in the description", () => {
const d = toDetail(job());
expect(d.description).toBe("Build APIs\nShip & iterate");
});
test("surfaces enrichment fields and a formatted salary", () => {
const d = toDetail(
job({
enrichment: { seniority: "senior", category: "backend", employment_type: "full_time", salary_min: 90000, salary_max: 120000, salary_currency: "EUR" },
}),
);
expect(d.seniority).toBe("senior");
expect(d.category).toBe("backend");
expect(d.salary).toBe("EUR 90000–120000");
});
test("null enrichment fields when the enrichment object is empty", () => {
const d = toDetail(job({ enrichment: {} }));
expect(d.seniority).toBeNull();
expect(d.salary).toBeNull();
});
});
describe("cleanHtml", () => {
test("preserves paragraph breaks between blocks", () => {
expect(cleanHtml("<p>One</p><p>Two</p>")).toBe("One\nTwo");
});
test("decodes hex numeric entities", () => {
expect(cleanHtml("Caf&#xE9;")).toBe("Café");
});
test("returns null for empty input", () => {
expect(cleanHtml("")).toBeNull();
expect(cleanHtml(null)).toBeNull();
});
});
describe("normalizeSlug", () => {
test("accepts a bare slug", () => {
expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("extracts the slug from a /jobs/<slug> URL", () => {
expect(normalizeSlug("https://freehire.me/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("rejects a non-slug string", () => {
expect(normalizeSlug("not a slug!")).toBeNull();
expect(normalizeSlug("")).toBeNull();
});
});
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiGet } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert the request
// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("apiGet request timeout", () => {
test("passes an AbortSignal timeout to fetch", async () => {
let init: RequestInit | undefined;
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
init = i;
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
await apiGet("/jobs");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiGet } from "../src/helpers";
// The portal contract requires backoff on 429/5xx. These tests pin the retry
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
// fires immediately so the exhaustion case does not sleep through the real
// 500ms -> 8s backoff schedule. apiGet's documented graceful-degradation
// contract (connection failures fail fast, no retry) is pinned too.
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
afterEach(() => {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
});
function instantTimers() {
globalThis.setTimeout = ((fn: () => void) =>
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
}
function stubFetch(responses: Array<() => Response>): { calls: number } {
const state = { calls: 0 };
globalThis.fetch = (async () => {
const i = Math.min(state.calls, responses.length - 1);
state.calls++;
return responses[i]();
}) as unknown as typeof fetch;
return state;
}
describe("apiGet retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response('{"data":[]}', { status: 200 }),
]);
const envelope = await apiGet<unknown[]>("/x");
expect(envelope).not.toBeNull();
expect(state.calls).toBe(2);
});
test("returns the documented null on 404 without retrying", async () => {
const state = stubFetch([() => new Response("", { status: 404 })]);
const envelope = await apiGet("/x");
expect(envelope).toBeNull();
expect(state.calls).toBe(1);
});
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
instantTimers();
const state = stubFetch([() => new Response("", { status: 500 })]);
await expect(apiGet("/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
test("fails fast on a connection error - no retry, per the graceful-degradation contract", async () => {
const state = { calls: 0 };
globalThis.fetch = (async () => {
state.calls++;
throw new TypeError("Unable to connect");
}) as unknown as typeof fetch;
await expect(apiGet("/x")).rejects.toThrow(/could not reach the freehire API/);
expect(state.calls).toBe(1);
});
});