Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 25 additions & 1 deletion scripts/verify-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand All @@ -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
Expand All @@ -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 };
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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);
}
Loading