mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-05 08:43:29 +08:00
Merge branch 'main' into main
This commit is contained in:
70
tests/codex-config.test.js
Normal file
70
tests/codex-config.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tests for `.codex/config.toml` reference defaults.
|
||||
*
|
||||
* Run with: node tests/codex-config.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const configPath = path.join(repoRoot, '.codex', 'config.toml');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
const codexAgentsDir = path.join(repoRoot, '.codex', 'agents');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('reference config does not pin a top-level model', () => {
|
||||
assert.ok(!/^model\s*=/m.test(config), 'Expected `.codex/config.toml` to inherit the CLI default model');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('reference config does not pin a top-level model provider', () => {
|
||||
assert.ok(
|
||||
!/^model_provider\s*=/m.test(config),
|
||||
'Expected `.codex/config.toml` to inherit the CLI default provider',
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('sample Codex role configs do not use o4-mini', () => {
|
||||
const roleFiles = fs.readdirSync(codexAgentsDir).filter(file => file.endsWith('.toml'));
|
||||
assert.ok(roleFiles.length > 0, 'Expected sample role config files under `.codex/agents`');
|
||||
|
||||
for (const roleFile of roleFiles) {
|
||||
const rolePath = path.join(codexAgentsDir, roleFile);
|
||||
const roleConfig = fs.readFileSync(rolePath, 'utf8');
|
||||
assert.ok(
|
||||
!/^model\s*=\s*"o4-mini"$/m.test(roleConfig),
|
||||
`Expected sample role config to avoid o4-mini: ${roleFile}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -98,6 +98,44 @@ function cleanupTestDir(testDir) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function normalizeComparablePath(targetPath) {
|
||||
if (!targetPath) return '';
|
||||
|
||||
let normalizedPath = String(targetPath).trim().replace(/\\/g, '/');
|
||||
|
||||
if (/^\/[a-zA-Z]\//.test(normalizedPath)) {
|
||||
normalizedPath = `${normalizedPath[1]}:/${normalizedPath.slice(3)}`;
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z]:\//.test(normalizedPath)) {
|
||||
normalizedPath = `${normalizedPath[0].toUpperCase()}:${normalizedPath.slice(2)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
normalizedPath = fs.realpathSync(normalizedPath);
|
||||
} catch {
|
||||
// Fall through to string normalization when the path cannot be resolved directly.
|
||||
}
|
||||
|
||||
return path.normalize(normalizedPath).replace(/\\/g, '/').replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
}
|
||||
|
||||
function pathsReferToSameLocation(leftPath, rightPath) {
|
||||
const normalizedLeftPath = normalizeComparablePath(leftPath);
|
||||
const normalizedRightPath = normalizeComparablePath(rightPath);
|
||||
|
||||
if (!normalizedLeftPath || !normalizedRightPath) return false;
|
||||
if (normalizedLeftPath === normalizedRightPath) return true;
|
||||
|
||||
try {
|
||||
const leftStats = fs.statSync(normalizedLeftPath);
|
||||
const rightStats = fs.statSync(normalizedRightPath);
|
||||
return leftStats.dev === rightStats.dev && leftStats.ino === rightStats.ino;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandShim(binDir, baseName, logFile) {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
@@ -155,6 +193,7 @@ async function runTests() {
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const scriptsDir = path.join(__dirname, '..', '..', 'scripts', 'hooks');
|
||||
|
||||
@@ -360,22 +399,30 @@ async function runTests() {
|
||||
|
||||
if (
|
||||
await asyncTest('creates or updates session file', async () => {
|
||||
// Run the script
|
||||
await runScript(path.join(scriptsDir, 'session-end.js'));
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-session-create-${Date.now()}`);
|
||||
|
||||
// Check if session file was created
|
||||
// Note: Without CLAUDE_SESSION_ID, falls back to project name (not 'default')
|
||||
// Use local time to match the script's getDateString() function
|
||||
const sessionsDir = path.join(os.homedir(), '.claude', 'sessions');
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
try {
|
||||
await runScript(path.join(scriptsDir, 'session-end.js'), '', {
|
||||
HOME: isoHome,
|
||||
USERPROFILE: isoHome
|
||||
});
|
||||
|
||||
// Get the expected session ID (project name fallback)
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
const expectedId = utils.getSessionIdShort();
|
||||
const sessionFile = path.join(sessionsDir, `${today}-${expectedId}-session.tmp`);
|
||||
// Check if session file was created
|
||||
// Note: Without CLAUDE_SESSION_ID, falls back to project/worktree name (not 'default')
|
||||
// Use local time to match the script's getDateString() function
|
||||
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`);
|
||||
// Get the expected session ID (project name fallback)
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
const expectedId = utils.getSessionIdShort();
|
||||
const sessionFile = path.join(sessionsDir, `${today}-${expectedId}-session.tmp`);
|
||||
|
||||
assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`);
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -404,6 +451,39 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('writes project, branch, and worktree metadata into new session files', async () => {
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-session-metadata-${Date.now()}`);
|
||||
const testSessionId = 'test-session-meta1234';
|
||||
const expectedShortId = testSessionId.slice(-8);
|
||||
const topLevel = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).stdout.trim();
|
||||
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).stdout.trim();
|
||||
const project = path.basename(topLevel);
|
||||
|
||||
try {
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), '', {
|
||||
HOME: isoHome,
|
||||
USERPROFILE: isoHome,
|
||||
CLAUDE_SESSION_ID: testSessionId
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Hook should exit 0');
|
||||
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const sessionFile = path.join(isoHome, '.claude', 'sessions', `${today}-${expectedShortId}-session.tmp`);
|
||||
const content = fs.readFileSync(sessionFile, 'utf8');
|
||||
|
||||
assert.ok(content.includes(`**Project:** ${project}`), 'Should persist project metadata');
|
||||
assert.ok(content.includes(`**Branch:** ${branch}`), 'Should persist branch metadata');
|
||||
assert.ok(content.includes(`**Worktree:** ${process.cwd()}`), 'Should persist worktree metadata');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// pre-compact.js tests
|
||||
console.log('\npre-compact.js:');
|
||||
|
||||
@@ -1218,7 +1298,10 @@ async function runTests() {
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson);
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
|
||||
HOME: testDir,
|
||||
USERPROFILE: testDir
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
// Session file should contain summary with tools used
|
||||
assert.ok(result.stderr.includes('Created session file') || result.stderr.includes('Updated session file'), 'Should create/update session file');
|
||||
@@ -2148,7 +2231,11 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - detect-project writes project metadata to the registry and project directory');
|
||||
console.log(' (skipped — bash script paths are not Windows-compatible)');
|
||||
skipped++;
|
||||
} else if (
|
||||
await asyncTest('detect-project writes project metadata to the registry and project directory', async () => {
|
||||
const testRoot = createTestDir();
|
||||
const homeDir = path.join(testRoot, 'home');
|
||||
@@ -2185,9 +2272,9 @@ async function runTests() {
|
||||
|
||||
assert.strictEqual(code, 0, `detect-project should source cleanly, stderr: ${stderr}`);
|
||||
|
||||
const [projectId, projectDir] = stdout.trim().split(/\r?\n/);
|
||||
const [projectId] = stdout.trim().split(/\r?\n/);
|
||||
const registryPath = path.join(homeDir, '.claude', 'homunculus', 'projects.json');
|
||||
const projectMetadataPath = path.join(projectDir, 'project.json');
|
||||
const projectMetadataPath = path.join(homeDir, '.claude', 'homunculus', 'projects', projectId, 'project.json');
|
||||
|
||||
assert.ok(projectId, 'detect-project should emit a project id');
|
||||
assert.ok(fs.existsSync(registryPath), 'projects.json should be created');
|
||||
@@ -2199,7 +2286,13 @@ async function runTests() {
|
||||
assert.ok(registry[projectId], 'registry should contain the detected project');
|
||||
assert.strictEqual(metadata.id, projectId, 'project.json should include the detected id');
|
||||
assert.strictEqual(metadata.name, path.basename(repoDir), 'project.json should include the repo name');
|
||||
assert.strictEqual(fs.realpathSync(metadata.root), fs.realpathSync(repoDir), 'project.json should include the repo root');
|
||||
const normalizedMetadataRoot = normalizeComparablePath(metadata.root);
|
||||
const normalizedRepoDir = normalizeComparablePath(repoDir);
|
||||
assert.ok(normalizedMetadataRoot, 'project.json should include a non-empty repo root');
|
||||
assert.ok(
|
||||
pathsReferToSameLocation(normalizedMetadataRoot, normalizedRepoDir),
|
||||
`project.json should include the repo root (expected ${normalizedRepoDir}, got ${normalizedMetadataRoot})`,
|
||||
);
|
||||
assert.strictEqual(metadata.remote, 'https://github.com/example/ecc-test.git', 'project.json should include the sanitized remote');
|
||||
assert.ok(metadata.created_at, 'project.json should include created_at');
|
||||
assert.ok(metadata.last_seen, 'project.json should include last_seen');
|
||||
@@ -2521,6 +2614,42 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('normalizes existing session headers with project, branch, and worktree metadata', async () => {
|
||||
const testDir = createTestDir();
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const utils = require('../../scripts/lib/utils');
|
||||
const today = utils.getDateString();
|
||||
const shortId = 'update04';
|
||||
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
|
||||
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).stdout.trim();
|
||||
const project = path.basename(spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).stdout.trim());
|
||||
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
`# Session: ${today}\n**Date:** ${today}\n**Started:** 09:00\n**Last Updated:** 09:00\n\n---\n\n## Current State\n\n[Session context goes here]\n`
|
||||
);
|
||||
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), '', {
|
||||
HOME: testDir,
|
||||
USERPROFILE: testDir,
|
||||
CLAUDE_SESSION_ID: `session-${shortId}`
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const updated = fs.readFileSync(sessionFile, 'utf8');
|
||||
assert.ok(updated.includes(`**Project:** ${project}`), 'Should inject project metadata into existing headers');
|
||||
assert.ok(updated.includes(`**Branch:** ${branch}`), 'Should inject branch metadata into existing headers');
|
||||
assert.ok(updated.includes(`**Worktree:** ${process.cwd()}`), 'Should inject worktree metadata into existing headers');
|
||||
|
||||
cleanupTestDir(testDir);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('replaces blank template with summary when updating existing file', async () => {
|
||||
const testDir = createTestDir();
|
||||
@@ -3888,6 +4017,8 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), oversizedPayload, {
|
||||
HOME: testDir,
|
||||
USERPROFILE: testDir,
|
||||
CLAUDE_TRANSCRIPT_PATH: transcriptPath
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
|
||||
@@ -4311,12 +4442,12 @@ async function runTests() {
|
||||
// ── Round 74: session-start.js main().catch handler ──
|
||||
console.log('\nRound 74: session-start.js (main catch — unrecoverable error):');
|
||||
|
||||
if (
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - session-start exits 0 with error message when HOME is non-directory');
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
skipped++;
|
||||
} else if (
|
||||
await asyncTest('session-start exits 0 with error message when HOME is non-directory', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR,
|
||||
// which propagates to main().catch — the top-level error boundary
|
||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||
@@ -4333,12 +4464,12 @@ async function runTests() {
|
||||
// ── Round 75: pre-compact.js main().catch handler ──
|
||||
console.log('\nRound 75: pre-compact.js (main catch — unrecoverable error):');
|
||||
|
||||
if (
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - pre-compact exits 0 with error message when HOME is non-directory');
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
skipped++;
|
||||
} else if (
|
||||
await asyncTest('pre-compact exits 0 with error message when HOME is non-directory', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR,
|
||||
// which propagates to main().catch — the top-level error boundary
|
||||
const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', {
|
||||
@@ -4355,12 +4486,12 @@ async function runTests() {
|
||||
// ── Round 75: session-end.js main().catch handler ──
|
||||
console.log('\nRound 75: session-end.js (main catch — unrecoverable error):');
|
||||
|
||||
if (
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - session-end exits 0 with error message when HOME is non-directory');
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
skipped++;
|
||||
} else if (
|
||||
await asyncTest('session-end exits 0 with error message when HOME is non-directory', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR inside main(),
|
||||
// which propagates to runMain().catch — the top-level error boundary
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), '{}', {
|
||||
@@ -4377,12 +4508,12 @@ async function runTests() {
|
||||
// ── Round 76: evaluate-session.js main().catch handler ──
|
||||
console.log('\nRound 76: evaluate-session.js (main catch — unrecoverable error):');
|
||||
|
||||
if (
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - evaluate-session exits 0 with error message when HOME is non-directory');
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
skipped++;
|
||||
} else if (
|
||||
await asyncTest('evaluate-session exits 0 with error message when HOME is non-directory', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// HOME=/dev/null makes ensureDir(learnedSkillsPath) throw ENOTDIR,
|
||||
// which propagates to main().catch — the top-level error boundary
|
||||
const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), '{}', {
|
||||
@@ -4399,12 +4530,12 @@ async function runTests() {
|
||||
// ── Round 76: suggest-compact.js main().catch handler ──
|
||||
console.log('\nRound 76: suggest-compact.js (main catch — double-failure):');
|
||||
|
||||
if (
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - suggest-compact exits 0 with error when TMPDIR is non-directory');
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
skipped++;
|
||||
} else if (
|
||||
await asyncTest('suggest-compact exits 0 with error when TMPDIR is non-directory', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// TMPDIR=/dev/null causes openSync to fail (ENOTDIR), then the catch
|
||||
// fallback writeFile also fails, propagating to main().catch
|
||||
const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', {
|
||||
@@ -4517,10 +4648,20 @@ async function runTests() {
|
||||
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp'));
|
||||
assert.ok(files.length > 0, 'Should create session file');
|
||||
const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8');
|
||||
const summaryMatch = content.match(
|
||||
/<!-- ECC:SUMMARY:START -->([\s\S]*?)<!-- ECC:SUMMARY:END -->/
|
||||
);
|
||||
// The real string message should appear
|
||||
assert.ok(content.includes('Real user message'), 'Should include the string content user message');
|
||||
// Numeric/boolean/object content should NOT appear as text
|
||||
assert.ok(!content.includes('42'), 'Numeric content should be skipped (else branch → empty string → filtered)');
|
||||
assert.ok(summaryMatch, 'Should include a generated summary block');
|
||||
const summaryBlock = summaryMatch[1];
|
||||
// Numeric/boolean/object content should NOT appear as task bullets
|
||||
assert.ok(
|
||||
!summaryBlock.includes('\n- 42\n'),
|
||||
'Numeric content should be skipped (else branch → empty string → filtered)'
|
||||
);
|
||||
assert.ok(!summaryBlock.includes('\n- true\n'), 'Boolean content should be skipped');
|
||||
assert.ok(!summaryBlock.includes('[object Object]'), 'Object content should be skipped');
|
||||
} finally {
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -4876,7 +5017,8 @@ Some random content without the expected ### Context to Load section
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}\n`);
|
||||
console.log(`Skipped: ${skipped}`);
|
||||
console.log(`Total: ${passed + failed + skipped}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
318
tests/lib/orchestration-session.test.js
Normal file
318
tests/lib/orchestration-session.test.js
Normal file
@@ -0,0 +1,318 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildSessionSnapshot,
|
||||
loadWorkerSnapshots,
|
||||
parseWorkerHandoff,
|
||||
parseWorkerStatus,
|
||||
parseWorkerTask,
|
||||
resolveSnapshotTarget
|
||||
} = require('../../scripts/lib/orchestration-session');
|
||||
|
||||
console.log('=== Testing orchestration-session.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${desc}: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
test('parseWorkerStatus extracts structured status fields', () => {
|
||||
const status = parseWorkerStatus([
|
||||
'# Status',
|
||||
'',
|
||||
'- State: completed',
|
||||
'- Updated: 2026-03-12T14:09:15Z',
|
||||
'- Branch: feature-branch',
|
||||
'- Worktree: `/tmp/worktree`',
|
||||
'',
|
||||
'- Handoff file: `/tmp/handoff.md`'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(status, {
|
||||
state: 'completed',
|
||||
updated: '2026-03-12T14:09:15Z',
|
||||
branch: 'feature-branch',
|
||||
worktree: '/tmp/worktree',
|
||||
taskFile: null,
|
||||
handoffFile: '/tmp/handoff.md'
|
||||
});
|
||||
});
|
||||
|
||||
test('parseWorkerTask extracts objective and seeded overlays', () => {
|
||||
const task = parseWorkerTask([
|
||||
'# Worker Task',
|
||||
'',
|
||||
'## Seeded Local Overlays',
|
||||
'- `scripts/orchestrate-worktrees.js`',
|
||||
'- `commands/orchestrate.md`',
|
||||
'',
|
||||
'## Objective',
|
||||
'Verify seeded files and summarize status.'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(task.seedPaths, [
|
||||
'scripts/orchestrate-worktrees.js',
|
||||
'commands/orchestrate.md'
|
||||
]);
|
||||
assert.strictEqual(task.objective, 'Verify seeded files and summarize status.');
|
||||
});
|
||||
|
||||
test('parseWorkerHandoff extracts summary, validation, and risks', () => {
|
||||
const handoff = parseWorkerHandoff([
|
||||
'# Handoff',
|
||||
'',
|
||||
'## Summary',
|
||||
'- Worker completed successfully',
|
||||
'',
|
||||
'## Validation',
|
||||
'- Ran tests',
|
||||
'',
|
||||
'## Remaining Risks',
|
||||
'- No runtime screenshot'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(handoff.summary, ['Worker completed successfully']);
|
||||
assert.deepStrictEqual(handoff.validation, ['Ran tests']);
|
||||
assert.deepStrictEqual(handoff.remainingRisks, ['No runtime screenshot']);
|
||||
});
|
||||
|
||||
test('parseWorkerHandoff also supports bold section headers', () => {
|
||||
const handoff = parseWorkerHandoff([
|
||||
'# Handoff',
|
||||
'',
|
||||
'**Summary**',
|
||||
'- Worker completed successfully',
|
||||
'',
|
||||
'**Validation**',
|
||||
'- Ran tests',
|
||||
'',
|
||||
'**Remaining Risks**',
|
||||
'- No runtime screenshot'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(handoff.summary, ['Worker completed successfully']);
|
||||
assert.deepStrictEqual(handoff.validation, ['Ran tests']);
|
||||
assert.deepStrictEqual(handoff.remainingRisks, ['No runtime screenshot']);
|
||||
});
|
||||
|
||||
test('parseWorkerHandoff accepts legacy verification and follow-up headings', () => {
|
||||
const handoff = parseWorkerHandoff([
|
||||
'# Handoff',
|
||||
'',
|
||||
'## Summary',
|
||||
'- Worker completed successfully',
|
||||
'',
|
||||
'## Tests / Verification',
|
||||
'- Ran tests',
|
||||
'',
|
||||
'## Follow-ups',
|
||||
'- Re-run screenshots after deploy'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(handoff.summary, ['Worker completed successfully']);
|
||||
assert.deepStrictEqual(handoff.validation, ['Ran tests']);
|
||||
assert.deepStrictEqual(handoff.remainingRisks, ['Re-run screenshots after deploy']);
|
||||
});
|
||||
|
||||
test('loadWorkerSnapshots reads coordination worker directories', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-session-'));
|
||||
const coordinationDir = path.join(tempRoot, 'coordination');
|
||||
const workerDir = path.join(coordinationDir, 'seed-check');
|
||||
const proofDir = path.join(coordinationDir, 'proof');
|
||||
fs.mkdirSync(workerDir, { recursive: true });
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(workerDir, 'status.md'), [
|
||||
'# Status',
|
||||
'',
|
||||
'- State: running',
|
||||
'- Branch: seed-branch',
|
||||
'- Worktree: `/tmp/seed-worktree`'
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(workerDir, 'task.md'), [
|
||||
'# Worker Task',
|
||||
'',
|
||||
'## Objective',
|
||||
'Inspect seed paths.'
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(workerDir, 'handoff.md'), [
|
||||
'# Handoff',
|
||||
'',
|
||||
'## Summary',
|
||||
'- Pending'
|
||||
].join('\n'));
|
||||
|
||||
const workers = loadWorkerSnapshots(coordinationDir);
|
||||
assert.strictEqual(workers.length, 1);
|
||||
assert.strictEqual(workers[0].workerSlug, 'seed-check');
|
||||
assert.strictEqual(workers[0].status.branch, 'seed-branch');
|
||||
assert.strictEqual(workers[0].task.objective, 'Inspect seed paths.');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSessionSnapshot merges tmux panes with worker metadata', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-snapshot-'));
|
||||
const coordinationDir = path.join(tempRoot, 'coordination');
|
||||
const workerDir = path.join(coordinationDir, 'seed-check');
|
||||
fs.mkdirSync(workerDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(workerDir, 'status.md'), '- State: completed\n- Branch: seed-branch\n');
|
||||
fs.writeFileSync(path.join(workerDir, 'task.md'), '## Objective\nInspect seed paths.\n');
|
||||
fs.writeFileSync(path.join(workerDir, 'handoff.md'), '## Summary\n- ok\n');
|
||||
|
||||
const snapshot = buildSessionSnapshot({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
coordinationDir,
|
||||
panes: [
|
||||
{
|
||||
paneId: '%95',
|
||||
windowIndex: 1,
|
||||
paneIndex: 2,
|
||||
title: 'seed-check',
|
||||
currentCommand: 'codex',
|
||||
currentPath: '/tmp/worktree',
|
||||
active: false,
|
||||
dead: false,
|
||||
pid: 1234
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.sessionActive, true);
|
||||
assert.strictEqual(snapshot.workerCount, 1);
|
||||
assert.strictEqual(snapshot.workerStates.completed, 1);
|
||||
assert.strictEqual(snapshot.workers[0].pane.paneId, '%95');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveSnapshotTarget handles plan files and direct session names', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-target-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
const planPath = path.join(repoRoot, 'plan.json');
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
repoRoot,
|
||||
coordinationRoot: path.join(repoRoot, '.claude', 'orchestration')
|
||||
}));
|
||||
|
||||
try {
|
||||
const fromPlan = resolveSnapshotTarget(planPath, repoRoot);
|
||||
assert.strictEqual(fromPlan.targetType, 'plan');
|
||||
assert.strictEqual(fromPlan.sessionName, 'workflow-visual-proof');
|
||||
|
||||
const fromSession = resolveSnapshotTarget('workflow-visual-proof', repoRoot);
|
||||
assert.strictEqual(fromSession.targetType, 'session');
|
||||
assert.ok(fromSession.coordinationDir.endsWith(path.join('.orchestration', 'workflow-visual-proof')));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveSnapshotTarget normalizes plan session names and defaults to the repo name', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-target-'));
|
||||
const repoRoot = path.join(tempRoot, 'My Repo');
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
|
||||
const namedPlanPath = path.join(repoRoot, 'named-plan.json');
|
||||
const defaultPlanPath = path.join(repoRoot, 'default-plan.json');
|
||||
|
||||
fs.writeFileSync(namedPlanPath, JSON.stringify({
|
||||
sessionName: 'Workflow Visual Proof',
|
||||
repoRoot
|
||||
}));
|
||||
fs.writeFileSync(defaultPlanPath, JSON.stringify({ repoRoot }));
|
||||
|
||||
try {
|
||||
const namedPlan = resolveSnapshotTarget(namedPlanPath, repoRoot);
|
||||
assert.strictEqual(namedPlan.sessionName, 'workflow-visual-proof');
|
||||
assert.ok(namedPlan.coordinationDir.endsWith(path.join('.orchestration', 'workflow-visual-proof')));
|
||||
|
||||
const defaultPlan = resolveSnapshotTarget(defaultPlanPath, repoRoot);
|
||||
assert.strictEqual(defaultPlan.sessionName, 'my-repo');
|
||||
assert.ok(defaultPlan.coordinationDir.endsWith(path.join('.orchestration', 'my-repo')));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveSnapshotTarget rejects malformed plan files and invalid config fields', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-target-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
|
||||
const invalidJsonPath = path.join(repoRoot, 'invalid-json.json');
|
||||
const blankFieldsPath = path.join(repoRoot, 'blank-fields.json');
|
||||
const invalidSessionNamePath = path.join(repoRoot, 'invalid-session.json');
|
||||
const invalidRepoRootPath = path.join(repoRoot, 'invalid-repo-root.json');
|
||||
const invalidCoordinationRootPath = path.join(repoRoot, 'invalid-coordination-root.json');
|
||||
|
||||
fs.writeFileSync(invalidJsonPath, '{not valid json');
|
||||
fs.writeFileSync(blankFieldsPath, JSON.stringify({
|
||||
sessionName: ' ',
|
||||
repoRoot: ' ',
|
||||
coordinationRoot: ' '
|
||||
}));
|
||||
fs.writeFileSync(invalidSessionNamePath, JSON.stringify({
|
||||
sessionName: 42,
|
||||
repoRoot
|
||||
}));
|
||||
fs.writeFileSync(invalidRepoRootPath, JSON.stringify({
|
||||
sessionName: 'workflow',
|
||||
repoRoot: ['not-a-string']
|
||||
}));
|
||||
fs.writeFileSync(invalidCoordinationRootPath, JSON.stringify({
|
||||
sessionName: 'workflow',
|
||||
repoRoot,
|
||||
coordinationRoot: false
|
||||
}));
|
||||
|
||||
try {
|
||||
const blankFields = resolveSnapshotTarget(blankFieldsPath, repoRoot);
|
||||
assert.strictEqual(blankFields.sessionName, 'repo');
|
||||
assert.strictEqual(blankFields.repoRoot, repoRoot);
|
||||
assert.ok(blankFields.coordinationDir.endsWith(path.join('.orchestration', 'repo')));
|
||||
|
||||
assert.throws(
|
||||
() => resolveSnapshotTarget(invalidJsonPath, repoRoot),
|
||||
/Invalid orchestration plan JSON/
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveSnapshotTarget(invalidSessionNamePath, repoRoot),
|
||||
/sessionName must be a string when provided/
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveSnapshotTarget(invalidRepoRootPath, repoRoot),
|
||||
/repoRoot must be a string when provided/
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveSnapshotTarget(invalidCoordinationRootPath, repoRoot),
|
||||
/coordinationRoot must be a string when provided/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -80,9 +80,10 @@ function runTests() {
|
||||
assert.strictEqual(result.shortId, 'abcdef12345678');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects short IDs less than 8 chars', () => {
|
||||
if (test('accepts short IDs under 8 chars', () => {
|
||||
const result = sessionManager.parseSessionFilename('2026-02-01-abc-session.tmp');
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.shortId, 'abc');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// parseSessionMetadata tests
|
||||
@@ -94,6 +95,9 @@ function runTests() {
|
||||
**Date:** 2026-02-01
|
||||
**Started:** 10:30
|
||||
**Last Updated:** 14:45
|
||||
**Project:** everything-claude-code
|
||||
**Branch:** feature/session-metadata
|
||||
**Worktree:** /tmp/ecc-worktree
|
||||
|
||||
### Completed
|
||||
- [x] Set up project
|
||||
@@ -114,6 +118,9 @@ src/main.ts
|
||||
assert.strictEqual(meta.date, '2026-02-01');
|
||||
assert.strictEqual(meta.started, '10:30');
|
||||
assert.strictEqual(meta.lastUpdated, '14:45');
|
||||
assert.strictEqual(meta.project, 'everything-claude-code');
|
||||
assert.strictEqual(meta.branch, 'feature/session-metadata');
|
||||
assert.strictEqual(meta.worktree, '/tmp/ecc-worktree');
|
||||
assert.strictEqual(meta.completed.length, 2);
|
||||
assert.strictEqual(meta.completed[0], 'Set up project');
|
||||
assert.strictEqual(meta.inProgress.length, 1);
|
||||
@@ -578,9 +585,16 @@ src/main.ts
|
||||
// parseSessionFilename edge cases
|
||||
console.log('\nparseSessionFilename (additional edge cases):');
|
||||
|
||||
if (test('rejects uppercase letters in short ID', () => {
|
||||
if (test('accepts uppercase letters in short ID', () => {
|
||||
const result = sessionManager.parseSessionFilename('2026-02-01-ABCD1234-session.tmp');
|
||||
assert.strictEqual(result, null, 'Uppercase letters should be rejected');
|
||||
assert.ok(result, 'Uppercase letters should be accepted');
|
||||
assert.strictEqual(result.shortId, 'ABCD1234');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts underscores in short ID', () => {
|
||||
const result = sessionManager.parseSessionFilename('2026-02-01-ChezMoi_2-session.tmp');
|
||||
assert.ok(result, 'Underscores should be accepted');
|
||||
assert.strictEqual(result.shortId, 'ChezMoi_2');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts hyphenated short IDs (extra segments)', () => {
|
||||
@@ -1910,20 +1924,22 @@ file.ts
|
||||
'Year 100+ is not affected by the 0-99 → 1900-1999 mapping');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 110: parseSessionFilename rejects uppercase IDs (regex is [a-z0-9]) ──
|
||||
console.log('\nRound 110: parseSessionFilename (uppercase ID — regex [a-z0-9]{8,} rejects [A-Z]):');
|
||||
if (test('parseSessionFilename rejects filenames with uppercase characters in short ID', () => {
|
||||
// SESSION_FILENAME_REGEX uses [a-z0-9]{8,} — strictly lowercase
|
||||
// ── Round 110: parseSessionFilename accepts mixed-case IDs ──
|
||||
console.log('\nRound 110: parseSessionFilename (mixed-case IDs are accepted):');
|
||||
if (test('parseSessionFilename accepts filenames with uppercase characters in short ID', () => {
|
||||
const upperResult = sessionManager.parseSessionFilename('2026-01-15-ABCD1234-session.tmp');
|
||||
assert.strictEqual(upperResult, null,
|
||||
'All-uppercase ID should be rejected by [a-z0-9]{8,}');
|
||||
assert.notStrictEqual(upperResult, null,
|
||||
'All-uppercase ID should be accepted');
|
||||
assert.strictEqual(upperResult.shortId, 'ABCD1234');
|
||||
|
||||
const mixedResult = sessionManager.parseSessionFilename('2026-01-15-AbCd1234-session.tmp');
|
||||
assert.strictEqual(mixedResult, null,
|
||||
'Mixed-case ID should be rejected by [a-z0-9]{8,}');
|
||||
// Confirm lowercase is accepted
|
||||
assert.notStrictEqual(mixedResult, null,
|
||||
'Mixed-case ID should be accepted');
|
||||
assert.strictEqual(mixedResult.shortId, 'AbCd1234');
|
||||
|
||||
const lowerResult = sessionManager.parseSessionFilename('2026-01-15-abcd1234-session.tmp');
|
||||
assert.notStrictEqual(lowerResult, null,
|
||||
'All-lowercase ID should be accepted');
|
||||
'All-lowercase ID should still be accepted');
|
||||
assert.strictEqual(lowerResult.shortId, 'abcd1234');
|
||||
})) passed++; else failed++;
|
||||
|
||||
@@ -2190,36 +2206,34 @@ file.ts
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 117: parseSessionFilename with uppercase short ID — regex rejects [A-Z] ──
|
||||
console.log('\nRound 117: parseSessionFilename (uppercase short ID — regex [a-z0-9] rejects uppercase):');
|
||||
if (test('parseSessionFilename rejects uppercase short IDs because regex uses [a-z0-9] not [a-zA-Z0-9]', () => {
|
||||
// The regex: /^(\d{4}-\d{2}-\d{2})(?:-([a-z0-9]{8,}))?-session\.tmp$/
|
||||
// Note: [a-z0-9] — lowercase only
|
||||
|
||||
// All uppercase — rejected
|
||||
// ── Round 117: parseSessionFilename accepts uppercase, underscores, and short IDs ──
|
||||
console.log('\nRound 117: parseSessionFilename (uppercase, underscores, and short IDs are accepted):');
|
||||
if (test('parseSessionFilename accepts uppercase short IDs, underscores, and 7-char names', () => {
|
||||
const upper = sessionManager.parseSessionFilename('2026-01-15-ABCDEFGH-session.tmp');
|
||||
assert.strictEqual(upper, null,
|
||||
'All-uppercase ID should be rejected (regex uses [a-z0-9])');
|
||||
assert.notStrictEqual(upper, null,
|
||||
'All-uppercase ID should be accepted');
|
||||
assert.strictEqual(upper.shortId, 'ABCDEFGH');
|
||||
|
||||
// Mixed case — rejected
|
||||
const mixed = sessionManager.parseSessionFilename('2026-01-15-AbCdEfGh-session.tmp');
|
||||
assert.strictEqual(mixed, null,
|
||||
'Mixed-case ID should be rejected (uppercase chars not in [a-z0-9])');
|
||||
assert.notStrictEqual(mixed, null,
|
||||
'Mixed-case ID should be accepted');
|
||||
assert.strictEqual(mixed.shortId, 'AbCdEfGh');
|
||||
|
||||
// All lowercase — accepted
|
||||
const lower = sessionManager.parseSessionFilename('2026-01-15-abcdefgh-session.tmp');
|
||||
assert.notStrictEqual(lower, null, 'All-lowercase ID should be accepted');
|
||||
assert.strictEqual(lower.shortId, 'abcdefgh');
|
||||
|
||||
// Uppercase hex-like (common in UUIDs) — rejected
|
||||
const hexUpper = sessionManager.parseSessionFilename('2026-01-15-A1B2C3D4-session.tmp');
|
||||
assert.strictEqual(hexUpper, null,
|
||||
'Uppercase hex ID should be rejected');
|
||||
assert.notStrictEqual(hexUpper, null, 'Uppercase hex ID should be accepted');
|
||||
assert.strictEqual(hexUpper.shortId, 'A1B2C3D4');
|
||||
|
||||
// Lowercase hex — accepted
|
||||
const hexLower = sessionManager.parseSessionFilename('2026-01-15-a1b2c3d4-session.tmp');
|
||||
assert.notStrictEqual(hexLower, null, 'Lowercase hex ID should be accepted');
|
||||
assert.strictEqual(hexLower.shortId, 'a1b2c3d4');
|
||||
const underscored = sessionManager.parseSessionFilename('2026-01-15-ChezMoi_2-session.tmp');
|
||||
assert.notStrictEqual(underscored, null, 'IDs with underscores should be accepted');
|
||||
assert.strictEqual(underscored.shortId, 'ChezMoi_2');
|
||||
|
||||
const shortName = sessionManager.parseSessionFilename('2026-01-15-homelab-session.tmp');
|
||||
assert.notStrictEqual(shortName, null, '7-character names should be accepted');
|
||||
assert.strictEqual(shortName.shortId, 'homelab');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 119: parseSessionMetadata "Context to Load" code block extraction ──
|
||||
|
||||
303
tests/lib/tmux-worktree-orchestrator.test.js
Normal file
303
tests/lib/tmux-worktree-orchestrator.test.js
Normal file
@@ -0,0 +1,303 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
slugify,
|
||||
renderTemplate,
|
||||
buildOrchestrationPlan,
|
||||
materializePlan,
|
||||
normalizeSeedPaths,
|
||||
overlaySeedPaths
|
||||
} = require('../../scripts/lib/tmux-worktree-orchestrator');
|
||||
|
||||
console.log('=== Testing tmux-worktree-orchestrator.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${desc}: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Helpers:');
|
||||
test('slugify normalizes mixed punctuation and casing', () => {
|
||||
assert.strictEqual(slugify('Feature Audit: Docs + Tmux'), 'feature-audit-docs-tmux');
|
||||
});
|
||||
|
||||
test('renderTemplate replaces supported placeholders', () => {
|
||||
const rendered = renderTemplate('run {worker_name} in {worktree_path}', {
|
||||
worker_name: 'Docs Fixer',
|
||||
worktree_path: '/tmp/repo-worker'
|
||||
});
|
||||
assert.strictEqual(rendered, 'run Docs Fixer in /tmp/repo-worker');
|
||||
});
|
||||
|
||||
test('renderTemplate rejects unknown placeholders', () => {
|
||||
assert.throws(
|
||||
() => renderTemplate('missing {unknown}', { worker_name: 'docs' }),
|
||||
/Unknown template variable/
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\nPlan generation:');
|
||||
test('buildOrchestrationPlan creates worktrees, branches, and tmux commands', () => {
|
||||
const repoRoot = path.join('/tmp', 'ecc');
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot,
|
||||
sessionName: 'Skill Audit',
|
||||
baseRef: 'main',
|
||||
launcherCommand: 'codex exec --cwd {worktree_path_sh} --task-file {task_file_sh}',
|
||||
workers: [
|
||||
{ name: 'Docs A', task: 'Fix skills 1-4' },
|
||||
{ name: 'Docs B', task: 'Fix skills 5-8' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.strictEqual(plan.sessionName, 'skill-audit');
|
||||
assert.strictEqual(plan.workerPlans.length, 2);
|
||||
assert.strictEqual(plan.workerPlans[0].branchName, 'orchestrator-skill-audit-docs-a');
|
||||
assert.strictEqual(plan.workerPlans[1].branchName, 'orchestrator-skill-audit-docs-b');
|
||||
assert.deepStrictEqual(
|
||||
plan.workerPlans[0].gitArgs.slice(0, 4),
|
||||
['worktree', 'add', '-b', 'orchestrator-skill-audit-docs-a'],
|
||||
'Should create branch-backed worktrees'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].worktreePath.endsWith(path.join('ecc-skill-audit-docs-a')),
|
||||
'Should create sibling worktree path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].taskFilePath.endsWith(path.join('.orchestration', 'skill-audit', 'docs-a', 'task.md')),
|
||||
'Should create per-worker task file'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].handoffFilePath.endsWith(path.join('.orchestration', 'skill-audit', 'docs-a', 'handoff.md')),
|
||||
'Should create per-worker handoff file'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(plan.workerPlans[0].taskFilePath),
|
||||
'Launch command should interpolate task file'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(plan.workerPlans[0].worktreePath),
|
||||
'Launch command should interpolate worktree path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.tmuxCommands.some(command => command.args.includes('split-window')),
|
||||
'Should include tmux split commands'
|
||||
);
|
||||
assert.ok(
|
||||
plan.tmuxCommands.some(command => command.args.includes('select-layout')),
|
||||
'Should include tiled layout command'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan requires at least one worker', () => {
|
||||
assert.throws(
|
||||
() => buildOrchestrationPlan({
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'empty',
|
||||
launcherCommand: 'codex exec --task-file {task_file}',
|
||||
workers: []
|
||||
}),
|
||||
/at least one worker/
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan normalizes global and worker seed paths', () => {
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'seeded',
|
||||
launcherCommand: 'echo run',
|
||||
seedPaths: ['scripts/orchestrate-worktrees.js', './.claude/plan/workflow-e2e-test.json'],
|
||||
workers: [
|
||||
{
|
||||
name: 'Docs',
|
||||
task: 'Update docs',
|
||||
seedPaths: ['commands/multi-workflow.md']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(plan.workerPlans[0].seedPaths, [
|
||||
'scripts/orchestrate-worktrees.js',
|
||||
'.claude/plan/workflow-e2e-test.json',
|
||||
'commands/multi-workflow.md'
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan rejects worker names that collapse to the same slug', () => {
|
||||
assert.throws(
|
||||
() => buildOrchestrationPlan({
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'duplicates',
|
||||
launcherCommand: 'echo run',
|
||||
workers: [
|
||||
{ name: 'Docs A', task: 'Fix skill docs' },
|
||||
{ name: 'Docs/A', task: 'Fix tests' }
|
||||
]
|
||||
}),
|
||||
/unique slugs/
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan exposes shell-safe launcher aliases alongside raw defaults', () => {
|
||||
const repoRoot = path.join('/tmp', 'My Repo');
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot,
|
||||
sessionName: 'Spacing Audit',
|
||||
launcherCommand: 'bash {repo_root_sh}/scripts/orchestrate-codex-worker.sh {task_file_sh} {handoff_file_sh} {status_file_sh} {worker_name_sh} {worker_name}',
|
||||
workers: [{ name: 'Docs Fixer', task: 'Update docs' }]
|
||||
});
|
||||
const quote = value => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
const resolvedRepoRoot = plan.workerPlans[0].repoRoot;
|
||||
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(`bash ${quote(resolvedRepoRoot)}/scripts/orchestrate-codex-worker.sh`),
|
||||
'repo_root_sh should provide a shell-safe path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(quote(plan.workerPlans[0].taskFilePath)),
|
||||
'task_file_sh should provide a shell-safe path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(`${quote(plan.workerPlans[0].workerName)} ${plan.workerPlans[0].workerName}`),
|
||||
'raw defaults should remain available alongside shell-safe aliases'
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSeedPaths rejects paths outside the repo root', () => {
|
||||
assert.throws(
|
||||
() => normalizeSeedPaths(['../outside.txt'], '/tmp/ecc'),
|
||||
/inside repoRoot/
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSeedPaths rejects repo root and git metadata paths', () => {
|
||||
assert.throws(
|
||||
() => normalizeSeedPaths(['.'], '/tmp/ecc'),
|
||||
/must not target the repo root/
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeSeedPaths(['.git/config'], '/tmp/ecc'),
|
||||
/must not target git metadata/
|
||||
);
|
||||
});
|
||||
|
||||
test('materializePlan keeps worker instructions inside the worktree boundary', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orchestrator-test-'));
|
||||
|
||||
try {
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot: tempRoot,
|
||||
coordinationRoot: path.join(tempRoot, '.claude', 'orchestration'),
|
||||
sessionName: 'Workflow E2E',
|
||||
launcherCommand: 'bash {repo_root_sh}/scripts/orchestrate-codex-worker.sh {task_file_sh} {handoff_file_sh} {status_file_sh}',
|
||||
workers: [{ name: 'Docs', task: 'Update the workflow docs.' }]
|
||||
});
|
||||
|
||||
materializePlan(plan);
|
||||
|
||||
const taskFile = fs.readFileSync(plan.workerPlans[0].taskFilePath, 'utf8');
|
||||
const handoffFile = fs.readFileSync(plan.workerPlans[0].handoffFilePath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
taskFile.includes('Report results in your final response.'),
|
||||
'Task file should tell the worker to report in stdout'
|
||||
);
|
||||
assert.ok(
|
||||
taskFile.includes('## Summary') &&
|
||||
taskFile.includes('## Validation') &&
|
||||
taskFile.includes('## Remaining Risks'),
|
||||
'Task file should require parser-compatible headings'
|
||||
);
|
||||
assert.ok(
|
||||
taskFile.includes('Do not spawn subagents or external agents for this task.'),
|
||||
'Task file should keep nested workers single-session'
|
||||
);
|
||||
assert.ok(
|
||||
!taskFile.includes('Write results and handoff notes to'),
|
||||
'Task file should not require writing handoff files outside the worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!taskFile.includes('Update `'),
|
||||
'Task file should not instruct the nested worker to update orchestration status files'
|
||||
);
|
||||
assert.ok(
|
||||
handoffFile.includes('## Summary') &&
|
||||
handoffFile.includes('## Validation') &&
|
||||
handoffFile.includes('## Remaining Risks'),
|
||||
'Handoff placeholder should seed parser-compatible headings'
|
||||
);
|
||||
assert.ok(
|
||||
!handoffFile.includes('## Files Changed') &&
|
||||
!handoffFile.includes('## Tests / Verification') &&
|
||||
!handoffFile.includes('## Follow-ups'),
|
||||
'Handoff placeholder should not use legacy headings'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('overlaySeedPaths copies local overlays into the worker worktree', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orchestrator-overlay-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
const worktreePath = path.join(tempRoot, 'worktree');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, '.claude', 'plan'), { recursive: true });
|
||||
fs.mkdirSync(path.join(worktreePath, 'scripts'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'scripts', 'orchestrate-worktrees.js'),
|
||||
'local-version\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, '.claude', 'plan', 'workflow-e2e-test.json'),
|
||||
'{"seeded":true}\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(worktreePath, 'scripts', 'orchestrate-worktrees.js'),
|
||||
'head-version\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
overlaySeedPaths({
|
||||
repoRoot,
|
||||
seedPaths: [
|
||||
'scripts/orchestrate-worktrees.js',
|
||||
'.claude/plan/workflow-e2e-test.json'
|
||||
],
|
||||
worktreePath
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(worktreePath, 'scripts', 'orchestrate-worktrees.js'), 'utf8'),
|
||||
'local-version\n'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(worktreePath, '.claude', 'plan', 'workflow-e2e-test.json'), 'utf8'),
|
||||
'{"seeded":true}\n'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
89
tests/scripts/orchestration-status.test.js
Normal file
89
tests/scripts/orchestration-status.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { parseArgs } = require('../../scripts/orchestration-status');
|
||||
|
||||
console.log('=== Testing orchestration-status.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${desc}: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
test('parseArgs reads a target with an optional write path', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseArgs([
|
||||
'node',
|
||||
'scripts/orchestration-status.js',
|
||||
'workflow-visual-proof',
|
||||
'--write',
|
||||
'/tmp/snapshot.json'
|
||||
]),
|
||||
{
|
||||
target: 'workflow-visual-proof',
|
||||
writePath: '/tmp/snapshot.json'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('parseArgs does not treat the write path as the target', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseArgs([
|
||||
'node',
|
||||
'scripts/orchestration-status.js',
|
||||
'--write',
|
||||
'/tmp/snapshot.json',
|
||||
'workflow-visual-proof'
|
||||
]),
|
||||
{
|
||||
target: 'workflow-visual-proof',
|
||||
writePath: '/tmp/snapshot.json'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('parseArgs rejects missing write values and unknown flags', () => {
|
||||
assert.throws(
|
||||
() => parseArgs([
|
||||
'node',
|
||||
'scripts/orchestration-status.js',
|
||||
'workflow-visual-proof',
|
||||
'--write'
|
||||
]),
|
||||
/--write requires an output path/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs([
|
||||
'node',
|
||||
'scripts/orchestration-status.js',
|
||||
'workflow-visual-proof',
|
||||
'--unknown'
|
||||
]),
|
||||
/Unknown flag/
|
||||
);
|
||||
});
|
||||
|
||||
test('parseArgs rejects multiple positional targets', () => {
|
||||
assert.throws(
|
||||
() => parseArgs([
|
||||
'node',
|
||||
'scripts/orchestration-status.js',
|
||||
'first',
|
||||
'second'
|
||||
]),
|
||||
/Expected a single session name or plan path/
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user