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,30 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
function parseError(stderr: string): { error: string; code: string } {
return JSON.parse(stderr);
}
describe("Jobbank CLI error contract", () => {
test("search without a filter fails with JSON on stderr", async () => {
const result = await runCLI(["search"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(parseError(result.stderr)).toEqual({
error: "--key or at least one filter is required",
code: "MISSING_REQUIRED",
});
});
test("detail without an ID fails before making a request", async () => {
const result = await runCLI(["detail"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(parseError(result.stderr)).toEqual({
error: "Job ID is required",
code: "MISSING_REQUIRED",
});
});
});
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
// dropped the last result instead of erroring. The --company filter flag
// also accepted negative and fractional values that were sent raw to the
// portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.ok).toBe(false);
expect(error.error.kind).toBe("validation");
expect(error.error.option).toBe(option);
}
describe("Jobbank CLI flag validation", () => {
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=-1"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --limit=0 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=0"]);
expectValidationError(result, "limit");
});
test("search --limit=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--limit=1.5"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("search --company=-1 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--company=-1"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --company=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--company=1.5"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => {
const result = await runCLI(["search", "--limit=5"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--key or at least one filter is required",
code: "MISSING_REQUIRED",
});
});
});
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", "--key", "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");
});
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
const result = await runCLI(["search", "--query", "test"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { parseJobPostingJsonLd } from "../src/helpers"
const script = (value: string) => `<script type="application/ld+json">${value}</script>`
describe("parseJobPostingJsonLd", () => {
test("finds a JobPosting inside an @graph", () => {
const html = script(
JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebPage", name: "Jobs" },
{ "@type": "JobPosting", title: "Data Engineer" },
],
}),
)
expect(parseJobPostingJsonLd(html)).toEqual({
"@type": "JobPosting",
title: "Data Engineer",
})
})
test("preserves top-level object and array support", () => {
expect(parseJobPostingJsonLd(script('{"@type":"JobPosting","title":"One"}'))?.title).toBe("One")
expect(
parseJobPostingJsonLd(script('[{"@type":"WebPage"},{"@type":"JobPosting","title":"Two"}]'))?.title,
).toBe("Two")
})
test("skips malformed scripts and checks later JSON-LD", () => {
const html = `${script("{not-json")}${script('{"@type":"JobPosting","title":"Valid"}')}`
expect(parseJobPostingJsonLd(html)?.title).toBe("Valid")
})
test("returns null when no JobPosting exists", () => {
expect(parseJobPostingJsonLd(script('{"@graph":[{"@type":"WebPage"}]}'))).toBeNull()
})
})
+39
View File
@@ -0,0 +1,39 @@
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[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
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}`
);
}
}
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, test } from "bun:test";
import { fetchWithUA } 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("fetchWithUA 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("<rss></rss>", { status: 200 });
}) as unknown as typeof fetch;
await fetchWithUA("https://jobbank.dk/job/rss");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, test } from "bun:test";
import { fetchWithUA } 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 -> 5s backoff schedule.
//
// fetchWithUA deliberately RETURNS non-retry statuses instead of throwing -
// callers own 4xx handling (e.g. rssFetch's Cloudflare 403 message). The 4xx
// test pins that contract.
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("fetchWithUA retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response("ok", { status: 200 }),
]);
const response = await fetchWithUA("https://jobbank.dk/x");
expect(response.status).toBe(200);
expect(state.calls).toBe(2);
});
test("returns a plain 4xx to the caller without retrying", async () => {
const state = stubFetch([() => new Response("", { status: 403 })]);
const response = await fetchWithUA("https://jobbank.dk/x");
expect(response.status).toBe(403);
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(fetchWithUA("https://jobbank.dk/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test";
import { rssFetch } from "../src/helpers";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("rssFetch", () => {
test("reports Cloudflare bot protection clearly", async () => {
globalThis.fetch = (async () =>
new Response("<html><title>Just a moment...</title></html>", {
status: 403,
statusText: "Forbidden",
})) as unknown as typeof fetch;
await expect(rssFetch({ key: "data" })).rejects.toThrow(/Cloudflare bot protection/);
});
});
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
extractJobIdFromUrl,
parseRssDescription,
rssFetch,
USER_AGENT,
} from "../src/helpers";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("rssFetch", () => {
test("serializes repeated filters and parses CDATA and plain RSS fields", async () => {
let requestedUrl = "";
let requestedUserAgent = "";
globalThis.fetch = (async (input, init) => {
requestedUrl = String(input);
requestedUserAgent = new Headers(init?.headers).get("User-Agent") ?? "";
return new Response(`<?xml version="1.0"?>
<rss><channel><item>
<title><![CDATA[Data Scientist]]></title>
<description><![CDATA[Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)]]></description>
<link>https://jobbank.dk/job/12345/acme/data-scientist</link>
<pubDate>Mon, 13 Jul 2026 08:00:00 GMT</pubDate>
</item></channel></rss>`);
}) as typeof fetch;
const items = await rssFetch({ key: "data science", cvtype: ["3", "6"] });
const url = new URL(requestedUrl);
expect(url.pathname).toBe("/job/rss");
expect(url.searchParams.get("key")).toBe("data science");
expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6"]);
expect(requestedUserAgent).toBe(USER_AGENT);
expect(items).toEqual([
{
title: "Data Scientist",
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
link: "https://jobbank.dk/job/12345/acme/data-scientist",
pubDate: "Mon, 13 Jul 2026 08:00:00 GMT",
},
]);
});
});
describe("parseRssDescription", () => {
test("separates multiple job types, company, location, and deadline", () => {
expect(
parseRssDescription(
"Fuldtidsjob, Graduate/trainee hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
),
).toEqual({
jobType: "Fuldtidsjob, Graduate/trainee",
company: "Acme A/S",
location: "København",
deadline: "2026-07-31",
});
});
test("passes an unrecognized deadline shape through for downstream defensive parsing", () => {
const parsed = parseRssDescription(
"Fuldtidsjob hos Acme A/S, Odense (Ansøgningsfrist: snarest muligt)",
);
expect(parsed.deadline).toBe("snarest muligt");
});
test("normalizes a rolling deadline to null", () => {
expect(
parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"),
).toEqual({
jobType: "Deltidsjob",
company: "Example ApS",
location: "Aarhus",
deadline: null,
});
});
test("keeps an unstructured description as the company fallback", () => {
expect(parseRssDescription("Unstructured feed value")).toEqual({
jobType: "",
company: "Unstructured feed value",
location: "",
deadline: null,
});
});
});
describe("extractJobIdFromUrl", () => {
test("extracts numeric IDs and rejects unsupported URL shapes", () => {
expect(extractJobIdFromUrl("https://jobbank.dk/job/12345/acme/role")).toBe("12345");
expect(extractJobIdFromUrl("https://jobbank.dk/job/not-a-number/acme/role")).toBe("");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { normalizeSearchItem } from "../src/commands/search";
import type { RssItem } from "../src/helpers";
function rssItem(): RssItem {
return {
title: "Data Scientist",
description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)",
link: "https://jobbank.dk/job/12345/acme/data-scientist",
pubDate: "Fri, 14 Aug 2026 09:30:00 +0200",
};
}
describe("Jobbank search normalization", () => {
test("derives the /scrape contract date from posted as YYYY-MM-DD", () => {
const result = normalizeSearchItem(rssItem());
expect(result.posted).toBe("2026-08-14T07:30:00.000Z");
expect(result.date).toBe((result.posted as string).slice(0, 10));
expect(result.date).toBe("2026-08-14");
});
test("emits a null date when pubDate is absent (posted is empty)", () => {
const result = normalizeSearchItem({ ...rssItem(), pubDate: "" });
expect(result.posted).toBe("");
expect(result.date).toBeNull();
});
test("keeps the native fields alongside the contract date (additive)", () => {
const result = normalizeSearchItem(rssItem());
expect(result.company).toBe("Acme A/S");
expect(result.location).toBe("København");
expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist");
expect(result.deadline).toBe("2026-07-31");
});
});