diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..309fd83 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Test + +on: + pull_request: + push: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['18', '20', '22'] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + # The library, CLI and test suite have zero dependencies, so there is + # nothing to install. mcp/ has its own deps and its own smoke test. + - name: Registry files are valid JSON + run: | + for f in registry/*.json docs/registry/*.json; do + node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" \ + || { echo "::error file=$f::invalid JSON"; exit 1; } + done + + - name: Run tests + run: node --test test/*.test.js + + registry-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + # docs/registry is the copy GitHub Pages serves. If it drifts from + # registry/, the site serves stale data and nothing else notices: + # the test suite is decoupled from live registry contents by design. + - name: docs/registry matches registry + run: | + npm run sync:registry-pages + if ! git diff --quiet -- docs/registry; then + echo "::error::docs/registry is out of sync with registry/. Run: npm run sync:registry-pages" + git diff --stat -- docs/registry + exit 1 + fi + echo "docs/registry is in sync" diff --git a/scripts/verify-registry.mjs b/scripts/verify-registry.mjs index e9fa70d..0997314 100644 --- a/scripts/verify-registry.mjs +++ b/scripts/verify-registry.mjs @@ -32,6 +32,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ADAPTERS } from '../src/adapters/index.js'; import { loadRegistry } from '../src/registry.js'; +import { ERROR_CODES } from '../src/errors.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -53,7 +54,24 @@ async function loadEntries() { return loadRegistry(); // { ats: [entries] } } -async function verifyOne(ats, entry) { +// Transient-failure detector. ATS APIs rate-limit under load (Workday and +// SmartRecruiters both do at modest volume), and a 429 says nothing about +// whether a board is real. Treating one as a failure produced a 27% false +// drop rate on a 118-entry run, so these retry with backoff instead. +// +// The decision is structural, never a message regex: Workday error messages +// embed the pod name next to the status ("(ufp/wd503/Careers): 404"), so a +// /5\d\d/ match on the text reads that terminal 404 as a retryable 5xx. +const RETRIES = Number(getArg('--retries', '3')); +const NETWORK_ERR = /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up/i; + +function isTransient(err) { + if (err?.code === ERROR_CODES.RATE_LIMITED) return true; // 429 + if (typeof err?.status === 'number') return err.status >= 500; // 5xx retry, 4xx terminal + return NETWORK_ERR.test(err?.message || ''); // DNS/socket, not an AtsError +} + +async function verifyOne(ats, entry, attempt = 0) { // Call the adapter directly with the CANONICAL slug — exactly what // fetchJobs does AFTER a registry lookup (it passes hit.entry.slug). // We deliberately do NOT route through fetchJobs({company}) here: that @@ -76,6 +94,12 @@ async function verifyOne(ats, entry) { const jobCount = Array.isArray(jobs) ? jobs.length : 0; return { ats, slug: entry.slug, name: entry.name, status: jobCount > 0 ? 'ok' : 'empty', jobCount }; } catch (err) { + if (attempt < RETRIES && isTransient(err)) { + // Exponential backoff with jitter: 2s, 4s, 8s. + const wait = 2000 * 2 ** attempt + Math.floor(Math.random() * 500); + await new Promise((r) => setTimeout(r, wait)); + return verifyOne(ats, entry, attempt + 1); + } return { ats, slug: entry.slug, name: entry.name, status: 'error', jobCount: 0, error: err.message }; } } diff --git a/src/errors.js b/src/errors.js index 3c94758..ddd98e5 100644 --- a/src/errors.js +++ b/src/errors.js @@ -22,10 +22,15 @@ export const ERROR_CODES = { * all keep working for existing library consumers. */ export class AtsError extends Error { - constructor(code, message) { + constructor(code, message, status) { super(message); this.name = 'AtsError'; this.code = code; + // Upstream HTTP status when one is known. Lets callers distinguish a + // retryable 503 from a terminal 404 without parsing the message, which + // is unsafe: Workday messages embed the pod name (wd503) alongside the + // status, so a message-level /5\d\d/ match reads "wd503: 404" as a 5xx. + if (status !== undefined) this.status = status; } } @@ -34,5 +39,5 @@ export class AtsError extends Error { * limited, anything else => unreachable) with the given message. */ export function atsErrorFromStatus(status, message) { - return new AtsError(status === 429 ? ERROR_CODES.RATE_LIMITED : ERROR_CODES.ATS_UNREACHABLE, message); + return new AtsError(status === 429 ? ERROR_CODES.RATE_LIMITED : ERROR_CODES.ATS_UNREACHABLE, message, status); }