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,27 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
describe("Jobdanmark CLI error contract", () => {
test("detail without a slug fails with JSON on stderr", async () => {
const result = await runCLI(["detail"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(JSON.parse(result.stderr)).toEqual({
error: "slug argument is required",
code: "MISSING_REQUIRED",
});
});
test("an invalid numeric option fails before making a request", async () => {
const result = await runCLI(["search", "--page", "not-a-number"]);
const error = JSON.parse(result.stderr);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(error.ok).toBe(false);
expect(error.error.kind).toBe("validation");
expect(error.error.option).toBe("page");
expect(error.error.message).toContain("Expected number");
});
});
@@ -0,0 +1,97 @@
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. Filter flags (--category,
// --jobtitle-id) 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("Jobdanmark CLI flag validation", () => {
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
const result = await runCLI(["search", "--limit=-1"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --page=0 is rejected on the 1-indexed portal", async () => {
const result = await runCLI(["search", "--page=0"]);
expectValidationError(result, "page");
});
test("search --category=-1 is rejected", async () => {
const result = await runCLI(["search", "--category=-1"]);
expectValidationError(result, "category");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --jobtitle-id=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--jobtitle-id=1.5"]);
expectValidationError(result, "jobtitle-id");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("search --limit=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--limit=1.5"]);
expectValidationError(result, "limit");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("categories --limit=-1 is rejected", async () => {
const result = await runCLI(["categories", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("locations --limit=-1 is rejected", async () => {
const result = await runCLI(["locations", "--query", "test", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("autocomplete --limit=-1 is rejected", async () => {
const result = await runCLI(["autocomplete", "--query", "test", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
const result = await runCLI(["autocomplete", "--limit=3"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--query 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", "--text", "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,96 @@
import { describe, expect, test } from "bun:test";
import { parseJobPostingFromHtml } from "../src/commands/detail";
function pageWithScripts(...scripts: string[]): string {
return `<html><head>${scripts
.map((content) => `<script type="application/ld+json">${content}</script>`)
.join("")}</head><body></body></html>`;
}
describe("parseJobPostingFromHtml JSON-LD", () => {
test("normalizes a JobPosting object and scalar employment type", () => {
const posting = {
"@context": "https://schema.org",
"@type": "JobPosting",
title: "Data Engineer",
datePosted: "2026-07-13",
validThrough: "2026-08-13",
employmentType: "FULL_TIME",
hiringOrganization: { name: "Acme A/S", logo: "https://cdn.example/logo.png" },
jobLocation: {
address: {
streetAddress: "Testvej 1",
addressLocality: "Odense",
addressRegion: "Syddanmark",
postalCode: "5000",
addressCountry: "DK",
},
},
description: "<p>Build reliable pipelines.</p>",
};
const parsed = parseJobPostingFromHtml(
pageWithScripts(JSON.stringify(posting)),
"data-engineer",
"https://jobdanmark.dk/job/data-engineer",
);
expect(parsed).toEqual({
slug: "data-engineer",
url: "https://jobdanmark.dk/job/data-engineer",
title: "Data Engineer",
datePosted: "2026-07-13",
validThrough: "2026-08-13",
employmentType: ["FULL_TIME"],
hiringOrganization: { name: "Acme A/S", logo: "https://cdn.example/logo.png" },
jobLocation: {
streetAddress: "Testvej 1",
addressLocality: "Odense",
addressRegion: "Syddanmark",
postalCode: "5000",
addressCountry: "DK",
},
description: "<p>Build reliable pipelines.</p>",
applyUrl: null,
});
});
test("skips malformed scripts and finds a JobPosting in an array", () => {
const scripts = [
"{not-json",
JSON.stringify([
{ "@type": "BreadcrumbList" },
{ "@type": "JobPosting", title: "Analyst", employmentType: ["FULL_TIME", "PART_TIME"] },
]),
];
const parsed = parseJobPostingFromHtml(
pageWithScripts(...scripts),
"analyst",
"https://jobdanmark.dk/job/analyst",
);
expect(parsed.title).toBe("Analyst");
expect(parsed.employmentType).toEqual(["FULL_TIME", "PART_TIME"]);
expect(parsed.validThrough).toBeNull();
expect(parsed.hiringOrganization).toEqual({ name: "", logo: null });
});
test("recognizes rendered not-found and unparseable pages", () => {
expect(() =>
parseJobPostingFromHtml(
"<html><head><title>404 | Jobdanmark</title></head><body></body></html>",
"missing",
"https://jobdanmark.dk/job/missing",
),
).toThrow("NOT_FOUND");
expect(() =>
parseJobPostingFromHtml(
"<html><head><title>Jobdanmark</title></head><body></body></html>",
"broken",
"https://jobdanmark.dk/job/broken",
),
).toThrow("Failed to parse job page HTML");
});
});
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import { parseJobPostingFromHtml } from "../src/commands/detail";
const HTML_WITHOUT_JSON_LD = `<!doctype html>
<html lang="da">
<head>
<title>Journalistisk udvikler s&#xF8;ges | jobdanmark</title>
</head>
<body>
<div class="job-list-details">
<div class="job-details-head row mx-0">
<div class="company-logo col-auto">
<img src="/media/jfm-logo.png?width=100" alt="JFM">
</div>
<h3 class="title">Journalistisk udvikler s&#xF8;ges</h3>
<a href="/virksomheder/jfm">JFM</a>
<span>Baneg&#xE5;rdspladsen 1, 5000 Odense C</span>
</div>
<p>Hvad nu hvis du med f&#xE5; klik kunne unders&#xF8;ge dit lokalomr&#xE5;de?</p>
<ul>
<li>identificere relevante datas&#xE6;t og muligheder</li>
</ul>
</div>
<a href="https://jfm.career.emply.com/da/apply/example" class="action primary count-click">Ans&#xF8;g nu</a>
<ul class="job-overview list-unstyled">
<li><strong>Udgivet:</strong> 03-07-2026</li>
<li><strong>Jobtype:</strong> Fuldtid</li>
<li><strong>Arbejdssted:</strong> Baneg&#xE5;rdspladsen 1, 5000 Odense C</li>
<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>
</ul>
</body>
</html>`;
describe("parseJobPostingFromHtml", () => {
test("falls back to rendered Jobdanmark HTML when JSON-LD is absent", () => {
const parsed = parseJobPostingFromHtml(
HTML_WITHOUT_JSON_LD,
"journalistisk-udvikler",
"https://jobdanmark.dk/job/journalistisk-udvikler",
);
expect(parsed.title).toBe("Journalistisk udvikler søges");
// The fallback must emit the same shapes as the JSON-LD branch: contract
// dates, not the page's raw DD-MM-YYYY text (review finding F25, 2026-08-19).
expect(parsed.datePosted).toBe("2026-07-03");
expect(parsed.validThrough).toBe("2026-08-02");
expect(parsed.employmentType).toEqual(["Fuldtid"]);
expect(parsed.hiringOrganization.name).toBe("JFM");
expect(parsed.hiringOrganization.logo).toBe("https://jobdanmark.dk/media/jfm-logo.png?width=100");
expect(parsed.jobLocation.streetAddress).toBe("Banegårdspladsen 1, 5000 Odense C");
expect(parsed.jobLocation.addressLocality).toBe("Odense C");
expect(parsed.description).toContain("identificere relevante datasæt");
expect(parsed.applyUrl).toBe("https://jfm.career.emply.com/da/apply/example");
});
test("maps a rolling deadline (Løbende) to null in the HTML fallback", () => {
// "Løbende" is free text meaning rolling/ongoing - jobbank's parser maps
// its equivalent to null, and a stored "Løbende" deadline would hit every
// date-arithmetic consumer (review finding F25, 2026-08-19).
const html = HTML_WITHOUT_JSON_LD.replace(
"<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>",
"<li><strong>Ansøgningsfrist:</strong> Løbende</li>",
);
const parsed = parseJobPostingFromHtml(html, "s", "https://jobdanmark.dk/job/s");
expect(parsed.validThrough).toBeNull();
});
test("does not reject titles containing '404' mid-phrase", () => {
const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace(
"<title>Journalistisk udvikler s&#xF8;ges | jobdanmark</title>",
"<title>HTTP 404 Page Designer | jobdanmark</title>",
).replace(
'<h3 class="title">Journalistisk udvikler s&#xF8;ges</h3>',
'<h3 class="title">HTTP 404 Page Designer</h3>',
);
const parsed = parseJobPostingFromHtml(
htmlWith404InTitle,
"http-404-designer",
"https://jobdanmark.dk/job/http-404-designer",
);
expect(parsed.title).toBe("HTTP 404 Page Designer");
expect(parsed.hiringOrganization.name).toBe("JFM");
});
});
+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,34 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, apiPost } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert both request
// wrappers carry an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("request timeout", () => {
test("apiFetch 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("{}", { status: 200 });
}) as unknown as typeof fetch;
await apiFetch("/search");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
test("apiPost 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("{}", { status: 200 });
}) as unknown as typeof fetch;
await apiPost("/search", { q: "x" });
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, apiPost } 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. apiFetch and apiPost carry separate copies of
// the loop, so both are exercised to keep them from drifting apart.
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;
}
const wrappers: Array<[string, () => Promise<{ ok: boolean }>]> = [
["apiFetch", () => apiFetch<{ ok: boolean }>("/x")],
["apiPost", () => apiPost<{ ok: boolean }>("/x", {})],
];
for (const [name, call] of wrappers) {
describe(`${name} retry/backoff`, () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response('{"ok":true}', { status: 200 }),
]);
const data = await call();
expect(data.ok).toBe(true);
expect(state.calls).toBe(2);
});
test("does not retry a plain 4xx", async () => {
const state = stubFetch([() => new Response("", { status: 400 })]);
await expect(call()).rejects.toThrow(/400/);
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(call()).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
}
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import { normalizeItem, type ApiSearchItem } from "../src/commands/search";
function item(): ApiSearchItem {
return {
title: "Softwareudvikler",
companyName: "Statens It",
companyLogo: null,
companyLogoSvgMarkup: null,
overlayColor: null,
companyAddress: "Lautruphøj 2, 2750 Ballerup",
jobTypes: ["fuldtid"],
boostJob: false,
publishedDate: "27-07-2026",
applicationDeadline: "17-08-2026",
url: "/job/softwareudvikler-til-statens-it",
coverImage: null,
silhouetteLogo: false,
};
}
describe("Jobdanmark search normalization", () => {
test("additively emits the /scrape contract fields (company, location, date, deadline)", () => {
const result = normalizeItem(item());
expect(result).toMatchObject({
company: "Statens It",
location: "Ballerup",
date: "2026-07-27",
deadline: "2026-08-17",
url: "https://jobdanmark.dk/job/softwareudvikler-til-statens-it",
});
});
test("maps a missing address zip and a null deadline to null", () => {
const result = normalizeItem({
...item(),
companyAddress: "Lautruphøj 2",
applicationDeadline: null,
});
expect(result.location).toBeNull();
expect(result.deadline).toBeNull();
expect(result.company).toBe("Statens It");
});
test("extracts the city when a comma follows the postcode (live jobdanmark shape)", () => {
const result = normalizeItem({
...item(),
companyAddress: "2670, Greve",
});
expect(result.location).toBe("Greve");
});
test("trims trailing whitespace from the extracted city", () => {
const result = normalizeItem({
...item(),
companyAddress: "7100, Vejle ",
});
expect(result.location).toBe("Vejle");
});
test("does not mistake a 4-digit street number for the postcode", () => {
const result = normalizeItem({
...item(),
companyAddress: "Vejlevej 1234, 7100 Vejle",
});
expect(result.location).toBe("Vejle");
});
test("survives a null companyAddress from the API", () => {
const result = normalizeItem({
...item(),
companyAddress: null,
});
expect(result.location).toBeNull();
expect(result.company).toBe("Statens It");
});
test("keeps native fields unchanged (additive contract)", () => {
const result = normalizeItem(item());
expect(result.companyName).toBe("Statens It");
expect(result.publishedDate).toBe("27-07-2026");
expect(result.applicationDeadline).toBe("17-08-2026");
});
test("omits presentation-only keys the agent can never use", () => {
// coverImage/companyLogo/companyLogoSvgMarkup/overlayColor/silhouetteLogo
// were ~40% of a live search payload, fed into agent context on every
// /scrape query (review finding F3, 2026-08-19). The #340 compatibility
// duplicates (companyName, publishedDate, applicationDeadline) stay.
const result = normalizeItem(item());
expect(result).not.toHaveProperty("coverImage");
expect(result).not.toHaveProperty("companyLogo");
expect(result).not.toHaveProperty("companyLogoSvgMarkup");
expect(result).not.toHaveProperty("overlayColor");
expect(result).not.toHaveProperty("silhouetteLogo");
});
});
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, apiPost, USER_AGENT } from "../src/helpers";
// Bun's fetch injects an anonymous default User-Agent (Bun/1.3.10) when code
// sets none. This CLI should say who is asking, in the honest style jobindex
// already uses on htmlFetch ("Mozilla/5.0 (compatible; jobindex-cli/1.0)").
// Assert the header is present on every request. Fails on the pre-change code.
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
function headerValue(headers: RequestInit["headers"], name: string): string | null {
if (headers instanceof Headers) return headers.get(name);
if (Array.isArray(headers)) {
const found = headers.find(([k]) => k === name);
return found ? String(found[1]) : null;
}
const value = headers?.[name];
return typeof value === "string" ? value : null;
}
describe("apiFetch user agent", () => {
test("sends a User-Agent header", async () => {
let init: RequestInit | undefined;
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
init = i;
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
await apiFetch("/api/search/autocomplete", { q: "it" });
expect(headerValue(init?.headers, "User-Agent")).toBe(USER_AGENT);
});
});
describe("apiPost user agent", () => {
test("sends a User-Agent header alongside Content-Type", async () => {
let init: RequestInit | undefined;
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
init = i;
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
await apiPost("/api/jobsearch/search/1", { q: "it" });
expect(headerValue(init?.headers, "User-Agent")).toBe(USER_AGENT);
expect(headerValue(init?.headers, "Content-Type")).toBe("application/json");
});
});