/**
* Tests for the LAX jobs client. Each test is named after the acceptance item it proves.
* Offline: every test passes its own fetch and sleep. A7 reads the live registry only when LAX_LIVE=1.
* Run: npm run test:jobs
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { createJobsClient, LaxJobsError } from '../src/index.js';
import type { FetchInitLike, FetchResponseLike, SubmissionInput } from '../src/index.js';
const BASE = 'https://lax.kognai.ai/registry';
const JOB = { id: 'job/1 a', title: 'A job', track: 'godman-protocols', status: 'open', bounty_usdc: 200 };
const SUBMISSION = { id: 's1', job_id: 'job-1', status: 'received', created_at: '2026-11-01T00:00:00Z' };
const GOOD: SubmissionInput = {
builder: 'Bounty Hunter',
contact: 'ops@example.com',
repo_or_patch_url: 'https://github.com/example/lax/commit/0123456789abcdef0123456789abcdef01234567',
payout_address: '0x3c1A1E01e790Cd326495631B3b38Bd7068F08d6F',
agent_did: 'did:pkh:eip155:8453:0x3c1A1E01e790Cd326495631B3b38Bd7068F08d6F',
notes: 'M1',
};
type Step = { status: number; body?: unknown; raw?: string; headers?: Record<string, string> } | { throws: string };
interface Call { url: string; init: FetchInitLike }
function fakeFetch(steps: Step[]) {
const calls: Call[] = [];
const fetch = async (url: string, init: FetchInitLike): Promise<FetchResponseLike> => {
calls.push({ url, init });
const step = steps[Math.min(calls.length - 1, steps.length - 1)];
if (!step) throw new Error('no step');
if ('throws' in step) throw new TypeError(step.throws);
const h = Object.fromEntries(Object.entries(step.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));
return {
status: step.status,
headers: { get: (n: string) => h[n.toLowerCase()] ?? null },
text: async () => (step.raw !== undefined ? step.raw : step.body === undefined ? '' : JSON.stringify(step.body)),
};
};
return { fetch, calls };
}
function fakeSleep() {
const waits: number[] = [];
return { sleep: async (ms: number) => { waits.push(ms); }, waits };
}
async function rejects(p: Promise<unknown>, check: Partial<{ status: number; code: string; message: string }>) {
try {
await p;
} catch (e) {
assert.ok(e instanceof LaxJobsError, 'throws LaxJobsError');
assert.ok(e instanceof Error);
for (const [k, v] of Object.entries(check)) assert.equal((e as unknown as Record<string, unknown>)[k], v, k);
return e as LaxJobsError;
}
assert.fail('expected a LaxJobsError');
}
test('A1 listJobs sends GET /jobs with only the given parameters and returns {jobs, count}', async () => {
const f = fakeFetch([{ status: 200, body: { jobs: [JOB], count: 1 } }]);
const c = createJobsClient({ fetch: f.fetch, sleep: fakeSleep().sleep });
assert.deepEqual(await c.listJobs(), { jobs: [JOB], count: 1 });
assert.equal(f.calls[0]!.url, `${BASE}/jobs`);
assert.equal(f.calls[0]!.init.method, 'GET');
await c.listJobs({ track: 'genesis-tools', limit: 100, offset: 0 });
assert.equal(f.calls[1]!.url, `${BASE}/jobs?track=genesis-tools&limit=100&offset=0`);
await c.listJobs({ limit: 1 });
assert.equal(f.calls[2]!.url, `${BASE}/jobs?limit=1`);
});
test('A1 listJobs refuses a limit outside 1 to 100 before any request', async () => {
const f = fakeFetch([{ status: 200, body: { jobs: [], count: 0 } }]);
const c = createJobsClient({ fetch: f.fetch });
await rejects(c.listJobs({ limit: 0 }), { code: 'invalid_input', status: 0 });
await rejects(c.listJobs({ limit: 101 }), { code: 'invalid_input' });
assert.equal(f.calls.length, 0);
});
test('A1 getJob sends GET /jobs/<id> with the id URL encoded and returns the job out of {job}', async () => {
const f = fakeFetch([{ status: 200, body: { job: JOB } }]);
const c = createJobsClient({ fetch: f.fetch, baseUrl: 'https://example.test/registry/' });
assert.deepEqual(await c.getJob('job/1 a'), JOB);
assert.equal(f.calls[0]!.url, 'https://example.test/registry/jobs/job%2F1%20a');
assert.equal(f.calls[0]!.init.method, 'GET');
});
test('A1 submit POSTs JSON to /jobs/<id>/submissions and returns the submission out of {submission}', async () => {
const f = fakeFetch([{ status: 201, body: { submission: SUBMISSION } }]);
const c = createJobsClient({ fetch: f.fetch });
assert.deepEqual(await c.submit('job-1', GOOD), SUBMISSION);
const call = f.calls[0]!;
assert.equal(call.url, `${BASE}/jobs/job-1/submissions`);
assert.equal(call.init.method, 'POST');
assert.equal(call.init.headers['content-type'], 'application/json');
assert.deepEqual(JSON.parse(call.init.body!), GOOD);
});
test('A1 a 2xx answer that is not JSON or lacks its key throws invalid_response', async () => {
const c1 = createJobsClient({ fetch: fakeFetch([{ status: 200, raw: '<html>' }]).fetch });
await rejects(c1.getJob('x'), { code: 'invalid_response', status: 200 });
const c2 = createJobsClient({ fetch: fakeFetch([{ status: 200, body: { nope: 1 } }]).fetch });
await rejects(c2.getJob('x'), { code: 'invalid_response' });
await rejects(c2.listJobs(), { code: 'invalid_response' });
});
for (const [status, code] of [[400, 'bad_request'], [404, 'not_found'], [409, 'conflict']] as const) {
test(`A2 a ${status} answer throws LaxJobsError ${code} with the registry message after a single request`, async () => {
for (const run of ['list', 'get', 'submit'] as const) {
const f = fakeFetch([{ status, body: { error: `registry says ${status}` } }]);
const s = fakeSleep();
const c = createJobsClient({ fetch: f.fetch, sleep: s.sleep, retries: 5 });
const p = run === 'list' ? c.listJobs() : run === 'get' ? c.getJob('j') : c.submit('j', GOOD);
await rejects(p, { status, code, message: `registry says ${status}` });
assert.equal(f.calls.length, 1, `${run}: one request`);
assert.equal(s.waits.length, 0);
}
});
}
test('A3 a 429 is retried after its Retry-After and then succeeds', async () => {
const f = fakeFetch([
{ status: 429, body: { error: 'slow down' }, headers: { 'Retry-After': '3' } },
{ status: 200, body: { job: JOB } },
]);
const s = fakeSleep();
const c = createJobsClient({ fetch: f.fetch, sleep: s.sleep });
assert.deepEqual(await c.getJob('x'), JOB);
assert.deepEqual(s.waits, [3000]);
assert.equal(f.calls.length, 2);
});
test('A3 Retry-After is capped at 30 seconds, and without it the wait starts at 500 ms and doubles', async () => {
const s = fakeSleep();
const c = createJobsClient({
fetch: fakeFetch([
{ status: 429, headers: { 'retry-after': '120' } },
{ status: 429 },
{ status: 429 },
{ status: 201, body: { submission: SUBMISSION } },
]).fetch,
sleep: s.sleep,
retries: 3,
});
assert.deepEqual(await c.submit('j', GOOD), SUBMISSION);
assert.deepEqual(s.waits, [30000, 1000, 2000]);
});
test('A3 a 429 that never ends throws rate_limited after the last retry', async () => {
for (const run of ['list', 'submit'] as const) {
const f = fakeFetch([{ status: 429, body: { error: 'too many submissions' }, headers: { 'retry-after': '1' } }]);
const s = fakeSleep();
const c = createJobsClient({ fetch: f.fetch, sleep: s.sleep });
const p = run === 'list' ? c.listJobs() : c.submit('j', GOOD);
await rejects(p, { status: 429, code: 'rate_limited', message: 'too many submissions' });
assert.equal(f.calls.length, 3, `${run}: 1 + 2 retries`);
assert.deepEqual(s.waits, [1000, 1000]);
}
});
test('A4 a 5xx on a read is retried with backoff and then throws server', async () => {
const f = fakeFetch([{ status: 503, body: { error: 'down' } }]);
const s = fakeSleep();
const c = createJobsClient({ fetch: f.fetch, sleep: s.sleep });
await rejects(c.listJobs(), { status: 503, code: 'server', message: 'down' });
assert.equal(f.calls.length, 3);
assert.deepEqual(s.waits, [500, 1000]);
});
test('A4 a 5xx then success on a read returns the answer', async () => {
const c = createJobsClient({
fetch: fakeFetch([{ status: 500 }, { status: 200, body: { job: JOB } }]).fetch,
sleep: fakeSleep().sleep,
});
assert.deepEqual(await c.getJob('x'), JOB);
});
test('A4 a network error on a read is retried and then throws network with status 0', async () => {
const f = fakeFetch([{ throws: 'fetch failed' }]);
const s = fakeSleep();
const c = createJobsClient({ fetch: f.fetch, sleep: s.sleep, retries: 1 });
await rejects(c.getJob('x'), { status: 0, code: 'network', message: 'fetch failed' });
assert.equal(f.calls.length, 2);
assert.deepEqual(s.waits, [500]);
});
test('A4 submit is not retried on a 5xx or a network error', async () => {
const f1 = fakeFetch([{ status: 502, body: { error: 'bad gateway' } }]);
const c1 = createJobsClient({ fetch: f1.fetch, sleep: fakeSleep().sleep });
await rejects(c1.submit('j', GOOD), { status: 502, code: 'server' });
assert.equal(f1.calls.length, 1);
const f2 = fakeFetch([{ throws: 'socket hang up' }]);
const c2 = createJobsClient({ fetch: f2.fetch, sleep: fakeSleep().sleep });
await rejects(c2.submit('j', GOOD), { status: 0, code: 'network' });
assert.equal(f2.calls.length, 1);
});
test('A4 a request that passes timeoutMs is aborted and reported as network', async () => {
const hanging = (_url: string, init: FetchInitLike) =>
new Promise<FetchResponseLike>((_res, rej) => {
(init.signal as { addEventListener(t: string, f: () => void): void }).addEventListener('abort', () => rej(new Error('aborted')));
});
const c = createJobsClient({ fetch: hanging, sleep: fakeSleep().sleep, retries: 0, timeoutMs: 20 });
const e = await rejects(c.getJob('x'), { status: 0, code: 'network' });
assert.match(e.message, /timed out/);
});
test('A5 submit refuses bad input with invalid_input before any request', async () => {
const f = fakeFetch([{ status: 201, body: { submission: SUBMISSION } }]);
const c = createJobsClient({ fetch: f.fetch });
const { agent_did: _omit, ...noDid } = GOOD;
const cases: unknown[] = [
noDid,
{ ...GOOD, agent_did: '' },
{ ...GOOD, agent_did: 'agent-42' },
{ ...GOOD, payout_address: '0x123' },
{ ...GOOD, payout_address: '3c1A1E01e790Cd326495631B3b38Bd7068F08d6F' },
{ ...GOOD, payout_address: '0xZZ1A1E01e790Cd326495631B3b38Bd7068F08d6F' },
{ ...GOOD, repo_or_patch_url: 'http://github.com/example/lax' },
{ ...GOOD, repo_or_patch_url: 'git@github.com:example/lax.git' },
{ ...GOOD, repo_or_patch_url: 'https://' },
];
for (const input of cases) {
await rejects(c.submit('j', input as SubmissionInput), { code: 'invalid_input', status: 0 });
}
assert.equal(f.calls.length, 0);
});
test('A6 no new runtime dependency and no console output from the client', async () => {
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
assert.deepEqual(pkg.dependencies ?? {}, {});
const src = readFileSync(new URL('../src/jobs.ts', import.meta.url), 'utf8');
assert.doesNotMatch(src, /\bconsole\./);
assert.doesNotMatch(src, /^\s*import\s.+from\s+['"](?!\.)/m, 'no package imports');
const methods = ['log', 'info', 'warn', 'error', 'debug'] as const;
const saved = methods.map((m) => console[m]);
let printed = 0;
for (const m of methods) console[m] = () => { printed++; };
try {
const s = fakeSleep();
const c = createJobsClient({ fetch: fakeFetch([{ status: 500 }, { throws: 'x' }, { status: 404, body: { error: 'nf' } }]).fetch, sleep: s.sleep });
await c.getJob('x').catch(() => undefined);
await c.submit('j', { ...GOOD, payout_address: 'bad' }).catch(() => undefined);
} finally {
methods.forEach((m, i) => { console[m] = saved[i]!; });
}
assert.equal(printed, 0);
});
test('A7 live: GET /jobs on the real registry (runs only with LAX_LIVE=1)', { skip: process.env.LAX_LIVE !== '1' && 'set LAX_LIVE=1 to run' }, async () => {
const c = createJobsClient();
const r = await c.listJobs({ limit: 5 });
assert.ok(Array.isArray(r.jobs));
assert.equal(typeof r.count, 'number');
assert.ok(r.jobs.length <= 5);
for (const j of r.jobs) assert.equal(typeof j.id, 'string');
});
agentgate-source: code mirror on Cloudflare Pages. All repos