From aaaf52fb1e447efe28f2257f77683e54a2c60ea0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 29 Apr 2026 18:21:31 -0400 Subject: [PATCH] test: cover session adapter edge cases --- tests/lib/session-adapters.test.js | 394 ++++++++++++++++++++++++++++- tests/plugin-manifest.test.js | 11 - 2 files changed, 393 insertions(+), 12 deletions(-) diff --git a/tests/lib/session-adapters.test.js b/tests/lib/session-adapters.test.js index 6895fb2f..40f5aa1f 100644 --- a/tests/lib/session-adapters.test.js +++ b/tests/lib/session-adapters.test.js @@ -6,8 +6,13 @@ const os = require('os'); const path = require('path'); const { + SESSION_SCHEMA_VERSION, + buildAggregates, getFallbackSessionRecordingPath, - persistCanonicalSnapshot + normalizeClaudeHistorySession, + normalizeDmuxSnapshot, + persistCanonicalSnapshot, + validateCanonicalSnapshot } = require('../../scripts/lib/session-adapters/canonical-session'); const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history'); const { createDmuxTmuxAdapter } = require('../../scripts/lib/session-adapters/dmux-tmux'); @@ -55,6 +60,75 @@ function withHome(homeDir, fn) { } } +function canonicalSnapshot(overrides = {}) { + const snapshot = { + schemaVersion: SESSION_SCHEMA_VERSION, + adapterId: 'test-adapter', + session: { + id: 'session-1', + kind: 'test', + state: 'active', + repoRoot: null, + sourceTarget: { + type: 'session', + value: 'session-1' + } + }, + workers: [{ + id: 'worker-1', + label: 'Worker 1', + state: 'running', + health: 'healthy', + branch: null, + worktree: null, + runtime: { + kind: 'test-runtime', + command: null, + pid: null, + active: true, + dead: false + }, + intent: { + objective: 'Test objective', + seedPaths: [] + }, + outputs: { + summary: [], + validation: [], + remainingRisks: [] + }, + artifacts: {} + }] + }; + + snapshot.aggregates = buildAggregates(snapshot.workers); + + if (overrides.session) { + snapshot.session = { ...snapshot.session, ...overrides.session }; + } + if (overrides.sourceTarget) { + snapshot.session.sourceTarget = { + ...snapshot.session.sourceTarget, + ...overrides.sourceTarget + }; + } + if (Object.prototype.hasOwnProperty.call(overrides, 'workers')) { + snapshot.workers = overrides.workers; + snapshot.aggregates = buildAggregates(Array.isArray(overrides.workers) ? overrides.workers : []); + } + if (overrides.aggregates) { + snapshot.aggregates = { ...snapshot.aggregates, ...overrides.aggregates }; + } + + for (const [key, value] of Object.entries(overrides)) { + if (!['session', 'sourceTarget', 'workers', 'aggregates'].includes(key)) { + snapshot[key] = value; + } + } + + return snapshot; +} + test('dmux adapter normalizes orchestration snapshots into canonical form', () => { const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); @@ -509,6 +583,324 @@ test('adapter registry lists adapter metadata and target types', () => { ); }); +test('canonical snapshot validation rejects malformed required fields', () => { + const invalidCases = [ + [null, /must be an object/], + [canonicalSnapshot({ schemaVersion: 'ecc.session.v0' }), /Unsupported canonical session schema version/], + [canonicalSnapshot({ adapterId: '' }), /adapterId/], + [canonicalSnapshot({ session: { id: '' } }), /session.id/], + [canonicalSnapshot({ session: { repoRoot: 42 } }), /session.repoRoot/], + [canonicalSnapshot({ sourceTarget: { type: '' } }), /session.sourceTarget.type/], + [(() => { + const snapshot = canonicalSnapshot(); + snapshot.workers = [null]; + snapshot.aggregates = { workerCount: 1, states: { unknown: 1 }, healths: { unknown: 1 } }; + return snapshot; + })(), /workers\[0\] to be an object/], + [canonicalSnapshot({ + workers: [{ + ...canonicalSnapshot().workers[0], + branch: 7 + }] + }), /workers\[0\].branch/], + [canonicalSnapshot({ + workers: [{ + ...canonicalSnapshot().workers[0], + runtime: { + ...canonicalSnapshot().workers[0].runtime, + command: 123 + } + }] + }), /workers\[0\].runtime.command/], + [canonicalSnapshot({ + workers: [{ + ...canonicalSnapshot().workers[0], + runtime: { + ...canonicalSnapshot().workers[0].runtime, + active: 'yes' + } + }] + }), /workers\[0\].runtime.active/], + [canonicalSnapshot({ + workers: [{ + ...canonicalSnapshot().workers[0], + intent: { + objective: 'ok', + seedPaths: ['README.md', 123] + } + }] + }), /workers\[0\].intent.seedPaths/], + [canonicalSnapshot({ + workers: [{ + ...canonicalSnapshot().workers[0], + outputs: { + summary: [], + validation: 'nope', + remainingRisks: [] + } + }] + }), /workers\[0\].outputs.validation/], + [canonicalSnapshot({ aggregates: { workerCount: 99 } }), /aggregates.workerCount to match/], + [canonicalSnapshot({ aggregates: { states: [] } }), /aggregates.states to be an object/], + [canonicalSnapshot({ aggregates: { states: { running: -1 } } }), /aggregates.states.running/], + [canonicalSnapshot({ aggregates: { healths: null } }), /aggregates.healths to be an object/] + ]; + + for (const [snapshot, pattern] of invalidCases) { + assert.throws(() => validateCanonicalSnapshot(snapshot), pattern); + } +}); + +function dmuxWorker(workerSlug, status = {}, overrides = {}) { + return { + workerSlug, + workerDir: `/tmp/${workerSlug}`, + status: { + state: 'running', + updated: new Date().toISOString(), + branch: null, + worktree: null, + ...status + }, + task: { + objective: `${workerSlug} objective`, + seedPaths: ['README.md'], + ...(overrides.task || {}) + }, + handoff: { + summary: ['summary'], + validation: ['validation'], + remainingRisks: ['risk'], + ...(overrides.handoff || {}) + }, + files: { + status: `/tmp/${workerSlug}/status.md`, + task: `/tmp/${workerSlug}/task.md`, + handoff: `/tmp/${workerSlug}/handoff.md`, + ...(overrides.files || {}) + }, + pane: Object.prototype.hasOwnProperty.call(overrides, 'pane') + ? overrides.pane + : { + currentCommand: 'codex', + pid: 123, + active: true, + dead: false + } + }; +} + +function dmuxSnapshot(overrides = {}) { + return { + sessionName: 'edge-session', + repoRoot: '/tmp/repo', + sessionActive: false, + workerStates: {}, + workerCount: 0, + workers: [], + ...overrides + }; +} + +test('dmux normalization covers missing failed idle and stale worker states', () => { + const sourceTarget = { type: 'session', value: 'edge-session' }; + + const missing = normalizeDmuxSnapshot(dmuxSnapshot(), sourceTarget); + assert.strictEqual(missing.session.state, 'missing'); + assert.strictEqual(missing.aggregates.workerCount, 0); + + const failed = normalizeDmuxSnapshot(dmuxSnapshot({ + workerStates: { failed: 1 }, + workerCount: 1, + workers: [ + dmuxWorker('failure', { state: 'failed' }, { pane: null }) + ] + }), sourceTarget); + assert.strictEqual(failed.session.state, 'failed'); + assert.strictEqual(failed.workers[0].health, 'degraded'); + assert.strictEqual(failed.workers[0].runtime.active, false); + assert.strictEqual(failed.workers[0].runtime.dead, false); + + const idle = normalizeDmuxSnapshot(dmuxSnapshot({ + workerStates: { running: 1, queued: 1 }, + workerCount: 2, + workers: [ + dmuxWorker('missing-update', { state: 'running', updated: undefined }), + dmuxWorker('stale-update', { state: 'active', updated: '2001-01-01T00:00:00Z' }), + dmuxWorker('dead-pane', { state: 'running' }, { pane: { dead: true, active: false } }), + dmuxWorker('mystery', { state: 'queued' }, { + task: { seedPaths: 'not-array' }, + handoff: { summary: 'not-array', validation: null, remainingRisks: undefined }, + pane: null + }) + ] + }), sourceTarget); + + assert.strictEqual(idle.session.state, 'idle'); + assert.deepStrictEqual( + idle.workers.map(worker => worker.health), + ['stale', 'stale', 'degraded', 'unknown'] + ); + assert.deepStrictEqual(idle.workers[3].intent.seedPaths, []); + assert.deepStrictEqual(idle.workers[3].outputs.summary, []); + + const completed = normalizeDmuxSnapshot(dmuxSnapshot({ + workerStates: null, + workerCount: 2, + workers: [ + dmuxWorker('done-a', { state: 'done' }), + dmuxWorker('done-b', { state: 'success' }) + ] + }), sourceTarget); + assert.strictEqual(completed.session.state, 'completed'); + assert.deepStrictEqual(completed.workers.map(worker => worker.health), ['healthy', 'healthy']); +}); + +test('claude history normalization falls back to filename ids and empty metadata defaults', () => { + const snapshot = normalizeClaudeHistorySession({ + shortId: 'no-id', + filename: '2026-03-13-no-id-session.tmp', + sessionPath: '/tmp/2026-03-13-no-id-session.tmp', + metadata: { + title: '', + completed: 'not-array', + inProgress: ['Resume from filename fallback'], + context: '', + notes: '' + } + }, { + type: 'claude-history', + value: 'latest' + }); + + assert.strictEqual(snapshot.session.id, '2026-03-13-no-id-session'); + assert.strictEqual(snapshot.workers[0].id, '2026-03-13-no-id-session'); + assert.strictEqual(snapshot.workers[0].label, '2026-03-13-no-id-session.tmp'); + assert.strictEqual(snapshot.workers[0].intent.objective, 'Resume from filename fallback'); + assert.deepStrictEqual(snapshot.workers[0].intent.seedPaths, []); + assert.deepStrictEqual(snapshot.workers[0].outputs.summary, []); + assert.deepStrictEqual(snapshot.workers[0].outputs.remainingRisks, []); + + const pathOnly = normalizeClaudeHistorySession({ + sessionPath: '/tmp/path-only-session.tmp', + metadata: { + title: 'Path Only', + inProgress: ['Continue work'], + context: ' README.md \n\n scripts/ecc.js ', + notes: 'No risks' + } + }, { + type: 'claude-history', + value: '/tmp/path-only-session.tmp' + }); + + assert.strictEqual(pathOnly.session.id, 'path-only-session'); + assert.strictEqual(pathOnly.workers[0].intent.objective, 'Continue work'); + assert.deepStrictEqual(pathOnly.workers[0].intent.seedPaths, ['README.md', 'scripts/ecc.js']); + assert.deepStrictEqual(pathOnly.workers[0].outputs.remainingRisks, ['No risks']); +}); + +test('fallback recordings sanitize paths, use env dirs, and preserve changed history', () => { + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-env-')); + const previousRecordingDir = process.env.ECC_SESSION_RECORDING_DIR; + + try { + process.env.ECC_SESSION_RECORDING_DIR = recordingDir; + const first = canonicalSnapshot({ + adapterId: 'adapter with spaces', + session: { id: 'session id/with:chars' } + }); + const recordingPath = getFallbackSessionRecordingPath(first); + assert.ok(recordingPath.includes(`${path.sep}adapter_with_spaces${path.sep}`)); + assert.ok(recordingPath.endsWith(`${path.sep}session_id_with_chars.json`)); + + fs.mkdirSync(path.dirname(recordingPath), { recursive: true }); + fs.writeFileSync(recordingPath, '{not json', 'utf8'); + + const firstPersistence = persistCanonicalSnapshot(first, { + loadStateStoreImpl: () => null + }); + const changed = canonicalSnapshot({ + adapterId: 'adapter with spaces', + session: { id: 'session id/with:chars', state: 'idle' } + }); + persistCanonicalSnapshot(changed, { loadStateStoreImpl: () => null }); + persistCanonicalSnapshot(changed, { loadStateStoreImpl: () => null }); + + const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); + assert.strictEqual(firstPersistence.backend, 'json-file'); + assert.strictEqual(firstPersistence.path, recordingPath); + assert.strictEqual(persisted.schemaVersion, 'ecc.session.recording.v1'); + assert.strictEqual(persisted.latest.session.state, 'idle'); + assert.strictEqual(persisted.history.length, 2); + assert.strictEqual(persisted.history[0].snapshot.session.state, 'active'); + assert.strictEqual(persisted.history[1].snapshot.session.state, 'idle'); + assert.strictEqual(persisted.createdAt, persisted.history[0].recordedAt); + } finally { + if (typeof previousRecordingDir === 'string') { + process.env.ECC_SESSION_RECORDING_DIR = previousRecordingDir; + } else { + delete process.env.ECC_SESSION_RECORDING_DIR; + } + fs.rmSync(recordingDir, { recursive: true, force: true }); + } +}); + +test('persistence supports skip mode, writer variants, and missing state-store fallback', () => { + const snapshot = canonicalSnapshot(); + const skipped = persistCanonicalSnapshot(snapshot, { persist: false }); + assert.deepStrictEqual(skipped, { + backend: 'skipped', + path: null, + recordedAt: null + }); + + const topLevelStore = { + calls: [], + recordCanonicalSessionSnapshot(snapshotArg, metadata) { + this.calls.push({ snapshot: snapshotArg, metadata }); + } + }; + const stateStoreResult = persistCanonicalSnapshot(snapshot, { stateStore: topLevelStore }); + assert.strictEqual(stateStoreResult.backend, 'state-store'); + assert.strictEqual(topLevelStore.calls.length, 1); + assert.strictEqual(topLevelStore.calls[0].metadata.sessionId, 'session-1'); + + const nestedStore = { + sessions: { + calls: [], + recordSessionSnapshot(snapshotArg, metadata) { + this.calls.push({ snapshot: snapshotArg, metadata }); + } + } + }; + persistCanonicalSnapshot(snapshot, { stateStore: nestedStore }); + assert.strictEqual(nestedStore.sessions.calls.length, 1); + + const noWriterDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-no-writer-')); + const missingModuleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-missing-module-')); + try { + const noWriter = persistCanonicalSnapshot(snapshot, { + recordingDir: noWriterDir, + stateStore: { createStateStore() {} } + }); + assert.strictEqual(noWriter.backend, 'json-file'); + + const missingModule = new Error("Cannot find module '../state-store'"); + missingModule.code = 'MODULE_NOT_FOUND'; + const fallback = persistCanonicalSnapshot(snapshot, { + recordingDir: missingModuleDir, + loadStateStoreImpl() { + throw missingModule; + } + }); + assert.strictEqual(fallback.backend, 'json-file'); + } finally { + fs.rmSync(noWriterDir, { recursive: true, force: true }); + fs.rmSync(missingModuleDir, { recursive: true, force: true }); + } +}); + test('persistence only falls back when the state-store module is missing', () => { const snapshot = { schemaVersion: 'ecc.session.v1', diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index 68e7d154..f067921e 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -19,7 +19,6 @@ const fs = require('fs'); const path = require('path'); const repoRoot = path.resolve(__dirname, '..'); -const repoRootWithSep = `${repoRoot}${path.sep}`; const packageJsonPath = path.join(repoRoot, 'package.json'); const packageLockPath = path.join(repoRoot, 'package-lock.json'); const rootAgentsPath = path.join(repoRoot, 'AGENTS.md'); @@ -70,16 +69,6 @@ function loadJsonObject(filePath, label) { return parsed; } -function assertSafeRepoRelativePath(relativePath, label) { - const normalized = path.posix.normalize(relativePath.replace(/\\/g, '/')); - - assert.ok(!path.isAbsolute(relativePath), `${label} must not be absolute: ${relativePath}`); - assert.ok( - !normalized.startsWith('../') && !normalized.includes('/../'), - `${label} must not traverse directories: ${relativePath}`, - ); -} - function collectMarkdownFiles(rootPath) { if (!fs.existsSync(rootPath)) { return [];