From db8312948ad3dc6b92161e7fda2b4c7230ef313c Mon Sep 17 00:00:00 2001 From: Oscar Madera <80536682+oscarbol09@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:23:46 -0500 Subject: [PATCH] test(scraper): pin the /scrape Step 2 search-output contract across portal CLIs (#344) The Step 2 contract ('Search output already includes title, company, location, date, and URL') had no cross-portal regression net: a CLI that quietly drops a contract field flags the portal as degraded on every /scrape run while CI stays green. That failure class landed for real (jobnet/jobdanmark/jobbank, fixed in #339/#340/#342). The contract fields are derived from the SKILL.md sentence itself (never hardcoded), compared against the real search output of every .agents/skills/*-search CLI, and detail.ts is deliberately excluded - the contract is about what /scrape consumes. Fails against pre-fix master on exactly the three portals the fixes cover; passes with them applied. --- tests/test_scrape_contract.py | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_scrape_contract.py diff --git a/tests/test_scrape_contract.py b/tests/test_scrape_contract.py new file mode 100644 index 0000000..f93371d --- /dev/null +++ b/tests/test_scrape_contract.py @@ -0,0 +1,78 @@ +"""Tests for the /scrape Step 2 search-output contract across portal CLIs. + +Mirrors the pattern of test_html_report_command.py: derive the contract from +the spec itself and compare it against the real portal CLIs, so a drift on +either side fails with a clean diff. + +Why this test exists: .claude/skills/job-scraper/SKILL.md Step 2 promises +"Search output already includes title, company, location, date, and URL" for +every portal CLI, and Step 4.75's degraded scan flags "company null or empty +on every result" as a half-working parser. A CLI that quietly stops emitting +those fields flags the portal as degraded on every /scrape run while CI stays +green, breaks the seen_jobs.json dedupe (url_or_company_title_key), and leaves +/rank without a posting URL. That failure class landed for real: jobnet-search +emitted only the raw API schema and jobdanmark-search emitted companyName with +no company/location/date keys until both were normalized. + +{helpers.ts, commands/search.ts} are the two files where every registered +CLI's search output currently lives (HTML-parsing portals normalize in +helpers.ts, API portals in commands/search.ts). detail.ts is deliberately +excluded: the contract is about the search output /scrape consumes. +""" + +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRAPER_SKILL = REPO_ROOT / ".claude" / "skills" / "job-scraper" / "SKILL.md" +PORTAL_CLIS = sorted((REPO_ROOT / ".agents" / "skills").glob("*-search")) + +# Derived, never copied: a hardcoded field list drifts in lockstep with +# nothing - if Step 2's prose drops or adds a field, the known-good portals +# and this pin would keep agreeing forever while the contract changed. +_CONTRACT_SENTENCE = re.compile(r"Search output already includes ([a-zA-Z0-9\s,]+)\.", re.MULTILINE) + + +def derive_contract_fields() -> frozenset[str]: + text = SCRAPER_SKILL.read_text(encoding="utf-8") + match = _CONTRACT_SENTENCE.search(text) + if match is None: + raise AssertionError("Step 2 contract sentence not found in job-scraper/SKILL.md") + fields_text = re.sub(r"\s+and\s+", ",", match.group(1)) + fields = {f.strip().lower() for f in fields_text.split(",") if f.strip()} + return frozenset(fields) + + +def search_output_source(search_ts: Path) -> str: + helpers_ts = search_ts.parent.parent / "helpers.ts" + files = [search_ts, helpers_ts] if helpers_ts.exists() else [search_ts] + return "\n".join(f.read_text(encoding="utf-8") for f in files) + + +class ScrapeSearchOutputContractTests(unittest.TestCase): + """Every portal CLI's search output must carry the Step 2 contract fields.""" + + def test_step2_contract_sentence_is_found_in_the_scraper_skill(self): + """Guards the anchor the field list is derived from.""" + fields = derive_contract_fields() + self.assertGreaterEqual(fields, {"title", "company", "location", "date", "url"}) + + def test_every_portal_cli_emits_the_step2_contract_fields(self): + contract = derive_contract_fields() + failures: list[str] = [] + for portal in PORTAL_CLIS: + search_ts = portal / "cli" / "src" / "commands" / "search.ts" + if not search_ts.exists(): + failures.append(f"{portal.name}: no cli/src/commands/search.ts") + continue + source = search_output_source(search_ts) + emitted = set(re.findall(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*):", source, re.MULTILINE)) + missing = sorted(contract - emitted) + if missing: + failures.append(f"{portal.name}: missing {missing} in search output") + self.assertEqual([], failures, "; ".join(failures) or "no portal CLIs checked") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file