From 9eda853e7ee5afc171f511d78d6d821d3f4819d4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:23:03 +0000 Subject: [PATCH] feat: add unit tests for src/lib.mjs via dependency injection Refactor src/lib.mjs to support dependency injection for cmdCheck, cmdLock, and cmdList. This allows unit testing without heavy mocking frameworks or side effects. Add test/lib.test.mjs covering: - cmdCheck: happy path, paused/excluded agents, unknown agents. - cmdLock: success, roster check, existing lock check, race check (lost), dry run. - cmdList: listing locks, empty state. Coverage for src/lib.mjs is now significantly improved. Verification: Ran `npm test` and verified all tests pass. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/lib.mjs | 156 ++++++++++++++++----------- test/lib.test.mjs | 262 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 59 deletions(-) create mode 100644 test/lib.test.mjs diff --git a/src/lib.mjs b/src/lib.mjs index 8a959bd..1cc177b 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -3,14 +3,14 @@ // TORCH — Task Orchestration via Relay-Coordinated Handoff // Generic Nostr-based task locking for multi-agent development. -import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure'; import { useWebSocketImplementation } from 'nostr-tools/relay'; import WebSocket from 'ws'; import { - loadTorchConfig, - getRelays, - getNamespace, - getTtl, + loadTorchConfig as _loadTorchConfig, + getRelays as _getRelays, + getNamespace as _getNamespace, + getTtl as _getTtl, } from './torch-config.mjs'; import { DEFAULT_DASHBOARD_PORT, @@ -18,8 +18,8 @@ import { VALID_CADENCES, } from './constants.mjs'; import { cmdInit, cmdUpdate } from './ops.mjs'; -import { getRoster } from './roster.mjs'; -import { queryLocks, publishLock, parseLockEvent } from './lock-ops.mjs'; +import { getRoster as _getRoster } from './roster.mjs'; +import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs'; import { cmdDashboard } from './dashboard.mjs'; useWebSocketImplementation(WebSocket); @@ -41,19 +41,31 @@ function nowUnix() { } // Re-export for backward compatibility/library usage -export { parseLockEvent, queryLocks, publishLock, cmdDashboard }; +export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock }; + + +export async function cmdCheck(cadence, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + loadTorchConfig = _loadTorchConfig, + queryLocks = _queryLocks, + getRoster = _getRoster, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; -export async function cmdCheck(cadence) { const relays = getRelays(); const namespace = getNamespace(); - const dateStr = todayDateStr(); + const dateStr = getDateStr(); const config = loadTorchConfig(); const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; - console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); - console.error(`Relays: ${relays.join(', ')}`); + error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); + error(`Relays: ${relays.join(', ')}`); if (pausedAgents.length > 0) { - console.error(`Paused agents: ${pausedAgents.join(', ')}`); + error(`Paused agents: ${pausedAgents.join(', ')}`); } const locks = await queryLocks(relays, cadence, dateStr, namespace); @@ -83,51 +95,67 @@ export async function cmdCheck(cadence) { })), }; - console.log(JSON.stringify(result, null, 2)); + log(JSON.stringify(result, null, 2)); return result; } -export async function cmdLock(agent, cadence, dryRun = false) { +export async function cmdLock(agent, cadence, dryRun = false, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + getTtl = _getTtl, + queryLocks = _queryLocks, + getRoster = _getRoster, + publishLock = _publishLock, + generateSecretKey = _generateSecretKey, + getPublicKey = _getPublicKey, + finalizeEvent = _finalizeEvent, + raceCheckDelayMs = RACE_CHECK_DELAY_MS, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + const relays = getRelays(); const namespace = getNamespace(); - const dateStr = todayDateStr(); + const dateStr = getDateStr(); const ttl = getTtl(); const now = nowUnix(); const expiresAt = now + ttl; - console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); - console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); - console.error(`Relays: ${relays.join(', ')}`); + error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + error(`Relays: ${relays.join(', ')}`); const roster = getRoster(cadence); if (!roster.includes(agent)) { - console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); - console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`); + error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); + error(`Allowed ${cadence} agents: ${roster.join(', ')}`); throw new ExitError(1, 'Agent not in roster'); } - console.error('Step 1: Checking for existing locks...'); + error('Step 1: Checking for existing locks...'); const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); const conflicting = existingLocks.filter((l) => l.agent === agent); if (conflicting.length > 0) { const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; - console.error( + error( `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, ); - console.log('LOCK_STATUS=denied'); - console.log('LOCK_REASON=already_locked'); - console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + log('LOCK_STATUS=denied'); + log('LOCK_REASON=already_locked'); + log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); throw new ExitError(3, 'Lock denied'); } - console.error('Step 2: Generating ephemeral keypair...'); + error('Step 2: Generating ephemeral keypair...'); const sk = generateSecretKey(); const pk = getPublicKey(sk); - console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); - console.error('Step 3: Building lock event...'); + error('Step 3: Building lock event...'); const event = finalizeEvent( { kind: 30078, @@ -153,17 +181,17 @@ export async function cmdLock(agent, cadence, dryRun = false) { sk, ); - console.error(` Event ID: ${event.id}`); + error(` Event ID: ${event.id}`); if (dryRun) { - console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); - console.error('RACE CHECK: won (dry run — no real contention possible)'); + error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + error('RACE CHECK: won (dry run — no real contention possible)'); } else { - console.error('Step 4: Publishing to relays...'); + error('Step 4: Publishing to relays...'); await publishLock(relays, event); - console.error('Step 5: Race check...'); - await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS)); + error('Step 5: Race check...'); + await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs)); const postLocks = await queryLocks(relays, cadence, dateStr, namespace); const racingLocks = postLocks @@ -172,36 +200,46 @@ export async function cmdLock(agent, cadence, dryRun = false) { if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { const winner = racingLocks[0]; - console.error( + error( `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, ); - console.log('LOCK_STATUS=race_lost'); - console.log('LOCK_REASON=earlier_claim_exists'); - console.log(`LOCK_WINNER_EVENT=${winner.eventId}`); + log('LOCK_STATUS=race_lost'); + log('LOCK_REASON=earlier_claim_exists'); + log(`LOCK_WINNER_EVENT=${winner.eventId}`); throw new ExitError(3, 'Race check lost'); } - console.error('RACE CHECK: won'); + error('RACE CHECK: won'); } - console.log('LOCK_STATUS=ok'); - console.log(`LOCK_EVENT_ID=${event.id}`); - console.log(`LOCK_PUBKEY=${pk}`); - console.log(`LOCK_AGENT=${agent}`); - console.log(`LOCK_CADENCE=${cadence}`); - console.log(`LOCK_DATE=${dateStr}`); - console.log(`LOCK_EXPIRES=${expiresAt}`); - console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + log('LOCK_STATUS=ok'); + log(`LOCK_EVENT_ID=${event.id}`); + log(`LOCK_PUBKEY=${pk}`); + log(`LOCK_AGENT=${agent}`); + log(`LOCK_CADENCE=${cadence}`); + log(`LOCK_DATE=${dateStr}`); + log(`LOCK_EXPIRES=${expiresAt}`); + log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); return { status: 'ok', eventId: event.id }; } -export async function cmdList(cadence) { +export async function cmdList(cadence, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + queryLocks = _queryLocks, + getRoster = _getRoster, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + const relays = getRelays(); const namespace = getNamespace(); - const dateStr = todayDateStr(); + const dateStr = getDateStr(); const cadences = cadence ? [cadence] : ['daily', 'weekly']; - console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); + error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); const results = await Promise.all( cadences.map(async (c) => { @@ -211,12 +249,12 @@ export async function cmdList(cadence) { ); for (const { c, locks } of results) { - console.log(`\n${'='.repeat(72)}`); - console.log(`Active ${namespace} ${c} locks (${dateStr})`); - console.log('='.repeat(72)); + log(`\n${'='.repeat(72)}`); + log(`Active ${namespace} ${c} locks (${dateStr})`); + log('='.repeat(72)); if (locks.length === 0) { - console.log(' (no active locks)'); + log(' (no active locks)'); continue; } @@ -227,7 +265,7 @@ export async function cmdList(cadence) { const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null; const remainMin = remaining ? Math.round(remaining / 60) : '?'; - console.log( + log( ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + `age: ${String(ageMin).padStart(4)}m ` + `ttl: ${String(remainMin).padStart(4)}m ` + @@ -242,11 +280,11 @@ export async function cmdList(cadence) { const available = roster.filter((a) => !lockedAgents.has(a)); if (unknownLockedAgents.length > 0) { - console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); + log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); } - console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`); - console.log(` Available: ${available.join(', ') || '(none)'}`); + log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + log(` Available: ${available.join(', ') || '(none)'}`); } } diff --git a/test/lib.test.mjs b/test/lib.test.mjs new file mode 100644 index 0000000..de8dab5 --- /dev/null +++ b/test/lib.test.mjs @@ -0,0 +1,262 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { cmdCheck, cmdLock, cmdList } from '../src/lib.mjs'; + +describe('src/lib.mjs', () => { + // Common mocks + const mockRelays = ['wss://relay.test']; + const mockNamespace = 'test-ns'; + const mockTtl = 3600; + const mockRoster = ['agent1', 'agent2', 'agent3']; + const mockDateStr = new Date().toISOString().slice(0, 10); + + let logs = []; + let errors = []; + + const mockLog = (...args) => logs.push(args.join(' ')); + const mockError = (...args) => errors.push(args.join(' ')); + + const baseDeps = { + getRelays: () => mockRelays, + getNamespace: () => mockNamespace, + getTtl: () => mockTtl, + getRoster: () => mockRoster, + loadTorchConfig: () => ({ scheduler: { paused: { daily: [], weekly: [] } } }), + getDateStr: () => mockDateStr, + log: mockLog, + error: mockError, + }; + + beforeEach(() => { + logs = []; + errors = []; + }); + + describe('cmdCheck', () => { + it('returns correct structure with empty locks', async () => { + const deps = { + ...baseDeps, + queryLocks: async () => [], + }; + + const result = await cmdCheck('daily', deps); + + assert.strictEqual(result.namespace, mockNamespace); + assert.strictEqual(result.cadence, 'daily'); + assert.strictEqual(result.date, mockDateStr); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.available, mockRoster.sort()); + assert.strictEqual(result.lockCount, 0); + }); + + it('identifies locked agents', async () => { + const locks = [ + { agent: 'agent1', eventId: 'ev1', createdAtIso: 'iso1', platform: 'test' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => locks, + }; + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.locked, ['agent1']); + assert.deepStrictEqual(result.available, ['agent2', 'agent3']); + assert.strictEqual(result.lockCount, 1); + }); + + it('excludes paused agents', async () => { + const deps = { + ...baseDeps, + queryLocks: async () => [], + loadTorchConfig: () => ({ scheduler: { paused: { daily: ['agent2'], weekly: [] } } }), + }; + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.paused, ['agent2']); + assert.deepStrictEqual(result.excluded, ['agent2']); + assert.deepStrictEqual(result.available, ['agent1', 'agent3']); + }); + + it('identifies unknown locked agents', async () => { + const locks = [ + { agent: 'unknown-agent', eventId: 'ev1' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => locks, + }; + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.unknownLockedAgents, ['unknown-agent']); + assert.deepStrictEqual(result.locked, ['unknown-agent']); + // unknown agents are not in roster, so available should be full roster (since no roster agent is locked) + assert.deepStrictEqual(result.available, mockRoster.sort()); + }); + }); + + describe('cmdLock', () => { + const mockPublishLock = async () => {}; + const mockGenerateSecretKey = () => new Uint8Array(32); + const mockGetPublicKey = () => 'mock-pubkey'; + const mockFinalizeEvent = (t) => ({ ...t, id: 'mock-id', sig: 'mock-sig' }); + + it('successfully locks an available agent', async () => { + let published = false; + const deps = { + ...baseDeps, + queryLocks: async () => [], // No existing locks, and race check returns empty + publishLock: async () => { published = true; }, + generateSecretKey: mockGenerateSecretKey, + getPublicKey: mockGetPublicKey, + finalizeEvent: mockFinalizeEvent, + raceCheckDelayMs: 1, // Fast race check + }; + + const result = await cmdLock('agent1', 'daily', false, deps); + + assert.strictEqual(result.status, 'ok'); + assert.strictEqual(result.eventId, 'mock-id'); + assert.strictEqual(published, true); + assert.match(logs.join('\n'), /LOCK_STATUS=ok/); + }); + + it('fails if agent is not in roster', async () => { + const deps = { ...baseDeps }; + + await assert.rejects( + async () => await cmdLock('unknown', 'daily', false, deps), + (err) => err.code === 1 && err.message === 'Agent not in roster' + ); + }); + + it('fails if agent is already locked', async () => { + const existingLocks = [ + { agent: 'agent1', eventId: 'existing', createdAt: 100, createdAtIso: 'time' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => existingLocks, + }; + + await assert.rejects( + async () => await cmdLock('agent1', 'daily', false, deps), + (err) => err.code === 3 && err.message === 'Lock denied' + ); + assert.match(logs.join('\n'), /LOCK_STATUS=denied/); + }); + + it('fails if race check is lost', async () => { + let callCount = 0; + const deps = { + ...baseDeps, + queryLocks: async () => { + callCount++; + if (callCount === 1) return []; // First check: no locks + // Second check (race): found an earlier lock + return [{ + agent: 'agent1', + eventId: 'winner', + createdAt: 100, // old timestamp + createdAtIso: 'old-time' + }, { + agent: 'agent1', + eventId: 'mock-id', + createdAt: 200, // our timestamp (mocked event) + // We need to ensure cmdLock sees this as later. + // In cmdLock, `now` is mocked via verify? No, `now` is `nowUnix()`. + // But we can't easily mock `nowUnix` inside `cmdLock` without injecting it too. + // However, `cmdLock` compares `createdAt`. + // The mocked event has `created_at: now`. + // If we return a lock with smaller `createdAt`, we lose. + }]; + }, + publishLock: async () => {}, + generateSecretKey: mockGenerateSecretKey, + getPublicKey: mockGetPublicKey, + finalizeEvent: mockFinalizeEvent, + raceCheckDelayMs: 1, + }; + + // We need to make sure the "winner" has explicit createdAt that is definitely before what cmdLock generates. + // cmdLock uses Math.floor(Date.now() / 1000). + // We can't mock Date.now easily unless we inject it. + // But we can inject `finalizeEvent` to control the created timestamp of our event? + // No, `finalizeEvent` receives the template. + // Let's rely on the fact that `cmdLock` uses `nowUnix()`. + // The "winner" in `queryLocks` return can have `createdAt: 0`. + + await assert.rejects( + async () => await cmdLock('agent1', 'daily', false, deps), + (err) => err.code === 3 && err.message === 'Race check lost' + ); + assert.match(logs.join('\n'), /LOCK_STATUS=race_lost/); + }); + + it('dry run does not publish', async () => { + let published = false; + const deps = { + ...baseDeps, + queryLocks: async () => [], + publishLock: async () => { published = true; }, + generateSecretKey: mockGenerateSecretKey, + getPublicKey: mockGetPublicKey, + finalizeEvent: mockFinalizeEvent, + }; + + const result = await cmdLock('agent1', 'daily', true, deps); + + assert.strictEqual(result.status, 'ok'); + assert.strictEqual(published, false); + assert.match(errors.join('\n'), /\[DRY RUN\]/); + }); + }); + + describe('cmdList', () => { + it('lists active locks', async () => { + const locks = [ + { agent: 'agent1', eventId: 'ev1', createdAt: Date.now()/1000 - 60, createdAtIso: 'iso1', platform: 'test' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => locks, + }; + + await cmdList('daily', deps); + + const output = logs.join('\n'); + assert.match(output, /Active test-ns daily locks/); + assert.match(output, /agent1/); + assert.match(output, /Locked: 1\/3/); + }); + + it('warns about unknown agents', async () => { + const locks = [ + { agent: 'unknown', eventId: 'ev1', createdAt: Date.now()/1000 } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => locks, + }; + + await cmdList('daily', deps); + + const output = logs.join('\n'); + assert.match(output, /Warning: lock events found with non-roster agent names: unknown/); + }); + + it('handles no locks', async () => { + const deps = { + ...baseDeps, + queryLocks: async () => [], + }; + + await cmdList('daily', deps); + + const output = logs.join('\n'); + assert.match(output, /\(no active locks\)/); + }); + }); +});