mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
4
scripts/lib/session-aliases.d.ts
vendored
4
scripts/lib/session-aliases.d.ts
vendored
@@ -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 }>;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
scripts/lib/utils.d.ts
vendored
3
scripts/lib/utils.d.ts
vendored
@@ -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<Record<string, unknown>>;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
420
tests/lib/session-aliases.test.js
Normal file
420
tests/lib/session-aliases.test.js
Normal file
@@ -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();
|
||||
336
tests/lib/session-manager.test.js
Normal file
336
tests/lib/session-manager.test.js
Normal file
@@ -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();
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user