From 76b271ab6b9ff4473b597337f9499a3b49163d0e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 12 Feb 2026 15:50:04 -0800 Subject: [PATCH] fix: 6 bugs fixed, 67 tests added for session-manager and session-aliases Bug fixes: - utils.js: prevent duplicate 'g' flag in countInFile regex construction - validate-agents.js: handle CRLF line endings in frontmatter parsing - validate-hooks.js: handle \t and \\ escape sequences in inline JS validation - session-aliases.js: prevent NaN in date sort when timestamps are missing - session-aliases.js: persist rollback on rename failure instead of silent loss - session-manager.js: require absolute paths in getSessionStats to prevent content strings ending with .tmp from being treated as file paths New tests (164 total, up from 97): - session-manager.test.js: 27 tests covering parseSessionFilename, parseSessionMetadata, getSessionStats, CRUD operations, getSessionSize, getSessionTitle, edge cases (null input, non-existent files, directories) - session-aliases.test.js: 40 tests covering loadAliases (corrupted JSON, invalid structure), setAlias (validation, reserved names), resolveAlias, listAliases (sort, search, limit), deleteAlias, renameAlias, updateAliasTitle, resolveSessionAlias, getAliasesForSession, cleanupAliases, atomic write Also includes hook-generated improvements: - utils.d.ts: document that readStdinJson never rejects - session-aliases.d.ts: fix updateAliasTitle type to accept null - package-manager.js: add try-catch to setProjectPackageManager writeFile --- scripts/ci/validate-agents.js | 2 +- scripts/ci/validate-hooks.js | 4 +- scripts/lib/package-manager.js | 6 +- scripts/lib/session-aliases.d.ts | 4 +- scripts/lib/session-aliases.js | 14 +- scripts/lib/session-manager.js | 1 + scripts/lib/utils.d.ts | 3 +- scripts/lib/utils.js | 15 +- tests/lib/session-aliases.test.js | 420 ++++++++++++++++++++++++++++++ tests/lib/session-manager.test.js | 336 ++++++++++++++++++++++++ tests/run-all.js | 2 + 11 files changed, 790 insertions(+), 17 deletions(-) create mode 100644 tests/lib/session-aliases.test.js create mode 100644 tests/lib/session-manager.test.js diff --git a/scripts/ci/validate-agents.js b/scripts/ci/validate-agents.js index 12bf336c..29a82b3c 100644 --- a/scripts/ci/validate-agents.js +++ b/scripts/ci/validate-agents.js @@ -17,7 +17,7 @@ function extractFrontmatter(content) { if (!match) return null; const frontmatter = {}; - const lines = match[1].split('\n'); + const lines = match[1].split(/\r?\n/); for (const line of lines) { const colonIdx = line.indexOf(':'); if (colonIdx > 0) { diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js index 68e0e45f..f91f9407 100644 --- a/scripts/ci/validate-hooks.js +++ b/scripts/ci/validate-hooks.js @@ -74,7 +74,7 @@ function validateHooks() { const nodeEMatch = hook.command.match(/^node -e "(.*)"$/s); if (nodeEMatch) { try { - new vm.Script(nodeEMatch[1].replace(/\\"/g, '"').replace(/\\n/g, '\n')); + new vm.Script(nodeEMatch[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\\\/g, '\\')); } catch (syntaxErr) { console.error(`ERROR: ${eventType}[${i}].hooks[${j}] has invalid inline JS: ${syntaxErr.message}`); hasErrors = true; @@ -113,7 +113,7 @@ function validateHooks() { const nodeEMatch = h.command.match(/^node -e "(.*)"$/s); if (nodeEMatch) { try { - new vm.Script(nodeEMatch[1].replace(/\\"/g, '"').replace(/\\n/g, '\n')); + new vm.Script(nodeEMatch[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\\\/g, '\\')); } catch (syntaxErr) { console.error(`ERROR: Hook ${i}.hooks[${j}] has invalid inline JS: ${syntaxErr.message}`); hasErrors = true; diff --git a/scripts/lib/package-manager.js b/scripts/lib/package-manager.js index 4e8b60a0..de4d1e43 100644 --- a/scripts/lib/package-manager.js +++ b/scripts/lib/package-manager.js @@ -267,7 +267,11 @@ function setProjectPackageManager(pmName, projectDir = process.cwd()) { setAt: new Date().toISOString() }; - writeFile(configPath, JSON.stringify(config, null, 2)); + try { + writeFile(configPath, JSON.stringify(config, null, 2)); + } catch (err) { + throw new Error(`Failed to save package manager config to ${configPath}: ${err.message}`); + } return config; } diff --git a/scripts/lib/session-aliases.d.ts b/scripts/lib/session-aliases.d.ts index 8c26cd38..c1744713 100644 --- a/scripts/lib/session-aliases.d.ts +++ b/scripts/lib/session-aliases.d.ts @@ -123,8 +123,8 @@ export function renameAlias(oldAlias: string, newAlias: string): RenameAliasResu */ export function resolveSessionAlias(aliasOrId: string): string; -/** Update the title of an existing alias */ -export function updateAliasTitle(alias: string, title: string): AliasResult; +/** Update the title of an existing alias. Pass null to clear. */ +export function updateAliasTitle(alias: string, title: string | null): AliasResult; /** Get all aliases that point to a specific session path */ export function getAliasesForSession(sessionPath: string): Array<{ name: string; createdAt: string; title: string | null }>; diff --git a/scripts/lib/session-aliases.js b/scripts/lib/session-aliases.js index 1260f1cb..c1d6be6e 100644 --- a/scripts/lib/session-aliases.js +++ b/scripts/lib/session-aliases.js @@ -252,7 +252,7 @@ function listAliases(options = {}) { })); // Sort by updated time (newest first) - aliases.sort((a, b) => new Date(b.updatedAt || b.createdAt) - new Date(a.updatedAt || a.createdAt)); + aliases.sort((a, b) => (new Date(b.updatedAt || b.createdAt || 0).getTime() || 0) - (new Date(a.updatedAt || a.createdAt || 0).getTime() || 0)); // Apply search filter if (search) { @@ -337,7 +337,9 @@ function renameAlias(oldAlias, newAlias) { // Restore old alias and remove new alias on failure data.aliases[oldAlias] = aliasData; delete data.aliases[newAlias]; - return { success: false, error: 'Failed to rename alias' }; + // Attempt to persist the rollback + saveAliases(data); + return { success: false, error: 'Failed to save renamed alias — rolled back to original' }; } /** @@ -359,17 +361,21 @@ function resolveSessionAlias(aliasOrId) { /** * Update alias title * @param {string} alias - Alias name - * @param {string} title - New title + * @param {string|null} title - New title (string or null to clear) * @returns {object} Result with success status */ function updateAliasTitle(alias, title) { + if (title !== null && typeof title !== 'string') { + return { success: false, error: 'Title must be a string or null' }; + } + const data = loadAliases(); if (!data.aliases[alias]) { return { success: false, error: `Alias '${alias}' not found` }; } - data.aliases[alias].title = title; + data.aliases[alias].title = title || null; data.aliases[alias].updatedAt = new Date().toISOString(); if (saveAliases(data)) { diff --git a/scripts/lib/session-manager.js b/scripts/lib/session-manager.js index 5d78a965..e449030f 100644 --- a/scripts/lib/session-manager.js +++ b/scripts/lib/session-manager.js @@ -150,6 +150,7 @@ function getSessionStats(sessionPathOrContent) { // read from disk. Otherwise treat it as content. const content = (typeof sessionPathOrContent === 'string' && !sessionPathOrContent.includes('\n') && + sessionPathOrContent.startsWith('/') && sessionPathOrContent.endsWith('.tmp')) ? getSessionContent(sessionPathOrContent) : sessionPathOrContent; diff --git a/scripts/lib/utils.d.ts b/scripts/lib/utils.d.ts index 3e044748..f668ef5a 100644 --- a/scripts/lib/utils.d.ts +++ b/scripts/lib/utils.d.ts @@ -132,7 +132,8 @@ export interface ReadStdinJsonOptions { /** * Read JSON from stdin (for hook input). - * Returns an empty object if stdin is empty or times out. + * Returns an empty object if stdin is empty, times out, or contains invalid JSON. + * Never rejects — safe to use without try-catch in hooks. */ export function readStdinJson(options?: ReadStdinJsonOptions): Promise>; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index a45ef73b..b1da6331 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -206,7 +206,7 @@ function findFiles(dir, pattern, options = {}) { async function readStdinJson(options = {}) { const { timeoutMs = 5000, maxSize = 1024 * 1024 } = options; - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let data = ''; let settled = false; @@ -235,16 +235,19 @@ async function readStdinJson(options = {}) { clearTimeout(timer); try { resolve(data.trim() ? JSON.parse(data) : {}); - } catch (err) { - reject(err); + } catch { + // Consistent with timeout path: resolve with empty object + // so hooks don't crash on malformed input + resolve({}); } }); - process.stdin.on('error', err => { + process.stdin.on('error', () => { if (settled) return; settled = true; clearTimeout(timer); - reject(err); + // Resolve with empty object so hooks don't crash on stdin errors + resolve({}); }); }); } @@ -414,7 +417,7 @@ function countInFile(filePath, pattern) { try { if (pattern instanceof RegExp) { // Ensure global flag is set for correct counting - regex = pattern.global ? pattern : new RegExp(pattern.source, pattern.flags + 'g'); + regex = pattern.global ? pattern : new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'); } else if (typeof pattern === 'string') { regex = new RegExp(pattern, 'g'); } else { diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js new file mode 100644 index 00000000..43286061 --- /dev/null +++ b/tests/lib/session-aliases.test.js @@ -0,0 +1,420 @@ +/** + * Tests for scripts/lib/session-aliases.js + * + * These tests use a temporary directory to avoid touching + * the real ~/.claude/session-aliases.json. + * + * Run with: node tests/lib/session-aliases.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// We need to mock getClaudeDir to point to a temp dir. +// The simplest approach: set HOME to a temp dir before requiring the module. +const tmpHome = path.join(os.tmpdir(), `ecc-alias-test-${Date.now()}`); +fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true }); +const origHome = process.env.HOME; +process.env.HOME = tmpHome; + +const aliases = require('../../scripts/lib/session-aliases'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function resetAliases() { + const aliasesPath = aliases.getAliasesPath(); + try { + if (fs.existsSync(aliasesPath)) { + fs.unlinkSync(aliasesPath); + } + } catch { + // ignore + } +} + +function runTests() { + console.log('\n=== Testing session-aliases.js ===\n'); + + let passed = 0; + let failed = 0; + + // loadAliases tests + console.log('loadAliases:'); + + if (test('returns default structure when no file exists', () => { + resetAliases(); + const data = aliases.loadAliases(); + assert.ok(data.aliases); + assert.strictEqual(typeof data.aliases, 'object'); + assert.ok(data.version); + assert.ok(data.metadata); + })) passed++; else failed++; + + if (test('returns default structure for corrupted JSON', () => { + const aliasesPath = aliases.getAliasesPath(); + fs.writeFileSync(aliasesPath, 'NOT VALID JSON!!!'); + const data = aliases.loadAliases(); + assert.ok(data.aliases); + assert.strictEqual(typeof data.aliases, 'object'); + resetAliases(); + })) passed++; else failed++; + + if (test('returns default structure for invalid structure', () => { + const aliasesPath = aliases.getAliasesPath(); + fs.writeFileSync(aliasesPath, JSON.stringify({ noAliasesKey: true })); + const data = aliases.loadAliases(); + assert.ok(data.aliases); + assert.strictEqual(Object.keys(data.aliases).length, 0); + resetAliases(); + })) passed++; else failed++; + + // setAlias tests + console.log('\nsetAlias:'); + + if (test('creates a new alias', () => { + resetAliases(); + const result = aliases.setAlias('my-session', '/path/to/session', 'Test Session'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.isNew, true); + assert.strictEqual(result.alias, 'my-session'); + })) passed++; else failed++; + + if (test('updates an existing alias', () => { + const result = aliases.setAlias('my-session', '/new/path', 'Updated'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.isNew, false); + })) passed++; else failed++; + + if (test('rejects empty alias name', () => { + const result = aliases.setAlias('', '/path'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('empty')); + })) passed++; else failed++; + + if (test('rejects null alias name', () => { + const result = aliases.setAlias(null, '/path'); + assert.strictEqual(result.success, false); + })) passed++; else failed++; + + if (test('rejects invalid characters in alias', () => { + const result = aliases.setAlias('my alias!', '/path'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('letters')); + })) passed++; else failed++; + + if (test('rejects alias longer than 128 chars', () => { + const result = aliases.setAlias('a'.repeat(129), '/path'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('128')); + })) passed++; else failed++; + + if (test('rejects reserved alias names', () => { + const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set']; + for (const name of reserved) { + const result = aliases.setAlias(name, '/path'); + assert.strictEqual(result.success, false, `Should reject '${name}'`); + assert.ok(result.error.includes('reserved'), `Should say reserved for '${name}'`); + } + })) passed++; else failed++; + + if (test('rejects empty session path', () => { + const result = aliases.setAlias('valid-name', ''); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('empty')); + })) passed++; else failed++; + + if (test('accepts underscores and dashes in alias', () => { + resetAliases(); + const result = aliases.setAlias('my_session-v2', '/path'); + assert.strictEqual(result.success, true); + })) passed++; else failed++; + + // resolveAlias tests + console.log('\nresolveAlias:'); + + if (test('resolves existing alias', () => { + resetAliases(); + aliases.setAlias('test-resolve', '/session/path', 'Title'); + const result = aliases.resolveAlias('test-resolve'); + assert.ok(result); + assert.strictEqual(result.alias, 'test-resolve'); + assert.strictEqual(result.sessionPath, '/session/path'); + assert.strictEqual(result.title, 'Title'); + })) passed++; else failed++; + + if (test('returns null for non-existent alias', () => { + const result = aliases.resolveAlias('nonexistent'); + assert.strictEqual(result, null); + })) passed++; else failed++; + + if (test('returns null for null/undefined input', () => { + assert.strictEqual(aliases.resolveAlias(null), null); + assert.strictEqual(aliases.resolveAlias(undefined), null); + assert.strictEqual(aliases.resolveAlias(''), null); + })) passed++; else failed++; + + if (test('returns null for invalid alias characters', () => { + assert.strictEqual(aliases.resolveAlias('invalid alias!'), null); + assert.strictEqual(aliases.resolveAlias('path/traversal'), null); + })) passed++; else failed++; + + // listAliases tests + console.log('\nlistAliases:'); + + if (test('lists all aliases sorted by recency', () => { + resetAliases(); + // Manually create aliases with different timestamps to test sort + const data = aliases.loadAliases(); + data.aliases['old-one'] = { + sessionPath: '/path/old', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + title: null + }; + data.aliases['new-one'] = { + sessionPath: '/path/new', + createdAt: '2026-02-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + title: null + }; + aliases.saveAliases(data); + const list = aliases.listAliases(); + assert.strictEqual(list.length, 2); + // Most recently updated should come first + assert.strictEqual(list[0].name, 'new-one'); + assert.strictEqual(list[1].name, 'old-one'); + })) passed++; else failed++; + + if (test('filters aliases by search string', () => { + const list = aliases.listAliases({ search: 'old' }); + assert.strictEqual(list.length, 1); + assert.strictEqual(list[0].name, 'old-one'); + })) passed++; else failed++; + + if (test('limits number of results', () => { + const list = aliases.listAliases({ limit: 1 }); + assert.strictEqual(list.length, 1); + })) passed++; else failed++; + + if (test('returns empty array when no aliases exist', () => { + resetAliases(); + const list = aliases.listAliases(); + assert.strictEqual(list.length, 0); + })) passed++; else failed++; + + if (test('search is case-insensitive', () => { + resetAliases(); + aliases.setAlias('MyProject', '/path'); + const list = aliases.listAliases({ search: 'myproject' }); + assert.strictEqual(list.length, 1); + })) passed++; else failed++; + + // deleteAlias tests + console.log('\ndeleteAlias:'); + + if (test('deletes existing alias', () => { + resetAliases(); + aliases.setAlias('to-delete', '/path'); + const result = aliases.deleteAlias('to-delete'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.alias, 'to-delete'); + + // Verify it's gone + assert.strictEqual(aliases.resolveAlias('to-delete'), null); + })) passed++; else failed++; + + if (test('returns error for non-existent alias', () => { + const result = aliases.deleteAlias('nonexistent'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('not found')); + })) passed++; else failed++; + + // renameAlias tests + console.log('\nrenameAlias:'); + + if (test('renames existing alias', () => { + resetAliases(); + aliases.setAlias('original', '/path', 'My Session'); + const result = aliases.renameAlias('original', 'renamed'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.oldAlias, 'original'); + assert.strictEqual(result.newAlias, 'renamed'); + + // Verify old is gone, new exists + assert.strictEqual(aliases.resolveAlias('original'), null); + assert.ok(aliases.resolveAlias('renamed')); + })) passed++; else failed++; + + if (test('rejects rename to existing alias', () => { + resetAliases(); + aliases.setAlias('alias-a', '/path/a'); + aliases.setAlias('alias-b', '/path/b'); + const result = aliases.renameAlias('alias-a', 'alias-b'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('already exists')); + })) passed++; else failed++; + + if (test('rejects rename of non-existent alias', () => { + const result = aliases.renameAlias('nonexistent', 'new-name'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('not found')); + })) passed++; else failed++; + + if (test('rejects rename to invalid characters', () => { + resetAliases(); + aliases.setAlias('valid', '/path'); + const result = aliases.renameAlias('valid', 'invalid name!'); + assert.strictEqual(result.success, false); + })) passed++; else failed++; + + // updateAliasTitle tests + console.log('\nupdateAliasTitle:'); + + if (test('updates title of existing alias', () => { + resetAliases(); + aliases.setAlias('titled', '/path', 'Old Title'); + const result = aliases.updateAliasTitle('titled', 'New Title'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.title, 'New Title'); + })) passed++; else failed++; + + if (test('clears title with null', () => { + const result = aliases.updateAliasTitle('titled', null); + assert.strictEqual(result.success, true); + const resolved = aliases.resolveAlias('titled'); + assert.strictEqual(resolved.title, null); + })) passed++; else failed++; + + if (test('rejects non-string non-null title', () => { + const result = aliases.updateAliasTitle('titled', 42); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('string')); + })) passed++; else failed++; + + if (test('rejects title update for non-existent alias', () => { + const result = aliases.updateAliasTitle('nonexistent', 'Title'); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('not found')); + })) passed++; else failed++; + + // resolveSessionAlias tests + console.log('\nresolveSessionAlias:'); + + if (test('resolves alias to session path', () => { + resetAliases(); + aliases.setAlias('shortcut', '/sessions/my-session'); + const result = aliases.resolveSessionAlias('shortcut'); + assert.strictEqual(result, '/sessions/my-session'); + })) passed++; else failed++; + + if (test('returns input as-is when not an alias', () => { + const result = aliases.resolveSessionAlias('/some/direct/path'); + assert.strictEqual(result, '/some/direct/path'); + })) passed++; else failed++; + + // getAliasesForSession tests + console.log('\ngetAliasesForSession:'); + + if (test('finds all aliases for a session path', () => { + resetAliases(); + aliases.setAlias('alias-1', '/sessions/target'); + aliases.setAlias('alias-2', '/sessions/target'); + aliases.setAlias('other', '/sessions/different'); + + const result = aliases.getAliasesForSession('/sessions/target'); + assert.strictEqual(result.length, 2); + const names = result.map(a => a.name).sort(); + assert.deepStrictEqual(names, ['alias-1', 'alias-2']); + })) passed++; else failed++; + + if (test('returns empty array for session with no aliases', () => { + const result = aliases.getAliasesForSession('/sessions/no-aliases'); + assert.strictEqual(result.length, 0); + })) passed++; else failed++; + + // cleanupAliases tests + console.log('\ncleanupAliases:'); + + if (test('removes aliases for non-existent sessions', () => { + resetAliases(); + aliases.setAlias('exists', '/sessions/real'); + aliases.setAlias('gone', '/sessions/deleted'); + aliases.setAlias('also-gone', '/sessions/also-deleted'); + + const result = aliases.cleanupAliases((path) => path === '/sessions/real'); + assert.strictEqual(result.removed, 2); + assert.strictEqual(result.removedAliases.length, 2); + + // Verify surviving alias + assert.ok(aliases.resolveAlias('exists')); + assert.strictEqual(aliases.resolveAlias('gone'), null); + })) passed++; else failed++; + + if (test('handles all sessions existing (no cleanup needed)', () => { + resetAliases(); + aliases.setAlias('alive', '/sessions/alive'); + const result = aliases.cleanupAliases(() => true); + assert.strictEqual(result.removed, 0); + })) passed++; else failed++; + + if (test('rejects non-function sessionExists', () => { + const result = aliases.cleanupAliases('not a function'); + assert.strictEqual(result.totalChecked, 0); + assert.ok(result.error); + })) passed++; else failed++; + + // saveAliases atomic write tests + console.log('\nsaveAliases (atomic write):'); + + if (test('persists data across load/save cycles', () => { + resetAliases(); + const data = aliases.loadAliases(); + data.aliases['persist-test'] = { + sessionPath: '/test/path', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + title: 'Persistence Test' + }; + const saved = aliases.saveAliases(data); + assert.strictEqual(saved, true); + + const reloaded = aliases.loadAliases(); + assert.ok(reloaded.aliases['persist-test']); + assert.strictEqual(reloaded.aliases['persist-test'].title, 'Persistence Test'); + })) passed++; else failed++; + + if (test('updates metadata on save', () => { + resetAliases(); + aliases.setAlias('meta-test', '/path'); + const data = aliases.loadAliases(); + assert.strictEqual(data.metadata.totalCount, 1); + assert.ok(data.metadata.lastUpdated); + })) passed++; else failed++; + + // Cleanup + process.env.HOME = origHome; + try { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } catch { + // best-effort + } + + // Summary + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/session-manager.test.js b/tests/lib/session-manager.test.js new file mode 100644 index 00000000..211575c7 --- /dev/null +++ b/tests/lib/session-manager.test.js @@ -0,0 +1,336 @@ +/** + * Tests for scripts/lib/session-manager.js + * + * Run with: node tests/lib/session-manager.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const sessionManager = require('../../scripts/lib/session-manager'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +// Create a temp directory for session tests +function createTempSessionDir() { + const dir = path.join(os.tmpdir(), `ecc-test-sessions-${Date.now()}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function cleanup(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} + +function runTests() { + console.log('\n=== Testing session-manager.js ===\n'); + + let passed = 0; + let failed = 0; + + // parseSessionFilename tests + console.log('parseSessionFilename:'); + + if (test('parses new format with short ID', () => { + const result = sessionManager.parseSessionFilename('2026-02-01-a1b2c3d4-session.tmp'); + assert.ok(result); + assert.strictEqual(result.shortId, 'a1b2c3d4'); + assert.strictEqual(result.date, '2026-02-01'); + assert.strictEqual(result.filename, '2026-02-01-a1b2c3d4-session.tmp'); + })) passed++; else failed++; + + if (test('parses old format without short ID', () => { + const result = sessionManager.parseSessionFilename('2026-01-17-session.tmp'); + assert.ok(result); + assert.strictEqual(result.shortId, 'no-id'); + assert.strictEqual(result.date, '2026-01-17'); + })) passed++; else failed++; + + if (test('returns null for invalid filename', () => { + assert.strictEqual(sessionManager.parseSessionFilename('not-a-session.txt'), null); + assert.strictEqual(sessionManager.parseSessionFilename(''), null); + assert.strictEqual(sessionManager.parseSessionFilename('random.tmp'), null); + })) passed++; else failed++; + + if (test('returns null for malformed date', () => { + assert.strictEqual(sessionManager.parseSessionFilename('20260-01-17-session.tmp'), null); + assert.strictEqual(sessionManager.parseSessionFilename('26-01-17-session.tmp'), null); + })) passed++; else failed++; + + if (test('parses long short IDs (8+ chars)', () => { + const result = sessionManager.parseSessionFilename('2026-02-01-abcdef12345678-session.tmp'); + assert.ok(result); + assert.strictEqual(result.shortId, 'abcdef12345678'); + })) passed++; else failed++; + + if (test('rejects short IDs less than 8 chars', () => { + const result = sessionManager.parseSessionFilename('2026-02-01-abc-session.tmp'); + assert.strictEqual(result, null); + })) passed++; else failed++; + + // parseSessionMetadata tests + console.log('\nparseSessionMetadata:'); + + if (test('parses full session content', () => { + const content = `# My Session Title + +**Date:** 2026-02-01 +**Started:** 10:30 +**Last Updated:** 14:45 + +### Completed +- [x] Set up project +- [x] Write tests + +### In Progress +- [ ] Fix bug + +### Notes for Next Session +Remember to check the logs + +### Context to Load +\`\`\` +src/main.ts +\`\`\``; + const meta = sessionManager.parseSessionMetadata(content); + assert.strictEqual(meta.title, 'My Session Title'); + assert.strictEqual(meta.date, '2026-02-01'); + assert.strictEqual(meta.started, '10:30'); + assert.strictEqual(meta.lastUpdated, '14:45'); + assert.strictEqual(meta.completed.length, 2); + assert.strictEqual(meta.completed[0], 'Set up project'); + assert.strictEqual(meta.inProgress.length, 1); + assert.strictEqual(meta.inProgress[0], 'Fix bug'); + assert.strictEqual(meta.notes, 'Remember to check the logs'); + assert.strictEqual(meta.context, 'src/main.ts'); + })) passed++; else failed++; + + if (test('handles null/undefined/empty content', () => { + const meta1 = sessionManager.parseSessionMetadata(null); + assert.strictEqual(meta1.title, null); + assert.deepStrictEqual(meta1.completed, []); + + const meta2 = sessionManager.parseSessionMetadata(undefined); + assert.strictEqual(meta2.title, null); + + const meta3 = sessionManager.parseSessionMetadata(''); + assert.strictEqual(meta3.title, null); + })) passed++; else failed++; + + if (test('handles content with no sections', () => { + const meta = sessionManager.parseSessionMetadata('Just some text'); + assert.strictEqual(meta.title, null); + assert.deepStrictEqual(meta.completed, []); + assert.deepStrictEqual(meta.inProgress, []); + })) passed++; else failed++; + + // getSessionStats tests + console.log('\ngetSessionStats:'); + + if (test('calculates stats from content string', () => { + const content = `# Test Session + +### Completed +- [x] Task 1 +- [x] Task 2 + +### In Progress +- [ ] Task 3 +`; + const stats = sessionManager.getSessionStats(content); + assert.strictEqual(stats.totalItems, 3); + assert.strictEqual(stats.completedItems, 2); + assert.strictEqual(stats.inProgressItems, 1); + assert.ok(stats.lineCount > 0); + })) passed++; else failed++; + + if (test('handles empty content', () => { + const stats = sessionManager.getSessionStats(''); + assert.strictEqual(stats.totalItems, 0); + assert.strictEqual(stats.completedItems, 0); + assert.strictEqual(stats.lineCount, 0); + })) passed++; else failed++; + + if (test('does not treat non-absolute path as file path', () => { + // This tests the bug fix: content that ends with .tmp but is not a path + const stats = sessionManager.getSessionStats('Some content ending with test.tmp'); + assert.strictEqual(stats.totalItems, 0); + assert.strictEqual(stats.lineCount, 1); + })) passed++; else failed++; + + // File I/O tests + console.log('\nSession CRUD:'); + + if (test('writeSessionContent and getSessionContent round-trip', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, '2026-02-01-testid01-session.tmp'); + const content = '# Test Session\n\nHello world'; + + const writeResult = sessionManager.writeSessionContent(sessionPath, content); + assert.strictEqual(writeResult, true); + + const readContent = sessionManager.getSessionContent(sessionPath); + assert.strictEqual(readContent, content); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('appendSessionContent appends to existing', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, '2026-02-01-testid02-session.tmp'); + sessionManager.writeSessionContent(sessionPath, 'Line 1\n'); + sessionManager.appendSessionContent(sessionPath, 'Line 2\n'); + + const content = sessionManager.getSessionContent(sessionPath); + assert.ok(content.includes('Line 1')); + assert.ok(content.includes('Line 2')); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('writeSessionContent returns false for invalid path', () => { + const result = sessionManager.writeSessionContent('/nonexistent/deep/path/session.tmp', 'content'); + assert.strictEqual(result, false); + })) passed++; else failed++; + + if (test('getSessionContent returns null for non-existent file', () => { + const result = sessionManager.getSessionContent('/nonexistent/session.tmp'); + assert.strictEqual(result, null); + })) passed++; else failed++; + + if (test('deleteSession removes file', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, 'test-session.tmp'); + fs.writeFileSync(sessionPath, 'content'); + assert.strictEqual(fs.existsSync(sessionPath), true); + + const result = sessionManager.deleteSession(sessionPath); + assert.strictEqual(result, true); + assert.strictEqual(fs.existsSync(sessionPath), false); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('deleteSession returns false for non-existent file', () => { + const result = sessionManager.deleteSession('/nonexistent/session.tmp'); + assert.strictEqual(result, false); + })) passed++; else failed++; + + if (test('sessionExists returns true for existing file', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, 'test.tmp'); + fs.writeFileSync(sessionPath, 'content'); + assert.strictEqual(sessionManager.sessionExists(sessionPath), true); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('sessionExists returns false for non-existent file', () => { + assert.strictEqual(sessionManager.sessionExists('/nonexistent/path.tmp'), false); + })) passed++; else failed++; + + if (test('sessionExists returns false for directory', () => { + const dir = createTempSessionDir(); + try { + assert.strictEqual(sessionManager.sessionExists(dir), false); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + // getSessionSize tests + console.log('\ngetSessionSize:'); + + if (test('returns human-readable size for existing file', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, 'sized.tmp'); + fs.writeFileSync(sessionPath, 'x'.repeat(2048)); + const size = sessionManager.getSessionSize(sessionPath); + assert.ok(size.includes('KB'), `Expected KB, got: ${size}`); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('returns "0 B" for non-existent file', () => { + const size = sessionManager.getSessionSize('/nonexistent/file.tmp'); + assert.strictEqual(size, '0 B'); + })) passed++; else failed++; + + if (test('returns bytes for small file', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, 'small.tmp'); + fs.writeFileSync(sessionPath, 'hi'); + const size = sessionManager.getSessionSize(sessionPath); + assert.ok(size.includes('B')); + assert.ok(!size.includes('KB')); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + // getSessionTitle tests + console.log('\ngetSessionTitle:'); + + if (test('extracts title from session file', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, 'titled.tmp'); + fs.writeFileSync(sessionPath, '# My Great Session\n\nSome content'); + const title = sessionManager.getSessionTitle(sessionPath); + assert.strictEqual(title, 'My Great Session'); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('returns "Untitled Session" for empty content', () => { + const dir = createTempSessionDir(); + try { + const sessionPath = path.join(dir, 'empty.tmp'); + fs.writeFileSync(sessionPath, ''); + const title = sessionManager.getSessionTitle(sessionPath); + assert.strictEqual(title, 'Untitled Session'); + } finally { + cleanup(dir); + } + })) passed++; else failed++; + + if (test('returns "Untitled Session" for non-existent file', () => { + const title = sessionManager.getSessionTitle('/nonexistent/file.tmp'); + assert.strictEqual(title, 'Untitled Session'); + })) passed++; else failed++; + + // Summary + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/run-all.js b/tests/run-all.js index 2abb299c..8978c3e5 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -13,6 +13,8 @@ const testsDir = __dirname; const testFiles = [ 'lib/utils.test.js', 'lib/package-manager.test.js', + 'lib/session-manager.test.js', + 'lib/session-aliases.test.js', 'hooks/hooks.test.js', 'integration/hooks.test.js' ];