chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Tests for scripts/auto-update.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
parseArgs,
|
||||
deriveRepoRootFromState,
|
||||
buildInstallApplyArgs,
|
||||
determineInstallCwd,
|
||||
runAutoUpdate,
|
||||
} = require('../../scripts/auto-update');
|
||||
const {
|
||||
createInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeRecord({ repoRoot, homeDir, projectRoot, adapter, request, resolution, operations }) {
|
||||
const targetRoot = adapter.kind === 'project'
|
||||
? path.join(projectRoot, `.${adapter.target}`)
|
||||
: path.join(homeDir, '.claude');
|
||||
const installStatePath = adapter.kind === 'project'
|
||||
? path.join(targetRoot, 'ecc-install-state.json')
|
||||
: path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
|
||||
const state = createInstallState({
|
||||
adapter,
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
request,
|
||||
resolution,
|
||||
operations,
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
adapter,
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
exists: true,
|
||||
state,
|
||||
error: null,
|
||||
repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureFakeRepo(repoRoot) {
|
||||
fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'everything-claude-code', version: '1.10.0' }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(path.join(repoRoot, 'scripts', 'install-apply.js'), '#!/usr/bin/env node\n');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing auto-update.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs reads repo-root, target, dry-run, and json flags', () => {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'scripts/auto-update.js',
|
||||
'--target',
|
||||
'cursor',
|
||||
'--repo-root',
|
||||
'/tmp/ecc',
|
||||
'--dry-run',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(parsed.targets, ['cursor']);
|
||||
assert.strictEqual(parsed.repoRoot, '/tmp/ecc');
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.strictEqual(parsed.json, true);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('parseArgs rejects unknown arguments', () => {
|
||||
assert.throws(
|
||||
() => parseArgs(['node', 'scripts/auto-update.js', '--bogus']),
|
||||
/Unknown argument: --bogus/
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('deriveRepoRootFromState uses sourcePath and sourceRelativePath', () => {
|
||||
const state = {
|
||||
operations: [
|
||||
{
|
||||
sourcePath: path.join('/tmp', 'ecc', 'scripts', 'setup-package-manager.js'),
|
||||
sourceRelativePath: path.join('scripts', 'setup-package-manager.js'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.strictEqual(
|
||||
deriveRepoRootFromState(state),
|
||||
path.resolve(path.join('/tmp', 'ecc'))
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('deriveRepoRootFromState fails when source metadata is unavailable', () => {
|
||||
assert.throws(
|
||||
() => deriveRepoRootFromState({ operations: [{ destinationPath: '/tmp/file' }] }),
|
||||
/Unable to infer ECC repo root/
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('buildInstallApplyArgs reconstructs legacy installs', () => {
|
||||
const record = {
|
||||
adapter: { target: 'claude', kind: 'home' },
|
||||
state: {
|
||||
target: { target: 'claude' },
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: ['typescript', 'python'],
|
||||
legacyMode: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(buildInstallApplyArgs(record), [
|
||||
'--target', 'claude',
|
||||
'typescript',
|
||||
'python',
|
||||
]);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('buildInstallApplyArgs reconstructs manifest installs', () => {
|
||||
const record = {
|
||||
adapter: { target: 'cursor', kind: 'project' },
|
||||
state: {
|
||||
target: { target: 'cursor' },
|
||||
request: {
|
||||
profile: 'developer',
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: ['component:alpha'],
|
||||
excludeComponents: ['component:beta'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(buildInstallApplyArgs(record), [
|
||||
'--target', 'cursor',
|
||||
'--profile', 'developer',
|
||||
'--modules', 'platform-configs',
|
||||
'--with', 'component:alpha',
|
||||
'--without', 'component:beta',
|
||||
]);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('determineInstallCwd uses the project root for project installs', () => {
|
||||
const record = {
|
||||
adapter: { kind: 'project' },
|
||||
state: {
|
||||
target: {
|
||||
root: path.join('/tmp', 'project', '.cursor'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(determineInstallCwd(record, '/tmp/ecc'), path.join('/tmp', 'project'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('runAutoUpdate reports when no install-state files are present', () => {
|
||||
const result = runAutoUpdate(
|
||||
{
|
||||
homeDir: '/tmp/home',
|
||||
projectRoot: '/tmp/project',
|
||||
dryRun: true,
|
||||
},
|
||||
{
|
||||
discoverInstalledStates: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.results.length, 0);
|
||||
assert.strictEqual(result.summary.checkedCount, 0);
|
||||
assert.strictEqual(result.summary.errorCount, 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('runAutoUpdate rejects mixed inferred repo roots', () => {
|
||||
const homeDir = createTempDir('auto-update-home-');
|
||||
const projectRoot = createTempDir('auto-update-project-');
|
||||
const repoOne = createTempDir('auto-update-repo-');
|
||||
const repoTwo = createTempDir('auto-update-repo-');
|
||||
|
||||
try {
|
||||
ensureFakeRepo(repoOne);
|
||||
ensureFakeRepo(repoTwo);
|
||||
|
||||
const records = [
|
||||
makeRecord({
|
||||
repoRoot: repoOne,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [] },
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourcePath: path.join(repoOne, 'rules', 'common', 'coding-style.md'),
|
||||
sourceRelativePath: path.join('rules', 'common', 'coding-style.md'),
|
||||
destinationPath: path.join(homeDir, '.claude', 'rules', 'common', 'coding-style.md'),
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeRecord({
|
||||
repoRoot: repoTwo,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'rules-core',
|
||||
sourcePath: path.join(repoTwo, '.cursor', 'mcp.json'),
|
||||
sourceRelativePath: path.join('.cursor', 'mcp.json'),
|
||||
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
assert.throws(
|
||||
() => runAutoUpdate(
|
||||
{
|
||||
homeDir,
|
||||
projectRoot,
|
||||
dryRun: true,
|
||||
},
|
||||
{
|
||||
discoverInstalledStates: () => records,
|
||||
}
|
||||
),
|
||||
/Multiple ECC repo roots detected/
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(repoOne);
|
||||
cleanup(repoTwo);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('runAutoUpdate fetches, pulls, and reinstalls using reconstructed args', () => {
|
||||
const homeDir = createTempDir('auto-update-home-');
|
||||
const projectRoot = createTempDir('auto-update-project-');
|
||||
const repoRoot = createTempDir('auto-update-repo-');
|
||||
|
||||
try {
|
||||
ensureFakeRepo(repoRoot);
|
||||
|
||||
const records = [
|
||||
makeRecord({
|
||||
repoRoot,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
request: {
|
||||
profile: 'developer',
|
||||
modules: [],
|
||||
includeComponents: ['component:alpha'],
|
||||
excludeComponents: ['component:beta'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourcePath: path.join(repoRoot, '.cursor', 'mcp.json'),
|
||||
sourceRelativePath: path.join('.cursor', 'mcp.json'),
|
||||
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const commands = [];
|
||||
const result = runAutoUpdate(
|
||||
{
|
||||
homeDir,
|
||||
projectRoot,
|
||||
dryRun: false,
|
||||
},
|
||||
{
|
||||
discoverInstalledStates: () => records,
|
||||
runExternalCommand: (command, args, options) => {
|
||||
commands.push({ command, args, options });
|
||||
if (command === process.execPath) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
dryRun: false,
|
||||
result: {
|
||||
installStatePath: path.join(projectRoot, '.cursor', 'ecc-install-state.json'),
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '' };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.summary.checkedCount, 1);
|
||||
assert.strictEqual(result.summary.updatedCount, 1);
|
||||
assert.deepStrictEqual(commands.map(entry => [entry.command, entry.args[0]]), [
|
||||
['git', 'fetch'],
|
||||
['git', 'pull'],
|
||||
[process.execPath, path.join(repoRoot, 'scripts', 'install-apply.js')],
|
||||
]);
|
||||
assert.deepStrictEqual(commands[2].args.slice(1), [
|
||||
'--target', 'cursor',
|
||||
'--profile', 'developer',
|
||||
'--with', 'component:alpha',
|
||||
'--without', 'component:beta',
|
||||
'--json',
|
||||
]);
|
||||
assert.strictEqual(commands[2].options.cwd, projectRoot);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(repoRoot);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for scripts/build-opencode.js
|
||||
*/
|
||||
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ✓ ${name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`)
|
||||
console.error(` ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("\n=== Testing build-opencode.js ===\n")
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..")
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
|
||||
)
|
||||
const buildScript = path.join(repoRoot, "scripts", "build-opencode.js")
|
||||
const distEntry = path.join(repoRoot, ".opencode", "dist", "index.js")
|
||||
const tests = [
|
||||
["package.json exposes the OpenCode build and prepack hooks", () => {
|
||||
assert.strictEqual(packageJson.scripts["build:opencode"], "node scripts/build-opencode.js")
|
||||
assert.strictEqual(packageJson.scripts.prepack, "npm run build:opencode")
|
||||
assert.ok(packageJson.files.includes(".opencode/"))
|
||||
}],
|
||||
["build script generates .opencode/dist", () => {
|
||||
const result = spawnSync("node", [buildScript], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
assert.ok(fs.existsSync(distEntry), ".opencode/dist/index.js should exist after build")
|
||||
}],
|
||||
["npm pack includes the compiled OpenCode dist payload", () => {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/dist/index.js"),
|
||||
"npm pack should include .opencode/dist/index.js"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/dist/plugins/index.js"),
|
||||
"npm pack should include compiled OpenCode plugin output"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/dist/tools/index.js"),
|
||||
"npm pack should include compiled OpenCode tool output"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".claude-plugin/marketplace.json"),
|
||||
"npm pack should include .claude-plugin/marketplace.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".claude-plugin/plugin.json"),
|
||||
"npm pack should include .claude-plugin/plugin.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".codex-plugin/plugin.json"),
|
||||
"npm pack should include .codex-plugin/plugin.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".agents/plugins/marketplace.json"),
|
||||
"npm pack should include .agents/plugins/marketplace.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/package.json"),
|
||||
"npm pack should include .opencode/package.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/package-lock.json"),
|
||||
"npm pack should include .opencode/package-lock.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has("agent.yaml"),
|
||||
"npm pack should include agent.yaml"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has("AGENTS.md"),
|
||||
"npm pack should include AGENTS.md"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has("VERSION"),
|
||||
"npm pack should include VERSION"
|
||||
)
|
||||
}],
|
||||
]
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`)
|
||||
console.log(`Failed: ${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for scripts/catalog.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'catalog.js');
|
||||
|
||||
function run(args = []) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing catalog.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with no arguments', () => {
|
||||
const result = run();
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Discover ECC install components and profiles'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help with an explicit help flag', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
assert.ok(result.stdout.includes('node scripts/catalog.js show <component-id>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install profiles', () => {
|
||||
const result = run(['profiles']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install profiles'));
|
||||
assert.ok(result.stdout.includes('core'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters components by family and emits JSON', () => {
|
||||
const result = run(['components', '--family', 'language', '--json']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(parsed.components));
|
||||
assert.ok(parsed.components.length > 0);
|
||||
assert.ok(parsed.components.every(component => component.family === 'language'));
|
||||
assert.ok(parsed.components.some(component => component.id === 'lang:typescript'));
|
||||
assert.ok(parsed.components.every(component => component.id !== 'framework:nextjs'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows a resolved component payload', () => {
|
||||
const result = run(['show', 'framework:nextjs', '--json']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.id, 'framework:nextjs');
|
||||
assert.strictEqual(parsed.family, 'framework');
|
||||
assert.deepStrictEqual(parsed.moduleIds, ['framework-language']);
|
||||
assert.ok(Array.isArray(parsed.modules));
|
||||
assert.strictEqual(parsed.modules[0].id, 'framework-language');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown subcommands', () => {
|
||||
const result = run(['bogus']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown catalog command'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown component ids', () => {
|
||||
const result = run(['show', 'framework:not-real']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown install component'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,203 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'ci', 'check-unicode-safety.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS: ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(`FAIL: ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCheck(root, args = []) {
|
||||
return spawnSync('node', [scriptPath, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_UNICODE_SCAN_ROOT: root,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function makeTempRoot(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
const warningEmoji = String.fromCodePoint(0x26A0, 0xFE0F);
|
||||
const toolsEmoji = String.fromCodePoint(0x1F6E0, 0xFE0F);
|
||||
const zeroWidthSpace = String.fromCodePoint(0x200B);
|
||||
const rocketEmoji = String.fromCodePoint(0x1F680);
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('fails on invisible unicode and emoji before cleanup', () => {
|
||||
const root = makeTempRoot('ecc-unicode-check-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'scripts'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'docs', 'guide.md'), `> ${warningEmoji} Important launch note\n`);
|
||||
fs.writeFileSync(path.join(root, 'scripts', 'sample.js'), `const x = "a${zeroWidthSpace}";\n`);
|
||||
|
||||
const result = runCheck(root);
|
||||
assert.notStrictEqual(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stderr, /dangerous-invisible U\+200B/);
|
||||
assert.match(result.stderr, /emoji U\+26A0/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('write mode removes emoji and invisible unicode', () => {
|
||||
const root = makeTempRoot('ecc-unicode-fix-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'docs', 'guide.md'), `> ${warningEmoji} Important launch note\n`);
|
||||
fs.writeFileSync(path.join(root, 'README.md'), `## ${toolsEmoji} Tools\n`);
|
||||
fs.writeFileSync(path.join(root, 'note.txt'), `one${zeroWidthSpace}two\n`);
|
||||
|
||||
const writeResult = runCheck(root, ['--write']);
|
||||
assert.strictEqual(writeResult.status, 0, writeResult.stdout + writeResult.stderr);
|
||||
|
||||
assert.strictEqual(fs.readFileSync(path.join(root, 'docs', 'guide.md'), 'utf8'), '> WARNING: Important launch note\n');
|
||||
assert.strictEqual(fs.readFileSync(path.join(root, 'README.md'), 'utf8'), '## Tools\n');
|
||||
assert.strictEqual(fs.readFileSync(path.join(root, 'note.txt'), 'utf8'), 'onetwo\n');
|
||||
|
||||
const cleanResult = runCheck(root);
|
||||
assert.strictEqual(cleanResult.status, 0, cleanResult.stdout + cleanResult.stderr);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('write mode does not rewrite executable files', () => {
|
||||
const root = makeTempRoot('ecc-unicode-code-');
|
||||
fs.mkdirSync(path.join(root, 'scripts'), { recursive: true });
|
||||
const scriptFile = path.join(root, 'scripts', 'sample.js');
|
||||
const original = `const label = "Launch ${rocketEmoji}";\n`;
|
||||
fs.writeFileSync(scriptFile, original);
|
||||
|
||||
const result = runCheck(root, ['--write']);
|
||||
assert.notStrictEqual(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stderr, /scripts[/\\]sample\.js:1:23 emoji U\+1F680/);
|
||||
assert.strictEqual(fs.readFileSync(scriptFile, 'utf8'), original);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('plain symbols like copyright remain allowed', () => {
|
||||
const root = makeTempRoot('ecc-unicode-symbols-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'docs', 'legal.md'), 'Copyright © ECC\nTrademark ® ECC\n');
|
||||
|
||||
const result = runCheck(root);
|
||||
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Invisible code points newly covered by the denylist. These were missing
|
||||
// from the previous denylist and silently passed through both detection and
|
||||
// `--write` mode. Each is a documented LLM-prompt-injection vector
|
||||
// (Tag block "ASCII smuggling"; the other invisibles are widely cited in
|
||||
// homograph / Discord / Twitter smuggling references).
|
||||
|
||||
const NEWLY_COVERED_RANGES = [
|
||||
{ codePoint: 0xE0041, label: 'Tag block U+E0041 (TAG LATIN CAPITAL LETTER A)' },
|
||||
{ codePoint: 0xE007F, label: 'Tag block U+E007F (CANCEL TAG, range end)' },
|
||||
{ codePoint: 0x180E, label: 'U+180E MONGOLIAN VOWEL SEPARATOR' },
|
||||
{ codePoint: 0x115F, label: 'U+115F HANGUL CHOSEONG FILLER' },
|
||||
{ codePoint: 0x1160, label: 'U+1160 HANGUL JUNGSEONG FILLER' },
|
||||
{ codePoint: 0x2061, label: 'U+2061 FUNCTION APPLICATION' },
|
||||
{ codePoint: 0x2064, label: 'U+2064 INVISIBLE PLUS (range end)' },
|
||||
{ codePoint: 0x3164, label: 'U+3164 HANGUL FILLER' },
|
||||
];
|
||||
|
||||
for (const { codePoint, label } of NEWLY_COVERED_RANGES) {
|
||||
if (
|
||||
test(`detects ${label}`, () => {
|
||||
const root = makeTempRoot('ecc-unicode-newly-covered-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
const hex = codePoint.toString(16).toUpperCase().padStart(4, '0');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'docs', `probe-${hex}.md`),
|
||||
`# Probe\n\nBenign${String.fromCodePoint(codePoint)}text\n`
|
||||
);
|
||||
const result = runCheck(root);
|
||||
assert.notStrictEqual(result.status, 0,
|
||||
`expected exit non-zero on U+${hex}, got ${result.status}: ${result.stderr}`);
|
||||
assert.match(result.stderr, new RegExp(`dangerous-invisible U\\+${hex}`),
|
||||
`expected violation message for U+${hex}, got: ${result.stderr}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
test('write mode strips newly-covered invisibles from markdown', () => {
|
||||
const root = makeTempRoot('ecc-unicode-newly-covered-write-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
const tagHidden = [...Array(5)].map((_, i) => String.fromCodePoint(0xE0041 + i)).join('');
|
||||
const mongolianHidden = String.fromCodePoint(0x180E);
|
||||
const filePath = path.join(root, 'docs', 'mixed.md');
|
||||
fs.writeFileSync(filePath, `# Title\n\nBenign${tagHidden}${mongolianHidden}text.\n`);
|
||||
|
||||
const writeResult = runCheck(root, ['--write']);
|
||||
assert.strictEqual(writeResult.status, 0,
|
||||
`expected --write to succeed, got ${writeResult.status}: ${writeResult.stderr}`);
|
||||
|
||||
const sanitized = fs.readFileSync(filePath, 'utf8');
|
||||
assert.doesNotMatch(sanitized, /[\u{E0000}-\u{E007F}]/u,
|
||||
'expected tag block characters stripped');
|
||||
assert.doesNotMatch(sanitized, /\u{180E}/u,
|
||||
'expected U+180E stripped');
|
||||
assert.strictEqual(sanitized, '# Title\n\nBenigntext.\n',
|
||||
'expected only the invisible characters removed, surrounding text preserved');
|
||||
|
||||
// Re-run without --write; should now pass cleanly.
|
||||
const clean = runCheck(root);
|
||||
assert.strictEqual(clean.status, 0,
|
||||
`expected post-sanitize re-run to pass, got: ${clean.stderr}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('skips Python virtual environments', () => {
|
||||
const root = makeTempRoot('ecc-unicode-venv-');
|
||||
fs.mkdirSync(path.join(root, '.venv', 'lib', 'python3.12', 'site-packages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'venv', 'lib', 'python3.12', 'site-packages'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, '.venv', 'lib', 'python3.12', 'site-packages', 'package.py'),
|
||||
`message = "hello ${rocketEmoji}"\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'venv', 'lib', 'python3.12', 'site-packages', 'package.py'),
|
||||
`message = "hello ${rocketEmoji}"\n`
|
||||
);
|
||||
|
||||
const result = runCheck(root);
|
||||
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Tests for scripts/claw.js
|
||||
*
|
||||
* Tests the NanoClaw agent REPL module — storage, context, delegation, meta.
|
||||
*
|
||||
* Run with: node tests/scripts/claw.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
getClawDir,
|
||||
getSessionPath,
|
||||
listSessions,
|
||||
loadHistory,
|
||||
appendTurn,
|
||||
loadECCContext,
|
||||
buildPrompt,
|
||||
askClaude,
|
||||
isValidSessionName,
|
||||
handleClear,
|
||||
getSessionMetrics,
|
||||
searchSessions,
|
||||
branchSession,
|
||||
exportSession,
|
||||
compactSession
|
||||
} = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
|
||||
|
||||
// Test helper — matches ECC's custom test pattern
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
if (err.stack) { console.log(` Stack: ${err.stack}`); }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'claw-test-'));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing claw.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ── Storage tests (6) ──────────────────────────────────────────────────
|
||||
|
||||
console.log('Storage:');
|
||||
|
||||
if (test('getClawDir() returns path ending in .claude/claw', () => {
|
||||
const dir = getClawDir();
|
||||
assert.ok(dir.endsWith(path.join('.claude', 'claw')),
|
||||
`Expected path ending in .claude/claw, got: ${dir}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionPath("foo") returns correct .md path', () => {
|
||||
const p = getSessionPath('foo');
|
||||
assert.ok(p.endsWith(path.join('.claude', 'claw', 'foo.md')),
|
||||
`Expected path ending in .claude/claw/foo.md, got: ${p}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('listSessions() returns empty array for empty dir', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const sessions = listSessions(tmpDir);
|
||||
assert.deepStrictEqual(sessions, []);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('listSessions() finds .md files and strips extension', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, 'alpha.md'), 'test');
|
||||
fs.writeFileSync(path.join(tmpDir, 'beta.md'), 'test');
|
||||
fs.writeFileSync(path.join(tmpDir, 'not-a-session.txt'), 'test');
|
||||
const sessions = listSessions(tmpDir);
|
||||
assert.ok(sessions.includes('alpha'), 'Should find alpha');
|
||||
assert.ok(sessions.includes('beta'), 'Should find beta');
|
||||
assert.strictEqual(sessions.length, 2, 'Should only find .md files');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadHistory() returns "" for non-existent file', () => {
|
||||
const result = loadHistory('/tmp/claw-test-nonexistent-' + Date.now() + '.md');
|
||||
assert.strictEqual(result, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('appendTurn() writes correct markdown format', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'test.md');
|
||||
try {
|
||||
appendTurn(filePath, 'User', 'Hello world', '2025-01-15T10:00:00.000Z');
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
assert.ok(content.includes('### [2025-01-15T10:00:00.000Z] User'),
|
||||
'Should include timestamp and role header');
|
||||
assert.ok(content.includes('Hello world'), 'Should include content');
|
||||
assert.ok(content.includes('---'), 'Should include separator');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Context tests (3) ─────────────────────────────────────────────────
|
||||
|
||||
console.log('\nContext:');
|
||||
|
||||
if (test('loadECCContext() returns "" when no skills specified', () => {
|
||||
const result = loadECCContext('');
|
||||
assert.strictEqual(result, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadECCContext() skips missing skill directories gracefully', () => {
|
||||
const result = loadECCContext('nonexistent-skill-xyz');
|
||||
assert.strictEqual(result, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadECCContext() concatenates multiple skill files', () => {
|
||||
// Use real skills from the ECC repo if they exist
|
||||
const skillsDir = path.join(process.cwd(), 'skills');
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
console.log(' (skipped — no skills/ directory in CWD)');
|
||||
return;
|
||||
}
|
||||
const available = fs.readdirSync(skillsDir).filter(d => {
|
||||
const skillFile = path.join(skillsDir, d, 'SKILL.md');
|
||||
return fs.existsSync(skillFile);
|
||||
});
|
||||
if (available.length < 2) {
|
||||
console.log(' (skipped — need 2+ skills with SKILL.md)');
|
||||
return;
|
||||
}
|
||||
const twoSkills = available.slice(0, 2).join(',');
|
||||
const result = loadECCContext(twoSkills);
|
||||
assert.ok(result.length > 0, 'Should return non-empty context');
|
||||
// Should contain content from both skills
|
||||
for (const name of available.slice(0, 2)) {
|
||||
const skillContent = fs.readFileSync(
|
||||
path.join(skillsDir, name, 'SKILL.md'), 'utf8'
|
||||
);
|
||||
// Check that at least part of each skill is present
|
||||
const firstLine = skillContent.split('\n').find(l => l.trim().length > 10);
|
||||
if (firstLine) {
|
||||
assert.ok(result.includes(firstLine.trim()),
|
||||
`Should include content from skill ${name}`);
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Delegation tests (2) ──────────────────────────────────────────────
|
||||
|
||||
console.log('\nDelegation:');
|
||||
|
||||
if (test('buildPrompt() constructs correct prompt structure', () => {
|
||||
const prompt = buildPrompt('system info', 'chat history', 'user question');
|
||||
assert.ok(prompt.includes('=== SYSTEM CONTEXT ==='), 'Should have system section');
|
||||
assert.ok(prompt.includes('system info'), 'Should include system prompt');
|
||||
assert.ok(prompt.includes('=== CONVERSATION HISTORY ==='), 'Should have history section');
|
||||
assert.ok(prompt.includes('chat history'), 'Should include history');
|
||||
assert.ok(prompt.includes('=== USER MESSAGE ==='), 'Should have user section');
|
||||
assert.ok(prompt.includes('user question'), 'Should include user message');
|
||||
// Sections should be in order
|
||||
const sysIdx = prompt.indexOf('SYSTEM CONTEXT');
|
||||
const histIdx = prompt.indexOf('CONVERSATION HISTORY');
|
||||
const userIdx = prompt.indexOf('USER MESSAGE');
|
||||
assert.ok(sysIdx < histIdx, 'System should come before history');
|
||||
assert.ok(histIdx < userIdx, 'History should come before user message');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('askClaude() handles subprocess error gracefully', () => {
|
||||
// Use a non-existent command to trigger an error
|
||||
const result = askClaude('sys', 'hist', 'msg');
|
||||
// Should return an error string, not throw
|
||||
assert.strictEqual(typeof result, 'string', 'Should return a string');
|
||||
// If claude is not installed, we get an error message
|
||||
// If claude IS installed, we get an actual response — both are valid
|
||||
assert.ok(result.length > 0, 'Should return non-empty result');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── REPL/Meta tests (3) ───────────────────────────────────────────────
|
||||
|
||||
console.log('\nREPL/Meta:');
|
||||
|
||||
if (test('module exports all required functions', () => {
|
||||
const claw = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
|
||||
const required = [
|
||||
'getClawDir', 'getSessionPath', 'listSessions', 'loadHistory',
|
||||
'appendTurn', 'loadECCContext', 'askClaude', 'main'
|
||||
];
|
||||
for (const fn of required) {
|
||||
assert.strictEqual(typeof claw[fn], 'function',
|
||||
`Should export function ${fn}`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('/clear truncates session file', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'session.md');
|
||||
try {
|
||||
fs.writeFileSync(filePath, 'some existing history content');
|
||||
assert.ok(fs.readFileSync(filePath, 'utf8').length > 0, 'File should have content before clear');
|
||||
handleClear(filePath);
|
||||
const after = fs.readFileSync(filePath, 'utf8');
|
||||
assert.strictEqual(after, '', 'File should be empty after clear');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('isValidSessionName rejects invalid characters', () => {
|
||||
assert.strictEqual(isValidSessionName('my-project'), true);
|
||||
assert.strictEqual(isValidSessionName('default'), true);
|
||||
assert.strictEqual(isValidSessionName('test123'), true);
|
||||
assert.strictEqual(isValidSessionName('a'), true);
|
||||
assert.strictEqual(isValidSessionName(''), false);
|
||||
assert.strictEqual(isValidSessionName('has spaces'), false);
|
||||
assert.strictEqual(isValidSessionName('has/slash'), false);
|
||||
assert.strictEqual(isValidSessionName('../traversal'), false);
|
||||
assert.strictEqual(isValidSessionName('-starts-dash'), false);
|
||||
assert.strictEqual(isValidSessionName(null), false);
|
||||
assert.strictEqual(isValidSessionName(undefined), false);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nNanoClaw v2:');
|
||||
|
||||
if (test('getSessionMetrics returns non-zero token estimate for populated history', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'metrics.md');
|
||||
try {
|
||||
appendTurn(filePath, 'User', 'Implement auth');
|
||||
appendTurn(filePath, 'Assistant', 'Working on it');
|
||||
const metrics = getSessionMetrics(filePath);
|
||||
assert.strictEqual(metrics.turns, 2);
|
||||
assert.ok(metrics.tokenEstimate > 0);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('searchSessions finds query in saved session', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const clawDir = path.join(tmpDir, '.claude', 'claw');
|
||||
const sessionPath = path.join(clawDir, 'alpha.md');
|
||||
fs.mkdirSync(clawDir, { recursive: true });
|
||||
appendTurn(sessionPath, 'User', 'Need oauth migration');
|
||||
const results = searchSessions('oauth', clawDir);
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].session, 'alpha');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('branchSession copies history into new branch session', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const clawDir = path.join(tmpDir, '.claude', 'claw');
|
||||
const source = path.join(clawDir, 'base.md');
|
||||
fs.mkdirSync(clawDir, { recursive: true });
|
||||
appendTurn(source, 'User', 'base content');
|
||||
const result = branchSession(source, 'feature-branch', clawDir);
|
||||
assert.strictEqual(result.ok, true);
|
||||
const branched = fs.readFileSync(result.path, 'utf8');
|
||||
assert.ok(branched.includes('base content'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exportSession writes JSON export', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'export.md');
|
||||
const outPath = path.join(tmpDir, 'export.json');
|
||||
try {
|
||||
appendTurn(filePath, 'User', 'hello');
|
||||
appendTurn(filePath, 'Assistant', 'world');
|
||||
const result = exportSession(filePath, 'json', outPath);
|
||||
assert.strictEqual(result.ok, true);
|
||||
const exported = JSON.parse(fs.readFileSync(outPath, 'utf8'));
|
||||
assert.strictEqual(Array.isArray(exported.turns), true);
|
||||
assert.strictEqual(exported.turns.length, 2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('compactSession reduces long histories', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'compact.md');
|
||||
try {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
appendTurn(filePath, i % 2 ? 'Assistant' : 'User', `turn-${i}`);
|
||||
}
|
||||
const changed = compactSession(filePath, 10);
|
||||
assert.strictEqual(changed, true);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
assert.ok(content.includes('NanoClaw Compaction'));
|
||||
assert.ok(!content.includes('turn-0'));
|
||||
assert.ok(content.includes('turn-29'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* Tests for Codex shell helpers.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const TOML = require('@iarna/toml');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh');
|
||||
const pluginCacheCheckScript = path.join(repoRoot, 'scripts', 'codex', 'check-plugin-cache.js');
|
||||
const mergeCodexConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-codex-config.js');
|
||||
const mergeMcpConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-mcp-config.js');
|
||||
const syncScript = path.join(repoRoot, 'scripts', 'sync-ecc-to-codex.sh');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
const packageVersion = packageJson.version;
|
||||
const deterministicPackageEnv = {
|
||||
CLAUDE_PACKAGE_MANAGER: 'npm',
|
||||
CLAUDE_CODE_PACKAGE_MANAGER: 'npm',
|
||||
};
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runBash(scriptPath, args = [], env = {}, cwd = repoRoot) {
|
||||
return spawnSync('bash', [scriptPath, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function runNode(scriptPath, args = [], env = {}, cwd = repoRoot) {
|
||||
return spawnSync('node', [scriptPath, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function makeHermeticCodexEnv(homeDir, codexDir, extraEnv = {}) {
|
||||
const agentsHome = path.join(homeDir, '.agents');
|
||||
const hooksDir = path.join(codexDir, 'git-hooks');
|
||||
return {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
XDG_CONFIG_HOME: path.join(homeDir, '.config'),
|
||||
GIT_CONFIG_GLOBAL: path.join(homeDir, '.gitconfig'),
|
||||
CODEX_HOME: codexDir,
|
||||
AGENTS_HOME: agentsHome,
|
||||
ECC_GLOBAL_HOOKS_DIR: hooksDir,
|
||||
CLAUDE_PACKAGE_MANAGER: 'npm',
|
||||
CLAUDE_CODE_PACKAGE_MANAGER: 'npm',
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
...extraEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function seedPluginCache(codexDir, manifest, files = []) {
|
||||
const cacheDir = path.join(codexDir, 'plugins', 'cache', 'ecc', 'ecc', packageVersion);
|
||||
writeJson(path.join(cacheDir, '.codex-plugin', 'plugin.json'), manifest);
|
||||
fs.writeFileSync(path.join(cacheDir, 'README.md'), '# cached plugin\n');
|
||||
for (const [relativePath, content] of files) {
|
||||
const target = path.join(cacheDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
const cacheManifestWithLocalRefs = {
|
||||
name: 'ecc',
|
||||
version: packageVersion,
|
||||
skills: './skills/',
|
||||
mcpServers: './.mcp.json',
|
||||
interface: {
|
||||
composerIcon: './assets/ecc-icon.svg',
|
||||
logo: './assets/hero.png',
|
||||
},
|
||||
};
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
|
||||
try {
|
||||
seedPluginCache(codexDir, cacheManifestWithLocalRefs);
|
||||
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
|
||||
|
||||
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /Plugin cache:/);
|
||||
assert.match(result.stdout, /\[FAIL\] skills missing/);
|
||||
assert.match(result.stdout, /\[FAIL\] mcpServers missing/);
|
||||
assert.match(result.stdout, /codex plugin list only confirms marketplace registration/);
|
||||
assert.match(result.stdout, /sync-ecc-to-codex\.sh/);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('check-plugin-cache rejects manifest references that escape the cache boundary', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-manifest-traversal-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
|
||||
try {
|
||||
seedPluginCache(codexDir, {
|
||||
name: 'ecc',
|
||||
version: packageVersion,
|
||||
skills: '../../../../../etc/passwd',
|
||||
mcpServers: '../../.mcp.json',
|
||||
});
|
||||
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
|
||||
|
||||
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /\[FAIL\] skills escapes cache boundary/);
|
||||
assert.match(result.stdout, /\[FAIL\] mcpServers escapes cache boundary/);
|
||||
assert.doesNotMatch(result.stdout, /etc\/passwd/);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('check-plugin-cache passes when cached manifest references resolve inside the cache', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-ok-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
|
||||
try {
|
||||
const cacheDir = seedPluginCache(codexDir, cacheManifestWithLocalRefs, [
|
||||
['.mcp.json', '{"mcpServers":{}}\n'],
|
||||
['assets/ecc-icon.svg', '<svg />\n'],
|
||||
['assets/hero.png', 'png\n'],
|
||||
]);
|
||||
fs.mkdirSync(path.join(cacheDir, 'skills'), { recursive: true });
|
||||
|
||||
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /\[OK\] skills/);
|
||||
assert.match(result.stdout, /\[OK\] mcpServers/);
|
||||
assert.match(result.stdout, /All cached manifest references resolve/);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('check-plugin-cache reports a missing installed cache clearly', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-missing-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
|
||||
try {
|
||||
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
|
||||
|
||||
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /Cached plugin manifest missing/);
|
||||
assert.match(result.stdout, /codex plugin marketplace add affaan-m\/ECC/);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('check-plugin-cache rejects traversal in cache path segments', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-traversal-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
|
||||
try {
|
||||
const result = runNode(
|
||||
pluginCacheCheckScript,
|
||||
['--marketplace', '../outside'],
|
||||
makeHermeticCodexEnv(homeDir, codexDir)
|
||||
);
|
||||
|
||||
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stderr, /Invalid --marketplace/);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('check-plugin-cache names custom missing cache entries in diagnostics', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-custom-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
|
||||
try {
|
||||
const result = runNode(
|
||||
pluginCacheCheckScript,
|
||||
['--marketplace', 'custom-market', '--plugin', 'custom-plugin', '--version', '1.2.3'],
|
||||
makeHermeticCodexEnv(homeDir, codexDir)
|
||||
);
|
||||
|
||||
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /No installed cache entries found for custom-market\/custom-plugin/);
|
||||
assert.match(result.stdout, /Install the requested plugin into the Codex plugin cache/);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Windows NTFS does not allow double-quote characters in file paths,
|
||||
// so the quoted-path shell-injection test is only meaningful on Unix.
|
||||
if (os.platform() === 'win32') {
|
||||
console.log(' - install-global-git-hooks.sh quoted paths (skipped on Windows)');
|
||||
} else if (
|
||||
test('install-global-git-hooks.sh handles quoted hook paths without shell injection', () => {
|
||||
const homeDir = createTempDir('codex-hooks-home-');
|
||||
const weirdHooksDir = path.join(homeDir, 'git-hooks "quoted"');
|
||||
|
||||
try {
|
||||
const result = runBash(installScript, [], {
|
||||
HOME: homeDir,
|
||||
ECC_GLOBAL_HOOKS_DIR: weirdHooksDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
||||
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-commit')));
|
||||
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-push')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-codex-config reports usage, missing files, and TOML parse failures', () => {
|
||||
const tempDir = createTempDir('codex-merge-errors-');
|
||||
|
||||
try {
|
||||
const noArgs = runNode(mergeCodexConfigScript);
|
||||
assert.strictEqual(noArgs.status, 1);
|
||||
assert.match(noArgs.stderr, /Usage: merge-codex-config\.js/);
|
||||
|
||||
const missingPath = path.join(tempDir, 'missing-config.toml');
|
||||
const missing = runNode(mergeCodexConfigScript, [missingPath]);
|
||||
assert.strictEqual(missing.status, 1);
|
||||
assert.match(missing.stderr, /Config file not found/);
|
||||
|
||||
const invalidPath = path.join(tempDir, 'invalid-config.toml');
|
||||
fs.writeFileSync(invalidPath, 'approval_policy = [\n');
|
||||
const invalid = runNode(mergeCodexConfigScript, [invalidPath]);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.match(invalid.stderr, /Failed to parse TOML/);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-codex-config dry-run reports additions without mutating the target', () => {
|
||||
const tempDir = createTempDir('codex-merge-dry-run-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = '';
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeCodexConfigScript, [configPath, '--dry-run']);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /\[add-root\]/);
|
||||
assert.match(result.stdout, /\[add-table\] \[features\]/);
|
||||
assert.match(result.stdout, /Dry run/);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-codex-config preserves user root choices while adding missing baseline tables', () => {
|
||||
const tempDir = createTempDir('codex-merge-add-only-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, 'approval_policy = "never"\n');
|
||||
const result = runNode(mergeCodexConfigScript, [configPath]);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /Done\. Baseline Codex settings merged\./);
|
||||
|
||||
const merged = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = TOML.parse(merged);
|
||||
assert.strictEqual(parsed.approval_policy, 'never');
|
||||
assert.strictEqual(parsed.sandbox_mode, 'workspace-write');
|
||||
assert.strictEqual(parsed.web_search, 'live');
|
||||
assert.strictEqual(parsed.features.multi_agent, true);
|
||||
assert.strictEqual(parsed.profiles.strict.approval_policy, 'on-request');
|
||||
assert.strictEqual(parsed.profiles.yolo.approval_policy, 'never');
|
||||
assert.strictEqual(parsed.agents.max_threads, 6);
|
||||
assert.strictEqual(parsed.agents.explorer.config_file, 'agents/explorer.toml');
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-codex-config no-ops when the Codex baseline is already present', () => {
|
||||
const tempDir = createTempDir('codex-merge-noop-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = fs.readFileSync(path.join(repoRoot, '.codex', 'config.toml'), 'utf8');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeCodexConfigScript, [configPath]);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /All baseline Codex settings already present/);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-codex-config warns when inline tables cannot be safely extended', () => {
|
||||
const tempDir = createTempDir('codex-merge-inline-warn-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = 'agents = { explorer = { description = "custom explorer" } }\n';
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeCodexConfigScript, [configPath, '--dry-run']);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stderr, /WARNING: Skipping missing keys/);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config reports usage, missing files, and TOML parse failures', () => {
|
||||
const tempDir = createTempDir('mcp-merge-errors-');
|
||||
|
||||
try {
|
||||
const noArgs = runNode(mergeMcpConfigScript, [], deterministicPackageEnv);
|
||||
assert.strictEqual(noArgs.status, 1);
|
||||
assert.match(noArgs.stderr, /Usage: merge-mcp-config\.js/);
|
||||
|
||||
const missingPath = path.join(tempDir, 'missing-config.toml');
|
||||
const missing = runNode(mergeMcpConfigScript, [missingPath], deterministicPackageEnv);
|
||||
assert.strictEqual(missing.status, 1);
|
||||
assert.match(missing.stderr, /Config file not found/);
|
||||
|
||||
const invalidPath = path.join(tempDir, 'invalid-config.toml');
|
||||
fs.writeFileSync(invalidPath, '[mcp_servers.github\n');
|
||||
const invalid = runNode(mergeMcpConfigScript, [invalidPath], deterministicPackageEnv);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.match(invalid.stderr, /Failed to parse/);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config dry-run appends the current default set without mutating target', () => {
|
||||
const tempDir = createTempDir('mcp-merge-dry-run-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = '';
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeMcpConfigScript, [configPath, '--dry-run'], deterministicPackageEnv);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /Package manager: npm \(exec: npx\)/);
|
||||
assert.match(result.stdout, /\[add\] mcp_servers\.chrome-devtools/);
|
||||
assert.match(result.stdout, /\[mcp_servers\.chrome-devtools\]/);
|
||||
assert.match(result.stdout, /Dry run/);
|
||||
// Retired defaults (June 2026 connector policy) must not be emitted.
|
||||
assert.doesNotMatch(result.stdout, /mcp_servers\.(supabase|playwright|context7|exa|github|memory|sequential-thinking)\b/);
|
||||
assert.doesNotMatch(result.stdout, /url = /);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config no-ops after all recommended servers are present', () => {
|
||||
const tempDir = createTempDir('mcp-merge-noop-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, '');
|
||||
const first = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||
assert.strictEqual(first.status, 0, `${first.stdout}\n${first.stderr}`);
|
||||
|
||||
const merged = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = TOML.parse(merged);
|
||||
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].command, 'npx');
|
||||
assert.deepStrictEqual(parsed.mcp_servers['chrome-devtools'].args, ['chrome-devtools-mcp@latest']);
|
||||
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].startup_timeout_sec, 30);
|
||||
// No retired server may be (re-)emitted — exa's url form broke Codex (#2224).
|
||||
assert.strictEqual(parsed.mcp_servers.exa, undefined);
|
||||
assert.strictEqual(parsed.mcp_servers.github, undefined);
|
||||
assert.strictEqual(parsed.mcp_servers.supabase, undefined);
|
||||
|
||||
const second = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||
assert.strictEqual(second.status, 0, `${second.stdout}\n${second.stderr}`);
|
||||
assert.match(second.stdout, /\[ok\] mcp_servers\.chrome-devtools/);
|
||||
assert.match(second.stdout, /All ECC MCP servers already present/);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), merged);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config repairs the invalid exa url entry from earlier ECC versions (#2224)', () => {
|
||||
const tempDir = createTempDir('mcp-merge-exa-repair-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = [
|
||||
'[mcp_servers.github]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@modelcontextprotocol/server-github"]',
|
||||
'',
|
||||
'[mcp_servers.exa]',
|
||||
'url = "https://mcp.exa.ai/mcp"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /\[repair\] mcp_servers\.exa/);
|
||||
|
||||
const updated = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = TOML.parse(updated);
|
||||
assert.strictEqual(parsed.mcp_servers.exa, undefined, 'invalid exa url entry must be removed');
|
||||
assert.doesNotMatch(updated, /url = "https:\/\/mcp\.exa\.ai\/mcp"/);
|
||||
// User-managed servers are untouched; current default is added.
|
||||
assert.strictEqual(parsed.mcp_servers.github.command, 'npx');
|
||||
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].command, 'npx');
|
||||
|
||||
// Re-running must not re-introduce the invalid entry.
|
||||
const second = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||
assert.strictEqual(second.status, 0, `${second.stdout}\n${second.stderr}`);
|
||||
assert.doesNotMatch(fs.readFileSync(configPath, 'utf8'), /mcp_servers\.exa/);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config leaves a user-managed stdio exa entry untouched', () => {
|
||||
const tempDir = createTempDir('mcp-merge-exa-stdio-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = [
|
||||
'[mcp_servers.exa]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "mcp-remote", "https://mcp.exa.ai/mcp"]',
|
||||
'startup_timeout_sec = 30',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.doesNotMatch(result.stdout, /\[repair\]/);
|
||||
|
||||
const parsed = TOML.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(parsed.mcp_servers.exa.command, 'npx');
|
||||
assert.deepStrictEqual(parsed.mcp_servers.exa.args, ['-y', 'mcp-remote', 'https://mcp.exa.ai/mcp']);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config update dry-run refreshes managed sections and leaves user servers alone', () => {
|
||||
const tempDir = createTempDir('mcp-merge-update-dry-run-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = [
|
||||
'[mcp_servers.chrome-devtools]',
|
||||
'command = "custom"',
|
||||
'args = ["old"]',
|
||||
'',
|
||||
'[mcp_servers.context7]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@upstash/context7-mcp@latest"]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeMcpConfigScript, [configPath, '--update-mcp', '--dry-run'], deterministicPackageEnv);
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /\[remove\] mcp_servers\.chrome-devtools/);
|
||||
assert.match(result.stdout, /\[mcp_servers\.chrome-devtools\]/);
|
||||
// Retired servers are no longer ECC-managed: never removed or re-added.
|
||||
assert.doesNotMatch(result.stdout, /\[remove\] mcp_servers\.context7/);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config removes disabled servers without appending replacements', () => {
|
||||
const tempDir = createTempDir('mcp-merge-disabled-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = [
|
||||
'[mcp_servers.chrome-devtools]',
|
||||
'command = "npx"',
|
||||
'args = ["chrome-devtools-mcp@latest"]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const result = runNode(mergeMcpConfigScript, [configPath], {
|
||||
...deterministicPackageEnv,
|
||||
ECC_DISABLED_MCPS: 'chrome-devtools',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /Disabled via ECC_DISABLED_MCPS/);
|
||||
assert.match(result.stdout, /\[skip\] mcp_servers\.chrome-devtools \(disabled\)/);
|
||||
assert.match(result.stdout, /\[update\] mcp_servers\.chrome-devtools \(disabled\)/);
|
||||
assert.match(result.stdout, /Done\. Removed 1 server section\(s\)\./);
|
||||
|
||||
const updated = fs.readFileSync(configPath, 'utf8');
|
||||
assert.doesNotMatch(updated, /chrome-devtools/);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('sync installs the missing Codex baseline and accepts the legacy context7 MCP section', () => {
|
||||
const homeDir = createTempDir('codex-sync-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
const agentsPath = path.join(codexDir, 'AGENTS.md');
|
||||
const config = [
|
||||
'persistent_instructions = ""',
|
||||
'',
|
||||
'[agents]',
|
||||
'explorer = { description = "Read-only codebase explorer for gathering evidence before changes are proposed." }',
|
||||
'',
|
||||
'[mcp_servers.context7]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@upstash/context7-mcp"]',
|
||||
'',
|
||||
'[mcp_servers.github]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@modelcontextprotocol/server-github"]',
|
||||
'',
|
||||
'[mcp_servers.memory]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@modelcontextprotocol/server-memory"]',
|
||||
'',
|
||||
'[mcp_servers.sequential-thinking]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, config);
|
||||
|
||||
const syncResult = runBash(syncScript, ['--update-mcp'], makeHermeticCodexEnv(homeDir, codexDir));
|
||||
assert.strictEqual(syncResult.status, 0, `${syncResult.stdout}\n${syncResult.stderr}`);
|
||||
|
||||
const syncedAgents = fs.readFileSync(agentsPath, 'utf8');
|
||||
assert.match(syncedAgents, /^# Everything Claude Code \(ECC\) — Agent Instructions/m);
|
||||
assert.match(syncedAgents, /^# Codex Supplement \(From ECC \.codex\/AGENTS\.md\)/m);
|
||||
|
||||
const syncedConfig = fs.readFileSync(configPath, 'utf8');
|
||||
const parsedConfig = TOML.parse(syncedConfig);
|
||||
assert.strictEqual(parsedConfig.approval_policy, 'on-request');
|
||||
assert.strictEqual(parsedConfig.sandbox_mode, 'workspace-write');
|
||||
assert.strictEqual(parsedConfig.web_search, 'live');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(parsedConfig, 'multi_agent'));
|
||||
assert.ok(parsedConfig.features);
|
||||
assert.strictEqual(parsedConfig.features.multi_agent, true);
|
||||
assert.ok(parsedConfig.profiles);
|
||||
assert.strictEqual(parsedConfig.profiles.strict.approval_policy, 'on-request');
|
||||
assert.strictEqual(parsedConfig.profiles.yolo.approval_policy, 'never');
|
||||
assert.ok(parsedConfig.agents);
|
||||
assert.strictEqual(parsedConfig.agents.max_threads, 6);
|
||||
assert.strictEqual(parsedConfig.agents.max_depth, 1);
|
||||
assert.strictEqual(parsedConfig.agents.explorer.config_file, 'agents/explorer.toml');
|
||||
assert.strictEqual(parsedConfig.agents.reviewer.config_file, 'agents/reviewer.toml');
|
||||
assert.strictEqual(parsedConfig.agents.docs_researcher.config_file, 'agents/docs-researcher.toml');
|
||||
// Current default connector is added; retired servers are not emitted,
|
||||
// and pre-existing user-managed entries are preserved untouched.
|
||||
assert.ok(parsedConfig.mcp_servers['chrome-devtools']);
|
||||
assert.strictEqual(parsedConfig.mcp_servers.exa, undefined);
|
||||
assert.ok(parsedConfig.mcp_servers.github);
|
||||
assert.ok(parsedConfig.mcp_servers.memory);
|
||||
assert.ok(parsedConfig.mcp_servers['sequential-thinking']);
|
||||
assert.ok(parsedConfig.mcp_servers.context7);
|
||||
|
||||
for (const roleFile of ['explorer.toml', 'reviewer.toml', 'docs-researcher.toml']) {
|
||||
assert.ok(fs.existsSync(path.join(codexDir, 'agents', roleFile)));
|
||||
}
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('sync adds parent-table keys when the target only declares an implicit parent table', () => {
|
||||
const homeDir = createTempDir('codex-sync-implicit-parent-home-');
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
const config = [
|
||||
'persistent_instructions = ""',
|
||||
'',
|
||||
'[agents.explorer]',
|
||||
'description = "Read-only codebase explorer for gathering evidence before changes are proposed."',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, config);
|
||||
|
||||
const syncResult = runBash(syncScript, [], makeHermeticCodexEnv(homeDir, codexDir));
|
||||
assert.strictEqual(syncResult.status, 0, `${syncResult.stdout}\n${syncResult.stderr}`);
|
||||
|
||||
const parsedConfig = TOML.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(parsedConfig.agents.max_threads, 6);
|
||||
assert.strictEqual(parsedConfig.agents.max_depth, 1);
|
||||
assert.strictEqual(parsedConfig.agents.explorer.config_file, 'agents/explorer.toml');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Tests for scripts/consult.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'consult.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, ...args], {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(stdout) {
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
function findMatch(payload, componentId) {
|
||||
return payload.matches.find(match => match.componentId === componentId);
|
||||
}
|
||||
|
||||
function findMatchIndex(payload, componentId) {
|
||||
return payload.matches.findIndex(match => match.componentId === componentId);
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing consult.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with an explicit help flag', () => {
|
||||
const result = run(['--help']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Consult ECC install components/);
|
||||
assert.match(result.stdout, /node scripts\/consult\.js "security reviews"/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help even when other flags would be invalid', () => {
|
||||
const result = run(['--help', '--target', 'not-a-target']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Consult ECC install components/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('recommends security components and profile for a natural language query', () => {
|
||||
const result = run(['security', 'reviews', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1');
|
||||
assert.strictEqual(payload.query, 'security reviews');
|
||||
assert.strictEqual(payload.target, 'claude');
|
||||
assert.strictEqual(payload.matches[0].componentId, 'capability:security');
|
||||
assert.ok(payload.matches[0].reasons.some(reason => reason.includes('security')));
|
||||
assert.strictEqual(
|
||||
payload.matches[0].installCommand,
|
||||
'npx ecc install --profile minimal --target claude --with capability:security'
|
||||
);
|
||||
assert.ok(payload.profiles.some(profile => profile.id === 'security'));
|
||||
assert.ok(payload.profiles.find(profile => profile.id === 'security').installCommand.includes('--profile security'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints text recommendations with install and plan commands', () => {
|
||||
const result = run(['I', 'want', 'a', 'skill', 'for', 'security', 'reviews']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /ECC consult/);
|
||||
assert.match(result.stdout, /capability:security/);
|
||||
assert.match(result.stdout, /npx ecc install --profile minimal --target claude --with capability:security/);
|
||||
assert.match(result.stdout, /npx ecc plan --profile minimal --target claude --with capability:security/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('recommends machine-learning component and reviewer agent', () => {
|
||||
const result = run(['mlops', 'training', 'model', 'deployment', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
const capabilityIndex = findMatchIndex(payload, 'capability:machine-learning');
|
||||
const reviewerIndex = findMatchIndex(payload, 'agent:mle-reviewer');
|
||||
assert.ok(capabilityIndex >= 0, 'Should include capability:machine-learning');
|
||||
assert.ok(reviewerIndex >= 0, 'Should include agent:mle-reviewer');
|
||||
assert.ok(capabilityIndex < reviewerIndex,
|
||||
'The workflow capability should rank ahead of the reviewer agent for broad MLE setup queries');
|
||||
assert.ok(findMatch(payload, 'capability:machine-learning').installCommand.includes('--with capability:machine-learning'));
|
||||
assert.ok(!payload.profiles.some(profile => profile.id === 'mle'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('matches tokenized model review queries without making review a generic alias', () => {
|
||||
const result = run(['model', 'review', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
const capabilityIndex = findMatchIndex(payload, 'capability:machine-learning');
|
||||
const securityIndex = findMatchIndex(payload, 'capability:security');
|
||||
const reviewerIndex = findMatchIndex(payload, 'agent:mle-reviewer');
|
||||
const codeReviewerIndex = findMatchIndex(payload, 'agent:code-reviewer');
|
||||
const reviewer = findMatch(payload, 'agent:mle-reviewer');
|
||||
assert.ok(reviewer, 'Should include agent:mle-reviewer');
|
||||
assert.ok(reviewer.reasons.includes('matched "model"'));
|
||||
assert.ok(!reviewer.reasons.includes('matched "review"'));
|
||||
assert.ok(!reviewer.reasons.includes('fuzzy matched "review"'));
|
||||
assert.ok(capabilityIndex >= 0, 'Should include capability:machine-learning');
|
||||
assert.ok(securityIndex < 0 || capabilityIndex < securityIndex,
|
||||
'Model review queries should prefer the MLE capability over generic security review');
|
||||
assert.ok(codeReviewerIndex < 0 || reviewerIndex < codeReviewerIndex,
|
||||
'Model review queries should prefer the MLE reviewer over generic code review');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('surfaces MLE reviewer for PyTorch model review queries', () => {
|
||||
const result = run(['pytorch', 'model', 'review', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
const reviewer = findMatch(payload, 'agent:mle-reviewer');
|
||||
assert.ok(findMatch(payload, 'capability:machine-learning'), 'Should include capability:machine-learning');
|
||||
assert.ok(reviewer, 'Should include agent:mle-reviewer');
|
||||
assert.ok(reviewer.reasons.includes('matched "pytorch"'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not route generic review queries to MLE components', () => {
|
||||
const result = run(['review', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.ok(!findMatch(payload, 'capability:machine-learning'));
|
||||
assert.ok(!findMatch(payload, 'agent:mle-reviewer'));
|
||||
assert.ok(!payload.profiles.some(profile => profile.id === 'mle'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('works from outside the ECC repository', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-consult-project-'));
|
||||
try {
|
||||
const result = run(['nextjs', 'react', '--json'], { cwd: projectDir });
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.matches[0].componentId, 'framework:nextjs');
|
||||
assert.ok(payload.matches.some(match => match.componentId === 'framework:react'));
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters recommendations by target and limit', () => {
|
||||
const result = run(['operator', 'workflows', '--target', 'codex', '--limit', '1', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.target, 'codex');
|
||||
assert.strictEqual(payload.matches.length, 1);
|
||||
assert.ok(payload.matches[0].targets.includes('codex'));
|
||||
assert.ok(payload.matches[0].installCommand.includes('--target codex'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown targets', () => {
|
||||
const result = run(['security', '--target', 'not-a-target']);
|
||||
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr, /Unknown install target/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects flag-like target values as missing target names', () => {
|
||||
const result = run(['security', '--target', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr, /Missing value for --target/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* Tests for scripts/control-pane.js and its local HTTP API.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const { createControlPaneServer, parseArgs, runAction, isAllowedHostHeader, isAllowedOrigin, buildAllowedHostnames } = require('../../scripts/lib/control-pane/server');
|
||||
const { main: runControlPaneCli } = require('../../scripts/control-pane');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'control-pane.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMinimalDatabase(dbPath) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
db.run(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
task_group TEXT NOT NULL DEFAULT '',
|
||||
agent_type TEXT NOT NULL,
|
||||
harness TEXT NOT NULL DEFAULT 'unknown',
|
||||
detected_harnesses_json TEXT NOT NULL DEFAULT '[]',
|
||||
working_dir TEXT NOT NULL DEFAULT '.',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
pid INTEGER,
|
||||
worktree_path TEXT,
|
||||
worktree_branch TEXT,
|
||||
worktree_base TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
files_changed INTEGER DEFAULT 0,
|
||||
duration_secs INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0.0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_heartbeat_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO sessions (
|
||||
id, task, agent_type, harness, detected_harnesses_json, working_dir, state,
|
||||
created_at, updated_at, last_heartbeat_at
|
||||
) VALUES (
|
||||
'session-a', 'Build the control pane', 'codex', 'codex', '["codex"]', '/repo/ecc',
|
||||
'running', '2026-06-03T10:00:00Z', '2026-06-03T10:05:00Z', '2026-06-03T10:05:00Z'
|
||||
);
|
||||
`);
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
function waitForCliReady(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`Timed out waiting for control pane CLI.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
||||
}, 5000);
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString('utf8');
|
||||
if (!settled && stdout.includes('ECC Control Pane:') && stdout.includes('Actions:')) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', error => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('exit', code => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`control pane CLI exited early with ${code}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise(resolve => {
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLocal(url, options) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing control-pane server ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
await test('parses CLI arguments for local-only serving', async () => {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'scripts/control-pane.js',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'8788',
|
||||
'--db',
|
||||
'/tmp/ecc2.db',
|
||||
'--state-db',
|
||||
'/tmp/ecc-state.db',
|
||||
'--query',
|
||||
'Hermes memory',
|
||||
'--no-open'
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.host, '127.0.0.1');
|
||||
assert.strictEqual(parsed.port, 8788);
|
||||
assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db');
|
||||
assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db');
|
||||
assert.strictEqual(parsed.query, 'Hermes memory');
|
||||
assert.strictEqual(parsed.openBrowser, false);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('rejects invalid CLI port values', async () => {
|
||||
assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), /Invalid --port value/);
|
||||
assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), /Invalid --port value/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('rejects missing state database path values', async () => {
|
||||
assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--state-db']), /Invalid --state-db value/);
|
||||
assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']), /Invalid --state-db value/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeMinimalDatabase(dbPath);
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath,
|
||||
repoRoot: REPO_ROOT,
|
||||
query: 'control pane',
|
||||
allowActions: false
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const html = await fetchLocal(`${app.url}/`).then(response => response.text());
|
||||
assert.ok(html.includes('ECC Control Pane'));
|
||||
assert.ok(html.includes('id="app"'));
|
||||
assert.ok(html.includes('id="work-items"'));
|
||||
assert.ok(html.includes('function renderWorkItems'));
|
||||
assert.ok(html.includes('function showError'));
|
||||
assert.ok(html.includes('response.ok'));
|
||||
// Board controls must use escaped data-* attributes + delegated
|
||||
// listeners, never ids concatenated into inline onclick JS (XSS).
|
||||
assert.ok(html.includes('data-wi-action'));
|
||||
assert.ok(!/onclick="ecc(Claim|Move)Item\(/.test(html), 'no inline onclick handlers with interpolated ids');
|
||||
|
||||
const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json());
|
||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 1);
|
||||
assert.strictEqual(snapshot.workItems.totalCount, 0);
|
||||
assert.strictEqual(snapshot.sessions[0].id, 'session-a');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('serves the 3D agent-airspace page and the proximity JSON feed', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-proximity-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeMinimalDatabase(dbPath);
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath,
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: false
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
// The Enterprise/Pro 3D observability view: a self-contained HTML page.
|
||||
const page = await fetchLocal(`${app.url}/proximity`);
|
||||
assert.strictEqual(page.status, 200);
|
||||
assert.ok((page.headers.get('content-type') || '').includes('text/html'));
|
||||
const html = await page.text();
|
||||
assert.ok(html.includes('Agent Airspace'), 'page is titled Agent Airspace');
|
||||
assert.ok(html.includes('<canvas'), 'page renders a canvas');
|
||||
assert.ok(html.includes('/api/proximity'), 'page polls the proximity feed');
|
||||
|
||||
// The feed the page polls: shape must carry the airspace arrays.
|
||||
const prox = await fetchLocal(`${app.url}/api/proximity`).then(r => r.json());
|
||||
assert.ok(Array.isArray(prox.positions), 'positions array present');
|
||||
assert.ok(Array.isArray(prox.links), 'links array present');
|
||||
assert.ok(Array.isArray(prox.advisories), 'advisories array present');
|
||||
assert.ok(prox.counts && typeof prox.counts === 'object', 'counts present');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-'));
|
||||
|
||||
try {
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath: path.join(tempDir, 'missing.db'),
|
||||
repoRoot: tempDir,
|
||||
allowActions: false
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json());
|
||||
assert.strictEqual(health.ok, true);
|
||||
assert.strictEqual(health.allowActions, false);
|
||||
|
||||
const realAssetApp = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath: path.join(tempDir, 'missing.db'),
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: false
|
||||
});
|
||||
await realAssetApp.listen();
|
||||
try {
|
||||
const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`);
|
||||
assert.strictEqual(realAsset.status, 200);
|
||||
assert.match(await realAsset.text(), /<svg/);
|
||||
} finally {
|
||||
await realAssetApp.close();
|
||||
}
|
||||
|
||||
const missingAsset = await fetchLocal(`${app.url}/assets/ecc-icon.svg`);
|
||||
assert.strictEqual(missingAsset.status, 404);
|
||||
assert.strictEqual(await missingAsset.text(), 'not found');
|
||||
|
||||
const missing = await fetchLocal(`${app.url}/not-here`).then(response => response.json());
|
||||
assert.strictEqual(missing.ok, false);
|
||||
assert.strictEqual(missing.error, 'not found');
|
||||
|
||||
const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query: 'memory' })
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(blocked.status, 403);
|
||||
assert.match(blocked.body.error, /disabled/);
|
||||
|
||||
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{bad json'
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(invalidBody.status, 403);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('guards copy-only and unknown action requests', async () => {
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: true
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(copyOnly.status, 400);
|
||||
assert.strictEqual(copyOnly.body.action, 'open-dashboard');
|
||||
assert.match(copyOnly.body.error, /copy-only/);
|
||||
|
||||
const unknown = await fetchLocal(`${app.url}/api/actions/nope`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(unknown.status, 500);
|
||||
assert.match(unknown.body.error, /Unknown control-pane action/);
|
||||
|
||||
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{bad json'
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(invalidBody.status, 500);
|
||||
assert.match(invalidBody.body.error, /JSON/);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('classifies Host and Origin headers against the loopback allowlist', async () => {
|
||||
const allowed = buildAllowedHostnames('127.0.0.1');
|
||||
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('LOCALHOST:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('[::1]:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', allowed), false);
|
||||
assert.strictEqual(isAllowedHostHeader('rebind.dnsbin.io', allowed), false);
|
||||
assert.strictEqual(isAllowedHostHeader('', allowed), false);
|
||||
assert.strictEqual(isAllowedHostHeader(undefined, allowed), false);
|
||||
|
||||
// Origin is optional; absence is allowed for non-browser clients.
|
||||
assert.strictEqual(isAllowedOrigin(undefined, allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('', allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('http://127.0.0.1:8765', allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('http://localhost', allowed), true);
|
||||
assert.strictEqual(isAllowedOrigin('http://attacker.example.com', allowed), false);
|
||||
assert.strictEqual(isAllowedOrigin('not-a-url', allowed), false);
|
||||
|
||||
// A non-default configured host should still admit loopback variants.
|
||||
const lan = buildAllowedHostnames('192.168.1.10');
|
||||
assert.strictEqual(isAllowedHostHeader('192.168.1.10:8765', lan), true);
|
||||
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', lan), true);
|
||||
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', lan), false);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('rejects requests forged with a non-loopback Host header (DNS rebinding gate)', async () => {
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: true
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const address = app.server.address();
|
||||
const actualPort = address && typeof address === 'object' ? address.port : 0;
|
||||
|
||||
const sendWithHeaders = (method, pathname, headers, body) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port: actualPort, method, path: pathname, headers }, response => {
|
||||
let chunks = '';
|
||||
response.on('data', chunk => {
|
||||
chunks += chunk.toString('utf8');
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({ status: response.statusCode, body: chunks });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const forgedHost = await sendWithHeaders('GET', '/api/health', { Host: 'attacker.example.com:1234' });
|
||||
assert.strictEqual(forgedHost.status, 421);
|
||||
assert.match(forgedHost.body, /Misdirected request/);
|
||||
|
||||
const forgedActionHost = await sendWithHeaders(
|
||||
'POST',
|
||||
'/api/actions/sync-knowledge',
|
||||
{ Host: 'attacker.example.com:1234', 'content-type': 'application/json' },
|
||||
JSON.stringify({ query: 'rebound' })
|
||||
);
|
||||
assert.strictEqual(forgedActionHost.status, 421);
|
||||
|
||||
const forgedOrigin = await sendWithHeaders('GET', '/api/health', {
|
||||
Host: '127.0.0.1:' + actualPort,
|
||||
Origin: 'http://attacker.example.com'
|
||||
});
|
||||
assert.strictEqual(forgedOrigin.status, 403);
|
||||
assert.match(forgedOrigin.body, /Forbidden origin/);
|
||||
|
||||
const okHost = await sendWithHeaders('GET', '/api/health', { Host: '127.0.0.1:' + actualPort });
|
||||
assert.strictEqual(okHost.status, 200);
|
||||
const okBody = JSON.parse(okHost.body);
|
||||
assert.strictEqual(okBody.ok, true);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('runAction captures success, failure, and bounded output', async () => {
|
||||
const repoRoot = REPO_ROOT;
|
||||
const success = await runAction({
|
||||
id: 'node-success',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stdout.write("x".repeat(21010))'],
|
||||
cwd: repoRoot
|
||||
});
|
||||
assert.strictEqual(success.ok, true);
|
||||
assert.strictEqual(success.code, 0);
|
||||
assert.ok(success.stdout.includes('[truncated '));
|
||||
|
||||
const failure = await runAction({
|
||||
id: 'node-failure',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stderr.write("bad"); process.exit(7)'],
|
||||
cwd: repoRoot
|
||||
});
|
||||
assert.strictEqual(failure.ok, false);
|
||||
assert.strictEqual(failure.code, 7);
|
||||
assert.strictEqual(failure.stderr, 'bad');
|
||||
|
||||
const spawnError = await runAction({
|
||||
id: 'spawn-error',
|
||||
command: 'definitely-not-ecc-control-pane-command',
|
||||
args: [],
|
||||
cwd: repoRoot
|
||||
});
|
||||
assert.strictEqual(spawnError.ok, false);
|
||||
assert.strictEqual(spawnError.code, null);
|
||||
assert.match(spawnError.error, /ENOENT/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('runAction terminates commands that exceed the local timeout', async () => {
|
||||
const timedOut = await runAction(
|
||||
{
|
||||
id: 'node-timeout',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'setTimeout(() => {}, 5000)'],
|
||||
cwd: REPO_ROOT
|
||||
},
|
||||
{ timeoutMs: 25 }
|
||||
);
|
||||
|
||||
assert.strictEqual(timedOut.ok, false);
|
||||
assert.strictEqual(timedOut.signal, 'SIGTERM');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('CLI prints help', async () => {
|
||||
const result = spawnSync('node', [SCRIPT, '--help'], {
|
||||
encoding: 'utf8',
|
||||
cwd: REPO_ROOT
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
assert.ok(result.stdout.includes('control-pane'));
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('CLI browser opener handles spawn errors', async () => {
|
||||
const source = fs.readFileSync(SCRIPT, 'utf8');
|
||||
|
||||
assert.match(source, /child\.on\('error'/);
|
||||
assert.match(source, /child\.unref\(\)/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('CLI main handles help without starting a server', async () => {
|
||||
const originalLog = console.log;
|
||||
const lines = [];
|
||||
console.log = line => {
|
||||
lines.push(String(line));
|
||||
};
|
||||
try {
|
||||
await runControlPaneCli(['node', 'scripts/control-pane.js', '--help']);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
assert.match(lines.join('\n'), /Usage:/);
|
||||
assert.match(lines.join('\n'), /--read-only/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => {
|
||||
const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db')
|
||||
}
|
||||
});
|
||||
const exitPromise = waitForExit(child);
|
||||
|
||||
try {
|
||||
const ready = await waitForCliReady(child);
|
||||
assert.match(ready.stdout, /ECC Control Pane: http:\/\/127\.0\.0\.1:\d+/);
|
||||
assert.match(ready.stdout, /Actions: read-only/);
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||
const result = await exitPromise;
|
||||
assert.ok(result.code === 0 || result.signal === 'SIGTERM', `expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}`);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('interactive board: claim and move work items via POST endpoints', async () => {
|
||||
const { createStateStore } = require('../../scripts/lib/state-store');
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-board-'));
|
||||
const stateDbPath = path.join(tempDir, 'state.db');
|
||||
|
||||
try {
|
||||
const store = await createStateStore({ dbPath: stateDbPath });
|
||||
store.upsertWorkItem({ id: 'wi-1', source: 'github-issue', title: 'Unassigned card', status: 'open', priority: 'high', owner: null, metadata: {} });
|
||||
store.upsertWorkItem({ id: 'wi-2', source: 'manual', title: 'Movable card', status: 'open', owner: 'codex', metadata: {} });
|
||||
store.close();
|
||||
|
||||
const post = (app, suffix, body) =>
|
||||
fetchLocal(`${app.url}/api/work-items/${suffix}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Read-only server rejects board edits.
|
||||
const ro = await createControlPaneServer({ host: '127.0.0.1', port: 0, stateDbPath, repoRoot: REPO_ROOT, allowActions: false });
|
||||
await ro.listen();
|
||||
try {
|
||||
const denied = await post(ro, 'wi-1/claim', { owner: 'alice' });
|
||||
assert.strictEqual(denied.status, 403);
|
||||
} finally {
|
||||
await ro.close();
|
||||
}
|
||||
|
||||
// Interactive server claims and moves.
|
||||
const app = await createControlPaneServer({ host: '127.0.0.1', port: 0, stateDbPath, repoRoot: REPO_ROOT, allowActions: true });
|
||||
await app.listen();
|
||||
try {
|
||||
const claim = await post(app, 'wi-1/claim', { owner: 'alice', as: 'human' }).then(r => r.json());
|
||||
assert.strictEqual(claim.ok, true);
|
||||
assert.strictEqual(claim.item.owner, 'alice');
|
||||
assert.strictEqual(claim.item.status, 'running');
|
||||
assert.strictEqual(claim.item.metadata.assigneeKind, 'human');
|
||||
|
||||
const move = await post(app, 'wi-2/move', { lane: 'blocked' }).then(r => r.json());
|
||||
assert.strictEqual(move.ok, true);
|
||||
assert.strictEqual(move.item.status, 'blocked');
|
||||
|
||||
// Snapshot reflects the mutations.
|
||||
const snapshot = await fetchLocal(`${app.url}/api/snapshot`).then(r => r.json());
|
||||
const byId = id => snapshot.workItems.items.find(i => i.id === id);
|
||||
assert.strictEqual(byId('wi-1').assigneeKind, 'human');
|
||||
assert.strictEqual(byId('wi-2').kanbanState, 'blocked');
|
||||
|
||||
// Invalid lane is a 400.
|
||||
const bad = await post(app, 'wi-2/move', { lane: 'nope' });
|
||||
assert.strictEqual(bad.status, 400);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,785 @@
|
||||
/**
|
||||
* Tests for scripts/dashboard-web.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js');
|
||||
|
||||
let testRoot;
|
||||
let testPassed = 0;
|
||||
let testFailed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
testPassed++;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
testFailed++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
// ===================== parsePort =====================
|
||||
|
||||
test('parsePort returns 3456 for undefined', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort(undefined), 3456);
|
||||
});
|
||||
|
||||
test('parsePort returns numeric port for valid string', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort('8080'), 8080);
|
||||
assert.strictEqual(parsePort('3456'), 3456);
|
||||
});
|
||||
|
||||
test('parsePort returns numeric port for numeric input', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort(8080), 8080);
|
||||
});
|
||||
|
||||
test('parsePort returns 3456 for port below 1', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort('-1'), 3456);
|
||||
assert.strictEqual(parsePort('0'), 3456);
|
||||
});
|
||||
|
||||
test('parsePort returns 3456 for port above 65535', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort('70000'), 3456);
|
||||
assert.strictEqual(parsePort('65536'), 3456);
|
||||
});
|
||||
|
||||
test('parsePort accepts boundary ports 1 and 65535', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort('1'), 1);
|
||||
assert.strictEqual(parsePort('65535'), 65535);
|
||||
});
|
||||
|
||||
test('parsePort returns 3456 for non-numeric string', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
assert.strictEqual(parsePort('abc'), 3456);
|
||||
assert.strictEqual(parsePort(''), 3456);
|
||||
});
|
||||
|
||||
// ===================== readFrontmatter =====================
|
||||
|
||||
test('readFrontmatter parses simple frontmatter', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'test.md', [
|
||||
'---',
|
||||
'name: test-agent',
|
||||
'description: A test agent description',
|
||||
'model: claude-sonnet-4-6',
|
||||
'---',
|
||||
'# Body content',
|
||||
'This is the body.',
|
||||
].join('\n'));
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'test.md'));
|
||||
assert.strictEqual(fm.name, 'test-agent');
|
||||
assert.strictEqual(fm.description, 'A test agent description');
|
||||
assert.strictEqual(fm.model, 'claude-sonnet-4-6');
|
||||
assert.ok(fm._body.includes('# Body content'));
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter parses array tools field', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'agent.md', [
|
||||
'---',
|
||||
'name: array-agent',
|
||||
'tools: [Bash, Read, Write]',
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'agent.md'));
|
||||
assert.strictEqual(fm.name, 'array-agent');
|
||||
assert.ok(Array.isArray(fm.tools));
|
||||
assert.deepStrictEqual(fm.tools, ['Bash', 'Read', 'Write']);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter handles quoted values', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'test.md', [
|
||||
'---',
|
||||
'name: "quoted-name"',
|
||||
"description: 'single-quoted-desc'",
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'test.md'));
|
||||
assert.strictEqual(fm.name, 'quoted-name');
|
||||
assert.strictEqual(fm.description, 'single-quoted-desc');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter returns empty object for file without frontmatter', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'no-fm.md', '# Just a heading\nNo frontmatter here.');
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'no-fm.md'));
|
||||
assert.deepStrictEqual(fm, {});
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter returns empty object for missing file', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
const fm = readFrontmatter('/nonexistent/path/test.md');
|
||||
assert.deepStrictEqual(fm, {});
|
||||
});
|
||||
|
||||
// ===================== readSkill =====================
|
||||
|
||||
test('readSkill parses skill frontmatter and body', () => {
|
||||
const { readSkill } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'SKILL.md', [
|
||||
'---',
|
||||
'name: test-skill',
|
||||
'description: A test skill',
|
||||
'---',
|
||||
'# Skill Workflow',
|
||||
'Step 1: Do this.',
|
||||
].join('\n'));
|
||||
|
||||
const skill = readSkill(path.join(testRoot, 'SKILL.md'));
|
||||
assert.strictEqual(skill.d, 'A test skill');
|
||||
assert.ok(skill.b.includes('# Skill Workflow'));
|
||||
assert.ok(!skill.b.includes('---')); // frontmatter stripped from body
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readSkill returns empty defaults for missing file', () => {
|
||||
const { readSkill } = require(SCRIPT);
|
||||
const skill = readSkill('/nonexistent/skill/SKILL.md');
|
||||
assert.strictEqual(skill.d, '');
|
||||
assert.strictEqual(skill.b, '');
|
||||
});
|
||||
|
||||
// ===================== loadAgents =====================
|
||||
|
||||
test('loadAgents returns empty array for missing directory', () => {
|
||||
const { loadAgents } = require(SCRIPT);
|
||||
const agents = loadAgents('/nonexistent/path');
|
||||
assert.deepStrictEqual(agents, []);
|
||||
});
|
||||
|
||||
test('loadAgents loads agent markdown files', () => {
|
||||
const { loadAgents } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'agents/typescript-reviewer.md', [
|
||||
'---',
|
||||
'name: typescript-reviewer',
|
||||
'description: Reviews TypeScript code',
|
||||
'model: claude-sonnet-4-6',
|
||||
'tools: [Bash, Read, Write, Grep]',
|
||||
'---',
|
||||
'# TypeScript Reviewer',
|
||||
'You are a TypeScript code reviewer.',
|
||||
].join('\n'));
|
||||
writeFile(testRoot, 'agents/python-reviewer.md', [
|
||||
'---',
|
||||
'name: python-reviewer',
|
||||
'description: Reviews Python code',
|
||||
'model: claude-opus-4-8',
|
||||
'tools: [Bash, Read]',
|
||||
'---',
|
||||
'# Python Reviewer',
|
||||
].join('\n'));
|
||||
|
||||
const agents = loadAgents(testRoot);
|
||||
assert.strictEqual(agents.length, 2);
|
||||
assert.strictEqual(agents[0].n, 'python-reviewer'); // alphabetical sort
|
||||
assert.strictEqual(agents[1].n, 'typescript-reviewer');
|
||||
assert.strictEqual(agents[1].m, 'claude-sonnet-4-6');
|
||||
assert.strictEqual(agents[1].d, 'Reviews TypeScript code');
|
||||
assert.deepStrictEqual(agents[1].t, ['Bash', 'Read', 'Write', 'Grep']);
|
||||
assert.ok(agents[1].b.length > 0);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadAgents defaults missing fields', () => {
|
||||
const { loadAgents } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'agents/minimal.md', [
|
||||
'# Minimal Agent',
|
||||
'No frontmatter at all.',
|
||||
].join('\n'));
|
||||
|
||||
const agents = loadAgents(testRoot);
|
||||
assert.strictEqual(agents.length, 1);
|
||||
assert.strictEqual(agents[0].n, 'minimal');
|
||||
assert.strictEqual(agents[0].m, 'default');
|
||||
assert.strictEqual(agents[0].d, '');
|
||||
assert.deepStrictEqual(agents[0].t, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadAgents ignores non-markdown files', () => {
|
||||
const { loadAgents } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'agents/agent.md', '---\nname: real-agent\n---\nbody');
|
||||
writeFile(testRoot, 'agents/README.txt', 'not an agent');
|
||||
|
||||
const agents = loadAgents(testRoot);
|
||||
assert.strictEqual(agents.length, 1);
|
||||
assert.strictEqual(agents[0].n, 'real-agent');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== loadSkills =====================
|
||||
|
||||
test('loadSkills returns empty array for missing directory', () => {
|
||||
const { loadSkills } = require(SCRIPT);
|
||||
const skills = loadSkills('/nonexistent/path');
|
||||
assert.deepStrictEqual(skills, []);
|
||||
});
|
||||
|
||||
test('loadSkills loads skill directories with SKILL.md', () => {
|
||||
const { loadSkills } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'skills/seo-audit/SKILL.md', [
|
||||
'---',
|
||||
'name: seo-audit',
|
||||
'description: Full website SEO audit',
|
||||
'---',
|
||||
'# SEO Audit Workflow',
|
||||
].join('\n'));
|
||||
writeFile(testRoot, 'skills/code-review/SKILL.md', [
|
||||
'---',
|
||||
'name: code-review',
|
||||
'description: Review code changes',
|
||||
'---',
|
||||
'# Code Review Workflow',
|
||||
].join('\n'));
|
||||
|
||||
const skills = loadSkills(testRoot);
|
||||
assert.strictEqual(skills.length, 2);
|
||||
assert.strictEqual(skills[0].n, 'code-review'); // alphabetical sort
|
||||
assert.strictEqual(skills[1].n, 'seo-audit');
|
||||
assert.strictEqual(skills[1].d, 'Full website SEO audit');
|
||||
assert.ok(skills[1].b.length > 0);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadSkills ignores non-directories', () => {
|
||||
const { loadSkills } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'skills/README.md', 'no skill here');
|
||||
writeFile(testRoot, 'skills/real-skill/SKILL.md', '---\ndescription: A real skill\n---\nbody');
|
||||
|
||||
const skills = loadSkills(testRoot);
|
||||
assert.strictEqual(skills.length, 1);
|
||||
assert.strictEqual(skills[0].n, 'real-skill');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== loadCommands =====================
|
||||
|
||||
test('loadCommands returns empty array for missing directory', () => {
|
||||
const { loadCommands } = require(SCRIPT);
|
||||
const commands = loadCommands('/nonexistent/path');
|
||||
assert.deepStrictEqual(commands, []);
|
||||
});
|
||||
|
||||
test('loadCommands loads command markdown files with category detection', () => {
|
||||
const { loadCommands } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'commands/pr.md', [
|
||||
'---',
|
||||
'description: Create a pull request',
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
writeFile(testRoot, 'commands/go-test.md', [
|
||||
'---',
|
||||
'description: Run Go tests',
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
writeFile(testRoot, 'commands/unknown-cmd.md', [
|
||||
'---',
|
||||
'description: Some unknown command',
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
|
||||
const commands = loadCommands(testRoot);
|
||||
assert.strictEqual(commands.length, 3);
|
||||
|
||||
const prCmd = commands.find(c => c.n === '/pr');
|
||||
assert.ok(prCmd);
|
||||
assert.strictEqual(prCmd.c, 'Git & PR');
|
||||
assert.strictEqual(prCmd.d, 'Create a pull request');
|
||||
|
||||
const goCmd = commands.find(c => c.n === '/go-test');
|
||||
assert.ok(goCmd);
|
||||
assert.strictEqual(goCmd.c, 'Languages');
|
||||
|
||||
const unknownCmd = commands.find(c => c.n === '/unknown-cmd');
|
||||
assert.ok(unknownCmd);
|
||||
assert.strictEqual(unknownCmd.c, 'Other');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== loadRules =====================
|
||||
|
||||
test('loadRules returns empty array for missing directory', () => {
|
||||
const { loadRules } = require(SCRIPT);
|
||||
const rules = loadRules('/nonexistent/path');
|
||||
assert.deepStrictEqual(rules, []);
|
||||
});
|
||||
|
||||
test('loadRules loads language directories with rule files', () => {
|
||||
const { loadRules } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'rules/python/coding-style.md', '');
|
||||
writeFile(testRoot, 'rules/python/testing.md', '');
|
||||
writeFile(testRoot, 'rules/python/patterns.md', '');
|
||||
writeFile(testRoot, 'rules/typescript/coding-style.md', '');
|
||||
writeFile(testRoot, 'rules/typescript/testing.md', '');
|
||||
|
||||
const rules = loadRules(testRoot);
|
||||
assert.strictEqual(rules.length, 2);
|
||||
|
||||
const pyRules = rules.find(r => r.l === 'python');
|
||||
assert.ok(pyRules);
|
||||
assert.strictEqual(pyRules.f.length, 3);
|
||||
assert.ok(pyRules.f.includes('coding-style'));
|
||||
assert.ok(pyRules.f.includes('testing'));
|
||||
assert.ok(pyRules.f.includes('patterns'));
|
||||
|
||||
const tsRules = rules.find(r => r.l === 'typescript');
|
||||
assert.ok(tsRules);
|
||||
assert.strictEqual(tsRules.f.length, 2);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadRules ignores non-directories in rules folder', () => {
|
||||
const { loadRules } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'rules/README.md', 'no rules here');
|
||||
writeFile(testRoot, 'rules/go/testing.md', '');
|
||||
|
||||
const rules = loadRules(testRoot);
|
||||
assert.strictEqual(rules.length, 1);
|
||||
assert.strictEqual(rules[0].l, 'go');
|
||||
assert.strictEqual(rules[0].f.length, 1);
|
||||
assert.strictEqual(rules[0].f[0], 'testing');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== loadMcps =====================
|
||||
|
||||
test('loadMcps returns empty array when no configs exist', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.deepStrictEqual(mcps, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadMcps loads .mcp.json config', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, '.mcp.json', JSON.stringify({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { SECRET: 'real-secret' },
|
||||
type: 'stdio',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.strictEqual(mcps.length, 1);
|
||||
assert.strictEqual(mcps[0].f, '.mcp.json');
|
||||
assert.strictEqual(mcps[0].s.length, 1);
|
||||
assert.strictEqual(mcps[0].s[0].n, 'test-server');
|
||||
assert.strictEqual(mcps[0].s[0].cmd, 'node');
|
||||
assert.deepStrictEqual(mcps[0].s[0].args, ['server.js']);
|
||||
assert.strictEqual(mcps[0].s[0].type, 'stdio');
|
||||
// Env vars should be masked
|
||||
assert.strictEqual(mcps[0].s[0].env.SECRET, '••••••');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadMcps loads mcp-configs/ directory files', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'mcp-configs/brave.json', JSON.stringify({
|
||||
mcpServers: {
|
||||
'brave-search': {
|
||||
command: 'npx',
|
||||
args: ['@anthropic/mcp-brave'],
|
||||
type: 'stdio',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.strictEqual(mcps.length, 1);
|
||||
assert.strictEqual(mcps[0].f, 'brave.json');
|
||||
assert.strictEqual(mcps[0].s.length, 1);
|
||||
assert.strictEqual(mcps[0].s[0].n, 'brave-search');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadMcps masks environment variables', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'mcp-configs/with-env.json', JSON.stringify({
|
||||
mcpServers: {
|
||||
server: {
|
||||
command: 'python',
|
||||
env: { API_KEY: 'super-secret-key', DEBUG: 'true' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.strictEqual(mcps[0].s[0].env.API_KEY, '••••••');
|
||||
assert.strictEqual(mcps[0].s[0].env.DEBUG, '••••••');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadMcps handles url-based MCP servers', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, '.mcp.json', JSON.stringify({
|
||||
mcpServers: {
|
||||
'remote-server': {
|
||||
url: 'https://example.com/mcp',
|
||||
type: 'sse',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.strictEqual(mcps[0].s[0].cmd, 'https://example.com/mcp');
|
||||
assert.strictEqual(mcps[0].s[0].type, 'sse');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadMcps handles malformed JSON gracefully', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, '.mcp.json', '{not valid json}');
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.deepStrictEqual(mcps, []); // returns empty array on parse error
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== loadHooks =====================
|
||||
|
||||
test('loadHooks returns empty array when hooks.json missing', () => {
|
||||
const { loadHooks } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
|
||||
const hooks = loadHooks(testRoot);
|
||||
assert.deepStrictEqual(hooks, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadHooks loads hook definitions', () => {
|
||||
const { loadHooks } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'hooks/hooks.json', JSON.stringify({
|
||||
hooks: {
|
||||
'post-commit': [
|
||||
{ matcher: '*.js', id: 'lint-js', description: 'Lint JS files after commit' },
|
||||
{ matcher: '*.py', id: 'lint-py', description: 'Lint Python files' },
|
||||
],
|
||||
'pre-push': [
|
||||
{ matcher: '*', id: 'run-tests', description: 'Run all tests before push' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const hooks = loadHooks(testRoot);
|
||||
assert.strictEqual(hooks.length, 3);
|
||||
const lintJs = hooks.find(h => h.id === 'lint-js');
|
||||
assert.ok(lintJs);
|
||||
assert.strictEqual(lintJs.ev, 'post-commit');
|
||||
assert.strictEqual(lintJs.m, '*.js');
|
||||
assert.strictEqual(lintJs.d, 'Lint JS files after commit');
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadHooks handles malformed JSON gracefully', () => {
|
||||
const { loadHooks } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'hooks/hooks.json', '{invalid}');
|
||||
|
||||
const hooks = loadHooks(testRoot);
|
||||
assert.deepStrictEqual(hooks, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== LANG =====================
|
||||
|
||||
test('LANG object has all expected language keys', () => {
|
||||
const { LANG, LANG_KEYS } = require(SCRIPT);
|
||||
assert.ok(LANG_KEYS.length >= 10); // at least 10 languages
|
||||
// Verify key languages are present
|
||||
assert.ok(LANG.en);
|
||||
assert.ok(LANG.pt);
|
||||
assert.ok(LANG.zh);
|
||||
assert.ok(LANG.de);
|
||||
assert.strictEqual(LANG_KEYS.length, Object.keys(LANG).length);
|
||||
});
|
||||
|
||||
test('LANG English has all required keys', () => {
|
||||
const { LANG } = require(SCRIPT);
|
||||
const en = LANG.en;
|
||||
assert.ok(en.title);
|
||||
assert.ok(en.search);
|
||||
assert.ok(en.agents);
|
||||
assert.ok(en.skills);
|
||||
assert.ok(en.commands);
|
||||
assert.ok(en.rules);
|
||||
assert.ok(en.mcps);
|
||||
assert.ok(en.hooks);
|
||||
assert.ok(en.all);
|
||||
assert.ok(en.description);
|
||||
assert.ok(en.tools);
|
||||
assert.ok(en.copied);
|
||||
});
|
||||
|
||||
// ===================== renderHTML =====================
|
||||
|
||||
test('renderHTML returns valid HTML string', () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
const data = {
|
||||
agents: [],
|
||||
skills: [],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
const html = renderHTML(data);
|
||||
assert.ok(typeof html === 'string');
|
||||
assert.ok(html.startsWith('<!DOCTYPE html>'));
|
||||
assert.ok(html.includes('</html>'));
|
||||
});
|
||||
|
||||
test('renderHTML includes agent data as JSON', () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
const data = {
|
||||
agents: [{ n: 'test-agent', d: 'Test desc', m: 'claude-sonnet-4-6', t: ['Bash'], b: 'body', f: 'test.md' }],
|
||||
skills: [],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
const html = renderHTML(data);
|
||||
assert.ok(html.includes('test-agent'));
|
||||
assert.ok(html.includes('claude-sonnet-4-6'));
|
||||
});
|
||||
|
||||
test('renderHTML escapes HTML in data values', () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
const data = {
|
||||
agents: [],
|
||||
skills: [{ n: '<script>alert("xss")</script>', d: 'Skill & description', b: '' }],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
const html = renderHTML(data);
|
||||
// The JSON serialization with .replace(/</g, '\\u003c') converts < to < in JS strings
|
||||
// So the rendered HTML contains <script> not <script> for data values
|
||||
assert.ok(html.includes('\\u003cscript'));
|
||||
assert.ok(html.includes('\\u003c/script'));
|
||||
});
|
||||
|
||||
test('renderHTML includes LANG and LANG_KEYS in the output', () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
const data = { agents: [], skills: [], commands: [], rules: [], mcps: [], hooks: [] };
|
||||
const html = renderHTML(data);
|
||||
assert.ok(html.includes('ECC Capabilities'));
|
||||
assert.ok(html.includes('const L ='));
|
||||
assert.ok(html.includes('const LANG_KEYS'));
|
||||
});
|
||||
|
||||
test('renderHTML includes the dashboard title and footer', () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
const data = { agents: [], skills: [], commands: [], rules: [], mcps: [], hooks: [] };
|
||||
const html = renderHTML(data);
|
||||
assert.ok(html.includes('ECC Capabilities'));
|
||||
assert.ok(html.includes('github.com/affaan-m/ECC'));
|
||||
});
|
||||
|
||||
// ===================== Server / HTTP =====================
|
||||
|
||||
test('server returns HTML on GET /', (done) => {
|
||||
const { server } = require(SCRIPT);
|
||||
// Server may or may not be listening — we start it on a random port
|
||||
const testServer = http.createServer(server._events.request);
|
||||
testServer.listen(0, () => {
|
||||
const port = testServer.address().port;
|
||||
http.get(`http://localhost:${port}/`, (res) => {
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
assert.ok(body.includes('<!DOCTYPE html>'));
|
||||
assert.ok(body.includes('ECC Capabilities'));
|
||||
testServer.close();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('server returns JSON on GET /api/data', (done) => {
|
||||
const { server } = require(SCRIPT);
|
||||
const testServer = http.createServer(server._events.request);
|
||||
testServer.listen(0, () => {
|
||||
const port = testServer.address().port;
|
||||
http.get(`http://localhost:${port}/api/data`, (res) => {
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'application/json');
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
const parsed = JSON.parse(body);
|
||||
assert.ok(Array.isArray(parsed.agents));
|
||||
assert.ok(Array.isArray(parsed.skills));
|
||||
assert.ok(Array.isArray(parsed.commands));
|
||||
assert.ok(Array.isArray(parsed.rules));
|
||||
assert.ok(Array.isArray(parsed.mcps));
|
||||
assert.ok(Array.isArray(parsed.hooks));
|
||||
testServer.close();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== esc function (via HTML output) =====================
|
||||
|
||||
test('HTML output escapes angle brackets in renderHTML', () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
const data = {
|
||||
agents: [],
|
||||
skills: [{ n: 'bad<script>', d: '<img onerror=alert(1)>', b: '' }],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
const html = renderHTML(data);
|
||||
// The < in data values are escaped to < in JS string literals
|
||||
// So we should find the escaped form in the output
|
||||
assert.ok(html.includes('bad\\u003cscript>'));
|
||||
assert.ok(html.includes('\\u003cimg onerror'));
|
||||
});
|
||||
|
||||
// ===================== Edge Cases =====================
|
||||
|
||||
test('parsePort handles whitespace', () => {
|
||||
const { parsePort } = require(SCRIPT);
|
||||
// parseInt handles whitespace naturally
|
||||
assert.strictEqual(parsePort(' 8080 '), 8080);
|
||||
});
|
||||
|
||||
test('readFrontmatter handles empty file', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'empty.md', '');
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'empty.md'));
|
||||
assert.deepStrictEqual(fm, {});
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter handles malformed frontmatter', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'malformed.md', [
|
||||
'---',
|
||||
'name: test',
|
||||
'this is not a key value',
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'malformed.md'));
|
||||
assert.strictEqual(fm.name, 'test');
|
||||
assert.ok(fm._body.includes('body'));
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadAgents handles empty agents directory', () => {
|
||||
const { loadAgents } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
fs.mkdirSync(path.join(testRoot, 'agents'));
|
||||
|
||||
const agents = loadAgents(testRoot);
|
||||
assert.deepStrictEqual(agents, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadSkills handles empty skills directory', () => {
|
||||
const { loadSkills } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
fs.mkdirSync(path.join(testRoot, 'skills'));
|
||||
|
||||
const skills = loadSkills(testRoot);
|
||||
assert.deepStrictEqual(skills, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('loadMcps handles empty mcp-configs directory', () => {
|
||||
const { loadMcps } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
fs.mkdirSync(path.join(testRoot, 'mcp-configs'));
|
||||
|
||||
const mcps = loadMcps(testRoot);
|
||||
assert.deepStrictEqual(mcps, []);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
// ===================== Results =====================
|
||||
|
||||
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
|
||||
process.exit(testFailed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Tests for scripts/discussion-audit.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'discussion-audit.js');
|
||||
const {
|
||||
DISCUSSION_ENABLED_QUERY,
|
||||
DISCUSSION_QUERY
|
||||
} = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions'));
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function discussionGhKey(owner, name, first = 100) {
|
||||
return `api graphql -f owner=${owner} -f name=${name} -F first=${first} -f query=${DISCUSSION_QUERY}`;
|
||||
}
|
||||
|
||||
function discussionEnabledGhKey(owner, name) {
|
||||
return `api graphql -f owner=${owner} -f name=${name} -f query=${DISCUSSION_ENABLED_QUERY}`;
|
||||
}
|
||||
|
||||
function writeGhShim(rootDir, responses) {
|
||||
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||
fs.writeFileSync(shimPath, `
|
||||
const responses = ${JSON.stringify(responses)};
|
||||
const args = process.argv.slice(2);
|
||||
const key = args.join(' ');
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
console.error('GITHUB_TOKEN should be unset by default');
|
||||
process.exit(42);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
console.error('Unexpected gh args: ' + key);
|
||||
process.exit(3);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(responses[key]));
|
||||
`);
|
||||
return shimPath;
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing discussion-audit.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('passes when discussions have maintainer touch and accepted answers', () => {
|
||||
const rootDir = createTempDir('discussion-audit-pass-');
|
||||
|
||||
try {
|
||||
const shimPath = writeGhShim(rootDir, {
|
||||
[discussionEnabledGhKey('affaan-m', 'ECC')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: true } }
|
||||
},
|
||||
[discussionGhKey('affaan-m', 'ECC')]: {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: {
|
||||
totalCount: 2,
|
||||
nodes: [
|
||||
{
|
||||
number: 1923,
|
||||
title: 'Does Continuous Learning v2 work with VS Code Claude Code?',
|
||||
url: 'https://github.com/example/discussions/1923',
|
||||
updatedAt: '2026-05-15T19:08:52Z',
|
||||
authorAssociation: 'NONE',
|
||||
category: { name: 'Q&A', isAnswerable: true },
|
||||
answer: { url: 'https://github.com/example/discussions/1923#discussioncomment-1', authorAssociation: 'OWNER' },
|
||||
comments: { nodes: [] }
|
||||
},
|
||||
{
|
||||
number: 73,
|
||||
title: 'Compacting during workflow',
|
||||
url: 'https://github.com/example/discussions/73',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
authorAssociation: 'NONE',
|
||||
category: { name: 'General', isAnswerable: false },
|
||||
answer: null,
|
||||
comments: { nodes: [{ authorAssociation: 'MEMBER' }] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--json',
|
||||
'--repo',
|
||||
'affaan-m/ECC'
|
||||
], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
ECC_GH_SHIM: shimPath,
|
||||
GITHUB_TOKEN: 'must-be-removed'
|
||||
}
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.totals.needingMaintainerTouch, 0);
|
||||
assert.strictEqual(parsed.totals.missingAcceptedAnswer, 0);
|
||||
assert.ok(parsed.checks.some(check => check.id === 'discussion-accepted-answers' && check.status === 'pass'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when Q&A lacks accepted answer and maintainer touch', () => {
|
||||
const rootDir = createTempDir('discussion-audit-fail-');
|
||||
|
||||
try {
|
||||
const shimPath = writeGhShim(rootDir, {
|
||||
[discussionEnabledGhKey('affaan-m', 'ECC')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: true } }
|
||||
},
|
||||
[discussionGhKey('affaan-m', 'ECC')]: {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: {
|
||||
totalCount: 1,
|
||||
nodes: [
|
||||
{
|
||||
number: 1239,
|
||||
title: 'Losing context',
|
||||
url: 'https://github.com/example/discussions/1239',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
authorAssociation: 'NONE',
|
||||
category: { name: 'Q&A', isAnswerable: true },
|
||||
answer: null,
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = runProcess([
|
||||
'--json',
|
||||
'--repo',
|
||||
'affaan-m/ECC',
|
||||
'--exit-code'
|
||||
], {
|
||||
cwd: rootDir,
|
||||
env: { ECC_GH_SHIM: shimPath }
|
||||
});
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
|
||||
assert.strictEqual(result.status, 2);
|
||||
assert.strictEqual(parsed.ready, false);
|
||||
assert.strictEqual(parsed.totals.needingMaintainerTouch, 1);
|
||||
assert.strictEqual(parsed.totals.missingAcceptedAnswer, 1);
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'discussion-maintainer-touch'));
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'discussion-accepted-answers'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes markdown output as a durable operator artifact', () => {
|
||||
const rootDir = createTempDir('discussion-audit-markdown-');
|
||||
const outputPath = path.join(rootDir, 'artifacts', 'discussion-audit.md');
|
||||
|
||||
try {
|
||||
const shimPath = writeGhShim(rootDir, {
|
||||
[discussionEnabledGhKey('affaan-m', 'ECC')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: true } }
|
||||
},
|
||||
[discussionGhKey('affaan-m', 'ECC')]: {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: { totalCount: 0, nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const stdout = run([
|
||||
'--markdown',
|
||||
'--write',
|
||||
outputPath,
|
||||
'--repo',
|
||||
'affaan-m/ECC'
|
||||
], {
|
||||
cwd: rootDir,
|
||||
env: { ECC_GH_SHIM: shimPath }
|
||||
});
|
||||
const written = fs.readFileSync(outputPath, 'utf8');
|
||||
|
||||
assert.strictEqual(stdout, written);
|
||||
assert.ok(written.includes('# ECC Discussion Audit'));
|
||||
assert.ok(written.includes('Answerable discussions missing accepted answer'));
|
||||
assert.ok(written.includes('- none'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('passes without heavy query when discussions are disabled', () => {
|
||||
const rootDir = createTempDir('discussion-audit-disabled-');
|
||||
|
||||
try {
|
||||
const shimPath = writeGhShim(rootDir, {
|
||||
[discussionEnabledGhKey('ECC-Tools', 'ECC-website')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: false } }
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--json',
|
||||
'--repo',
|
||||
'ECC-Tools/ECC-website'
|
||||
], {
|
||||
cwd: rootDir,
|
||||
env: { ECC_GH_SHIM: shimPath }
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.repos[0].discussions.enabled, false);
|
||||
assert.strictEqual(parsed.totals.totalDiscussions, 0);
|
||||
assert.strictEqual(parsed.totals.errors, 0);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cli help and invalid args exit cleanly', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/discussion-audit.js'));
|
||||
|
||||
const invalid = runProcess(['--format', 'xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.ok(invalid.stderr.includes('Invalid format'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Tests for scripts/doctor.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing doctor.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('reports a healthy install with exit code 0', () => {
|
||||
const homeDir = createTempDir('doctor-home-');
|
||||
const projectRoot = createTempDir('doctor-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const managedFile = path.join(targetRoot, 'rules', 'common', 'coding-style.md');
|
||||
const sourceContent = fs.readFileSync(path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'), 'utf8');
|
||||
fs.mkdirSync(path.dirname(managedFile), { recursive: true });
|
||||
fs.writeFileSync(managedFile, sourceContent);
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-claude-rules'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
destinationPath: managedFile,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = run(['--target', 'claude'], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Doctor report'));
|
||||
assert.ok(result.stdout.includes('Status: OK'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports issues and exits 1 for unhealthy installs', () => {
|
||||
const homeDir = createTempDir('doctor-home-');
|
||||
const projectRoot = createTempDir('doctor-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: path.join(targetRoot, 'hooks.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = run(['--target', 'cursor', '--json'], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 1);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.summary.errorCount, 1);
|
||||
assert.ok(parsed.results[0].issues.some(issue => issue.code === 'missing-managed-files'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Behavioral tests for ecc_dashboard.py helper functions.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const runtimeHelpersPath = path.join(repoRoot, 'scripts', 'lib', 'ecc_dashboard_runtime.py');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runPython(source) {
|
||||
const candidates = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
|
||||
let lastError = null;
|
||||
|
||||
for (const command of candidates) {
|
||||
const result = spawnSync(command, ['-c', source], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
lastError = result.error;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || '').trim() || `${command} exited ${result.status}`);
|
||||
}
|
||||
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
throw lastError || new Error('No Python interpreter available');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing ecc_dashboard.py ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('build_terminal_launch keeps Linux path separate from shell command text', () => {
|
||||
const output = runPython(`
|
||||
import importlib.util, json
|
||||
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
argv, kwargs = module.build_terminal_launch('/tmp/proj; rm -rf ~', os_name='posix', system_name='Linux')
|
||||
print(json.dumps({'argv': argv, 'kwargs': kwargs}))
|
||||
`);
|
||||
const parsed = JSON.parse(output);
|
||||
assert.deepStrictEqual(
|
||||
parsed.argv,
|
||||
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', '/tmp/proj; rm -rf ~']
|
||||
);
|
||||
assert.deepStrictEqual(parsed.kwargs, {});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('build_terminal_launch uses cwd + CREATE_NEW_CONSOLE style launch on Windows', () => {
|
||||
const output = runPython(`
|
||||
import importlib.util, json
|
||||
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
argv, kwargs = module.build_terminal_launch(r'C:\\\\Users\\\\user\\\\proj & del C:\\\\*', os_name='nt', system_name='Windows')
|
||||
print(json.dumps({'argv': argv, 'kwargs': kwargs}))
|
||||
`);
|
||||
const parsed = JSON.parse(output);
|
||||
assert.deepStrictEqual(parsed.argv, ['cmd.exe']);
|
||||
assert.ok(parsed.kwargs.cwd.includes('proj & del'), 'path should remain a literal cwd value');
|
||||
assert.ok(parsed.kwargs.cwd.includes('C:'), 'windows drive prefix should be preserved');
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('launch_terminal rejects missing or non-directory paths', () => {
|
||||
const output = runPython(`
|
||||
import importlib.util, json
|
||||
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
try:
|
||||
module.launch_terminal('/definitely/not/a/real/ecc/path')
|
||||
except ValueError as exc:
|
||||
print(json.dumps({'error': str(exc)}))
|
||||
`);
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(parsed.error.includes('Path is not a valid directory'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('maximize_window falls back to Linux zoom attribute when zoomed state is unsupported', () => {
|
||||
const output = runPython(`
|
||||
import importlib.util, json
|
||||
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class FakeWindow:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def state(self, value):
|
||||
self.calls.append(['state', value])
|
||||
raise RuntimeError('bad argument "zoomed"')
|
||||
|
||||
def attributes(self, name, value):
|
||||
self.calls.append(['attributes', name, value])
|
||||
|
||||
original = module.platform.system
|
||||
module.platform.system = lambda: 'Linux'
|
||||
try:
|
||||
window = FakeWindow()
|
||||
module.maximize_window(window)
|
||||
finally:
|
||||
module.platform.system = original
|
||||
|
||||
print(json.dumps(window.calls))
|
||||
`);
|
||||
const parsed = JSON.parse(output);
|
||||
assert.deepStrictEqual(parsed, [
|
||||
['state', 'zoomed'],
|
||||
['attributes', '-zoomed', true],
|
||||
]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Tests for scripts/ecc.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
||||
|
||||
function runCli(args, options = {}) {
|
||||
const envOverrides = {
|
||||
...(options.env || {}),
|
||||
};
|
||||
|
||||
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
|
||||
envOverrides.USERPROFILE = envOverrides.HOME;
|
||||
}
|
||||
|
||||
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
|
||||
envOverrides.HOME = envOverrides.USERPROFILE;
|
||||
}
|
||||
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
cwd: options.cwd || process.cwd(),
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function parseJson(stdout) {
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.error(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('\n=== Testing ecc.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const tests = [
|
||||
['shows top-level help', () => {
|
||||
const result = runCli(['--help']);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.match(result.stdout, /ECC selective-install CLI/);
|
||||
assert.match(result.stdout, /catalog/);
|
||||
assert.match(result.stdout, /list-installed/);
|
||||
assert.match(result.stdout, /doctor/);
|
||||
assert.match(result.stdout, /auto-update/);
|
||||
assert.match(result.stdout, /consult/);
|
||||
assert.match(result.stdout, /control-pane/);
|
||||
assert.match(result.stdout, /loop-status/);
|
||||
assert.match(result.stdout, /work-items/);
|
||||
assert.match(result.stdout, /platform-audit/);
|
||||
assert.match(result.stdout, /security-ioc-scan/);
|
||||
}],
|
||||
['delegates explicit install command', () => {
|
||||
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, true);
|
||||
assert.strictEqual(payload.plan.mode, 'legacy-compat');
|
||||
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
|
||||
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
|
||||
}],
|
||||
['routes implicit top-level args to install', () => {
|
||||
const result = runCli(['--dry-run', '--json', 'typescript']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, true);
|
||||
assert.strictEqual(payload.plan.mode, 'legacy-compat');
|
||||
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
|
||||
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
|
||||
}],
|
||||
['delegates plan command', () => {
|
||||
const result = runCli(['plan', '--list-profiles', '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.ok(Array.isArray(payload.profiles));
|
||||
assert.ok(payload.profiles.length > 0);
|
||||
}],
|
||||
['delegates catalog command', () => {
|
||||
const result = runCli(['catalog', 'show', 'framework:nextjs', '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.id, 'framework:nextjs');
|
||||
assert.deepStrictEqual(payload.moduleIds, ['framework-language']);
|
||||
}],
|
||||
['delegates consult command', () => {
|
||||
const result = runCli(['consult', 'security', 'reviews', '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1');
|
||||
assert.strictEqual(payload.matches[0].componentId, 'capability:security');
|
||||
}],
|
||||
['supports help for the control-pane subcommand', () => {
|
||||
const result = runCli(['help', 'control-pane']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage:/);
|
||||
assert.match(result.stdout, /control-pane/);
|
||||
}],
|
||||
['delegates lifecycle commands', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const projectRoot = createTempDir('ecc-cli-project-');
|
||||
const result = runCli(['list-installed', '--json'], {
|
||||
cwd: projectRoot,
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.records, []);
|
||||
}],
|
||||
['delegates auto-update command', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const projectRoot = createTempDir('ecc-cli-project-');
|
||||
const result = runCli(['auto-update', '--dry-run', '--json'], {
|
||||
cwd: projectRoot,
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.results, []);
|
||||
}],
|
||||
['delegates session-inspect command', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# ECC Session\n\n**Branch:** feat/ecc-cli\n'
|
||||
);
|
||||
|
||||
const result = runCli(['session-inspect', 'claude:latest'], {
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.adapterId, 'claude-history');
|
||||
assert.strictEqual(payload.workers[0].branch, 'feat/ecc-cli');
|
||||
}],
|
||||
['delegates loop-status command', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const transcriptDir = path.join(homeDir, '.claude', 'projects', '-tmp-ecc');
|
||||
fs.mkdirSync(transcriptDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(transcriptDir, 'session-loop.jsonl'),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-04-30T09:00:00.000Z',
|
||||
sessionId: 'session-loop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_loop',
|
||||
name: 'ScheduleWakeup',
|
||||
input: { delaySeconds: 300 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
const result = runCli(['loop-status', '--home', homeDir, '--now', '2026-04-30T10:00:00.000Z', '--json']);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
|
||||
assert.strictEqual(payload.sessions[0].sessionId, 'session-loop');
|
||||
}],
|
||||
['supports help for a subcommand', () => {
|
||||
const result = runCli(['help', 'repair']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/repair\.js/);
|
||||
}],
|
||||
['supports help for the auto-update subcommand', () => {
|
||||
const result = runCli(['help', 'auto-update']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/auto-update\.js/);
|
||||
}],
|
||||
['supports help for the catalog subcommand', () => {
|
||||
const result = runCli(['help', 'catalog']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /node scripts\/catalog\.js show <component-id>/);
|
||||
}],
|
||||
['supports help for the consult subcommand', () => {
|
||||
const result = runCli(['help', 'consult']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /node scripts\/consult\.js "security reviews"/);
|
||||
}],
|
||||
['supports help for the work-items subcommand', () => {
|
||||
const result = runCli(['help', 'work-items']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /node scripts\/work-items\.js upsert/);
|
||||
}],
|
||||
['supports help for the platform-audit subcommand', () => {
|
||||
const result = runCli(['help', 'platform-audit']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/platform-audit\.js/);
|
||||
}],
|
||||
['supports help for the security-ioc-scan subcommand', () => {
|
||||
const result = runCli(['help', 'security-ioc-scan']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/ci\/scan-supply-chain-iocs\.js/);
|
||||
}],
|
||||
['delegates security-ioc-scan command', () => {
|
||||
const projectRoot = createTempDir('ecc-cli-ioc-scan-');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ dependencies: { leftpad: '1.0.0' } }, null, 2)
|
||||
);
|
||||
|
||||
const result = runCli(['security-ioc-scan', '--root', projectRoot, '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.findings, []);
|
||||
}],
|
||||
['fails on unknown commands instead of treating them as installs', () => {
|
||||
const result = runCli(['bogus']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr, /Unknown command: bogus/);
|
||||
}],
|
||||
['fails on unknown help subcommands', () => {
|
||||
const result = runCli(['help', 'bogus']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr, /Unknown command: bogus/);
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests for scripts/gemini-adapt-agents.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'gemini-adapt-agents.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: options.cwd,
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-gemini-adapt-'));
|
||||
}
|
||||
|
||||
function cleanupTempDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeAgent(dirPath, name, body) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, name), body);
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing gemini-adapt-agents.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with an explicit help flag', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Adapt ECC agent frontmatter for Gemini CLI'));
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('adapts Claude Code tool names and strips unsupported color metadata', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.gemini', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'gan-planner.md',
|
||||
[
|
||||
'---',
|
||||
'name: gan-planner',
|
||||
'description: Planner agent',
|
||||
'tools: [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__resolve-library-id]',
|
||||
'model: opus',
|
||||
'color: purple',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Updated 1 agent file(s)'));
|
||||
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'gan-planner.md'), 'utf8');
|
||||
assert.ok(updated.includes('tools: ["read_file", "write_file", "replace", "run_shell_command", "grep_search", "glob", "google_web_search", "web_fetch", "mcp_context7_resolve_library_id"]'));
|
||||
assert.ok(!updated.includes('color: purple'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('defaults to the cwd .gemini/agents directory', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.gemini', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'architect.md',
|
||||
[
|
||||
'---',
|
||||
'name: architect',
|
||||
'description: Architect agent',
|
||||
'tools: ["Read", "Grep", "Glob"]',
|
||||
'model: opus',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([], { cwd: tempDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'architect.md'), 'utf8');
|
||||
assert.ok(updated.includes('tools: ["read_file", "grep_search", "glob"]'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Tests for scripts/github-coordination.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { createStateStore } = require('../../scripts/lib/state-store');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'github-coordination.js');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeGhShim(rootDir, responses) {
|
||||
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||
const logPath = path.join(rootDir, 'gh-calls.jsonl');
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
`
|
||||
const fs = require('fs');
|
||||
const responses = ${JSON.stringify(responses)};
|
||||
const args = process.argv.slice(2);
|
||||
const key = args.join(' ');
|
||||
const logPath = process.env.ECC_GH_SHIM_LOG;
|
||||
if (logPath) {
|
||||
fs.appendFileSync(logPath, JSON.stringify({ args }, null, 0) + '\\n');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
process.stdout.write(JSON.stringify(responses[key]));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === 'issue' && (args[1] === 'edit' || args[1] === 'comment')) {
|
||||
process.stdout.write('{}');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('Unexpected gh args: ' + key);
|
||||
process.exit(3);
|
||||
`
|
||||
);
|
||||
return { shimPath, logPath };
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(stdout) {
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
process.stdout.write(` PASS ${name}\n`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
process.stdout.write(` FAIL ${name}\n`);
|
||||
process.stdout.write(` Error: ${error.message}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readStore(dbPath) {
|
||||
const store = await createStateStore({ dbPath });
|
||||
try {
|
||||
return store.listWorkItems({ limit: 20 });
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
process.stdout.write('\n=== Testing github-coordination.js ===\n\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
await test('claims an epic issue, updates GitHub state, and caches a work item', async () => {
|
||||
const rootDir = createTempDir('github-coordination-claim-');
|
||||
const dbPath = path.join(rootDir, 'state.db');
|
||||
|
||||
try {
|
||||
const epicBody = ['# Ship GitHub-native coordination', '', 'We want deterministic epic state.', '', '## Tasks', '- [ ] Claim the epic', '- [ ] Validate the epic'].join('\n');
|
||||
const issueView = {
|
||||
number: 12,
|
||||
title: 'Ship GitHub-native coordination',
|
||||
body: epicBody,
|
||||
url: 'https://github.com/affaan-m/ECC/issues/12',
|
||||
state: 'OPEN',
|
||||
labels: [{ name: 'epic' }],
|
||||
author: { login: 'maintainer' },
|
||||
updatedAt: '2026-06-01T12:00:00Z'
|
||||
};
|
||||
const shim = writeGhShim(rootDir, {
|
||||
'issue view 12 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': issueView
|
||||
});
|
||||
|
||||
const result = run(['claim', '12', '--repo', 'affaan-m/ECC', '--actor', 'codex', '--db', dbPath, '--json'], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
ECC_GH_SHIM: shim.shimPath,
|
||||
ECC_GH_SHIM_LOG: shim.logPath
|
||||
}
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.status, 'claimed');
|
||||
assert.strictEqual(payload.owner, 'codex');
|
||||
assert.strictEqual(payload.project.state, 'in-progress');
|
||||
|
||||
const logEntries = fs
|
||||
.readFileSync(shim.logPath, 'utf8')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map(line => JSON.parse(line));
|
||||
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit'));
|
||||
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment'));
|
||||
|
||||
const stored = await readStore(dbPath);
|
||||
const epicItem = stored.items.find(item => item.source === 'github-epic');
|
||||
assert.ok(epicItem, 'expected github epic work item');
|
||||
assert.strictEqual(epicItem.status, 'in-progress');
|
||||
assert.strictEqual(epicItem.metadata.coordination.status, 'claimed');
|
||||
assert.strictEqual(epicItem.metadata.coordination.owner, 'codex');
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('unblocks an epic when dependencies are closed', async () => {
|
||||
const rootDir = createTempDir('github-coordination-unblock-');
|
||||
const dbPath = path.join(rootDir, 'state.db');
|
||||
|
||||
try {
|
||||
const blockedBody = [
|
||||
'# Release readiness',
|
||||
'',
|
||||
'Dependencies: #2',
|
||||
'',
|
||||
'<!-- ecc-coordination:start -->',
|
||||
'```json',
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 'ecc.github.coordination.v1',
|
||||
kind: 'epic',
|
||||
status: 'blocked',
|
||||
owner: 'codex',
|
||||
branch: 'feat/release-readiness',
|
||||
validation: 'pending',
|
||||
review: 'requested',
|
||||
project: { state: 'blocked', fields: {} },
|
||||
dependencies: [2],
|
||||
tasks: [{ title: 'Check release checklist', done: false }],
|
||||
labels: ['epic', 'coordination:blocked'],
|
||||
lastAction: 'claim',
|
||||
lastActionAt: '2026-06-01T13:00:00Z',
|
||||
lastSyncAt: '2026-06-01T13:00:00Z',
|
||||
notes: null
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'```',
|
||||
'<!-- ecc-coordination:end -->'
|
||||
].join('\n');
|
||||
const openIssue = {
|
||||
number: 1,
|
||||
title: 'Release readiness',
|
||||
body: blockedBody,
|
||||
url: 'https://github.com/affaan-m/ECC/issues/1',
|
||||
state: 'OPEN',
|
||||
labels: [{ name: 'epic' }, { name: 'coordination:blocked' }],
|
||||
author: { login: 'codex' },
|
||||
updatedAt: '2026-06-01T13:00:00Z'
|
||||
};
|
||||
const closedDependency = {
|
||||
number: 2,
|
||||
title: 'Release prerequisite',
|
||||
body: '# Release prerequisite',
|
||||
url: 'https://github.com/affaan-m/ECC/issues/2',
|
||||
state: 'CLOSED',
|
||||
labels: [{ name: 'blocked-by-release' }],
|
||||
author: { login: 'maintainer' },
|
||||
updatedAt: '2026-06-01T10:00:00Z'
|
||||
};
|
||||
const shim = writeGhShim(rootDir, {
|
||||
'issue list --repo affaan-m/ECC --state all --limit 100 --json number,title,body,url,state,labels,author,updatedAt,assignees': [openIssue, closedDependency],
|
||||
'issue view 1 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': openIssue
|
||||
});
|
||||
|
||||
const result = run(['unblock', '--repo', 'affaan-m/ECC', '--db', dbPath, '--json'], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
ECC_GH_SHIM: shim.shimPath,
|
||||
ECC_GH_SHIM_LOG: shim.logPath
|
||||
}
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.count, 1);
|
||||
assert.strictEqual(payload.items[0].status, 'ready');
|
||||
|
||||
const logEntries = fs
|
||||
.readFileSync(shim.logPath, 'utf8')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map(line => JSON.parse(line));
|
||||
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit'));
|
||||
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment'));
|
||||
|
||||
const stored = await readStore(dbPath);
|
||||
const epicItem = stored.items.find(item => item.source === 'github-epic');
|
||||
assert.ok(epicItem, 'expected github epic work item');
|
||||
assert.strictEqual(epicItem.metadata.coordination.status, 'ready');
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Tests for scripts/harness-audit.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'harness-audit.js');
|
||||
const { parseArgs, findPluginInstall, compareVersionDesc } = require(SCRIPT);
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function buildEnv(options = {}) {
|
||||
const userProfile = options.userProfile || options.homeDir || process.env.USERPROFILE;
|
||||
const env = {
|
||||
...process.env,
|
||||
USERPROFILE: userProfile,
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(options, 'homeDir')) {
|
||||
env.HOME = options.homeDir;
|
||||
} else {
|
||||
env.HOME = process.env.HOME;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env: buildEnv(options),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env: buildEnv(options),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing harness-audit.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts supported forms and rejects invalid arguments', () => {
|
||||
const rootDir = createTempDir('harness-audit-args-root-');
|
||||
|
||||
try {
|
||||
assert.strictEqual(parseArgs(['node', 'script', '--help']).help, true);
|
||||
assert.strictEqual(parseArgs(['node', 'script', '-h']).help, true);
|
||||
|
||||
const spaced = parseArgs(['node', 'script', '--format', 'json', '--scope', 'skills', '--root', rootDir]);
|
||||
assert.strictEqual(spaced.format, 'json');
|
||||
assert.strictEqual(spaced.scope, 'skills');
|
||||
assert.strictEqual(spaced.root, path.resolve(rootDir));
|
||||
|
||||
const equals = parseArgs(['node', 'script', '--format=json', '--scope=hooks', `--root=${rootDir}`]);
|
||||
assert.strictEqual(equals.format, 'json');
|
||||
assert.strictEqual(equals.scope, 'hooks');
|
||||
assert.strictEqual(equals.root, path.resolve(rootDir));
|
||||
|
||||
assert.strictEqual(parseArgs(['node', 'script', 'commands']).scope, 'commands');
|
||||
assert.strictEqual(parseArgs(['node', 'script', '--scope']).scope, 'repo');
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format: xml/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--scope', 'bad-scope']), /Invalid scope: bad-scope/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument: --unknown/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cli help exits cleanly and invalid cli args exit with stderr', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.strictEqual(help.stderr, '');
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/harness-audit.js'));
|
||||
assert.ok(help.stdout.includes('Deterministic harness audit'));
|
||||
|
||||
const invalid = runProcess(['--format', 'xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.strictEqual(invalid.stdout, '');
|
||||
assert.ok(invalid.stderr.includes('Error: Invalid format: xml. Use text or json.'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('json output is deterministic between runs', () => {
|
||||
const first = run(['repo', '--format', 'json']);
|
||||
const second = run(['repo', '--format', 'json']);
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('report includes bounded scores and fixed categories', () => {
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json']));
|
||||
|
||||
assert.strictEqual(parsed.deterministic, true);
|
||||
assert.strictEqual(parsed.rubric_version, '2026-05-19');
|
||||
assert.strictEqual(parsed.target_mode, 'repo');
|
||||
assert.ok(parsed.overall_score >= 0);
|
||||
assert.ok(parsed.max_score > 0);
|
||||
assert.ok(parsed.overall_score <= parsed.max_score);
|
||||
|
||||
const categoryNames = Object.keys(parsed.categories);
|
||||
assert.ok(categoryNames.includes('Tool Coverage'));
|
||||
assert.ok(categoryNames.includes('Context Efficiency'));
|
||||
assert.ok(categoryNames.includes('Quality Gates'));
|
||||
assert.ok(categoryNames.includes('Memory Persistence'));
|
||||
assert.ok(categoryNames.includes('Eval Coverage'));
|
||||
assert.ok(categoryNames.includes('Security Guardrails'));
|
||||
assert.ok(categoryNames.includes('Cost Efficiency'));
|
||||
assert.ok(categoryNames.includes('GitHub Integration'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('report exposes applicable_categories and category_count', () => {
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json']));
|
||||
|
||||
assert.ok(Array.isArray(parsed.applicable_categories), 'applicable_categories must be an array');
|
||||
assert.ok(parsed.applicable_categories.length > 0);
|
||||
assert.strictEqual(parsed.category_count, parsed.applicable_categories.length);
|
||||
for (const name of parsed.applicable_categories) {
|
||||
assert.ok(parsed.categories[name].max > 0, `${name} must have max > 0 to be applicable`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('GitHub Integration category scores against a fully-wired consumer fixture', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-gh-');
|
||||
const projectRoot = createTempDir('harness-audit-project-gh-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'ISSUE_TEMPLATE'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'PULL_REQUEST_TEMPLATE.md'), '# PR\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'ISSUE_TEMPLATE', 'bug.md'), '# Bug\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'CODEOWNERS'), '* @owner\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'dependabot.yml'), 'version: 2\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ name: 'gh-test' }));
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
const github = parsed.categories['GitHub Integration'];
|
||||
|
||||
assert.ok(github, 'GitHub Integration category must exist');
|
||||
assert.strictEqual(github.score, 10, `GitHub Integration should score 10/10, got ${github.score}`);
|
||||
assert.strictEqual(github.earned, github.max);
|
||||
assert.ok(parsed.applicable_categories.includes('GitHub Integration'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('provider categories are omitted unless a marker is present', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-no-provider-');
|
||||
const projectRoot = createTempDir('harness-audit-project-no-provider-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ name: 'p' }));
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
|
||||
assert.ok(!parsed.applicable_categories.includes('Vercel Integration'));
|
||||
const vercel = parsed.categories['Vercel Integration'];
|
||||
assert.ok(!vercel || vercel.max === 0, 'Vercel Integration should not contribute when no marker');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Vercel Integration category scores when vercel.json present', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-vercel-');
|
||||
const projectRoot = createTempDir('harness-audit-project-vercel-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'vercel.json'), '{}\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.env.example'), 'VERCEL_TOKEN=\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'deploy.yml'), 'uses: amondnet/vercel-action@v25\n');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'p', scripts: { build: 'next build', deploy: 'vercel deploy' } })
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
const vercel = parsed.categories['Vercel Integration'];
|
||||
|
||||
assert.ok(vercel, 'Vercel Integration category must exist when vercel.json present');
|
||||
assert.ok(vercel.max > 0);
|
||||
assert.ok(parsed.applicable_categories.includes('Vercel Integration'));
|
||||
assert.strictEqual(vercel.score, 10, `Vercel should score 10/10 with full wiring, got ${vercel.score}`);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detector map: Netlify, Cloudflare, Fly each trigger their category', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-multi-');
|
||||
|
||||
function probe(markerFile, markerContents, expectedCategory) {
|
||||
const root = createTempDir('harness-audit-project-multi-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'p' }));
|
||||
fs.writeFileSync(path.join(root, markerFile), markerContents);
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: root, homeDir }));
|
||||
assert.ok(
|
||||
parsed.applicable_categories.includes(expectedCategory),
|
||||
`${markerFile} should activate ${expectedCategory}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
|
||||
probe('netlify.toml', '[build]\n', 'Netlify Integration');
|
||||
probe('wrangler.toml', 'name = "p"\n', 'Cloudflare Integration');
|
||||
probe('fly.toml', 'app = "p"\n', 'Fly Integration');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('max_score reflects only applicable categories', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-max-');
|
||||
const noVercel = createTempDir('harness-audit-project-max-novercel-');
|
||||
const withVercel = createTempDir('harness-audit-project-max-vercel-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join(noVercel, 'package.json'), JSON.stringify({ name: 'p' }));
|
||||
fs.writeFileSync(path.join(withVercel, 'package.json'), JSON.stringify({ name: 'p' }));
|
||||
fs.writeFileSync(path.join(withVercel, 'vercel.json'), '{}\n');
|
||||
|
||||
const noVercelParsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: noVercel, homeDir }));
|
||||
const withVercelParsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: withVercel, homeDir }));
|
||||
|
||||
assert.ok(
|
||||
withVercelParsed.max_score > noVercelParsed.max_score,
|
||||
`with-vercel max_score (${withVercelParsed.max_score}) should exceed no-vercel (${noVercelParsed.max_score})`
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(noVercel);
|
||||
cleanup(withVercel);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('non-git directory does not crash the script', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-bare-');
|
||||
const bare = createTempDir('harness-audit-project-bare-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(path.join(bare, 'package.json'), JSON.stringify({ name: 'p' }));
|
||||
|
||||
const output = run(['repo', '--format', 'json'], { cwd: bare, homeDir });
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(parsed.overall_score >= 0);
|
||||
assert.ok(parsed.max_score > 0);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(bare);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('scope filtering changes max score and check list', () => {
|
||||
const full = JSON.parse(run(['repo', '--format', 'json']));
|
||||
const scoped = JSON.parse(run(['hooks', '--format', 'json']));
|
||||
|
||||
assert.strictEqual(scoped.scope, 'hooks');
|
||||
assert.ok(scoped.max_score < full.max_score);
|
||||
assert.ok(scoped.checks.length < full.checks.length);
|
||||
assert.ok(scoped.checks.every(check => check.path.includes('hooks') || check.path.includes('scripts/hooks')));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('text format includes summary header', () => {
|
||||
const output = run(['repo']);
|
||||
assert.ok(output.includes('Harness Audit (repo, repo):'));
|
||||
assert.ok(output.includes('Top 3 Actions:') || output.includes('Checks:'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects repo mode from structural markers when package name differs', () => {
|
||||
const projectRoot = createTempDir('harness-audit-structural-repo-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(projectRoot, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude-plugin'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'agents'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'skills'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'scripts', 'harness-audit.js'), '#!/usr/bin/env node\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'ecc' }, null, 2));
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'forked-harness', scripts: { test: 'node scripts/validate-commands.js && node tests/run-all.js' } }, null, 2)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`]));
|
||||
assert.strictEqual(parsed.target_mode, 'repo');
|
||||
assert.strictEqual(parsed.root_dir, path.resolve(projectRoot));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('audits consumer projects from cwd instead of the ECC repo root', () => {
|
||||
const homeDir = createTempDir('harness-audit-home-');
|
||||
const projectRoot = createTempDir('harness-audit-project-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'ecc', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
|
||||
assert.strictEqual(parsed.target_mode, 'consumer');
|
||||
assert.strictEqual(parsed.root_dir, fs.realpathSync(projectRoot));
|
||||
assert.ok(parsed.overall_score > 0, 'Consumer project should receive non-zero score when harness signals exist');
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
|
||||
assert.ok(parsed.checks.every(check => !check.path.startsWith('agents/') && !check.path.startsWith('skills/')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('scores empty consumer projects without plugin or harness signals as failing checks', () => {
|
||||
const homeDir = createTempDir('harness-audit-empty-home-');
|
||||
const projectRoot = createTempDir('harness-audit-empty-project-');
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
|
||||
assert.strictEqual(parsed.target_mode, 'consumer');
|
||||
assert.strictEqual(parsed.overall_score, 0);
|
||||
assert.ok(parsed.max_score > 0);
|
||||
assert.strictEqual(parsed.top_actions.length, 3);
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && !check.pass));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-project-overrides' && !check.pass));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-secret-hygiene' && !check.pass));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints no top actions when consumer checks all pass', () => {
|
||||
const homeDir = createTempDir('harness-audit-passing-home-');
|
||||
const projectRoot = createTempDir('harness-audit-passing-project-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude', 'plugins', 'ecc@ecc'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.claude', 'plugins', 'ecc@ecc', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc' }, null, 2)
|
||||
);
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows', 'nested'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'ISSUE_TEMPLATE'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'docs', 'adr'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'evals'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude', 'hooks.json'), JSON.stringify({ hooks: [] }, null, 2));
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.local.json'), JSON.stringify({ local: true }, null, 2));
|
||||
fs.writeFileSync(path.join(projectRoot, 'CLAUDE.md'), '# Consumer instructions\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'src', 'app.spec.ts'), 'test placeholder\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'nested', 'ci.yaml'), 'name: ci\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'PULL_REQUEST_TEMPLATE.md'), '# PR\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'ISSUE_TEMPLATE', 'bug.md'), '# Bug\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'CODEOWNERS'), '* @owner\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'docs', 'adr', '001.md'), '# Record\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'evals', 'smoke.json'), '{}\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'dependabot.yml'), 'version: 2\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env.local\n');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'passing-consumer', scripts: {} }, null, 2)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
assert.strictEqual(parsed.target_mode, 'consumer');
|
||||
assert.strictEqual(parsed.overall_score, parsed.max_score);
|
||||
|
||||
const text = run(['repo'], { cwd: projectRoot, homeDir });
|
||||
assert.ok(text.includes(`Harness Audit (repo, consumer): ${parsed.max_score}/${parsed.max_score}`));
|
||||
assert.ok(text.includes('Checks: 16 total, 0 failing'));
|
||||
assert.ok(!text.includes('Top 3 Actions:'));
|
||||
|
||||
const scopedText = run(['agents'], { cwd: projectRoot, homeDir });
|
||||
assert.ok(scopedText.includes('Harness Audit (agents, consumer):'));
|
||||
assert.ok(scopedText.includes('Checks: 1 total, 0 failing'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects marketplace-installed Claude plugins under home marketplaces/', () => {
|
||||
const homeDir = createTempDir('harness-audit-marketplace-home-');
|
||||
const projectRoot = createTempDir('harness-audit-marketplace-project-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'everything-claude-code' }, null, 2)
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects marketplace-installed Claude plugins under project marketplaces/', () => {
|
||||
const homeDir = createTempDir('harness-audit-marketplace-home-');
|
||||
const projectRoot = createTempDir('harness-audit-marketplace-project-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'everything-claude-code' }, null, 2)
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects marketplace-installed Claude plugins from USERPROFILE fallback on Windows-style setups', () => {
|
||||
const homeDir = createTempDir('harness-audit-marketplace-home-');
|
||||
const projectRoot = createTempDir('harness-audit-marketplace-project-');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'everything-claude-code' }, null, 2)
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
|
||||
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
|
||||
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir: '',
|
||||
userProfile: homeDir,
|
||||
}));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects Claude plugin installs from installed_plugins.json', () => {
|
||||
const homeDir = createTempDir('harness-audit-manifest-home-');
|
||||
const projectRoot = createTempDir('harness-audit-manifest-project-');
|
||||
const pluginsDir = path.join(homeDir, '.claude', 'plugins');
|
||||
const installRoot = path.join(pluginsDir, 'cache', 'everything-claude-code', 'ecc', '2.0.0');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(installRoot, '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(installRoot, '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc', version: '2.0.0' }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify({
|
||||
plugins: {
|
||||
'ecc@everything-claude-code': [
|
||||
{ installPath: path.join('cache', 'everything-claude-code', 'ecc', '2.0.0') },
|
||||
],
|
||||
},
|
||||
}, null, 2)
|
||||
);
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
try {
|
||||
const found = findPluginInstall(projectRoot);
|
||||
assert.ok(found);
|
||||
assert.ok(found.includes(`${path.sep}cache${path.sep}everything-claude-code${path.sep}ecc${path.sep}2.0.0${path.sep}`));
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects newest Claude plugin install from cache marketplace layout', () => {
|
||||
const homeDir = createTempDir('harness-audit-cache-home-');
|
||||
const projectRoot = createTempDir('harness-audit-cache-project-');
|
||||
const pluginRoot = path.join(homeDir, '.claude', 'plugins', 'cache', 'everything-claude-code', 'ecc');
|
||||
|
||||
try {
|
||||
for (const version of ['1.8.0', '1.10.0']) {
|
||||
fs.mkdirSync(path.join(pluginRoot, version, '.claude-plugin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, version, '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({ name: 'ecc', version }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
try {
|
||||
const found = findPluginInstall(projectRoot);
|
||||
assert.ok(found);
|
||||
assert.ok(found.includes(`${path.sep}1.10.0${path.sep}`), `expected newest version, got ${found}`);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('compareVersionDesc orders numeric version components', () => {
|
||||
const versions = ['1.8.0', '1.10.0', '1.9.0', '2.0.0'].sort(compareVersionDesc);
|
||||
assert.deepStrictEqual(versions, ['2.0.0', '1.10.0', '1.9.0', '1.8.0']);
|
||||
assert.doesNotThrow(() => compareVersionDesc('1.0.0-rc.1', '1.0.0'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,962 @@
|
||||
/**
|
||||
* Tests for scripts/install-apply.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const DEFAULT_INSTALL_APPLY_TIMEOUT_MS = process.platform === 'win32' ? 30000 : 10000;
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const homeDir = options.homeDir || process.env.HOME;
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
...(options.env || {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: options.timeout || DEFAULT_INSTALL_APPLY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || error.message || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install-apply.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with --help', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
assert.ok(result.stdout.includes('--dry-run'));
|
||||
assert.ok(result.stdout.includes('--profile <name>'));
|
||||
assert.ok(result.stdout.includes('--modules <id,id,...>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects mixing legacy languages with manifest profile flags', () => {
|
||||
const result = run(['--profile', 'core', 'typescript']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('cannot be combined'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Claude rules and writes install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'typescript', 'testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'coding-standards', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
|
||||
const statePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json');
|
||||
const state = readJson(statePath);
|
||||
assert.strictEqual(state.target.id, 'claude-home');
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.deepStrictEqual(state.request.modules, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('rules-core'));
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath === path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')
|
||||
)),
|
||||
'Should record common rule file operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rewrites namespaced skill links to the ecc/ rules path (#2340)', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const skillPath = path.join(claudeRoot, 'skills', 'ecc', 'react-patterns', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'react-patterns SKILL.md should be installed');
|
||||
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('../../../rules/ecc/react/'),
|
||||
'source-relative rules link should be rewritten for the ecc/ namespace'
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('](../../rules/'),
|
||||
'no un-namespaced ](../../rules/ links should remain'
|
||||
);
|
||||
|
||||
// The rewritten link must resolve to a file that actually exists on disk.
|
||||
const linkTarget = path.join(
|
||||
path.dirname(skillPath),
|
||||
'../../../rules/ecc/react/hooks.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(linkTarget), 'rewritten link target should exist');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Cursor configs and writes install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'README.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'ecc-architect.md')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'mcp.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md')));
|
||||
|
||||
const hooksConfig = readJson(path.join(projectDir, '.cursor', 'hooks.json'));
|
||||
const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json'));
|
||||
assert.strictEqual(hooksConfig.version, 1);
|
||||
assert.ok(hooksConfig.hooks.sessionStart, 'Should keep Cursor sessionStart hooks');
|
||||
assert.ok(mcpConfig.mcpServers['chrome-devtools'], 'Should install shared MCP servers into Cursor');
|
||||
|
||||
const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json');
|
||||
const state = readJson(statePath);
|
||||
const normalizedProjectDir = fs.realpathSync(projectDir);
|
||||
assert.strictEqual(state.target.id, 'cursor-project');
|
||||
assert.strictEqual(state.target.root, path.join(normalizedProjectDir, '.cursor'));
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath === path.join(normalizedProjectDir, '.cursor', 'commands', 'plan.md')
|
||||
)),
|
||||
'Should record manifest command file copy operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Cursor MCP config by merging bundled servers into an existing mcp.json', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const cursorRoot = path.join(projectDir, '.cursor');
|
||||
fs.mkdirSync(cursorRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(cursorRoot, 'mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
custom: {
|
||||
command: 'node',
|
||||
args: ['custom-mcp.js'],
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json'));
|
||||
assert.ok(mcpConfig.mcpServers.custom, 'Should preserve existing custom Cursor MCP servers');
|
||||
assert.ok(mcpConfig.mcpServers['chrome-devtools'], 'Should merge the bundled chrome-devtools MCP server');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Antigravity configs and writes install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'antigravity', 'typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'typescript-testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md')));
|
||||
|
||||
const statePath = path.join(projectDir, '.agent', 'ecc-install-state.json');
|
||||
const state = readJson(statePath);
|
||||
assert.strictEqual(state.target.id, 'antigravity-project');
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.deepStrictEqual(state.resolution.selectedModules, ['rules-core', 'agents-core', 'commands-core']);
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath.endsWith(path.join('.agent', 'workflows', 'plan.md'))
|
||||
)),
|
||||
'Should record manifest command file copy operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs JoyCode profile through managed install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'joycode', '--profile', 'minimal'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.joycode', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.joycode', 'rules', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.joycode', 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.joycode', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.joycode', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.joycode', 'mcp-configs', 'mcp-servers.json')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.joycode', 'hooks')));
|
||||
|
||||
const statePath = path.join(projectDir, '.joycode', 'ecc-install-state.json');
|
||||
const state = readJson(statePath);
|
||||
assert.strictEqual(state.target.id, 'joycode-project');
|
||||
assert.deepStrictEqual(state.request.modules, []);
|
||||
assert.strictEqual(state.request.profile, 'minimal');
|
||||
assert.ok(state.resolution.selectedModules.includes('workflow-quality'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath.endsWith(path.join('.joycode', 'skills', 'tdd-workflow', 'SKILL.md'))
|
||||
)),
|
||||
'Should record JoyCode skill file operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Qwen profile through managed home install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'qwen', '--profile', 'minimal'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.qwen', 'QWEN.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.qwen', 'rules', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.qwen', 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.qwen', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.qwen', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.qwen', 'mcp-configs', 'mcp-servers.json')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.qwen', 'hooks')));
|
||||
|
||||
const statePath = path.join(homeDir, '.qwen', 'ecc-install-state.json');
|
||||
const state = readJson(statePath);
|
||||
assert.strictEqual(state.target.id, 'qwen-home');
|
||||
assert.deepStrictEqual(state.request.modules, []);
|
||||
assert.strictEqual(state.request.profile, 'minimal');
|
||||
assert.ok(state.resolution.selectedModules.includes('workflow-quality'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath.endsWith(path.join('.qwen', 'skills', 'tdd-workflow', 'SKILL.md'))
|
||||
)),
|
||||
'Should record Qwen skill file operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports dry-run without mutating the target project', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(result.stdout.includes('Mode: legacy-compat'));
|
||||
assert.ok(result.stdout.includes('Legacy languages: typescript'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'ecc-install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports manifest profile dry-runs through the installer', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'core', '--dry-run'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: core'));
|
||||
assert.ok(result.stdout.includes('Included components: (none)'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, hooks-runtime, platform-configs, workflow-quality'));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('full profile dry-runs include delivery-gate in the install plan', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'full', '--dry-run', '--json'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.ok(parsed.plan.selectedModuleIds.includes('workflow-quality'));
|
||||
assert.ok(
|
||||
parsed.plan.operations.some(operation => (
|
||||
String(operation.sourceRelativePath || '').replace(/\\/g, '/').startsWith('skills/delivery-gate/')
|
||||
)),
|
||||
'Full profile dry-run should include the delivery-gate skill'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports minimal profile dry-runs without hooks through the installer', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'minimal', '--dry-run'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: minimal'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, platform-configs, workflow-quality'));
|
||||
assert.ok(!result.stdout.includes('hooks-runtime'));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs manifest profiles and writes non-legacy install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'session-manager.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'core');
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('platform-configs'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath === path.join(claudeRoot, 'commands', 'plan.md')
|
||||
)),
|
||||
'Should record manifest-driven command file copy'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves existing top-level Claude rules and skills during managed install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const userRulePath = path.join(claudeRoot, 'rules', 'common', 'coding-style.md');
|
||||
const userSkillPath = path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md');
|
||||
fs.mkdirSync(path.dirname(userRulePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
|
||||
fs.writeFileSync(userRulePath, '# User custom rule\n');
|
||||
fs.writeFileSync(userSkillPath, '# User custom skill\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.strictEqual(fs.readFileSync(userRulePath, 'utf8'), '# User custom rule\n');
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs antigravity manifest profiles while skipping only unsupported modules', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'antigravity', '--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(projectDir, '.agent', 'ecc-install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'core');
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.deepStrictEqual(
|
||||
state.resolution.selectedModules,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
);
|
||||
assert.ok(state.resolution.skippedModules.includes('hooks-runtime'));
|
||||
assert.ok(!state.resolution.skippedModules.includes('workflow-quality'));
|
||||
assert.ok(!state.resolution.skippedModules.includes('platform-configs'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs explicit modules for cursor using manifest operations', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--modules', 'platform-configs'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
|
||||
const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
assert.deepStrictEqual(state.request.modules, ['platform-configs']);
|
||||
assert.deepStrictEqual(state.request.includeComponents, []);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, []);
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.ok(state.resolution.selectedModules.includes('platform-configs'));
|
||||
assert.ok(
|
||||
!state.operations.some(operation => operation.destinationPath.endsWith('ecc-install-state.json')),
|
||||
'Manifest copy operations should not include generated install-state files'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown explicit manifest modules before resolution', () => {
|
||||
const result = run(['--modules', 'ghost-module']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs claude hooks without generating settings.json', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'settings.json')), 'settings.json should not be created just to install managed hooks');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs claude hooks with the safe plugin bootstrap contract', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json'));
|
||||
|
||||
const installedBashDispatcherEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:dispatcher');
|
||||
assert.ok(installedBashDispatcherEntry, 'hooks/hooks.json should include the consolidated Bash dispatcher hook');
|
||||
assert.strictEqual(typeof installedBashDispatcherEntry.hooks[0].command, 'string', 'hooks/hooks.json should install string-form commands for Claude Code schema compatibility');
|
||||
assert.ok(
|
||||
installedBashDispatcherEntry.hooks[0].command.startsWith('node -e '),
|
||||
'hooks/hooks.json should use the inline node bootstrap contract'
|
||||
);
|
||||
assert.ok(
|
||||
installedBashDispatcherEntry.hooks[0].command.includes('plugin-hook-bootstrap.js'),
|
||||
'hooks/hooks.json should route plugin-managed hooks through the shared bootstrap'
|
||||
);
|
||||
assert.ok(
|
||||
installedBashDispatcherEntry.hooks[0].command.includes('CLAUDE_PLUGIN_ROOT'),
|
||||
'hooks/hooks.json should still consult CLAUDE_PLUGIN_ROOT for runtime resolution'
|
||||
);
|
||||
assert.ok(
|
||||
installedBashDispatcherEntry.hooks[0].command.includes('pre-bash-dispatcher.js'),
|
||||
'hooks/hooks.json should point the Bash preflight contract at the consolidated dispatcher'
|
||||
);
|
||||
assert.ok(
|
||||
!installedBashDispatcherEntry.hooks[0].command.includes('\\"'),
|
||||
'hooks/hooks.json should avoid escaped double quotes that break Windows Git Bash parsing'
|
||||
);
|
||||
assert.ok(
|
||||
!installedBashDispatcherEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
||||
'hooks/hooks.json should not retain raw CLAUDE_PLUGIN_ROOT shell placeholders after install'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves existing settings.json without mutating it during claude install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(claudeRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claudeRoot, 'settings.json'),
|
||||
JSON.stringify({
|
||||
effortLevel: 'high',
|
||||
env: { MY_VAR: '1' },
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }],
|
||||
UserPromptSubmit: [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
|
||||
},
|
||||
}, null, 2)
|
||||
);
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const settings = readJson(path.join(claudeRoot, 'settings.json'));
|
||||
assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved');
|
||||
assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved');
|
||||
assert.deepStrictEqual(
|
||||
settings.hooks.UserPromptSubmit,
|
||||
[{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
|
||||
'existing hooks should be left untouched'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
settings.hooks.PreToolUse,
|
||||
[{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }],
|
||||
'managed Claude hooks should not be injected into settings.json'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters copied mcp config files when ECC_DISABLED_MCPS is set', () => {
|
||||
const tempDir = createTempDir('install-apply-mcp-');
|
||||
const sourcePath = path.join(tempDir, '.mcp.json');
|
||||
const destinationPath = path.join(tempDir, 'installed', '.mcp.json');
|
||||
const installStatePath = path.join(tempDir, 'installed', 'ecc-install-state.json');
|
||||
const previousValue = process.env.ECC_DISABLED_MCPS;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
fs.writeFileSync(sourcePath, JSON.stringify({
|
||||
mcpServers: {
|
||||
github: { command: 'npx' },
|
||||
exa: { url: 'https://mcp.exa.ai/mcp' },
|
||||
memory: { command: 'npx' },
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
process.env.ECC_DISABLED_MCPS = 'github,memory';
|
||||
|
||||
applyInstallPlan({
|
||||
targetRoot: path.join(tempDir, 'installed'),
|
||||
installStatePath,
|
||||
statePreview: {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: new Date().toISOString(),
|
||||
target: {
|
||||
id: 'test-install',
|
||||
kind: 'project',
|
||||
root: path.join(tempDir, 'installed'),
|
||||
installStatePath,
|
||||
},
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['test-mcp'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['test-mcp'],
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: null,
|
||||
repoCommit: null,
|
||||
manifestVersion: 1,
|
||||
},
|
||||
operations: [],
|
||||
},
|
||||
operations: [{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'test-mcp',
|
||||
sourcePath,
|
||||
sourceRelativePath: '.mcp.json',
|
||||
destinationPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
}],
|
||||
});
|
||||
|
||||
const installed = readJson(destinationPath);
|
||||
assert.deepStrictEqual(Object.keys(installed.mcpServers), ['exa']);
|
||||
} finally {
|
||||
if (previousValue === undefined) {
|
||||
delete process.env.ECC_DISABLED_MCPS;
|
||||
} else {
|
||||
process.env.ECC_DISABLED_MCPS = previousValue;
|
||||
}
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reinstall does not create settings.json when only managed hooks are installed', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
|
||||
|
||||
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reinstall leaves pre-existing hook-based settings.json untouched', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(claudeRoot, { recursive: true });
|
||||
const settingsPath = path.join(claudeRoot, 'settings.json');
|
||||
const legacySettings = {
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo legacy-pretool' }] }],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(legacySettings, null, 2));
|
||||
|
||||
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
|
||||
|
||||
const afterSecondInstall = readJson(settingsPath);
|
||||
assert.deepStrictEqual(afterSecondInstall, legacySettings);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores malformed existing settings.json during claude install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(claudeRoot, { recursive: true });
|
||||
const settingsPath = path.join(claudeRoot, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, '{ invalid json\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores non-object existing settings.json during claude install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(claudeRoot, { recursive: true });
|
||||
const settingsPath = path.join(claudeRoot, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, '[]\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when source hooks.json root is not an object before copying files', () => {
|
||||
const tempDir = createTempDir('install-apply-invalid-hooks-');
|
||||
const targetRoot = path.join(tempDir, '.claude');
|
||||
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const sourceHooksPath = path.join(tempDir, 'hooks.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceHooksPath, '[]\n');
|
||||
|
||||
assert.throws(() => {
|
||||
applyInstallPlan({
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
statePreview: {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: new Date().toISOString(),
|
||||
target: {
|
||||
id: 'claude-home',
|
||||
kind: 'home',
|
||||
root: targetRoot,
|
||||
installStatePath,
|
||||
},
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['hooks-runtime'],
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: null,
|
||||
repoCommit: null,
|
||||
manifestVersion: 1,
|
||||
},
|
||||
operations: [],
|
||||
},
|
||||
adapter: { target: 'claude' },
|
||||
operations: [{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'hooks-runtime',
|
||||
sourcePath: sourceHooksPath,
|
||||
sourceRelativePath: 'hooks/hooks.json',
|
||||
destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'),
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
}],
|
||||
});
|
||||
}, /Invalid hooks config at .*expected a JSON object/);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(targetRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
|
||||
assert.ok(!fs.existsSync(installStatePath), 'install state should not be written when source hooks are invalid');
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs from ecc-install.json and persists component selections', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'claude',
|
||||
profile: 'developer',
|
||||
include: ['capability:security'],
|
||||
exclude: ['capability:orchestration'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--config', configPath], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['capability:security']);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']);
|
||||
assert.ok(state.resolution.selectedModules.includes('security'));
|
||||
assert.ok(!state.resolution.selectedModules.includes('orchestration'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('auto-detects ecc-install.json from the project root', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'claude',
|
||||
profile: 'developer',
|
||||
include: ['capability:security'],
|
||||
exclude: ['capability:orchestration'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run([], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['capability:security']);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']);
|
||||
assert.ok(state.resolution.selectedModules.includes('security'));
|
||||
assert.ok(!state.resolution.selectedModules.includes('orchestration'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves legacy language installs when a project config is present', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'claude',
|
||||
profile: 'developer',
|
||||
include: ['capability:security'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
assert.deepStrictEqual(state.request.includeComponents, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
assert.ok(!state.resolution.selectedModules.includes('security'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Tests for scripts/install-plan.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-plan.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: options.cwd,
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install-plan.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with no arguments', () => {
|
||||
const result = run();
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Inspect ECC selective-install manifests'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install profiles', () => {
|
||||
const result = run(['--list-profiles']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install profiles'));
|
||||
assert.ok(result.stdout.includes('core'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install modules', () => {
|
||||
const result = run(['--list-modules']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install modules'));
|
||||
assert.ok(result.stdout.includes('rules-core'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install components', () => {
|
||||
const result = run(['--list-components', '--family', 'language']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install components'));
|
||||
assert.ok(result.stdout.includes('lang:typescript'));
|
||||
assert.ok(!result.stdout.includes('capability:security'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints a filtered install plan for a profile and target', () => {
|
||||
const result = run([
|
||||
'--profile', 'developer',
|
||||
'--with', 'capability:security',
|
||||
'--without', 'capability:orchestration',
|
||||
'--target', 'cursor'
|
||||
]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install plan'));
|
||||
assert.ok(result.stdout.includes('Included components: capability:security'));
|
||||
assert.ok(result.stdout.includes('Excluded components: capability:orchestration'));
|
||||
assert.ok(result.stdout.includes('Adapter: cursor-project'));
|
||||
assert.ok(result.stdout.includes('Target root:'));
|
||||
assert.ok(result.stdout.includes('Install-state:'));
|
||||
assert.ok(result.stdout.includes('Operation plan'));
|
||||
assert.ok(result.stdout.includes('Excluded by selection'));
|
||||
assert.ok(result.stdout.includes('security'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('emits JSON for explicit module resolution', () => {
|
||||
const result = run([
|
||||
'--modules', 'security',
|
||||
'--with', 'capability:research',
|
||||
'--target', 'cursor',
|
||||
'--json'
|
||||
]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.ok(parsed.selectedModuleIds.includes('security'));
|
||||
assert.ok(parsed.selectedModuleIds.includes('research-apis'));
|
||||
assert.ok(parsed.selectedModuleIds.includes('workflow-quality'));
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['capability:research']);
|
||||
assert.strictEqual(parsed.targetAdapterId, 'cursor-project');
|
||||
assert.ok(Array.isArray(parsed.operations));
|
||||
assert.ok(parsed.operations.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('emits JSON for --skills without pulling parent module', () => {
|
||||
const result = run([
|
||||
'--skills', 'continuous-learning-v2',
|
||||
'--target', 'claude',
|
||||
'--json',
|
||||
]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['skill:continuous-learning-v2']);
|
||||
assert.deepStrictEqual(parsed.selectedModuleIds, ['skill-continuous-learning-v2']);
|
||||
assert.ok(parsed.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'));
|
||||
assert.ok(!parsed.operations.some(operation => operation.sourceRelativePath === 'skills/tdd-workflow'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loads planning intent from ecc-install.json', () => {
|
||||
const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-config');
|
||||
const configPath = path.join(configDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
require('fs').mkdirSync(configDir, { recursive: true });
|
||||
require('fs').writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'cursor',
|
||||
profile: 'core',
|
||||
include: ['capability:security'],
|
||||
exclude: ['capability:orchestration'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--config', configPath, '--json']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.target, 'cursor');
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['capability:security']);
|
||||
assert.deepStrictEqual(parsed.excludedComponentIds, ['capability:orchestration']);
|
||||
assert.ok(parsed.selectedModuleIds.includes('security'));
|
||||
assert.ok(!parsed.selectedModuleIds.includes('orchestration'));
|
||||
} finally {
|
||||
require('fs').rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('auto-detects planning intent from project ecc-install.json', () => {
|
||||
const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-autodetect');
|
||||
const configPath = path.join(configDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
require('fs').mkdirSync(configDir, { recursive: true });
|
||||
require('fs').writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'cursor',
|
||||
profile: 'core',
|
||||
include: ['capability:security'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--json'], { cwd: configDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.target, 'cursor');
|
||||
assert.strictEqual(parsed.profileId, 'core');
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['capability:security']);
|
||||
assert.ok(parsed.selectedModuleIds.includes('security'));
|
||||
} finally {
|
||||
require('fs').rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown arguments', () => {
|
||||
const result = run(['--unknown-flag']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown argument'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on invalid install target', () => {
|
||||
const result = run(['--profile', 'core', '--target', 'not-a-target']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown install target'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests for install.ps1 wrapper delegation
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'install.ps1');
|
||||
const PACKAGE_JSON = path.join(__dirname, '..', '..', 'package.json');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function resolvePowerShellCommand() {
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['powershell.exe', 'pwsh.exe', 'pwsh']
|
||||
: ['pwsh'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync(candidate, ['-NoLogo', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (!result.error && result.status === 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(powerShellCommand, args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
USERPROFILE: options.homeDir || process.env.USERPROFILE,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install.ps1 ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const powerShellCommand = resolvePowerShellCommand();
|
||||
|
||||
if (test('publishes ecc-install through the Node installer runtime for cross-platform npm usage', () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8'));
|
||||
assert.strictEqual(packageJson.bin['ecc-install'], 'scripts/install-apply.js');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (!powerShellCommand) {
|
||||
console.log(' - skipped delegation test; PowerShell is not available in PATH');
|
||||
} else if (test('delegates to the Node installer and preserves dry-run output', () => {
|
||||
const homeDir = createTempDir('install-ps1-home-');
|
||||
const projectDir = createTempDir('install-ps1-project-');
|
||||
|
||||
try {
|
||||
const result = run(powerShellCommand, ['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (!powerShellCommand) {
|
||||
console.log(' - skipped help text test; PowerShell is not available in PATH');
|
||||
} else if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(powerShellCommand, ['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
'help text should describe the Claude target as a full ~/.claude install surface'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Regression coverage for install/uninstall clarity in README.md.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const README = path.join(__dirname, '..', '..', 'README.md');
|
||||
const RULES_README = path.join(__dirname, '..', '..', 'rules', 'README.md');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install README clarity ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const readme = fs.readFileSync(README, 'utf8');
|
||||
const rulesReadme = fs.readFileSync(RULES_README, 'utf8');
|
||||
|
||||
if (test('README marks one default path and warns against stacked installs', () => {
|
||||
assert.ok(
|
||||
readme.includes('### Pick one path only'),
|
||||
'README should surface a top-level install decision section'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('**Recommended default:** install the Claude Code plugin'),
|
||||
'README should name the recommended default install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('**Do not stack install methods.**'),
|
||||
'README should explicitly warn against stacking install methods'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('If you choose this path, stop there. Do not also run `/plugin install`.'),
|
||||
'README should tell manual-install users not to continue layering installs'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README documents reset and uninstall flow', () => {
|
||||
assert.ok(
|
||||
readme.includes('### Reset / Uninstall ECC'),
|
||||
'README should have a visible reset/uninstall section'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('node scripts/uninstall.js --dry-run'),
|
||||
'README should document dry-run uninstall'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('node scripts/ecc.js list-installed'),
|
||||
'README should document install-state inspection before reinstalling'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('node scripts/ecc.js doctor'),
|
||||
'README should document doctor before reinstalling'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('ECC only removes files recorded in its install-state.'),
|
||||
'README should explain uninstall safety boundaries'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README documents low-context no-hooks install path', () => {
|
||||
assert.ok(
|
||||
readme.includes('### Low-context / no-hooks path'),
|
||||
'README should surface a low-context no-hooks install option near Quick Start'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('./install.sh --profile minimal --target claude'),
|
||||
'README should document the shell minimal profile command'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('npx ecc-install --profile minimal --target claude'),
|
||||
'README should document the npx minimal profile command'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('--profile core --without baseline:hooks --target claude'),
|
||||
'README should document the hook opt-out path for the core profile'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('This profile intentionally excludes `hooks-runtime`.'),
|
||||
'README should state that the minimal profile excludes hooks'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README documents consult-based component discovery', () => {
|
||||
assert.ok(
|
||||
readme.includes('### Find the right components first'),
|
||||
'README should surface component discovery before install steps'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('npx ecc consult "security reviews" --target claude'),
|
||||
'README should document the packaged consult command'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('It returns matching components, related profiles, and preview/install commands.'),
|
||||
'README should explain what consult returns'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README documents Cursor agent namespace and loading caveat', () => {
|
||||
assert.ok(
|
||||
readme.includes('`.cursor/agents/ecc-*.md`'),
|
||||
'README should document the Cursor agent namespace'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('Cursor-native loading behavior can vary by Cursor build.'),
|
||||
'README should avoid overclaiming Cursor agent loading semantics'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('ECC does not install root `AGENTS.md` into `.cursor/`.'),
|
||||
'README should explain why root AGENTS.md is not copied into Cursor context'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README explains plugin-path cleanup and rules scoping', () => {
|
||||
assert.ok(
|
||||
readme.includes('remove the plugin from Claude Code'),
|
||||
'README should tell plugin users how to start cleanup'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('Start with `rules/common` plus one language or framework pack you actually use.'),
|
||||
'README should steer users away from copying every rules directory'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('~/.claude/rules/ecc/'),
|
||||
'README should steer plugin-path rules into an ECC-owned namespace'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rules README mirrors ECC namespaced install path', () => {
|
||||
assert.ok(
|
||||
rulesReadme.includes('mkdir -p ~/.claude/rules/ecc'),
|
||||
'rules README should create the ECC-owned user-level rules namespace'
|
||||
);
|
||||
assert.ok(
|
||||
rulesReadme.includes('cp -r rules/common ~/.claude/rules/ecc/'),
|
||||
'rules README should copy common rules under ~/.claude/rules/ecc/'
|
||||
);
|
||||
assert.ok(
|
||||
rulesReadme.includes('cp -r rules/typescript ~/.claude/rules/ecc/'),
|
||||
'rules README should copy language rules under ~/.claude/rules/ecc/'
|
||||
);
|
||||
assert.ok(
|
||||
rulesReadme.includes('mkdir -p .claude/rules/ecc'),
|
||||
'rules README should document the project-local ECC namespace'
|
||||
);
|
||||
assert.ok(
|
||||
!rulesReadme.includes('~/.claude/rules/typescript'),
|
||||
'rules README should not recommend flat user-level rule destinations'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Tests for install.sh wrapper delegation
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'install.sh');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('bash', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - skipped on Windows; install.ps1 covers the native wrapper path');
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (test('delegates to the Node installer and preserves dry-run output', () => {
|
||||
const homeDir = createTempDir('install-sh-home-');
|
||||
const projectDir = createTempDir('install-sh-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
'help text should describe the Claude target as a full ~/.claude install surface'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,336 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const cliPath = path.join(
|
||||
repoRoot,
|
||||
'skills',
|
||||
'continuous-learning-v2',
|
||||
'scripts',
|
||||
'instinct-cli.py'
|
||||
);
|
||||
|
||||
function detectPython3() {
|
||||
for (const bin of ['python3', 'python']) {
|
||||
const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
||||
if (r.status === 0 && /Python 3/.test(r.stdout + r.stderr)) return bin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PYTHON3 = detectPython3();
|
||||
if (!PYTHON3) {
|
||||
console.log('\n=== Testing instinct-cli.py projects maintenance ===\n');
|
||||
console.log(' - skipped: Python 3 not found in PATH');
|
||||
console.log('\nPassed: 0');
|
||||
console.log('Failed: 0');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-instinct-cli-projects-'));
|
||||
}
|
||||
|
||||
function cleanupDir(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function writeInstinct(filePath, id, confidence = 0.9) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
'---',
|
||||
`id: ${id}`,
|
||||
'trigger: "when repeated"',
|
||||
`confidence: ${confidence}`,
|
||||
'domain: workflow',
|
||||
'---',
|
||||
'',
|
||||
`Action for ${id}.`,
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function seedProject(root, id, options = {}) {
|
||||
const projectDir = path.join(root, 'projects', id);
|
||||
const personalDir = path.join(projectDir, 'instincts', 'personal');
|
||||
const inheritedDir = path.join(projectDir, 'instincts', 'inherited');
|
||||
fs.mkdirSync(personalDir, { recursive: true });
|
||||
fs.mkdirSync(inheritedDir, { recursive: true });
|
||||
|
||||
for (const instinct of options.personal || []) {
|
||||
writeInstinct(path.join(personalDir, `${instinct}.yaml`), instinct);
|
||||
}
|
||||
for (const instinct of options.inherited || []) {
|
||||
writeInstinct(path.join(inheritedDir, `${instinct}.yaml`), instinct);
|
||||
}
|
||||
if (options.observations) {
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'observations.jsonl'),
|
||||
options.observations.map(row => JSON.stringify(row)).join('\n') + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
function projectHash(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function runGit(cwd, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function runCli(root, args, options = {}) {
|
||||
return spawnSync(PYTHON3, [cliPath, ...args], {
|
||||
cwd: options.cwd || repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLV2_HOMUNCULUS_DIR: root,
|
||||
HOME: path.join(root, 'home'),
|
||||
USERPROFILE: path.join(root, 'home'),
|
||||
CLAUDE_PROJECT_DIR: '',
|
||||
...(options.env || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== Testing instinct-cli.py projects maintenance ===\n');
|
||||
|
||||
test('projects delete --dry-run preserves registry and project files', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const registryPath = path.join(root, 'projects.json');
|
||||
seedProject(root, 'alpha123', {
|
||||
personal: ['keep-me'],
|
||||
observations: [{ event: 'tool_complete' }],
|
||||
});
|
||||
writeJson(registryPath, {
|
||||
alpha123: { name: 'alpha', root: '/repo/alpha', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||
});
|
||||
|
||||
const result = runCli(root, ['projects', 'delete', 'alpha123', '--dry-run']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /would delete/i);
|
||||
assert.ok(fs.existsSync(path.join(root, 'projects', 'alpha123')));
|
||||
assert.ok(readJson(registryPath).alpha123);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('projects delete --force removes registry entry and project directory', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const registryPath = path.join(root, 'projects.json');
|
||||
seedProject(root, 'alpha123', { personal: ['delete-me'] });
|
||||
writeJson(registryPath, {
|
||||
alpha123: { name: 'alpha', root: '/repo/alpha', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||
});
|
||||
|
||||
const result = runCli(root, ['projects', 'delete', 'alpha123', '--force']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(!fs.existsSync(path.join(root, 'projects', 'alpha123')));
|
||||
assert.ok(!readJson(registryPath).alpha123);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('projects gc --force removes only zero-value project entries', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const registryPath = path.join(root, 'projects.json');
|
||||
seedProject(root, 'empty000');
|
||||
seedProject(root, 'active999', { personal: ['active'] });
|
||||
writeJson(registryPath, {
|
||||
empty000: { name: 'empty', root: '/tmp/empty', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||
active999: { name: 'active', root: '/repo/active', remote: '', last_seen: '2026-01-02T00:00:00Z' },
|
||||
});
|
||||
|
||||
const result = runCli(root, ['projects', 'gc', '--force']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const registry = readJson(registryPath);
|
||||
assert.ok(!registry.empty000);
|
||||
assert.ok(registry.active999);
|
||||
assert.ok(!fs.existsSync(path.join(root, 'projects', 'empty000')));
|
||||
assert.ok(fs.existsSync(path.join(root, 'projects', 'active999')));
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('projects merge deduplicates instincts, appends observations, and removes source', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const registryPath = path.join(root, 'projects.json');
|
||||
seedProject(root, 'from111', {
|
||||
personal: ['shared', 'from-only'],
|
||||
observations: [{ event: 'from-event' }],
|
||||
});
|
||||
seedProject(root, 'into222', {
|
||||
personal: ['shared', 'into-only'],
|
||||
observations: [{ event: 'into-event' }],
|
||||
});
|
||||
writeJson(registryPath, {
|
||||
from111: { name: 'from', root: '/repo/from', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||
into222: { name: 'into', root: '/repo/into', remote: '', last_seen: '2026-01-02T00:00:00Z' },
|
||||
});
|
||||
|
||||
const result = runCli(root, ['projects', 'merge', 'from111', 'into222', '--force']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(!fs.existsSync(path.join(root, 'projects', 'from111')));
|
||||
assert.ok(!readJson(registryPath).from111);
|
||||
assert.ok(readJson(registryPath).into222);
|
||||
|
||||
const intoPersonal = path.join(root, 'projects', 'into222', 'instincts', 'personal');
|
||||
assert.ok(fs.existsSync(path.join(intoPersonal, 'shared.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(intoPersonal, 'from-only.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(intoPersonal, 'into-only.yaml')));
|
||||
|
||||
const observations = fs.readFileSync(
|
||||
path.join(root, 'projects', 'into222', 'observations.jsonl'),
|
||||
'utf8'
|
||||
);
|
||||
assert.match(observations, /from-event/);
|
||||
assert.match(observations, /into-event/);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('status warns when legacy ~/.claude/homunculus contains files', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const legacyDir = path.join(root, 'home', '.claude', 'homunculus', 'instincts', 'personal');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'old-instinct.yaml'), '---\nid: old\n---\nOld instinct.\n');
|
||||
|
||||
const result = runCli(root, ['status']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /LEGACY DATA DETECTED/);
|
||||
assert.match(result.stdout, /legacy path/i);
|
||||
assert.match(result.stdout, /migration script/i);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('status does not warn when legacy dir is empty', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const legacyDir = path.join(root, 'home', '.claude', 'homunculus');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
|
||||
const result = runCli(root, ['status']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.doesNotMatch(result.stdout, /LEGACY DATA DETECTED/);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('status does not warn when no legacy dir exists', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const result = runCli(root, ['status']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.doesNotMatch(result.stdout, /LEGACY DATA DETECTED/);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('status does not warn when CLV2_HOMUNCULUS_DIR points at legacy path', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const legacyDir = path.join(root, 'home', '.claude', 'homunculus', 'instincts', 'personal');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'active.yaml'), '---\nid: active\n---\nActive.\n');
|
||||
|
||||
const result = runCli(root, ['status'], {
|
||||
env: { CLV2_HOMUNCULUS_DIR: path.join(root, 'home', '.claude', 'homunculus') },
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.doesNotMatch(result.stdout, /LEGACY DATA DETECTED/);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('status migrates legacy no-remote linked worktree project dirs to main worktree id', () => {
|
||||
const root = createTempDir();
|
||||
const repoParent = createTempDir();
|
||||
try {
|
||||
const mainWorktree = path.join(repoParent, 'main');
|
||||
const linkedWorktree = path.join(repoParent, 'linked');
|
||||
fs.mkdirSync(mainWorktree, { recursive: true });
|
||||
runGit(mainWorktree, ['init']);
|
||||
runGit(mainWorktree, ['config', 'user.email', 'ecc@example.test']);
|
||||
runGit(mainWorktree, ['config', 'user.name', 'ECC Test']);
|
||||
fs.writeFileSync(path.join(mainWorktree, 'README.md'), 'test\n');
|
||||
runGit(mainWorktree, ['add', 'README.md']);
|
||||
runGit(mainWorktree, ['commit', '-m', 'init']);
|
||||
runGit(mainWorktree, ['worktree', 'add', linkedWorktree]);
|
||||
|
||||
const mainRoot = runGit(mainWorktree, ['rev-parse', '--show-toplevel']);
|
||||
const linkedRoot = runGit(linkedWorktree, ['rev-parse', '--show-toplevel']);
|
||||
const oldLinkedId = projectHash(linkedRoot);
|
||||
const mainId = projectHash(mainRoot);
|
||||
seedProject(root, oldLinkedId, { personal: ['legacy-worktree'] });
|
||||
|
||||
const result = runCli(root, ['status'], { cwd: linkedRoot });
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(!fs.existsSync(path.join(root, 'projects', oldLinkedId)));
|
||||
assert.ok(fs.existsSync(path.join(root, 'projects', mainId)));
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(root, 'projects', mainId, 'instincts', 'personal', 'legacy-worktree.yaml'))
|
||||
);
|
||||
assert.match(result.stdout, new RegExp(`\\(${mainId}\\)`));
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
cleanupDir(repoParent);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for scripts/list-installed.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'list-installed.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing list-installed.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('reports when no install-state files are present', () => {
|
||||
const homeDir = createTempDir('list-installed-home-');
|
||||
const projectRoot = createTempDir('list-installed-project-');
|
||||
|
||||
try {
|
||||
const result = run([], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('No ECC install-state files found'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('emits JSON for discovered install-state records', () => {
|
||||
const homeDir = createTempDir('list-installed-home-');
|
||||
const projectRoot = createTempDir('list-installed-project-');
|
||||
|
||||
try {
|
||||
const statePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json');
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: path.join(projectRoot, '.cursor'),
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['rules-core', 'platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = run(['--json'], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.records.length, 1);
|
||||
assert.strictEqual(parsed.records[0].state.target.id, 'cursor-project');
|
||||
assert.strictEqual(parsed.records[0].state.request.profile, 'core');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* Tests for scripts/loop-status.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
|
||||
const {
|
||||
analyzeTranscript,
|
||||
buildStatus,
|
||||
getStatusExitCode,
|
||||
parseArgs,
|
||||
writeStatusSnapshots,
|
||||
} = require('../../scripts/loop-status');
|
||||
const NOW = '2026-04-30T10:00:00.000Z';
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const envOverrides = {
|
||||
...(options.env || {}),
|
||||
};
|
||||
|
||||
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
|
||||
envOverrides.USERPROFILE = envOverrides.HOME;
|
||||
}
|
||||
|
||||
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
|
||||
envOverrides.HOME = envOverrides.USERPROFILE;
|
||||
}
|
||||
|
||||
const result = spawnSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: result.status || (result.signal ? 1 : 0),
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function createTempHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-home-'));
|
||||
}
|
||||
|
||||
function writeTranscript(homeDir, projectSlug, fileName, entries) {
|
||||
const transcriptDir = path.join(homeDir, '.claude', 'projects', projectSlug);
|
||||
fs.mkdirSync(transcriptDir, { recursive: true });
|
||||
const transcriptPath = path.join(transcriptDir, fileName);
|
||||
fs.writeFileSync(
|
||||
transcriptPath,
|
||||
entries.map(entry => JSON.stringify(entry)).join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
return transcriptPath;
|
||||
}
|
||||
|
||||
function toolUse(timestamp, sessionId, id, name, input = {}) {
|
||||
return {
|
||||
timestamp,
|
||||
sessionId,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolResult(timestamp, sessionId, toolUseId, content = 'ok') {
|
||||
return {
|
||||
timestamp,
|
||||
sessionId,
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(timestamp, sessionId, text) {
|
||||
return {
|
||||
timestamp,
|
||||
sessionId,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parsePayload(stdout) {
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.error(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing loop-status.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('reports overdue ScheduleWakeup calls from Claude transcripts', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
const transcriptPath = writeTranscript(homeDir, '-Users-affoon-project-a', 'session-a.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-a', 'toolu_wake', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Iter 15: continue autonomous loop',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
|
||||
assert.strictEqual(payload.sessions.length, 1);
|
||||
assert.strictEqual(payload.sessions[0].sessionId, 'session-a');
|
||||
assert.strictEqual(payload.sessions[0].transcriptPath, transcriptPath);
|
||||
assert.strictEqual(payload.sessions[0].state, 'attention');
|
||||
assert.ok(payload.sessions[0].signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
|
||||
assert.strictEqual(payload.sessions[0].latestWake.dueAt, '2026-04-30T09:05:00.000Z');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('analyzeTranscript applies default thresholds when called directly', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
const transcriptPath = writeTranscript(homeDir, '-Users-affoon-project-direct', 'session-direct.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-direct', 'toolu_direct_wake', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Direct API default threshold check',
|
||||
}),
|
||||
]);
|
||||
|
||||
const session = analyzeTranscript(transcriptPath, { now: NOW });
|
||||
|
||||
assert.strictEqual(session.state, 'attention');
|
||||
assert.ok(session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports stale Bash tool_use entries without matching tool_result', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-b', 'session-b.jsonl', [
|
||||
toolUse('2026-04-30T09:10:00.000Z', 'session-b', 'toolu_bash', 'Bash', {
|
||||
command: 'pytest tests/integration/test_pipeline.py',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions[0].state, 'attention');
|
||||
assert.ok(payload.sessions[0].signals.some(signal => (
|
||||
signal.type === 'pending_bash_tool_result'
|
||||
&& signal.toolUseId === 'toolu_bash'
|
||||
&& signal.ageSeconds === 3000
|
||||
)));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not flag Bash tool_use entries that have a matching tool_result', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-c', 'session-c.jsonl', [
|
||||
toolUse('2026-04-30T09:40:00.000Z', 'session-c', 'toolu_bash_ok', 'Bash', {
|
||||
command: 'npm test',
|
||||
}),
|
||||
toolResult('2026-04-30T09:41:00.000Z', 'session-c', 'toolu_bash_ok', 'passed'),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions[0].state, 'ok');
|
||||
assert.deepStrictEqual(payload.sessions[0].signals, []);
|
||||
assert.deepStrictEqual(payload.sessions[0].pendingTools, []);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not flag ScheduleWakeup when later assistant progress exists', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-d', 'session-d.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-d', 'toolu_wake_ok', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
assistantMessage('2026-04-30T09:06:00.000Z', 'session-d', 'Wake fired; continuing.'),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions[0].state, 'ok');
|
||||
assert.ok(!payload.sessions[0].signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports inspecting one transcript path directly', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
const transcriptPath = writeTranscript(homeDir, '-Users-affoon-project-e', 'session-e.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-e', 'toolu_direct', 'Bash', {
|
||||
command: 'sleep 999',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run(['--transcript', transcriptPath, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions.length, 1);
|
||||
assert.strictEqual(payload.sessions[0].transcriptPath, transcriptPath);
|
||||
assert.ok(payload.sessions[0].signals.some(signal => signal.type === 'pending_bash_tool_result'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints text output with state and recommended action', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-f', 'session-f.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-f', 'toolu_text', 'ScheduleWakeup', {
|
||||
delaySeconds: 600,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.match(result.stdout, /session-f/);
|
||||
assert.match(result.stdout, /attention/);
|
||||
assert.match(result.stdout, /schedule_wakeup_overdue/);
|
||||
assert.match(result.stdout, /Open the transcript or interrupt the parked session/);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('continues when an explicit transcript path cannot be read', () => {
|
||||
const missingTranscript = path.join(os.tmpdir(), `missing-loop-status-${Date.now()}.jsonl`);
|
||||
|
||||
const result = run(['--transcript', missingTranscript, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.deepStrictEqual(payload.sessions, []);
|
||||
assert.strictEqual(payload.errors.length, 1);
|
||||
assert.strictEqual(payload.errors[0].transcriptPath, missingTranscript);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('text output distinguishes explicit transcript read failures from empty discovery', () => {
|
||||
const missingTranscript = path.join(os.tmpdir(), `missing-loop-status-text-${Date.now()}.jsonl`);
|
||||
|
||||
const result = run(['--transcript', missingTranscript, '--now', NOW]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.match(result.stdout, /No readable Claude transcript JSONL files were found/);
|
||||
assert.match(result.stdout, /Skipped transcript errors/);
|
||||
assert.ok(!result.stdout.includes('No Claude transcript JSONL files found under'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('continues when one transcript directory cannot be read', () => {
|
||||
const homeDir = createTempHome();
|
||||
const blockedDir = path.join(homeDir, '.claude', 'projects', '-blocked-project');
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-readable', 'session-readable.jsonl', [
|
||||
toolResult('2026-04-30T09:41:00.000Z', 'session-readable', 'toolu_done', 'done'),
|
||||
]);
|
||||
fs.mkdirSync(blockedDir, { recursive: true });
|
||||
fs.readdirSync = (dir, options) => {
|
||||
if (path.resolve(dir) === path.resolve(blockedDir)) {
|
||||
const error = new Error('permission denied');
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
}
|
||||
return originalReaddirSync(dir, options);
|
||||
};
|
||||
|
||||
const payload = buildStatus({ home: homeDir, now: NOW });
|
||||
|
||||
assert.strictEqual(payload.sessions.length, 1);
|
||||
assert.strictEqual(payload.sessions[0].sessionId, 'session-readable');
|
||||
assert.strictEqual(payload.errors.length, 1);
|
||||
assert.strictEqual(payload.errors[0].code, 'EACCES');
|
||||
assert.strictEqual(payload.errors[0].transcriptPath, blockedDir);
|
||||
} finally {
|
||||
fs.readdirSync = originalReaddirSync;
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports malformed JSONL lines as an attention signal', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
const transcriptDir = path.join(homeDir, '.claude', 'projects', '-Users-affoon-project-malformed');
|
||||
fs.mkdirSync(transcriptDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(transcriptDir, 'session-malformed.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-04-30T09:55:00.000Z',
|
||||
sessionId: 'session-malformed',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'partial log' }] },
|
||||
}),
|
||||
'{"timestamp":',
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json']);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions[0].state, 'attention');
|
||||
assert.ok(payload.sessions[0].signals.some(signal => (
|
||||
signal.type === 'transcript_parse_errors'
|
||||
&& signal.count === 1
|
||||
)));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects non-integer limit values', () => {
|
||||
const result = run(['--limit', '1.5']);
|
||||
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.match(result.stderr, /--limit must be a positive integer/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses watch mode controls', () => {
|
||||
const options = parseArgs([
|
||||
'node',
|
||||
'scripts/loop-status.js',
|
||||
'--exit-code',
|
||||
'--watch',
|
||||
'--watch-count',
|
||||
'2',
|
||||
'--watch-interval-seconds',
|
||||
'0.01',
|
||||
]);
|
||||
|
||||
assert.strictEqual(options.exitCode, true);
|
||||
assert.strictEqual(options.watch, true);
|
||||
assert.strictEqual(options.watchCount, 2);
|
||||
assert.strictEqual(options.watchIntervalSeconds, 0.01);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses write-dir snapshot option', () => {
|
||||
const options = parseArgs([
|
||||
'node',
|
||||
'scripts/loop-status.js',
|
||||
'--write-dir',
|
||||
'/tmp/ecc-loop-snapshots',
|
||||
]);
|
||||
|
||||
assert.strictEqual(options.writeDir, '/tmp/ecc-loop-snapshots');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode returns 2 when attention signals are present', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-exit-code', 'session-exit-code.jsonl', [
|
||||
toolUse('2026-04-30T09:10:00.000Z', 'session-exit-code', 'toolu_exit_bash', 'Bash', {
|
||||
command: 'pytest tests/integration/test_pipeline.py',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json', '--exit-code']);
|
||||
|
||||
assert.strictEqual(result.code, 2, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions[0].state, 'attention');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode returns 1 for scan errors without attention signals', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-missing-'));
|
||||
const missingTranscript = path.join(tempDir, 'missing.jsonl');
|
||||
const result = run(['--transcript', missingTranscript, '--now', NOW, '--json', '--exit-code']);
|
||||
|
||||
try {
|
||||
assert.strictEqual(result.code, 1, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions.length, 0);
|
||||
assert.strictEqual(payload.errors.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode rejects unbounded watch mode', () => {
|
||||
const result = run(['--watch', '--exit-code']);
|
||||
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.match(result.stderr, /--exit-code with --watch requires --watch-count/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getStatusExitCode prioritizes attention signals over scan errors', () => {
|
||||
const payload = {
|
||||
errors: [{ message: 'unreadable' }],
|
||||
sessions: [{ state: 'attention' }],
|
||||
};
|
||||
|
||||
assert.strictEqual(getStatusExitCode(payload), 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('watch mode emits repeated JSON status frames', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-watch', 'session-watch.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-watch', 'toolu_watch', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--watch',
|
||||
'--watch-count',
|
||||
'2',
|
||||
'--watch-interval-seconds',
|
||||
'0.01',
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const frames = result.stdout.trim().split(/\r?\n/).map(line => JSON.parse(line));
|
||||
assert.strictEqual(frames.length, 2);
|
||||
assert.strictEqual(frames[0].schemaVersion, 'ecc.loop-status.v1');
|
||||
assert.strictEqual(frames[1].schemaVersion, 'ecc.loop-status.v1');
|
||||
assert.strictEqual(frames[0].sessions[0].sessionId, 'session-watch');
|
||||
assert.strictEqual(frames[1].sessions[0].sessionId, 'session-watch');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('watch mode honors exit-code after bounded refreshes', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-watch-exit', 'session-watch-exit.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-watch-exit', 'toolu_watch_exit', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--watch',
|
||||
'--watch-count',
|
||||
'1',
|
||||
'--watch-interval-seconds',
|
||||
'0.01',
|
||||
'--exit-code',
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 2, result.stderr);
|
||||
const frame = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(frame.sessions[0].state, 'attention');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes per-session status snapshots and index when write-dir is set', () => {
|
||||
const homeDir = createTempHome();
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-snapshots-'));
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-snapshot', 'session-snapshot.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-snapshot', 'toolu_snapshot', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
snapshotDir,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const stdoutPayload = parsePayload(result.stdout);
|
||||
assert.strictEqual(stdoutPayload.schemaVersion, 'ecc.loop-status.v1');
|
||||
|
||||
const indexPath = path.join(snapshotDir, 'index.json');
|
||||
const snapshotPath = path.join(snapshotDir, 'session-snapshot.json');
|
||||
assert.ok(fs.existsSync(indexPath), 'write-dir should include an index.json file');
|
||||
assert.ok(fs.existsSync(snapshotPath), 'write-dir should include a per-session snapshot');
|
||||
|
||||
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
|
||||
assert.strictEqual(indexPayload.sessions.length, 1);
|
||||
assert.strictEqual(indexPayload.sessions[0].sessionId, 'session-snapshot');
|
||||
assert.strictEqual(indexPayload.sessions[0].state, 'attention');
|
||||
assert.strictEqual(indexPayload.sessions[0].snapshotPath, snapshotPath);
|
||||
|
||||
const snapshotPayload = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
|
||||
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
|
||||
assert.strictEqual(snapshotPayload.generatedAt, NOW);
|
||||
assert.strictEqual(snapshotPayload.session.sessionId, 'session-snapshot');
|
||||
assert.ok(snapshotPayload.session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('keeps index.json reserved when session id sanitizes to index', () => {
|
||||
const homeDir = createTempHome();
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-index-collision-'));
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-index-collision', 'index.jsonl', [
|
||||
assistantMessage('2026-04-30T09:55:00.000Z', 'index', 'Loop checkpoint.'),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
snapshotDir,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const indexPath = path.join(snapshotDir, 'index.json');
|
||||
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
|
||||
assert.strictEqual(indexPayload.sessions.length, 1);
|
||||
assert.strictEqual(indexPayload.sessions[0].sessionId, 'index');
|
||||
assert.notStrictEqual(indexPayload.sessions[0].snapshotPath, indexPath);
|
||||
|
||||
const snapshotPayload = JSON.parse(fs.readFileSync(indexPayload.sessions[0].snapshotPath, 'utf8'));
|
||||
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
|
||||
assert.strictEqual(snapshotPayload.session.sessionId, 'index');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('avoids Windows reserved basenames for session snapshots', () => {
|
||||
const homeDir = createTempHome();
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-windows-name-'));
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con.jsonl', [
|
||||
assistantMessage('2026-04-30T09:55:00.000Z', 'con', 'Loop checkpoint.'),
|
||||
]);
|
||||
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con-txt.jsonl', [
|
||||
assistantMessage('2026-04-30T09:56:00.000Z', 'con.txt', 'Loop checkpoint.'),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
snapshotDir,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const indexPath = path.join(snapshotDir, 'index.json');
|
||||
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
assert.strictEqual(indexPayload.sessions.length, 2);
|
||||
|
||||
for (const sessionIndex of indexPayload.sessions) {
|
||||
const snapshotName = path.basename(sessionIndex.snapshotPath);
|
||||
assert.notStrictEqual(snapshotName.toLowerCase(), `${sessionIndex.sessionId}.json`);
|
||||
assert.ok(!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(snapshotName.split('.')[0]));
|
||||
|
||||
const snapshotPayload = JSON.parse(fs.readFileSync(sessionIndex.snapshotPath, 'utf8'));
|
||||
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
|
||||
assert.strictEqual(snapshotPayload.session.sessionId, sessionIndex.sessionId);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cleans temporary snapshot files when atomic rename fails', () => {
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-rename-failure-'));
|
||||
const originalRenameSync = fs.renameSync;
|
||||
|
||||
try {
|
||||
fs.renameSync = () => {
|
||||
throw new Error('simulated rename failure');
|
||||
};
|
||||
|
||||
assert.throws(() => writeStatusSnapshots({
|
||||
errors: [],
|
||||
generatedAt: NOW,
|
||||
sessions: [
|
||||
{
|
||||
eventCount: 1,
|
||||
lastEventAt: NOW,
|
||||
pendingTools: [],
|
||||
recommendedAction: 'No action needed.',
|
||||
sessionId: 'rename-failure',
|
||||
signals: [],
|
||||
state: 'ok',
|
||||
transcriptPath: path.join(snapshotDir, 'rename-failure.jsonl'),
|
||||
},
|
||||
],
|
||||
source: {},
|
||||
}, snapshotDir), /simulated rename failure/);
|
||||
|
||||
const tempFiles = fs.readdirSync(snapshotDir).filter(fileName => fileName.endsWith('.tmp'));
|
||||
assert.deepStrictEqual(tempFiles, []);
|
||||
} finally {
|
||||
fs.renameSync = originalRenameSync;
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('write-dir failures do not suppress normal stdout', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
const blockedPath = path.join(homeDir, 'snapshot-target-is-a-file');
|
||||
fs.writeFileSync(blockedPath, 'not a directory\n', 'utf8');
|
||||
writeTranscript(homeDir, '-Users-affoon-project-write-error', 'session-write-error.jsonl', [
|
||||
assistantMessage('2026-04-30T09:55:00.000Z', 'session-write-error', 'Loop checkpoint.'),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
blockedPath,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
|
||||
assert.strictEqual(payload.sessions[0].sessionId, 'session-write-error');
|
||||
assert.match(result.stderr, /\[loop-status\] WARNING: could not write status snapshots:/);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Regression coverage for supported manual Claude hook installation guidance.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const README = path.join(__dirname, '..', '..', 'README.md');
|
||||
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing manual hook install docs ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const readme = fs.readFileSync(README, 'utf8');
|
||||
const hooksReadme = fs.readFileSync(HOOKS_README, 'utf8');
|
||||
|
||||
if (test('README warns against raw hook file copying', () => {
|
||||
assert.ok(
|
||||
readme.includes('Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`'),
|
||||
'README should warn against unsupported raw hook copying'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
|
||||
'README should document the supported Bash hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
|
||||
'README should document the supported PowerShell hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('%USERPROFILE%\\\\.claude'),
|
||||
'README should call out the correct Windows Claude config root'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('hooks/README mirrors supported manual install guidance', () => {
|
||||
assert.ok(
|
||||
hooksReadme.includes('do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`'),
|
||||
'hooks/README should warn against unsupported raw hook copying'
|
||||
);
|
||||
assert.ok(
|
||||
hooksReadme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
|
||||
'hooks/README should document the supported Bash hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
|
||||
'hooks/README should document the supported PowerShell hook install path'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Tests for the npm publish surface contract.
|
||||
*/
|
||||
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ✓ ${name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`)
|
||||
console.error(` ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublishPath(value) {
|
||||
return String(value).replace(/\\/g, "/").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function isCoveredByAncestor(target, roots) {
|
||||
const parts = target.split("/")
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
const ancestor = parts.slice(0, index).join("/")
|
||||
if (roots.has(ancestor)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildExpectedPublishPaths(repoRoot) {
|
||||
const modules = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "manifests", "install-modules.json"), "utf8")
|
||||
).modules
|
||||
|
||||
const extraPaths = [
|
||||
"manifests",
|
||||
"scripts/ecc.js",
|
||||
"scripts/catalog.js",
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/dashboard-web.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/doctor.js",
|
||||
"scripts/status.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/work-items.js",
|
||||
"scripts/install-apply.js",
|
||||
"scripts/install-plan.js",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/loop-status.js",
|
||||
"scripts/observability-readiness.js",
|
||||
"scripts/plan-canvas.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
"scripts/platform-audit.js",
|
||||
"scripts/preview-pack-smoke.js",
|
||||
"scripts/release-approval-gate.js",
|
||||
"scripts/release-video-suite.js",
|
||||
"scripts/skill-create-output.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/harness-adapter-compliance.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/uninstall.js",
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/codex/check-plugin-cache.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
".codex-plugin",
|
||||
"plugins/ecc",
|
||||
".mcp.json",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
"schemas",
|
||||
"agent.yaml",
|
||||
"VERSION",
|
||||
"assets/ecc-icon.svg",
|
||||
"assets/hero.png",
|
||||
]
|
||||
const exclusionPaths = [
|
||||
"!**/__pycache__/**",
|
||||
"!**/*.pyc",
|
||||
"!**/*.pyo",
|
||||
"!**/*.pyd",
|
||||
"!**/.pytest_cache/**",
|
||||
]
|
||||
|
||||
const combined = new Set(
|
||||
[...modules.flatMap((module) => module.paths || []), ...extraPaths, ...exclusionPaths].map(normalizePublishPath)
|
||||
)
|
||||
|
||||
return [...combined]
|
||||
.filter((publishPath) => !isCoveredByAncestor(publishPath, combined))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("\n=== Testing npm publish surface ===\n")
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..")
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
|
||||
)
|
||||
|
||||
const expectedPublishPaths = buildExpectedPublishPaths(repoRoot)
|
||||
const actualPublishPaths = packageJson.files.map(normalizePublishPath).sort()
|
||||
|
||||
const tests = [
|
||||
["package.json files align to the module graph and explicit runtime allowlist", () => {
|
||||
assert.deepStrictEqual(actualPublishPaths, expectedPublishPaths)
|
||||
}],
|
||||
["npm pack publishes the reduced runtime surface", () => {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
|
||||
for (const requiredPath of [
|
||||
"scripts/catalog.js",
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
"scripts/preview-pack-smoke.js",
|
||||
"scripts/release-approval-gate.js",
|
||||
"scripts/release-video-suite.js",
|
||||
"scripts/work-items.js",
|
||||
"scripts/platform-audit.js",
|
||||
"scripts/codex/check-plugin-cache.js",
|
||||
".gemini/GEMINI.md",
|
||||
".qwen/QWEN.md",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
"plugins/ecc/.codex-plugin/plugin.json",
|
||||
"assets/ecc-icon.svg",
|
||||
"assets/hero.png",
|
||||
"schemas/install-state.schema.json",
|
||||
"skills/backend-patterns/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
packagedPaths.has(requiredPath),
|
||||
`npm pack should include ${requiredPath}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const excludedPath of [
|
||||
"contexts/dev.md",
|
||||
"examples/CLAUDE.md",
|
||||
"plugins/README.md",
|
||||
"scripts/ci/catalog.js",
|
||||
"skills/skill-comply/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
!packagedPaths.has(excludedPath),
|
||||
`npm pack should not include ${excludedPath}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const packagedPath of packagedPaths) {
|
||||
assert.ok(
|
||||
!packagedPath.includes("__pycache__/"),
|
||||
`npm pack should not include Python bytecode cache path ${packagedPath}`
|
||||
)
|
||||
assert.ok(
|
||||
!/\.py[cod]$/.test(packagedPath),
|
||||
`npm pack should not include Python bytecode file ${packagedPath}`
|
||||
)
|
||||
}
|
||||
}],
|
||||
]
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`)
|
||||
console.log(`Failed: ${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Tests for scripts/observability-readiness.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'observability-readiness.js');
|
||||
const { buildReport, parseArgs } = require(SCRIPT);
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function seedMinimalRepo(rootDir, overrides = {}) {
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'everything-claude-code',
|
||||
files: ['scripts/observability-readiness.js'],
|
||||
scripts: {
|
||||
'harness:audit': 'node scripts/harness-audit.js',
|
||||
'observability:ready': 'node scripts/observability-readiness.js'
|
||||
}
|
||||
}, null, 2),
|
||||
'scripts/loop-status.js': '--json --watch --write-dir',
|
||||
'scripts/session-inspect.js': '--list-adapters --write inspectSessionTarget',
|
||||
'scripts/lib/session-adapters/registry.js': 'module.exports = {};',
|
||||
'scripts/harness-audit.js': 'Deterministic harness audit --format overall_score',
|
||||
'scripts/work-items.js': 'sync-github github-pr github-issue sourceClosedAt ecc-work-items-sync-github',
|
||||
'scripts/hooks/session-activity-tracker.js': 'tool-usage.jsonl session_id tool_name',
|
||||
'ecc2/src/observability/mod.rs': 'ToolCallEvent RiskAssessment ToolLogger',
|
||||
'ecc2/src/session/store.rs': 'insert_tool_log query_tool_logs',
|
||||
'ecc2/src/session/manager.rs': 'sync_tool_activity_metrics tool-usage.jsonl',
|
||||
'docs/architecture/observability-readiness.md': 'node scripts/observability-readiness.js --format json',
|
||||
'docs/architecture/progress-sync-contract.md': [
|
||||
'Linear GitHub handoff work-items issue capacity status update',
|
||||
'queue counts release gate flow lanes evidence'
|
||||
].join('\n'),
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'Execution Lanes And Tracking Contract',
|
||||
'docs/architecture/progress-sync-contract.md',
|
||||
'Linear progress',
|
||||
'Every significant merge batch'
|
||||
].join('\n'),
|
||||
'docs/architecture/hud-status-session-control.md': [
|
||||
'context toolCalls activeAgents todos checks cost risk queueState',
|
||||
'create resume status stop diff pr mergeQueue conflictQueue',
|
||||
'Linear GitHub handoff'
|
||||
].join('\n'),
|
||||
'examples/hud-status-contract.json': JSON.stringify({
|
||||
schema_version: 'ecc.hud-status.v1',
|
||||
context: {},
|
||||
toolCalls: {},
|
||||
activeAgents: [],
|
||||
todos: {},
|
||||
checks: {},
|
||||
cost: {},
|
||||
risk: {},
|
||||
queueState: {},
|
||||
sessionControls: {},
|
||||
sync: {}
|
||||
}, null, 2),
|
||||
'docs/releases/2.0.0-rc.1/quickstart.md': 'observability-readiness.md',
|
||||
'docs/releases/2.0.0-rc.1/release-notes.md': 'observability-readiness.md',
|
||||
'docs/releases/2.0.0-rc.1/publication-readiness.md': [
|
||||
'Publication Gates',
|
||||
'Required Command Evidence',
|
||||
'Do Not Publish If',
|
||||
'npm dist-tag',
|
||||
'GitGuardian',
|
||||
'Dependabot alerts',
|
||||
'npm audit signatures'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-13-post-hardening.md': [
|
||||
'npm audit --json',
|
||||
'npm audit signatures',
|
||||
'cargo audit',
|
||||
'Dependabot alert API',
|
||||
'TanStack',
|
||||
'Mini Shai-Hulud',
|
||||
'GitGuardian Security Checks'
|
||||
].join('\n'),
|
||||
'docs/security/supply-chain-incident-response.md': [
|
||||
'TanStack',
|
||||
'Mini Shai-Hulud',
|
||||
'scan-supply-chain-iocs.js',
|
||||
'gh-token-monitor',
|
||||
'.claude/settings.json',
|
||||
'.vscode/tasks.json',
|
||||
'npm audit signatures',
|
||||
'trusted publishing',
|
||||
'pull_request_target',
|
||||
'id-token: write'
|
||||
].join('\n'),
|
||||
'scripts/ci/validate-workflow-security.js': [
|
||||
'persist-credentials: false',
|
||||
'npm audit signatures',
|
||||
'pull_request_target',
|
||||
'id-token: write',
|
||||
'shared cache'
|
||||
].join('\n'),
|
||||
'scripts/ci/scan-supply-chain-iocs.js': 'TanStack Mini Shai-Hulud gh-token-monitor',
|
||||
'tests/ci/scan-supply-chain-iocs.test.js': 'scan-supply-chain-iocs',
|
||||
'tests/ci/validate-workflow-security.test.js': 'npm audit signatures persist-credentials: false',
|
||||
'tests/scripts/npm-publish-surface.test.js': 'npm pack --dry-run Python bytecode',
|
||||
'tests/docs/ecc2-release-surface.test.js': 'publication-readiness.md',
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing observability-readiness.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts supported forms and rejects invalid input', () => {
|
||||
const rootDir = createTempDir('observability-readiness-args-');
|
||||
|
||||
try {
|
||||
assert.strictEqual(parseArgs(['node', 'script', '--help']).help, true);
|
||||
assert.strictEqual(parseArgs(['node', 'script', '-h']).help, true);
|
||||
|
||||
const spaced = parseArgs(['node', 'script', '--format', 'json', '--root', rootDir]);
|
||||
assert.strictEqual(spaced.format, 'json');
|
||||
assert.strictEqual(spaced.root, path.resolve(rootDir));
|
||||
|
||||
const equals = parseArgs(['node', 'script', '--format=json', `--root=${rootDir}`]);
|
||||
assert.strictEqual(equals.format, 'json');
|
||||
assert.strictEqual(equals.root, path.resolve(rootDir));
|
||||
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format: xml/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--root']), /--root requires a value/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument: --unknown/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cli help exits cleanly and invalid cli args exit with stderr', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.strictEqual(help.stderr, '');
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/observability-readiness.js'));
|
||||
|
||||
const invalid = runProcess(['--format', 'xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.strictEqual(invalid.stdout, '');
|
||||
assert.ok(invalid.stderr.includes('Error: Invalid format: xml. Use text or json.'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('current repo reports a complete readiness score', () => {
|
||||
const parsed = JSON.parse(run(['--format=json']));
|
||||
|
||||
assert.strictEqual(parsed.schema_version, 'ecc.observability-readiness.v1');
|
||||
assert.strictEqual(parsed.deterministic, true);
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.overall_score, parsed.max_score);
|
||||
assert.strictEqual(parsed.top_actions.length, 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('text output includes summary, categories, and checks', () => {
|
||||
const output = run();
|
||||
|
||||
assert.ok(output.includes('Observability Readiness:'));
|
||||
assert.ok(output.includes('Categories:'));
|
||||
assert.ok(output.includes('Checks:'));
|
||||
assert.ok(output.includes('PASS loop-status-live-signal'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('minimal seeded repo passes all checks', () => {
|
||||
const projectRoot = createTempDir('observability-readiness-pass-');
|
||||
|
||||
try {
|
||||
seedMinimalRepo(projectRoot);
|
||||
const report = buildReport(projectRoot);
|
||||
|
||||
assert.strictEqual(report.ready, true);
|
||||
assert.strictEqual(report.overall_score, report.max_score);
|
||||
assert.deepStrictEqual(report.top_actions, []);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('missing tool logger surfaces become prioritized top actions', () => {
|
||||
const projectRoot = createTempDir('observability-readiness-fail-');
|
||||
|
||||
try {
|
||||
seedMinimalRepo(projectRoot, {
|
||||
'ecc2/src/observability/mod.rs': 'ToolCallEvent only'
|
||||
});
|
||||
const report = buildReport(projectRoot);
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.top_actions.some(action => action.id === 'ecc2-tool-risk-ledger'));
|
||||
assert.ok(report.checks.some(check => check.id === 'ecc2-tool-risk-ledger' && !check.pass));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('missing release onramp fails without disturbing core tool checks', () => {
|
||||
const projectRoot = createTempDir('observability-readiness-doc-fail-');
|
||||
|
||||
try {
|
||||
seedMinimalRepo(projectRoot, {
|
||||
'docs/releases/2.0.0-rc.1/quickstart.md': 'quickstart without link'
|
||||
});
|
||||
const report = buildReport(projectRoot);
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.checks.some(check => check.id === 'release-observability-onramp' && !check.pass));
|
||||
assert.ok(report.checks.some(check => check.id === 'loop-status-live-signal' && check.pass));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('missing HUD status contract fails without disturbing core tool checks', () => {
|
||||
const projectRoot = createTempDir('observability-readiness-hud-fail-');
|
||||
|
||||
try {
|
||||
seedMinimalRepo(projectRoot, {
|
||||
'examples/hud-status-contract.json': null
|
||||
});
|
||||
const report = buildReport(projectRoot);
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.checks.some(check => check.id === 'hud-status-control-contract' && !check.pass));
|
||||
assert.ok(report.checks.some(check => check.id === 'loop-status-live-signal' && check.pass));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('missing progress sync contract fails without disturbing core tool checks', () => {
|
||||
const projectRoot = createTempDir('observability-readiness-sync-fail-');
|
||||
|
||||
try {
|
||||
seedMinimalRepo(projectRoot, {
|
||||
'docs/architecture/progress-sync-contract.md': null
|
||||
});
|
||||
const report = buildReport(projectRoot);
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.checks.some(check => check.id === 'progress-sync-contract' && !check.pass));
|
||||
assert.ok(report.checks.some(check => check.id === 'loop-status-live-signal' && check.pass));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('missing release safety evidence fails without disturbing live status checks', () => {
|
||||
const projectRoot = createTempDir('observability-readiness-release-safety-fail-');
|
||||
|
||||
try {
|
||||
seedMinimalRepo(projectRoot, {
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-13-post-hardening.md': 'npm audit --json only'
|
||||
});
|
||||
const report = buildReport(projectRoot);
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.checks.some(check => check.id === 'release-safety-evidence' && !check.pass));
|
||||
assert.ok(report.checks.some(check => check.id === 'loop-status-live-signal' && check.pass));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nResults:');
|
||||
console.log(` Passed: ${passed}`);
|
||||
console.log(` Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'skills',
|
||||
'openclaw-persona-forge',
|
||||
'gacha.py'
|
||||
);
|
||||
|
||||
function findPython() {
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'python3']
|
||||
: ['python3', 'python'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
|
||||
if (result.status === 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function runGacha(pythonBin, arg) {
|
||||
return spawnSync(pythonBin, [SCRIPT, arg], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, PYTHONUTF8: '1' },
|
||||
});
|
||||
}
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS: ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL: ${name}`);
|
||||
console.error(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSingleDrawOutput(result) {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /\[身份\] 前世身份:/);
|
||||
assert.match(result.stdout, /\[概括\] 一句话概括:/);
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('\n=== Testing openclaw-persona-forge/gacha.py ===\n');
|
||||
|
||||
const pythonBin = findPython();
|
||||
if (!pythonBin) {
|
||||
console.log(' PASS: skipped (python runtime unavailable)');
|
||||
return;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const tests = [
|
||||
['clamps zero draws to one', () => {
|
||||
assertSingleDrawOutput(runGacha(pythonBin, '0'));
|
||||
}],
|
||||
['clamps negative draws to one', () => {
|
||||
assertSingleDrawOutput(runGacha(pythonBin, '-3'));
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,840 @@
|
||||
/**
|
||||
* Tests for scripts/operator-readiness-dashboard.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'operator-readiness-dashboard.js');
|
||||
const { buildReport, parseArgs, renderMarkdown, renderText } = require(SCRIPT);
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function seedRepo(rootDir, overrides = {}) {
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'everything-claude-code',
|
||||
files: [
|
||||
'scripts/observability-readiness.js',
|
||||
'scripts/operator-readiness-dashboard.js',
|
||||
'scripts/platform-audit.js',
|
||||
'scripts/preview-pack-smoke.js',
|
||||
'scripts/release-video-suite.js'
|
||||
],
|
||||
scripts: {
|
||||
'discussion:audit': 'node scripts/discussion-audit.js',
|
||||
'observability:ready': 'node scripts/observability-readiness.js',
|
||||
'operator:dashboard': 'node scripts/operator-readiness-dashboard.js',
|
||||
'platform:audit': 'node scripts/platform-audit.js',
|
||||
'preview-pack:smoke': 'node scripts/preview-pack-smoke.js',
|
||||
'release:video-suite': 'node scripts/release-video-suite.js',
|
||||
'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
|
||||
'security:advisory-sources': 'node scripts/ci/supply-chain-advisory-sources.js'
|
||||
}
|
||||
}, null, 2),
|
||||
'scripts/operator-readiness-dashboard.js': 'operator dashboard generator',
|
||||
'scripts/preview-pack-smoke.js': [
|
||||
'ecc.preview-pack-smoke.v1',
|
||||
'preview-pack-artifacts-present',
|
||||
'hermes-boundary-sanitized',
|
||||
'publication-blockers-preserved'
|
||||
].join('\n'),
|
||||
'scripts/release-video-suite.js': [
|
||||
'ecc.release-video-suite.v1',
|
||||
'video-source-assets-present',
|
||||
'video-release-artifacts-present'
|
||||
].join('\n'),
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||
'Linear ITO-44 ITO-59',
|
||||
'AgentShield PR #92 #78-#92 checksum-backed policy export policy promote checksum-verified policy promotion',
|
||||
'AgentShield Enterprise Iteration',
|
||||
'ECC-Tools PR #78',
|
||||
'hosted promotion',
|
||||
'operator-visible promotion output values',
|
||||
'hosted promotion judge audit traces',
|
||||
'package-manager hardening Action outputs',
|
||||
'production Marketplace readback state',
|
||||
'eb69412',
|
||||
'Marketplace webhook provenance',
|
||||
'2859678',
|
||||
'Wrangler OAuth readback',
|
||||
'42653f9',
|
||||
'target account billing readback',
|
||||
'632e059',
|
||||
'select-ready-target',
|
||||
'selected-target official announcement gate',
|
||||
'billing gate env-file operator path',
|
||||
'non-breaking operator bearer path',
|
||||
'announcementGateReady` is `true',
|
||||
'd3d62df83fa075660fa4530c3e0edc311a4355fe',
|
||||
'72119a1',
|
||||
'16a5bb3',
|
||||
'f14ed2fe-a219-470c-8119-63429e197027',
|
||||
'old "no Marketplace-managed Pro target billing-state" blocker is cleared',
|
||||
'30f60710',
|
||||
'26135974576',
|
||||
'467d148a-712a-4777-aad9-95593e9f1739',
|
||||
'7642ee9c-3107-400c-a229-53e2895a8914',
|
||||
'69ca535',
|
||||
'team feedback controls',
|
||||
'e56fc1a',
|
||||
'1Password CLI authorization timed out',
|
||||
'Cloudflare API auth returned `Authentication error [code: 10000]`',
|
||||
'announcementGate',
|
||||
'ITO-55',
|
||||
'Linear live sync is current for the May 17 merge batch',
|
||||
'operator progress snapshot'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/publication-readiness.md': 'Claude plugin Codex plugin release-name-plugin-publication-checklist-2026-05-18.md',
|
||||
'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md': 'Claude plugin Codex plugin npm package Publication Paths',
|
||||
'docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md': [
|
||||
'Ship `v2.0.0-rc.1` as **ECC**',
|
||||
'affaan-m/ECC',
|
||||
'ecc-universal',
|
||||
'claude plugin tag .claude-plugin --dry-run',
|
||||
'codex plugin marketplace add',
|
||||
'Do not rename the npm package until rc.1 is published'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/preview-pack-manifest.md': [
|
||||
'publication-readiness.md release-notes.md quickstart.md',
|
||||
'release-name-plugin-publication-checklist-2026-05-18.md',
|
||||
'owner-approval-packet-2026-05-19.md',
|
||||
'`scripts/preview-pack-smoke.js`',
|
||||
'npm run preview-pack:smoke'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md': [
|
||||
'Owner Approval Packet',
|
||||
'Decision Register',
|
||||
'GitHub prerelease',
|
||||
'npm `next` publish',
|
||||
'Claude plugin tag',
|
||||
'Video upload',
|
||||
'Final URL Fill-In',
|
||||
'Do Not Approve If',
|
||||
'No outbound email, personal-account post, package publish, plugin tag, or billing announcement is authorized by this packet alone.'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/release-notes.md': 'release notes',
|
||||
'docs/releases/2.0.0-rc.1/x-thread.md': 'x thread',
|
||||
'docs/releases/2.0.0-rc.1/linkedin-post.md': 'linkedin post',
|
||||
'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-18.md': [
|
||||
'This dashboard is generated by `npm run operator:dashboard`',
|
||||
'operator:dashboard',
|
||||
'Prompt-To-Artifact Checklist',
|
||||
'Next Work Order',
|
||||
'ITO-44',
|
||||
'ITO-59',
|
||||
'PR queue',
|
||||
'Not complete'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-19.md': [
|
||||
'This dashboard is generated by `npm run operator:dashboard`',
|
||||
'operator:dashboard',
|
||||
'Growth Baseline',
|
||||
'hypergrowth release command center',
|
||||
'Prompt-To-Artifact Checklist',
|
||||
'Next Work Order',
|
||||
'ITO-44',
|
||||
'ITO-59',
|
||||
'PR queue',
|
||||
'Not complete'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-20.md': [
|
||||
'This dashboard is generated by `npm run operator:dashboard`',
|
||||
'operator:dashboard',
|
||||
'Growth Baseline',
|
||||
'hypergrowth release command center',
|
||||
'Prompt-To-Artifact Checklist',
|
||||
'Next Work Order',
|
||||
'ITO-44',
|
||||
'ITO-59',
|
||||
'PR queue',
|
||||
'Not complete'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/owner-queue-cleanup-2026-05-18.md': [
|
||||
'Owner-wide open PRs after cleanup: 0.',
|
||||
'Owner-wide open issues after cleanup: 0.',
|
||||
'Stale dependency-bot PRs closed: 24.',
|
||||
'Stale legacy payments/0EM roadmap issues closed: 72.'
|
||||
].join('\n'),
|
||||
'docs/HERMES-SETUP.md': 'Hermes setup Public Release Candidate Scope',
|
||||
'skills/hermes-imports/SKILL.md': 'Hermes imports Sanitization Checklist Do not ship raw workspace exports Output Contract',
|
||||
'docs/stale-pr-salvage-ledger.md': [
|
||||
'Remaining Manual-Review Backlog',
|
||||
'Linear ITO-55',
|
||||
'#1687 zh-CN localization tail',
|
||||
'#1609 Persian README translation',
|
||||
'#1563 zh-TW README sync',
|
||||
'#1564 Turkish README sync',
|
||||
'#1565 pt-BR README sync',
|
||||
'not a release-blocking salvage task'
|
||||
].join('\n'),
|
||||
'docs/legacy-artifact-inventory.md': [
|
||||
'Translator/manual review',
|
||||
'ITO-55',
|
||||
'#1687 zh-CN localization tail',
|
||||
'#1609 Persian README translation',
|
||||
'#1563 zh-TW README sync',
|
||||
'#1564 Turkish README sync',
|
||||
'#1565 pt-BR README sync',
|
||||
'no automatic import remains release-blocking'
|
||||
].join('\n'),
|
||||
'docs/architecture/progress-sync-contract.md': [
|
||||
'GitHub PRs/issues/discussions Linear project local handoff repo roadmap scripts/work-items.js',
|
||||
'node scripts/work-items.js sync-github --repo <owner/repo>',
|
||||
'node scripts/status.js --json',
|
||||
'Linear remains the external status surface'
|
||||
].join('\n'),
|
||||
'docs/architecture/observability-readiness.md': 'observability-readiness.js',
|
||||
'docs/security/supply-chain-incident-response.md': 'TanStack Mini Shai-Hulud node-ipc scan-supply-chain-iocs.js supply-chain-advisory-sources.js',
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-18.md': [
|
||||
'TanStack',
|
||||
'Mini Shai-Hulud',
|
||||
'Home persistence IOC scan',
|
||||
'Supply-Chain Watch',
|
||||
'npm signatures',
|
||||
'Node IPC follow-up node-ipc IOC scan'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md': [
|
||||
'Release video suite',
|
||||
'growth outreach',
|
||||
'Operator dashboard',
|
||||
'GitGuardian',
|
||||
'macOS/Ubuntu/Windows test matrix',
|
||||
'2568 passed',
|
||||
'Business baseline',
|
||||
'$1,728/mo',
|
||||
'$8,272/mo'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md': [
|
||||
'harness-native operator system',
|
||||
'| MRR | `$1,728/mo` | `$10,000/mo` | `$8,272/mo` |',
|
||||
'Video Suite',
|
||||
'Distribution Plan',
|
||||
'Owner Approvals'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/video-suite-production.md': [
|
||||
'ECC 2.0 Video Suite Production Manifest',
|
||||
'Primary launch video',
|
||||
'Self-Eval Gate',
|
||||
'timeline'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md': [
|
||||
'Sponsor Outbound',
|
||||
'Platform Partner DM',
|
||||
'Consulting Intro',
|
||||
'Talk And Podcast Pitch',
|
||||
'GitHub Discussion Announcement',
|
||||
'Do Not Send Or Publish If'
|
||||
].join('\n'),
|
||||
'.github/workflows/supply-chain-watch.yml': 'name: Supply-Chain Watch supply-chain-advisory-sources.js supply-chain-advisory-sources.json'
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function buildSeededReport(rootDir) {
|
||||
return buildReport({
|
||||
allowUntracked: [],
|
||||
exitCode: false,
|
||||
format: 'json',
|
||||
generatedAt: '2026-05-15T00:00:00.000Z',
|
||||
help: false,
|
||||
repos: [],
|
||||
root: rootDir,
|
||||
skipGithub: true,
|
||||
thresholds: { maxOpenPrs: 20, maxOpenIssues: 20, maxDirtyFiles: 0 },
|
||||
useEnvGithubToken: false,
|
||||
writePath: null
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing operator-readiness-dashboard.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts dashboard flags and rejects invalid values', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-args-');
|
||||
|
||||
try {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'script',
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
'--skip-github',
|
||||
'--allow-untracked',
|
||||
'docs/drafts/',
|
||||
'--repo',
|
||||
'affaan-m/ECC',
|
||||
'--generated-at',
|
||||
'2026-05-15T00:00:00.000Z'
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.format, 'json');
|
||||
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||
assert.strictEqual(parsed.skipGithub, true);
|
||||
assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']);
|
||||
assert.deepStrictEqual(parsed.repos, ['affaan-m/ECC']);
|
||||
assert.strictEqual(parsed.generatedAt, '2026-05-15T00:00:00.000Z');
|
||||
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--write', 'dashboard.md', '--format', 'text']), /--write requires/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('seeded repo emits an objective audit with remaining work', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-report-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const report = buildSeededReport(rootDir);
|
||||
|
||||
assert.strictEqual(report.schema_version, 'ecc.operator-readiness-dashboard.v1');
|
||||
assert.strictEqual(report.generatedAt, '2026-05-15T00:00:00.000Z');
|
||||
assert.strictEqual(report.dashboardReady, true);
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.strictEqual(report.publicationReady, false);
|
||||
assert.ok(report.requirements.some(item => item.id === 'completion-dashboard' && item.status === 'complete'));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'ecc-preview-pack'
|
||||
&& item.status === 'current'
|
||||
&& item.evidence.includes('deterministic smoke gate')
|
||||
&& item.gap === 'repeat clean-checkout preview-pack smoke before publication'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'hermes-specialized-skills'
|
||||
&& item.status === 'current'
|
||||
&& item.evidence.includes('covered by preview-pack smoke')
|
||||
&& item.gap === 'repeat preview-pack smoke before release review'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => item.id === 'ecc-tools-next-level' && item.status === 'in_progress'));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'agentshield-enterprise-iteration'
|
||||
&& item.gap === 'deepen live operator approval/readback after Marketplace/payment gates'
|
||||
&& item.evidence.includes('policy-promotion Action outputs')
|
||||
&& item.evidence.includes('hosted promotion judge audit traces')
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'ecc-tools-next-level'
|
||||
&& item.gap === 'repeat KV readback and selected-target announcement gate immediately before launch; keep native-payments copy behind the final release, plugin, URL, and owner-approval gates'
|
||||
&& item.evidence.includes('operator-visible promotion output details')
|
||||
&& item.evidence.includes('hosted promotion judge audit traces')
|
||||
&& item.evidence.includes('selected-target announcement gate')
|
||||
&& item.evidence.includes('billing gate env-file operator path')
|
||||
&& item.evidence.includes('non-breaking operator bearer path')
|
||||
&& item.evidence.includes('billing announcement preflight')
|
||||
&& item.evidence.includes('aggregate production billing KV readback')
|
||||
&& item.evidence.includes('Wrangler selected-target readback')
|
||||
&& item.evidence.includes('target-account billing readback')
|
||||
&& item.evidence.includes('provenance-aware Marketplace billing-state gates')
|
||||
&& item.evidence.includes('ready Marketplace Pro target selection')
|
||||
&& item.evidence.includes('hosted team-learning feedback controls')
|
||||
&& item.evidence.includes('ECC-Tools Dependabot alert remediation')
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'naming-and-plugin-publication'
|
||||
&& item.artifact.includes('release-name-plugin-publication checklist')
|
||||
&& item.evidence.includes('release publication checklist')
|
||||
&& item.gap === 'real tag/push, marketplace submission, and final channel choice remain approval-gated'
|
||||
)));
|
||||
assert.deepStrictEqual(report.growth, {
|
||||
currentMrr: '$1,728/mo',
|
||||
targetMrr: '$10,000/mo',
|
||||
gapMrr: '$8,272/mo',
|
||||
lanes: [
|
||||
'GitHub Sponsors and OSS partner sponsors',
|
||||
'ECC Tools Pro subscriptions',
|
||||
'consulting and implementation contracts',
|
||||
'talks, podcasts, conference demos, and partner webinars',
|
||||
],
|
||||
});
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'hypergrowth-command-center'
|
||||
&& item.status === 'current'
|
||||
&& item.evidence.includes('current MRR')
|
||||
&& item.gap === 'refresh after every MRR, channel, or approval-state change before public launch'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'release-video-suite'
|
||||
&& item.status === 'in_progress'
|
||||
&& item.evidence.includes('deterministic video-suite gate')
|
||||
&& item.gap === 'render final owner-approved MP4s, captions, platform reframes, and editable timeline before posting'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'partner-sponsor-talks-pack'
|
||||
&& item.status === 'in_progress'
|
||||
&& item.evidence.includes('sponsor outbound')
|
||||
&& item.gap === 'replace final URLs after publication gates, then get explicit approval before outbound or personal-account posts'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'owner-approval-packet'
|
||||
&& item.status === 'current'
|
||||
&& item.evidence.includes('release, package, plugin, video, billing, social, and outbound decisions')
|
||||
&& item.gap === 'review owner approvals from the final release commit before any publication or outbound action'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'supply-chain-local-protection'
|
||||
&& item.artifact.includes('AgentShield package-manager hardening')
|
||||
&& item.evidence.includes('known AI-tool persistence IOCs')
|
||||
&& item.evidence.includes('unsupported npm age-key drift')
|
||||
&& item.gap === 'repeat advisory/source refresh and Linear sync after each significant supply-chain batch'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'legacy-salvage'
|
||||
&& item.status === 'current'
|
||||
&& item.evidence.includes('all localization tails are attached to Linear ITO-55')
|
||||
&& item.gap === 'repeat legacy scan before release'
|
||||
)));
|
||||
assert.ok(report.requirements.some(item => (
|
||||
item.id === 'linear-roadmap-and-progress'
|
||||
&& item.status === 'current'
|
||||
&& item.evidence.includes('May 20 Marketplace Pro release-gate comments')
|
||||
&& item.gap === 'repeat Linear/project status update and local work-items sync after each significant merge batch'
|
||||
)));
|
||||
assert.ok(report.top_actions.some(item => item.id === 'naming-and-plugin-publication'));
|
||||
assert.ok(report.top_actions.some(item => item.id === 'release-video-suite'));
|
||||
assert.ok(report.top_actions.some(item => item.id === 'partner-sponsor-talks-pack'));
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'owner-approval-packet'));
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'ecc-preview-pack'));
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'hermes-specialized-skills'));
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'hypergrowth-command-center'));
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'legacy-salvage'));
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'linear-roadmap-and-progress'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release video suite moves current when publish-candidate evidence is recorded', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-video-current-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md': [
|
||||
'Release video suite',
|
||||
'growth outreach',
|
||||
'Operator dashboard',
|
||||
'GitGuardian',
|
||||
'macOS/Ubuntu/Windows test matrix',
|
||||
'2568 passed',
|
||||
'Business baseline',
|
||||
'$1,728/mo',
|
||||
'$8,272/mo',
|
||||
'Ready true',
|
||||
'15/15 source assets present',
|
||||
'13/13 render, timeline, caption, EDL, and segment artifacts present',
|
||||
'12/12 publish-candidate outputs present with zero detected black-frame segments',
|
||||
'primary rough render self-eval passed'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
const report = buildSeededReport(rootDir);
|
||||
const releaseVideo = report.requirements.find(item => item.id === 'release-video-suite');
|
||||
|
||||
assert.strictEqual(releaseVideo.status, 'current');
|
||||
assert.ok(releaseVideo.evidence.includes('15/15 source assets'));
|
||||
assert.ok(releaseVideo.evidence.includes('12/12 publish candidates'));
|
||||
assert.ok(releaseVideo.evidence.includes('zero detected black-frame segments'));
|
||||
assert.strictEqual(releaseVideo.gap, 'final owner approval, upload, and public video URLs remain approval-gated');
|
||||
assert.ok(!report.top_actions.some(item => item.id === 'release-video-suite'));
|
||||
assert.ok(report.next_work_order.some(item => item.includes('Review the owner-approved primary launch video candidates')));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Linear progress stays in progress until live sync evidence is mirrored', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-linear-progress-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||
'Linear ITO-44 ITO-59',
|
||||
'AgentShield Enterprise Iteration',
|
||||
'ECC-Tools PR #78',
|
||||
'hosted promotion',
|
||||
'announcementGate',
|
||||
'ITO-55'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
const report = buildSeededReport(rootDir);
|
||||
const linearProgress = report.requirements.find(item => item.id === 'linear-roadmap-and-progress');
|
||||
assert.strictEqual(linearProgress.status, 'in_progress');
|
||||
assert.strictEqual(linearProgress.evidence, 'repo mirror and progress-sync contract are present');
|
||||
assert.strictEqual(linearProgress.gap, 'recurring Linear status sync and productized realtime sync remain pending');
|
||||
assert.ok(report.top_actions.some(item => item.id === 'linear-roadmap-and-progress'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preview pack and Hermes gates stay in progress until smoke gate is wired', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-preview-smoke-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'package.json': JSON.stringify({
|
||||
files: [
|
||||
'scripts/observability-readiness.js',
|
||||
'scripts/operator-readiness-dashboard.js',
|
||||
'scripts/platform-audit.js'
|
||||
],
|
||||
scripts: {
|
||||
'discussion:audit': 'node scripts/discussion-audit.js',
|
||||
'observability:ready': 'node scripts/observability-readiness.js',
|
||||
'operator:dashboard': 'node scripts/operator-readiness-dashboard.js',
|
||||
'platform:audit': 'node scripts/platform-audit.js',
|
||||
'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
|
||||
'security:advisory-sources': 'node scripts/ci/supply-chain-advisory-sources.js'
|
||||
}
|
||||
}, null, 2),
|
||||
'scripts/preview-pack-smoke.js': null,
|
||||
'docs/releases/2.0.0-rc.1/preview-pack-manifest.md': 'publication-readiness.md release-notes.md quickstart.md'
|
||||
});
|
||||
|
||||
const report = buildSeededReport(rootDir);
|
||||
const previewPack = report.requirements.find(item => item.id === 'ecc-preview-pack');
|
||||
const hermes = report.requirements.find(item => item.id === 'hermes-specialized-skills');
|
||||
|
||||
assert.strictEqual(previewPack.status, 'in_progress');
|
||||
assert.strictEqual(previewPack.gap, 'final clean-checkout release approval and publish evidence still pending');
|
||||
assert.strictEqual(hermes.status, 'in_progress');
|
||||
assert.strictEqual(hermes.gap, 'final preview-pack smoke and release review pending');
|
||||
assert.ok(report.top_actions.some(item => item.id === 'ecc-preview-pack'));
|
||||
assert.ok(report.top_actions.some(item => item.id === 'hermes-specialized-skills'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('owner approval packet fails closed when it is missing from the release pack', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-owner-packet-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md': null,
|
||||
'docs/releases/2.0.0-rc.1/preview-pack-manifest.md': [
|
||||
'publication-readiness.md release-notes.md quickstart.md',
|
||||
'release-name-plugin-publication-checklist-2026-05-18.md',
|
||||
'`scripts/preview-pack-smoke.js`',
|
||||
'npm run preview-pack:smoke'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
const report = buildSeededReport(rootDir);
|
||||
const ownerPacket = report.requirements.find(item => item.id === 'owner-approval-packet');
|
||||
|
||||
assert.strictEqual(ownerPacket.status, 'not_complete');
|
||||
assert.strictEqual(ownerPacket.evidence, 'owner approval packet is missing or incomplete');
|
||||
assert.strictEqual(ownerPacket.gap, 'add the owner decision sheet before publication review');
|
||||
assert.ok(report.top_actions.some(item => item.id === 'owner-approval-packet'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('AgentShield enterprise evidence covers export and policy promotion markers', () => {
|
||||
const cases = [
|
||||
{
|
||||
marker: 'AgentShield PR #92',
|
||||
gap: 'workflow automation around protected rollout and richer runtime review UX pending after policy promotion shipped'
|
||||
},
|
||||
{
|
||||
marker: 'AgentShield #92',
|
||||
gap: 'workflow automation around protected rollout and richer runtime review UX pending after policy promotion shipped'
|
||||
},
|
||||
{
|
||||
marker: 'policy promote',
|
||||
gap: 'workflow automation around protected rollout and richer runtime review UX pending after policy promotion shipped'
|
||||
},
|
||||
{
|
||||
marker: 'checksum-verified policy promotion',
|
||||
gap: 'workflow automation around protected rollout and richer runtime review UX pending after policy promotion shipped'
|
||||
},
|
||||
{
|
||||
marker: 'hosted promotion judge audit traces',
|
||||
gap: 'deepen live operator approval/readback after Marketplace/payment gates'
|
||||
},
|
||||
{
|
||||
marker: '#78-#91',
|
||||
gap: 'workflow automation plus policy promotion/review UX pending after policy export shipped'
|
||||
},
|
||||
{
|
||||
marker: 'AgentShield PR #91',
|
||||
gap: 'workflow automation plus policy promotion/review UX pending after policy export shipped'
|
||||
},
|
||||
{
|
||||
marker: 'AgentShield #91',
|
||||
gap: 'workflow automation plus policy promotion/review UX pending after policy export shipped'
|
||||
},
|
||||
{
|
||||
marker: 'checksum-backed policy export',
|
||||
gap: 'workflow automation plus policy promotion/review UX pending after policy export shipped'
|
||||
},
|
||||
{
|
||||
marker: '#78-#90',
|
||||
gap: 'durable policy export and fleet-review workflow automation remain pending after reviewItems shipped'
|
||||
}
|
||||
];
|
||||
|
||||
for (const { marker, gap } of cases) {
|
||||
const rootDir = createTempDir('operator-dashboard-agentshield-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||
'Linear ITO-44 ITO-59',
|
||||
'AgentShield Enterprise Iteration',
|
||||
marker,
|
||||
'ECC-Tools PR #78',
|
||||
'hosted promotion',
|
||||
'announcementGate',
|
||||
'ITO-55'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
const report = buildSeededReport(rootDir);
|
||||
const item = report.requirements.find(requirement => requirement.id === 'agentshield-enterprise-iteration');
|
||||
assert.strictEqual(item.status, 'in_progress', marker);
|
||||
assert.strictEqual(item.gap, gap, marker);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('legacy salvage recognizes the real manual-review backlog heading', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-legacy-salvage-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||
'Linear ITO-44 ITO-59',
|
||||
'AgentShield PR #92 #78-#92 checksum-backed policy export policy promote checksum-verified policy promotion',
|
||||
'AgentShield Enterprise Iteration',
|
||||
'ECC-Tools PR #78',
|
||||
'hosted promotion',
|
||||
'announcementGate'
|
||||
].join('\n'),
|
||||
'docs/stale-pr-salvage-ledger.md': [
|
||||
'# Stale PR Salvage Ledger',
|
||||
'',
|
||||
'## Remaining Manual-Review Backlog',
|
||||
'',
|
||||
'- #1609 Persian README translation',
|
||||
'- #1563 zh-TW README sync'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
const report = buildSeededReport(rootDir);
|
||||
|
||||
const legacySalvage = report.requirements.find(item => item.id === 'legacy-salvage');
|
||||
assert.strictEqual(legacySalvage.status, 'in_progress');
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('markdown output can be written as the dashboard artifact', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-markdown-');
|
||||
const outputPath = path.join(rootDir, 'artifacts', 'dashboard.md');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const stdout = run([
|
||||
'--markdown',
|
||||
'--skip-github',
|
||||
`--root=${rootDir}`,
|
||||
'--generated-at=2026-05-15T00:00:00.000Z',
|
||||
'--write',
|
||||
outputPath
|
||||
], { cwd: rootDir });
|
||||
const written = fs.readFileSync(outputPath, 'utf8');
|
||||
|
||||
assert.strictEqual(stdout, written);
|
||||
assert.ok(written.includes('# ECC Operator Readiness Dashboard'));
|
||||
assert.ok(written.includes('Generated: 2026-05-15T00:00:00.000Z'));
|
||||
assert.ok(written.includes('## Growth Baseline'));
|
||||
assert.ok(written.includes('| MRR | $1,728/mo | $10,000/mo | $8,272/mo |'));
|
||||
assert.ok(written.includes('## Prompt-To-Artifact Checklist'));
|
||||
assert.ok(written.includes('Build ITO-44 completion dashboard into a repeatable command'));
|
||||
assert.ok(written.includes('## Next Work Order'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('text output renders compact status and top actions', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-text-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const stdout = run([
|
||||
'--format=text',
|
||||
'--skip-github',
|
||||
`--root=${rootDir}`,
|
||||
'--generated-at=2026-05-15T00:00:00.000Z'
|
||||
], { cwd: rootDir });
|
||||
|
||||
assert.ok(stdout.includes('ECC Operator Readiness Dashboard'));
|
||||
assert.ok(stdout.includes('work remaining'));
|
||||
assert.ok(stdout.includes('Dashboard ready: true'));
|
||||
assert.ok(stdout.includes('Publication ready: false'));
|
||||
assert.ok(stdout.includes('MRR: $1,728/mo -> $10,000/mo (gap $8,272/mo)'));
|
||||
assert.ok(stdout.includes('Top actions:'));
|
||||
assert.ok(stdout.includes('naming-and-plugin-publication'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renderers handle a ready report with no top actions', () => {
|
||||
const report = {
|
||||
dashboardReady: true,
|
||||
generatedAt: '2026-05-15T00:00:00.000Z',
|
||||
head: 'abc123',
|
||||
next_work_order: ['Ship release evidence'],
|
||||
platform: {
|
||||
blockingDirtyCount: 0,
|
||||
discussionsMissingAcceptedAnswer: 0,
|
||||
discussionsNeedingMaintainerTouch: 0,
|
||||
githubSkipped: false,
|
||||
ignoredDirtyCount: 0,
|
||||
openIssues: 1,
|
||||
openPrs: 1,
|
||||
ready: true
|
||||
},
|
||||
publicationReady: true,
|
||||
ready: true,
|
||||
requirements: [
|
||||
{
|
||||
artifact: 'artifact.md',
|
||||
evidence: 'verified',
|
||||
gap: '',
|
||||
id: 'release',
|
||||
requirement: 'Release is approved',
|
||||
status: 'complete'
|
||||
}
|
||||
],
|
||||
top_actions: []
|
||||
};
|
||||
|
||||
const text = renderText(report);
|
||||
assert.ok(text.includes('objective ready'));
|
||||
assert.ok(text.includes('Commit: abc123'));
|
||||
assert.ok(text.includes(' none'));
|
||||
|
||||
const markdown = renderMarkdown(report);
|
||||
assert.ok(markdown.includes('Status: objective ready'));
|
||||
assert.ok(markdown.includes('| PR queue | Current | 1 open PRs across tracked repos |'));
|
||||
assert.ok(markdown.includes('| Publication | Ready |'));
|
||||
assert.ok(markdown.includes('- none'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode fails closed while macro objective has gaps', () => {
|
||||
const rootDir = createTempDir('operator-dashboard-exit-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const result = runProcess([
|
||||
'--json',
|
||||
'--skip-github',
|
||||
`--root=${rootDir}`,
|
||||
'--generated-at=2026-05-15T00:00:00.000Z',
|
||||
'--exit-code'
|
||||
], { cwd: rootDir });
|
||||
|
||||
assert.strictEqual(result.status, 2);
|
||||
assert.strictEqual(result.stderr, '');
|
||||
assert.ok(result.stdout.includes('"ready": false'));
|
||||
assert.ok(result.stdout.includes('"publicationReady": false'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cli help exits successfully and invalid cli flags fail before reporting', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.strictEqual(help.stderr, '');
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/operator-readiness-dashboard.js'));
|
||||
assert.ok(help.stdout.includes('--write <path>'));
|
||||
|
||||
const invalid = runProcess(['--format=xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.strictEqual(invalid.stdout, '');
|
||||
assert.match(invalid.stderr, /Error: Invalid format/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'orchestrate-codex-worker.sh');
|
||||
|
||||
console.log('=== Testing orchestrate-codex-worker.sh ===\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('fails fast for an unreadable task file and records failure artifacts', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-worker-'));
|
||||
const handoffFile = path.join(tempRoot, '.orchestration', 'docs', 'handoff.md');
|
||||
const statusFile = path.join(tempRoot, '.orchestration', 'docs', 'status.md');
|
||||
const missingTaskFile = path.join(tempRoot, '.orchestration', 'docs', 'task.md');
|
||||
|
||||
try {
|
||||
spawnSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' });
|
||||
|
||||
const result = spawnSync('bash', [SCRIPT, missingTaskFile, handoffFile, statusFile], {
|
||||
cwd: tempRoot,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
assert.notStrictEqual(result.status, 0, 'Script should fail when task file is unreadable');
|
||||
assert.ok(fs.existsSync(statusFile), 'Script should still write a status file');
|
||||
assert.ok(fs.existsSync(handoffFile), 'Script should still write a handoff file');
|
||||
|
||||
const statusContent = fs.readFileSync(statusFile, 'utf8');
|
||||
const handoffContent = fs.readFileSync(handoffFile, 'utf8');
|
||||
|
||||
assert.ok(statusContent.includes('- State: failed'), 'Status file should record the failure state');
|
||||
assert.ok(
|
||||
statusContent.includes('task file is missing or unreadable'),
|
||||
'Status file should explain the task-file failure'
|
||||
);
|
||||
assert.ok(
|
||||
handoffContent.includes('Task file is missing or unreadable'),
|
||||
'Handoff file should explain the task-file failure'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Tests for scripts/orchestration-status.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'orchestration-status.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing orchestration-status.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('emits canonical dmux snapshots for plan files', () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-status-repo-'));
|
||||
|
||||
try {
|
||||
const planPath = path.join(repoRoot, 'workflow.json');
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
repoRoot,
|
||||
coordinationRoot: path.join(repoRoot, '.claude', 'orchestration')
|
||||
}));
|
||||
|
||||
const result = run([planPath], { cwd: repoRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.adapterId, 'dmux-tmux');
|
||||
assert.strictEqual(payload.session.id, 'workflow-visual-proof');
|
||||
assert.strictEqual(payload.session.sourceTarget.type, 'plan');
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Integration tests for the Plan Canvas server (scripts/lib/plan-canvas/).
|
||||
*
|
||||
* Spins up the real HTTP server in-process and drives it exactly like the
|
||||
* browser chrome (fetch + SSE) and the agent CLI (long-poll) do.
|
||||
*
|
||||
* Run with: node tests/scripts/plan-canvas.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions');
|
||||
const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.stack || err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function request(port, method, requestPath, { body = null, headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === null ? null : JSON.stringify(body);
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
method,
|
||||
path: requestPath,
|
||||
agent: false,
|
||||
headers: payload
|
||||
? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), ...headers }
|
||||
: headers
|
||||
},
|
||||
res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: data }));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function jsonBody(res) {
|
||||
return JSON.parse(res.body.trim());
|
||||
}
|
||||
|
||||
// Open an SSE stream and collect parsed events into `received`.
|
||||
function openSse(port, key) {
|
||||
const received = [];
|
||||
let close = () => {};
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ host: '127.0.0.1', port, path: `/events/${key}`, agent: false },
|
||||
res => {
|
||||
let buffer = '';
|
||||
res.on('data', chunk => {
|
||||
buffer += chunk;
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||
const frame = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
const eventMatch = frame.match(/^event: (.+)$/m);
|
||||
const dataMatch = frame.match(/^data: (.+)$/m);
|
||||
if (eventMatch && dataMatch) {
|
||||
received.push({ event: eventMatch[1], data: JSON.parse(dataMatch[1]) });
|
||||
}
|
||||
}
|
||||
});
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
close = () => req.destroy();
|
||||
});
|
||||
return { received, ready, close: () => close() };
|
||||
}
|
||||
|
||||
function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
if (predicate()) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
} else if (Date.now() - startedAt > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('waitFor timed out'));
|
||||
}
|
||||
}, intervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== Testing plan-canvas server ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-server-'));
|
||||
const artifact = path.join(tmp, 'demo.plan.md');
|
||||
fs.writeFileSync(artifact, '# Plan: Demo\n\n## Files to Change\n\n| File | Action |\n|---|---|\n| `a.js` | UPDATE |\n');
|
||||
const htmlArtifact = path.join(tmp, 'report.html');
|
||||
fs.writeFileSync(htmlArtifact, '<!DOCTYPE html><html><body><h1>Report</h1></body></html>');
|
||||
fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }');
|
||||
fs.writeFileSync(path.join(os.tmpdir(), 'plan-canvas-outside.txt'), 'secret');
|
||||
|
||||
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
|
||||
let idleFired = false;
|
||||
const canvas = createPlanCanvasServer({
|
||||
store,
|
||||
version: '9.9.9-test',
|
||||
heartbeatMs: 25,
|
||||
idleTimeoutMs: 0,
|
||||
onIdleShutdown: () => {
|
||||
idleFired = true;
|
||||
}
|
||||
});
|
||||
const { port } = await canvas.listen(0);
|
||||
|
||||
let key = null;
|
||||
let htmlKey = null;
|
||||
|
||||
if (await test('GET /health identifies the app and version', async () => {
|
||||
const res = await request(port, 'GET', '/health');
|
||||
assert.deepStrictEqual(jsonBody(res), { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test' });
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('requests with a non-loopback Host header are rejected', async () => {
|
||||
const res = await request(port, 'GET', '/health', { headers: { host: 'evil.example.com' } });
|
||||
assert.strictEqual(res.statusCode, 403);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('requests with a cross-site Origin are rejected', async () => {
|
||||
const res = await request(port, 'POST', '/shutdown', { headers: { origin: 'https://evil.example.com' } });
|
||||
assert.strictEqual(res.statusCode, 403);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('POST /api/sessions opens a session for an existing artifact', async () => {
|
||||
const res = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
const body = jsonBody(res);
|
||||
assert.strictEqual(body.status, 'open');
|
||||
assert.match(body.key, /^[a-f0-9]{12}$/);
|
||||
key = body.key;
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('POST /api/sessions 404s for a missing artifact', async () => {
|
||||
const res = await request(port, 'POST', '/api/sessions', { body: { file: path.join(tmp, 'nope.md') } });
|
||||
assert.strictEqual(res.statusCode, 404);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('GET /canvas/:key serves the ECC chrome with CSP', async () => {
|
||||
const res = await request(port, 'GET', `/canvas/${key}`);
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.ok(res.headers['content-security-policy'].includes("default-src 'self'"));
|
||||
assert.ok(res.body.includes('Plan Canvas'));
|
||||
assert.ok(res.body.includes('pc-session'));
|
||||
assert.ok(res.body.includes('Approve plan'));
|
||||
assert.ok(res.body.includes('sandbox="allow-scripts allow-forms allow-popups"'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('markdown artifacts render in the ECC plan template with the SDK', async () => {
|
||||
const res = await request(port, 'GET', `/artifact/${key}/`);
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.ok(res.body.includes('<h1 id="plan-demo">'));
|
||||
assert.ok(res.body.includes('<table>'));
|
||||
assert.ok(res.body.includes('<script src="/sdk.js">'));
|
||||
assert.strictEqual(res.headers['content-security-policy'], undefined);
|
||||
// No diagram in this plan → no Mermaid loader shipped.
|
||||
assert.ok(!res.body.includes('mermaid.run'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('a plan containing ```mermaid serves the themed Mermaid loader', async () => {
|
||||
const diagram = path.join(tmp, 'flow.plan.md');
|
||||
fs.writeFileSync(diagram, '# Flow\n\n```mermaid\nflowchart LR\n A --> B\n```\n');
|
||||
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: diagram } }));
|
||||
const res = await request(port, 'GET', `/artifact/${opened.key}/`);
|
||||
assert.ok(res.body.includes('<pre class="mermaid">'), 'diagram container present');
|
||||
assert.ok(res.body.includes('mermaid.run'), 'loader injected');
|
||||
assert.ok(res.body.includes("securityLevel: 'strict'"), 'sanitizing config present');
|
||||
await request(port, 'POST', '/api/end', { body: { file: diagram } });
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('HTML artifacts pass through with the SDK injected before </body>', async () => {
|
||||
const open = await request(port, 'POST', '/api/sessions', { body: { file: htmlArtifact } });
|
||||
htmlKey = jsonBody(open).key;
|
||||
const res = await request(port, 'GET', `/artifact/${htmlKey}/`);
|
||||
assert.ok(res.body.includes('<h1>Report</h1>'));
|
||||
assert.ok(res.body.includes('<script src="/sdk.js"></script>\n</body>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('sibling assets are served, traversal is blocked', async () => {
|
||||
const ok = await request(port, 'GET', `/artifact/${key}/style.css`);
|
||||
assert.strictEqual(ok.statusCode, 200);
|
||||
assert.ok(ok.body.includes('color: red'));
|
||||
const escape = await request(port, 'GET', `/artifact/${key}/..%2Fplan-canvas-outside.txt`);
|
||||
assert.strictEqual(escape.statusCode, 403);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('static chrome assets are served', async () => {
|
||||
for (const asset of ['/canvas.css', '/client.js', '/sdk.js']) {
|
||||
const res = await request(port, 'GET', asset);
|
||||
assert.strictEqual(res.statusCode, 200, `${asset} should be 200`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('await with timeoutMs returns waiting when idle', async () => {
|
||||
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`);
|
||||
assert.strictEqual(jsonBody(res).status, 'waiting');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('await returns missing for files without a session', async () => {
|
||||
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(path.join(tmp, 'other.md'))}`);
|
||||
assert.strictEqual(jsonBody(res).status, 'missing');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('browser feedback wakes a blocking await; presence transitions', async () => {
|
||||
const sse = openSse(port, key);
|
||||
await sse.ready;
|
||||
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
|
||||
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'listening'));
|
||||
|
||||
const post = await request(port, 'POST', `/api/session/${key}/feedback`, {
|
||||
body: {
|
||||
items: [
|
||||
{ kind: 'annotation', text: 'tighten this', anchor: { selector: 'h2:nth-of-type(1)', tag: 'h2', snippet: 'Files to Change' } },
|
||||
{ kind: 'verdict', verdict: 'request-changes' }
|
||||
]
|
||||
}
|
||||
});
|
||||
assert.strictEqual(jsonBody(post).accepted, 2);
|
||||
|
||||
const result = jsonBody(await awaitPromise);
|
||||
assert.strictEqual(result.status, 'feedback');
|
||||
assert.strictEqual(result.items.length, 2);
|
||||
assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)');
|
||||
assert.strictEqual(result.items[1].verdict, 'request-changes');
|
||||
|
||||
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'working'));
|
||||
await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2));
|
||||
sse.close();
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('long-poll heartbeat whitespace arrives before the payload', async () => {
|
||||
const chunks = [];
|
||||
const done = new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ host: '127.0.0.1', port, path: `/api/await?file=${encodeURIComponent(artifact)}`, agent: false },
|
||||
res => {
|
||||
res.on('data', chunk => chunks.push(chunk.toString()));
|
||||
res.on('end', resolve);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
});
|
||||
// Heartbeats tick every 25ms in this test server; wait for a few first.
|
||||
await waitFor(() => chunks.join('').length >= 3);
|
||||
assert.ok(/^\s+$/.test(chunks.join('')), 'expected only whitespace before payload');
|
||||
await request(port, 'POST', `/api/session/${key}/feedback`, { body: { items: [{ kind: 'chat', text: 'wake up' }] } });
|
||||
await done;
|
||||
const full = chunks.join('');
|
||||
assert.strictEqual(JSON.parse(full.trim()).status, 'feedback');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('agent reply lands in the chat via SSE chat-sync', async () => {
|
||||
const sse = openSse(port, key);
|
||||
await sse.ready;
|
||||
const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } });
|
||||
assert.strictEqual(jsonBody(res).status, 'sent');
|
||||
await waitFor(() =>
|
||||
sse.received.some(
|
||||
e => e.event === 'chat-sync' && e.data.chat.some(m => m.role === 'agent' && m.text.includes('reworked'))
|
||||
)
|
||||
);
|
||||
sse.close();
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('live reload: editing the artifact emits an SSE reload event', async () => {
|
||||
const sse = openSse(port, key);
|
||||
await sse.ready;
|
||||
fs.appendFileSync(artifact, '\n## Addendum\n');
|
||||
await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 });
|
||||
sse.close();
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('send-and-end delivers the final batch and ends the session', async () => {
|
||||
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
|
||||
await waitFor(() => canvas.presenceFor(key) === 'listening');
|
||||
await request(port, 'POST', `/api/session/${key}/feedback`, {
|
||||
body: { items: [{ kind: 'chat', text: 'looks good, wrapping up' }], endSession: true }
|
||||
});
|
||||
const result = jsonBody(await awaitPromise);
|
||||
assert.strictEqual(result.status, 'feedback');
|
||||
assert.strictEqual(result.sessionEnded, true);
|
||||
assert.strictEqual(result.endedBy, 'user');
|
||||
const after = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=0`);
|
||||
assert.strictEqual(jsonBody(after).status, 'ended');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('user-ended sessions return 409 on plain reopen, open with reopen:true', async () => {
|
||||
const refused = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
|
||||
assert.strictEqual(refused.statusCode, 409);
|
||||
assert.strictEqual(jsonBody(refused).status, 'user-ended');
|
||||
const forced = await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } });
|
||||
assert.strictEqual(forced.statusCode, 200);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('agent end via POST /api/end allows plain reopen', async () => {
|
||||
const res = await request(port, 'POST', '/api/end', { body: { file: artifact } });
|
||||
assert.strictEqual(jsonBody(res).endedBy, 'agent');
|
||||
const reopened = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
|
||||
assert.strictEqual(reopened.statusCode, 200);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('feedback on an ended session is refused with 409', async () => {
|
||||
await request(port, 'POST', `/api/end`, { body: { file: htmlArtifact } });
|
||||
const res = await request(port, 'POST', `/api/session/${htmlKey}/feedback`, {
|
||||
body: { items: [{ kind: 'chat', text: 'too late' }] }
|
||||
});
|
||||
assert.strictEqual(res.statusCode, 409);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('GET / lists sessions in the ECC shell', async () => {
|
||||
const res = await request(port, 'GET', '/');
|
||||
assert.ok(res.body.includes('Plan Canvas sessions'));
|
||||
assert.ok(res.body.includes('demo.plan.md'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('POST /shutdown triggers the shutdown callback', async () => {
|
||||
const res = await request(port, 'POST', '/shutdown');
|
||||
assert.strictEqual(jsonBody(res).status, 'stopping');
|
||||
await waitFor(() => idleFired);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('close() settles a held long-poll instead of hanging', async () => {
|
||||
await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } });
|
||||
const held = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
|
||||
await waitFor(() => canvas.presenceFor(store.findByFile(artifact).key) === 'listening');
|
||||
await canvas.close();
|
||||
const result = jsonBody(await held);
|
||||
assert.strictEqual(result.status, 'waiting');
|
||||
assert.ok(result.note.includes('shutting down'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
fs.rmSync(path.join(os.tmpdir(), 'plan-canvas-outside.txt'), { force: true });
|
||||
|
||||
console.log('\n' + '='.repeat(40));
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log('='.repeat(40));
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
console.log('Passed: 0');
|
||||
console.log('Failed: 1');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Tests for scripts/platform-audit.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js');
|
||||
const {
|
||||
DISCUSSION_ENABLED_QUERY,
|
||||
DISCUSSION_QUERY
|
||||
} = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions'));
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function seedRepo(rootDir, overrides = {}) {
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'everything-claude-code',
|
||||
scripts: {
|
||||
'platform:audit': 'node scripts/platform-audit.js',
|
||||
'discussion:audit': 'node scripts/discussion-audit.js',
|
||||
'operator:dashboard': 'node scripts/operator-readiness-dashboard.js',
|
||||
'observability:ready': 'node scripts/observability-readiness.js',
|
||||
'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
|
||||
'security:advisory-sources': 'node scripts/ci/supply-chain-advisory-sources.js',
|
||||
'harness:audit': 'node scripts/harness-audit.js'
|
||||
}
|
||||
}, null, 2),
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'ECC Platform Roadmap',
|
||||
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||
'ITO-44',
|
||||
'ITO-59'
|
||||
].join('\n'),
|
||||
'docs/architecture/progress-sync-contract.md': [
|
||||
'GitHub PRs/issues/discussions',
|
||||
'Linear project',
|
||||
'local handoff',
|
||||
'repo roadmap',
|
||||
'scripts/work-items.js'
|
||||
].join('\n'),
|
||||
'docs/security/supply-chain-incident-response.md': [
|
||||
'TanStack',
|
||||
'Mini Shai-Hulud',
|
||||
'node-ipc',
|
||||
'scan-supply-chain-iocs.js',
|
||||
'supply-chain-advisory-sources.js'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md': [
|
||||
'Release video suite',
|
||||
'growth outreach',
|
||||
'Operator dashboard',
|
||||
'GitGuardian',
|
||||
'macOS/Ubuntu/Windows test matrix',
|
||||
'2568 passed'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-20.md': [
|
||||
'This dashboard is generated by `npm run operator:dashboard`',
|
||||
'Growth Baseline',
|
||||
'hypergrowth release command center',
|
||||
'Prompt-To-Artifact Checklist',
|
||||
'ITO-44',
|
||||
'ITO-59',
|
||||
'PR queue',
|
||||
'Not complete',
|
||||
'operator:dashboard',
|
||||
'Next Work Order'
|
||||
].join('\n'),
|
||||
'scripts/operator-readiness-dashboard.js': 'operator dashboard generator'
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function discussionGhKey(owner, name, first = 100) {
|
||||
return `api graphql -f owner=${owner} -f name=${name} -F first=${first} -f query=${DISCUSSION_QUERY}`;
|
||||
}
|
||||
|
||||
function discussionEnabledGhKey(owner, name) {
|
||||
return `api graphql -f owner=${owner} -f name=${name} -f query=${DISCUSSION_ENABLED_QUERY}`;
|
||||
}
|
||||
|
||||
function writeGhShim(rootDir, responses) {
|
||||
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||
fs.writeFileSync(shimPath, `
|
||||
const responses = ${JSON.stringify(responses)};
|
||||
const args = process.argv.slice(2);
|
||||
const key = args.join(' ');
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
console.error('GITHUB_TOKEN should be unset by default');
|
||||
process.exit(42);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
console.error('Unexpected gh args: ' + key);
|
||||
process.exit(3);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(responses[key]));
|
||||
`);
|
||||
return shimPath;
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing platform-audit.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts supported flags and rejects invalid values', () => {
|
||||
const { parseArgs } = require(SCRIPT);
|
||||
const rootDir = createTempDir('platform-audit-args-');
|
||||
|
||||
try {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'script',
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
'--json',
|
||||
'--repo',
|
||||
'affaan-m/ECC',
|
||||
'--max-open-prs',
|
||||
'5',
|
||||
'--max-open-issues',
|
||||
'6',
|
||||
'--allow-untracked',
|
||||
'docs/drafts/'
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.format, 'json');
|
||||
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||
assert.deepStrictEqual(parsed.repos, ['affaan-m/ECC']);
|
||||
assert.strictEqual(parsed.thresholds.maxOpenPrs, 5);
|
||||
assert.strictEqual(parsed.thresholds.maxOpenIssues, 6);
|
||||
assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']);
|
||||
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--write', 'audit.md']), /--write requires/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--repo']), /--repo requires a value/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skip-github report checks local release and security evidence', () => {
|
||||
const projectRoot = createTempDir('platform-audit-local-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`, '--skip-github'], { cwd: projectRoot }));
|
||||
|
||||
assert.strictEqual(parsed.schema_version, 'ecc.platform-audit.v1');
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.github.skipped, true);
|
||||
assert.ok(parsed.checks.some(check => check.id === 'roadmap-linear-mirror' && check.status === 'pass'));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'supply-chain-runbook' && check.status === 'pass'));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'operator-dashboard-command' && check.status === 'pass'));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'operator-readiness-dashboard' && check.status === 'pass'));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'release-evidence-current' && check.status === 'pass'));
|
||||
assert.deepStrictEqual(parsed.top_actions, []);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release evidence gate rejects stale root suite counts', () => {
|
||||
const projectRoot = createTempDir('platform-audit-stale-release-evidence-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot, {
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md': [
|
||||
'Release video suite',
|
||||
'growth outreach',
|
||||
'Operator dashboard',
|
||||
'GitGuardian',
|
||||
'macOS/Ubuntu/Windows test matrix',
|
||||
'2560 passed'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`, '--skip-github'], { cwd: projectRoot }));
|
||||
const releaseEvidence = parsed.checks.find(check => check.id === 'release-evidence-current');
|
||||
|
||||
assert.strictEqual(releaseEvidence.status, 'fail');
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'release-evidence-current'));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('markdown output can be written as an operator artifact', () => {
|
||||
const projectRoot = createTempDir('platform-audit-markdown-');
|
||||
const outputPath = path.join(projectRoot, 'artifacts', 'platform-audit.md');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const stdout = run([
|
||||
'--markdown',
|
||||
'--write',
|
||||
outputPath,
|
||||
`--root=${projectRoot}`,
|
||||
'--skip-github'
|
||||
], { cwd: projectRoot });
|
||||
const written = fs.readFileSync(outputPath, 'utf8');
|
||||
|
||||
assert.strictEqual(stdout, written);
|
||||
assert.ok(written.includes('# ECC Platform Audit'));
|
||||
assert.ok(written.includes('## Queue Summary'));
|
||||
assert.ok(written.includes('| Open PRs | 0 | 20 | PASS |'));
|
||||
assert.ok(written.includes('`roadmap-linear-mirror`'));
|
||||
assert.ok(written.includes('## Top Actions'));
|
||||
assert.ok(written.includes('- none'));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('github queue and discussion budgets pass with maintainer touch', () => {
|
||||
const projectRoot = createTempDir('platform-audit-github-pass-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const shimPath = writeGhShim(projectRoot, {
|
||||
'pr list --repo affaan-m/ECC --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [],
|
||||
'issue list --repo affaan-m/ECC --state open --json number,title,updatedAt,url,author,labels': [],
|
||||
[discussionEnabledGhKey('affaan-m', 'ECC')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: true } }
|
||||
},
|
||||
[discussionGhKey('affaan-m', 'ECC')]: {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: {
|
||||
totalCount: 1,
|
||||
nodes: [
|
||||
{
|
||||
number: 73,
|
||||
title: 'Compacting during workflow',
|
||||
url: 'https://github.com/example/discussions/73',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
authorAssociation: 'NONE',
|
||||
category: { name: 'General', isAnswerable: false },
|
||||
answer: null,
|
||||
comments: { nodes: [{ authorAssociation: 'OWNER' }] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--format=json',
|
||||
`--root=${projectRoot}`,
|
||||
'--repo',
|
||||
'affaan-m/ECC'
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
ECC_GH_SHIM: shimPath,
|
||||
GITHUB_TOKEN: 'must-be-removed'
|
||||
}
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.github.totals.openPrs, 0);
|
||||
assert.strictEqual(parsed.github.totals.openIssues, 0);
|
||||
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0);
|
||||
assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 0);
|
||||
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass'));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-answers' && check.status === 'pass'));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('threshold failures and untouched discussions become top actions', () => {
|
||||
const projectRoot = createTempDir('platform-audit-github-fail-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const prs = Array.from({ length: 3 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
title: `PR ${index + 1}`,
|
||||
isDraft: false,
|
||||
mergeStateStatus: 'CLEAN',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
url: `https://github.com/example/pull/${index + 1}`,
|
||||
author: { login: 'contributor' }
|
||||
}));
|
||||
const shimPath = writeGhShim(projectRoot, {
|
||||
'pr list --repo affaan-m/ECC --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs,
|
||||
'issue list --repo affaan-m/ECC --state open --json number,title,updatedAt,url,author,labels': [],
|
||||
[discussionEnabledGhKey('affaan-m', 'ECC')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: true } }
|
||||
},
|
||||
[discussionGhKey('affaan-m', 'ECC')]: {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: {
|
||||
totalCount: 1,
|
||||
nodes: [
|
||||
{
|
||||
number: 1239,
|
||||
title: 'Losing context',
|
||||
url: 'https://github.com/example/discussions/1239',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
authorAssociation: 'NONE',
|
||||
category: { name: 'Q&A', isAnswerable: true },
|
||||
answer: null,
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--format=json',
|
||||
`--root=${projectRoot}`,
|
||||
'--repo',
|
||||
'affaan-m/ECC',
|
||||
'--max-open-prs',
|
||||
'2'
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
env: { ECC_GH_SHIM: shimPath }
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, false);
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'github-open-pr-budget'));
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-touch'));
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-answers'));
|
||||
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1);
|
||||
assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 1);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('discussion-disabled repos skip the heavy discussion query', () => {
|
||||
const projectRoot = createTempDir('platform-audit-discussions-disabled-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const shimPath = writeGhShim(projectRoot, {
|
||||
'pr list --repo ECC-Tools/ECC-website --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [],
|
||||
'issue list --repo ECC-Tools/ECC-website --state open --json number,title,updatedAt,url,author,labels': [],
|
||||
[discussionEnabledGhKey('ECC-Tools', 'ECC-website')]: {
|
||||
data: { repository: { hasDiscussionsEnabled: false } }
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--format=json',
|
||||
`--root=${projectRoot}`,
|
||||
'--repo',
|
||||
'ECC-Tools/ECC-website'
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
env: { ECC_GH_SHIM: shimPath }
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.github.repos[0].discussions.enabled, false);
|
||||
assert.strictEqual(parsed.github.repos[0].discussions.totalCount, 0);
|
||||
assert.strictEqual(parsed.github.totals.errors, 0);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cli help and invalid args exit cleanly', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/platform-audit.js'));
|
||||
|
||||
const invalid = runProcess(['--format', 'xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.ok(invalid.stderr.includes('Invalid format'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-command-log.js');
|
||||
const { sanitizeCommand } = require(scriptPath);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS: ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(`FAIL: ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runHook(mode, payload, homeDir) {
|
||||
return spawnSync('node', [scriptPath, mode], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('sanitizeCommand redacts common secret formats', () => {
|
||||
const input = 'gh pr create --token abc123 Authorization: Bearer hello password=swordfish ghp_abc github_pat_xyz';
|
||||
const sanitized = sanitizeCommand(input);
|
||||
assert.ok(!sanitized.includes('abc123'));
|
||||
assert.ok(!sanitized.includes('swordfish'));
|
||||
assert.ok(!sanitized.includes('ghp_abc'));
|
||||
assert.ok(!sanitized.includes('github_pat_xyz'));
|
||||
assert.ok(sanitized.includes('--token=<REDACTED>'));
|
||||
assert.ok(sanitized.includes('Authorization:<REDACTED>'));
|
||||
assert.ok(sanitized.includes('password=<REDACTED>'));
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('audit mode logs sanitized bash commands and preserves stdout', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-bash-log-'));
|
||||
const payload = {
|
||||
tool_input: {
|
||||
command: 'git push --token abc123',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = runHook('audit', payload, homeDir);
|
||||
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
|
||||
assert.strictEqual(result.stdout, JSON.stringify(payload));
|
||||
|
||||
const logFile = path.join(homeDir, '.claude', 'bash-commands.log');
|
||||
const logContent = fs.readFileSync(logFile, 'utf8');
|
||||
assert.ok(logContent.includes('--token=<REDACTED>'));
|
||||
assert.ok(!logContent.includes('abc123'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('cost mode writes command metrics log', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-log-'));
|
||||
const payload = {
|
||||
tool_input: {
|
||||
command: 'npm publish',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = runHook('cost', payload, homeDir);
|
||||
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
|
||||
|
||||
const logFile = path.join(homeDir, '.claude', 'cost-tracker.log');
|
||||
const logContent = fs.readFileSync(logFile, 'utf8');
|
||||
assert.match(logContent, /tool=Bash command=npm publish/);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,260 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'preview-pack-smoke.js');
|
||||
const {
|
||||
REQUIRED_ARTIFACTS,
|
||||
REQUIRED_PUBLICATION_BLOCKERS,
|
||||
REQUIRED_VERIFICATION_COMMANDS,
|
||||
buildReport,
|
||||
parseArgs,
|
||||
renderText,
|
||||
} = require(SCRIPT);
|
||||
|
||||
const RELEASE_DIR = 'docs/releases/2.0.0-rc.1';
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function manifestContent() {
|
||||
return [
|
||||
'# ECC v2.0.0-rc.1 Preview Pack Manifest',
|
||||
'',
|
||||
'## Pack Contents',
|
||||
'',
|
||||
'| Artifact | Role | Gate |',
|
||||
'| --- | --- | --- |',
|
||||
...REQUIRED_ARTIFACTS.map(artifact => `| \`${artifact}\` | release artifact | checked |`),
|
||||
'',
|
||||
'## Hermes Skill Boundary',
|
||||
'',
|
||||
'- no raw workspace exports;',
|
||||
'',
|
||||
'## Final Verification Commands',
|
||||
'',
|
||||
'```bash',
|
||||
...REQUIRED_VERIFICATION_COMMANDS,
|
||||
'```',
|
||||
'',
|
||||
'## Publication Blockers',
|
||||
'',
|
||||
...REQUIRED_PUBLICATION_BLOCKERS.map(blocker => `- ${blocker}`),
|
||||
'',
|
||||
'The preview pack is not public without approval-gated release, package, plugin, and announcement steps.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function seedRepo(rootDir, overrides = {}) {
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
files: ['scripts/preview-pack-smoke.js'],
|
||||
scripts: {
|
||||
'preview-pack:smoke': 'node scripts/preview-pack-smoke.js',
|
||||
},
|
||||
}, null, 2),
|
||||
'scripts/preview-pack-smoke.js': 'preview pack smoke script',
|
||||
[`${RELEASE_DIR}/preview-pack-manifest.md`]: manifestContent(),
|
||||
'docs/HERMES-SETUP.md': [
|
||||
'# Hermes Setup',
|
||||
'Public Release Candidate Scope',
|
||||
'ECC v2.0.0-rc.1 documents the Hermes surface',
|
||||
'No raw workspace export is included.',
|
||||
].join('\n'),
|
||||
'skills/hermes-imports/SKILL.md': [
|
||||
'---',
|
||||
'name: hermes-imports',
|
||||
'---',
|
||||
'Sanitization Checklist',
|
||||
'Do not ship raw workspace exports',
|
||||
'Output Contract',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
for (const artifact of REQUIRED_ARTIFACTS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(files, artifact)) {
|
||||
files[artifact] = `${artifact} public preview-pack content`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing preview-pack-smoke.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts smoke flags and rejects invalid values', () => {
|
||||
const rootDir = createTempDir('preview-pack-smoke-args-');
|
||||
|
||||
try {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'script',
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.format, 'json');
|
||||
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--root']), /--root requires a value/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('seeded release pack passes every smoke check', () => {
|
||||
const rootDir = createTempDir('preview-pack-smoke-pass-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const report = buildReport({ root: rootDir });
|
||||
|
||||
assert.strictEqual(report.schema_version, 'ecc.preview-pack-smoke.v1');
|
||||
assert.strictEqual(report.ready, true);
|
||||
assert.strictEqual(report.summary.failed, 0);
|
||||
assert.ok(report.checks.every(check => check.status === 'pass'));
|
||||
|
||||
const text = renderText(report);
|
||||
assert.ok(text.includes('Ready: yes'));
|
||||
assert.ok(text.includes('Passed: 5'));
|
||||
assert.ok(text.includes('Failed: 0'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('script registration fails closed without package wiring', () => {
|
||||
const rootDir = createTempDir('preview-pack-smoke-package-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
'package.json': JSON.stringify({ files: [], scripts: {} }, null, 2),
|
||||
});
|
||||
|
||||
const report = buildReport({ root: rootDir });
|
||||
const registration = report.checks.find(check => check.id === 'preview-pack-script-registered');
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.strictEqual(registration.status, 'fail');
|
||||
assert.ok(registration.fix.includes('preview-pack:smoke'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Hermes boundary fails closed on private local paths', () => {
|
||||
const rootDir = createTempDir('preview-pack-smoke-private-path-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {
|
||||
[`${RELEASE_DIR}/quickstart.md`]: 'Do not ship /Users/affoon/private-state in public docs.',
|
||||
});
|
||||
|
||||
const report = buildReport({ root: rootDir });
|
||||
const boundary = report.checks.find(check => check.id === 'hermes-boundary-sanitized');
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.strictEqual(boundary.status, 'fail');
|
||||
assert.ok(boundary.evidence.includes(`${RELEASE_DIR}/quickstart.md:1`));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI emits json and uses status 2 for failed smoke reports', () => {
|
||||
const rootDir = createTempDir('preview-pack-smoke-cli-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const stdout = run(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
|
||||
const parsed = JSON.parse(stdout);
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
|
||||
writeFile(rootDir, 'package.json', JSON.stringify({ files: [], scripts: {} }, null, 2));
|
||||
const failedRun = runProcess(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
|
||||
assert.strictEqual(failedRun.status, 2);
|
||||
assert.strictEqual(failedRun.stderr, '');
|
||||
assert.ok(failedRun.stdout.includes('"ready": false'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI help exits successfully and invalid flags fail before reporting', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.strictEqual(help.stderr, '');
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/preview-pack-smoke.js'));
|
||||
|
||||
const invalid = runProcess(['--format=xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.strictEqual(invalid.stdout, '');
|
||||
assert.match(invalid.stderr, /Error: Invalid format/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'release-approval-gate.js');
|
||||
const {
|
||||
REQUIRED_DECISIONS,
|
||||
REQUIRED_URL_SURFACES,
|
||||
buildReport,
|
||||
parseArgs,
|
||||
renderText,
|
||||
} = require(SCRIPT);
|
||||
|
||||
const CURRENT_RELEASE = require(path.join(__dirname, '..', '..', 'package.json')).version;
|
||||
const RC_RELEASE = '2.0.0-rc.1';
|
||||
|
||||
function releaseDirFor(release) {
|
||||
return `docs/releases/${release}`;
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function approvedPacketContent(overrides = {}, release = CURRENT_RELEASE) {
|
||||
const decisions = new Map(REQUIRED_DECISIONS.map(decision => [decision.label, 'approve']));
|
||||
for (const [label, value] of Object.entries(overrides)) {
|
||||
decisions.set(label, value);
|
||||
}
|
||||
|
||||
return [
|
||||
`# ECC v${release} Owner Approval Packet`,
|
||||
'',
|
||||
'## Decision Register',
|
||||
'',
|
||||
'| Decision | Approve / defer / block | Evidence required first | Notes |',
|
||||
'| --- | --- | --- | --- |',
|
||||
...REQUIRED_DECISIONS.map(decision => (
|
||||
`| ${decision.label} | ${decisions.get(decision.label)} | final evidence | approved fixture |`
|
||||
)),
|
||||
'',
|
||||
'## Final Evidence Commands',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run release:approval-gate -- --format json',
|
||||
'```',
|
||||
'',
|
||||
'No outbound email, personal-account post, package publish, plugin tag, or billing announcement is authorized by this packet alone.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function finalLedgerContent(extra = '', release = CURRENT_RELEASE) {
|
||||
return [
|
||||
`# ECC v${release} Release URL Ledger`,
|
||||
'',
|
||||
'## Final Published URLs',
|
||||
'',
|
||||
'| Surface | URL | Verification |',
|
||||
'| --- | --- | --- |',
|
||||
...REQUIRED_URL_SURFACES.map(surface => (
|
||||
`| ${surface.label} | ${surface.exampleUrl.split(RC_RELEASE).join(release)} | readback from final release commit |`
|
||||
)),
|
||||
'',
|
||||
'## Final Verification Commands',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run release:approval-gate -- --format json',
|
||||
'```',
|
||||
'',
|
||||
extra,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function manifestContent(release = CURRENT_RELEASE) {
|
||||
return [
|
||||
`# ECC v${release} Preview Pack Manifest`,
|
||||
'',
|
||||
'| Artifact | Role | Gate |',
|
||||
'| --- | --- | --- |',
|
||||
'| `scripts/release-approval-gate.js` | Final owner approval and live URL gate | Verified by `npm run release:approval-gate -- --format json` |',
|
||||
'',
|
||||
'## Final Verification Commands',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run release:approval-gate -- --format json',
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function seedRepo(rootDir, overrides = {}, options = {}) {
|
||||
const release = options.release || CURRENT_RELEASE;
|
||||
const releaseDir = releaseDirFor(release);
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
version: release,
|
||||
files: ['scripts/release-approval-gate.js'],
|
||||
scripts: {
|
||||
'release:approval-gate': 'node scripts/release-approval-gate.js',
|
||||
},
|
||||
}, null, 2),
|
||||
'scripts/release-approval-gate.js': 'release approval gate script',
|
||||
[`${releaseDir}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({}, release),
|
||||
[`${releaseDir}/release-url-ledger-2026-05-19.md`]: finalLedgerContent('', release),
|
||||
[`${releaseDir}/preview-pack-manifest.md`]: manifestContent(release),
|
||||
[`${releaseDir}/release-notes.md`]: 'Release notes with final URLs.',
|
||||
[`${releaseDir}/x-thread.md`]: 'X post with final URLs.',
|
||||
[`${releaseDir}/linkedin-post.md`]: 'LinkedIn post with final URLs.',
|
||||
[`${releaseDir}/article-outline.md`]: 'Article outline with final URLs.',
|
||||
[`${releaseDir}/partner-sponsor-talks-pack.md`]: 'Outbound copy with final URLs.',
|
||||
'docs/business/social-launch-copy.md': 'Business launch copy with final URLs.',
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing release-approval-gate.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts approval gate flags and rejects invalid values', () => {
|
||||
const rootDir = createTempDir('release-approval-args-');
|
||||
|
||||
try {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'script',
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.format, 'json');
|
||||
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--root']), /--root requires a value/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('seeded approved release passes every publication approval check', () => {
|
||||
const rootDir = createTempDir('release-approval-pass-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const report = buildReport({ root: rootDir });
|
||||
|
||||
assert.strictEqual(report.schema_version, 'ecc.release-approval-gate.v1');
|
||||
assert.strictEqual(report.release, CURRENT_RELEASE);
|
||||
assert.strictEqual(report.ready, true);
|
||||
assert.strictEqual(report.summary.failed, 0);
|
||||
assert.deepStrictEqual(report.top_actions, []);
|
||||
assert.ok(report.checks.every(check => check.status === 'pass'));
|
||||
|
||||
const text = renderText(report);
|
||||
assert.ok(text.includes('Ready: yes'));
|
||||
assert.ok(text.includes('Failed: 0'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release override keeps rc.1 approval fixtures testable', () => {
|
||||
const rootDir = createTempDir('release-approval-rc-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {}, { release: RC_RELEASE });
|
||||
const report = buildReport({ root: rootDir, release: RC_RELEASE });
|
||||
|
||||
assert.strictEqual(report.release, RC_RELEASE);
|
||||
assert.strictEqual(report.ready, true);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deferred owner decisions keep the publication gate blocked', () => {
|
||||
const rootDir = createTempDir('release-approval-deferred-');
|
||||
|
||||
try {
|
||||
const releaseDir = releaseDirFor(CURRENT_RELEASE);
|
||||
seedRepo(rootDir, {
|
||||
[`${releaseDir}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({
|
||||
'GitHub prerelease': 'defer',
|
||||
'Sponsor, partner, consulting, conference, podcast outreach': 'block',
|
||||
}),
|
||||
});
|
||||
|
||||
const report = buildReport({ root: rootDir });
|
||||
const decisions = report.checks.find(check => check.id === 'owner-decisions-approved');
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.strictEqual(decisions.status, 'fail');
|
||||
assert.ok(decisions.evidence.includes('GitHub prerelease=defer'));
|
||||
assert.ok(decisions.evidence.includes('Sponsor, partner, consulting, conference, podcast outreach=block'));
|
||||
assert.ok(report.top_actions.some(action => action.includes('Approve, defer, or block')));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('approval-gated URL ledger rows keep the publication gate blocked', () => {
|
||||
const rootDir = createTempDir('release-approval-ledger-');
|
||||
|
||||
try {
|
||||
const releaseDir = releaseDirFor(CURRENT_RELEASE);
|
||||
seedRepo(rootDir, {
|
||||
[`${releaseDir}/release-url-ledger-2026-05-19.md`]: [
|
||||
`# ECC v${CURRENT_RELEASE} Release URL Ledger`,
|
||||
'',
|
||||
'## Approval-Gated URLs',
|
||||
'',
|
||||
'| Surface | Intended URL or command | Gate before use |',
|
||||
'| --- | --- | --- |',
|
||||
`| GitHub prerelease | https://github.com/affaan-m/ECC/releases/tag/v${CURRENT_RELEASE} | must return the prerelease |`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const report = buildReport({ root: rootDir });
|
||||
const ledger = report.checks.find(check => check.id === 'release-url-ledger-finalized');
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.strictEqual(ledger.status, 'fail');
|
||||
assert.ok(ledger.evidence.includes('approval-gated URL section still present'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('announcement drafts fail on unresolved placeholders and private paths', () => {
|
||||
const rootDir = createTempDir('release-approval-copy-');
|
||||
|
||||
try {
|
||||
const releaseDir = releaseDirFor(CURRENT_RELEASE);
|
||||
seedRepo(rootDir, {
|
||||
[`${releaseDir}/x-thread.md`]: 'Ship copy with <video-url> and /Users/affaan/raw-footage.',
|
||||
});
|
||||
|
||||
const report = buildReport({ root: rootDir });
|
||||
const copy = report.checks.find(check => check.id === 'announcement-copy-finalized');
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.strictEqual(copy.status, 'fail');
|
||||
assert.ok(copy.evidence.includes(`${releaseDir}/x-thread.md:1`));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI emits json and uses status 2 for blocked approval reports', () => {
|
||||
const rootDir = createTempDir('release-approval-cli-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
const stdout = run(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
|
||||
const parsed = JSON.parse(stdout);
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.release, CURRENT_RELEASE);
|
||||
|
||||
const releaseDir = releaseDirFor(CURRENT_RELEASE);
|
||||
writeFile(
|
||||
rootDir,
|
||||
`${releaseDir}/owner-approval-packet-2026-05-19.md`,
|
||||
approvedPacketContent({ 'Video upload': 'defer' })
|
||||
);
|
||||
const failedRun = runProcess(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
|
||||
assert.strictEqual(failedRun.status, 2);
|
||||
assert.strictEqual(failedRun.stderr, '');
|
||||
assert.ok(failedRun.stdout.includes('"ready": false'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI help exits successfully and invalid flags fail before reporting', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.strictEqual(help.stderr, '');
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/release-approval-gate.js'));
|
||||
|
||||
const invalid = runProcess(['--format=xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.strictEqual(invalid.stdout, '');
|
||||
assert.match(invalid.stderr, /Error: Invalid format/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function load(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
console.log('\n=== Testing release publish workflow ===\n');
|
||||
|
||||
for (const workflow of [
|
||||
'.github/workflows/release.yml',
|
||||
'.github/workflows/reusable-release.yml',
|
||||
]) {
|
||||
const content = load(workflow);
|
||||
const jobsIndex = content.search(/^jobs:\s*$/m);
|
||||
const workflowHeader = jobsIndex >= 0 ? content.slice(0, jobsIndex) : content;
|
||||
|
||||
test(`${workflow} scopes id-token to the publish job for npm provenance`, () => {
|
||||
assert.doesNotMatch(workflowHeader, /id-token:\s*write/);
|
||||
assert.match(content, /\n\s+permissions:\n\s+contents:\s*write\n\s+id-token:\s*write/m);
|
||||
});
|
||||
|
||||
test(`${workflow} configures the npm registry`, () => {
|
||||
assert.match(content, /registry-url:\s*['"]https:\/\/registry\.npmjs\.org['"]/);
|
||||
});
|
||||
|
||||
test(`${workflow} ignores dependency lifecycle scripts before privileged publish`, () => {
|
||||
assert.match(content, /npm ci --ignore-scripts/);
|
||||
});
|
||||
|
||||
test(`${workflow} checks whether the tagged npm version already exists`, () => {
|
||||
assert.match(content, /Check npm publish state/);
|
||||
assert.match(content, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" version/);
|
||||
});
|
||||
|
||||
test(`${workflow} publishes new tag versions to npm`, () => {
|
||||
assert.match(content, /npm publish "\$\{\{ needs\.verify\.outputs\.package_file \}\}" --access public --provenance/);
|
||||
assert.match(content, /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/);
|
||||
});
|
||||
|
||||
test(`${workflow} creates the GitHub Release before publishing to npm`, () => {
|
||||
const releaseIndex = content.indexOf('name: Create GitHub Release');
|
||||
const publishIndex = content.indexOf('name: Publish npm package');
|
||||
|
||||
assert.ok(releaseIndex >= 0, `${workflow} should create a GitHub Release`);
|
||||
assert.ok(publishIndex >= 0, `${workflow} should publish the npm package`);
|
||||
assert.ok(
|
||||
releaseIndex < publishIndex,
|
||||
`${workflow} should not publish to npm until GitHub Release creation has succeeded`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.log(`\nFailed: ${failed}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Tests for scripts/release-video-suite.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'release-video-suite.js');
|
||||
const {
|
||||
REQUIRED_PUBLISH_CANDIDATES,
|
||||
REQUIRED_SOURCE_ASSETS,
|
||||
REQUIRED_SUITE_ARTIFACTS,
|
||||
buildReport,
|
||||
parseArgs,
|
||||
renderText,
|
||||
summarizeReport,
|
||||
} = require(SCRIPT);
|
||||
|
||||
const CURRENT_RELEASE = require(path.join(__dirname, '..', '..', 'package.json')).version;
|
||||
const RC_RELEASE = '2.0.0-rc.1';
|
||||
|
||||
function releaseDirFor(release) {
|
||||
return `docs/releases/${release}`;
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content = 'fixture') {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function videoManifestContent(extra = '') {
|
||||
return [
|
||||
'# ECC 2.0 Video Suite Production Manifest',
|
||||
'ECC_VIDEO_SOURCE_ROOT',
|
||||
'ECC_VIDEO_RELEASE_SUITE_ROOT',
|
||||
'Primary launch video',
|
||||
'video-use compatible workflow',
|
||||
'Self-Eval Gate',
|
||||
'Do Not Publish If',
|
||||
'Do not commit raw footage, transcript JSON, or timeline exports',
|
||||
extra,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function seedRepo(rootDir, overrides = {}, options = {}) {
|
||||
const release = options.release || CURRENT_RELEASE;
|
||||
const releaseDir = releaseDirFor(release);
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'ecc-universal',
|
||||
version: release,
|
||||
files: ['scripts/release-video-suite.js'],
|
||||
scripts: {
|
||||
'release:video-suite': 'node scripts/release-video-suite.js',
|
||||
},
|
||||
}, null, 2),
|
||||
[`${releaseDir}/video-suite-production.md`]: videoManifestContent(),
|
||||
'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md': [
|
||||
'Keep raw absolute paths out of public docs',
|
||||
'Pick final video cuts, upload after approval, and attach public URLs',
|
||||
].join('\n'),
|
||||
[`${releaseDir}/preview-pack-manifest.md`]: 'video-suite-production.md',
|
||||
[`${releaseDir}/launch-checklist.md`]: 'release video suite',
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function seedMedia(sourceRoot, suiteRoot) {
|
||||
for (const asset of REQUIRED_SOURCE_ASSETS) {
|
||||
writeFile(sourceRoot, asset.file, `source ${asset.id}`);
|
||||
}
|
||||
|
||||
for (const artifact of REQUIRED_SUITE_ARTIFACTS) {
|
||||
writeFile(suiteRoot, artifact.relativePath, `artifact ${artifact.id}`);
|
||||
}
|
||||
|
||||
for (const candidate of REQUIRED_PUBLISH_CANDIDATES) {
|
||||
writeFile(suiteRoot, candidate.relativePath, `candidate ${candidate.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing release-video-suite.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts release video flags and rejects invalid values', () => {
|
||||
const rootDir = createTempDir('release-video-args-');
|
||||
const sourceRoot = createTempDir('release-video-source-');
|
||||
const suiteRoot = createTempDir('release-video-suite-');
|
||||
|
||||
try {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'script',
|
||||
'--json',
|
||||
`--root=${rootDir}`,
|
||||
'--source-root',
|
||||
sourceRoot,
|
||||
`--suite-root=${suiteRoot}`,
|
||||
'--skip-probe',
|
||||
'--summary',
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.format, 'json');
|
||||
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||
assert.strictEqual(parsed.sourceRoot, path.resolve(sourceRoot));
|
||||
assert.strictEqual(parsed.suiteRoot, path.resolve(suiteRoot));
|
||||
assert.strictEqual(parsed.skipProbe, true);
|
||||
assert.strictEqual(parsed.summary, true);
|
||||
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--source-root']), /--source-root requires a value/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
cleanup(sourceRoot);
|
||||
cleanup(suiteRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('buildReport passes with a sanitized manifest and complete local media fixture', () => {
|
||||
const rootDir = createTempDir('release-video-report-');
|
||||
const sourceRoot = createTempDir('release-video-source-');
|
||||
const suiteRoot = createTempDir('release-video-suite-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
seedMedia(sourceRoot, suiteRoot);
|
||||
|
||||
const report = buildReport({
|
||||
root: rootDir,
|
||||
sourceRoot,
|
||||
suiteRoot,
|
||||
skipProbe: true,
|
||||
generatedAt: '2026-05-19T00:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.strictEqual(report.schema_version, 'ecc.release-video-suite.v1');
|
||||
assert.strictEqual(report.release, CURRENT_RELEASE);
|
||||
assert.strictEqual(report.ready, true);
|
||||
assert.strictEqual(report.mediaPathsRedacted, true);
|
||||
assert.ok(report.checks.every(check => check.status === 'pass'));
|
||||
assert.ok(report.checks.some(check => (
|
||||
check.id === 'video-primary-render-self-eval'
|
||||
&& check.summary.includes('skipped by --skip-probe')
|
||||
)));
|
||||
assert.strictEqual(report.sourceAssets.length, REQUIRED_SOURCE_ASSETS.length);
|
||||
assert.strictEqual(report.suiteArtifacts.length, REQUIRED_SUITE_ARTIFACTS.length);
|
||||
assert.strictEqual(report.publishCandidates.length, REQUIRED_PUBLISH_CANDIDATES.length);
|
||||
assert.ok(renderText(report).includes('Ready: yes'));
|
||||
assert.strictEqual(summarizeReport(report).sourceAssetSummary.present, REQUIRED_SOURCE_ASSETS.length);
|
||||
assert.strictEqual(
|
||||
summarizeReport(report).publishCandidateSummary.present,
|
||||
REQUIRED_PUBLISH_CANDIDATES.length
|
||||
);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
cleanup(sourceRoot);
|
||||
cleanup(suiteRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release override keeps rc.1 video fixtures testable', () => {
|
||||
const rootDir = createTempDir('release-video-rc-');
|
||||
const sourceRoot = createTempDir('release-video-source-');
|
||||
const suiteRoot = createTempDir('release-video-suite-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir, {}, { release: RC_RELEASE });
|
||||
seedMedia(sourceRoot, suiteRoot);
|
||||
|
||||
const report = buildReport({
|
||||
root: rootDir,
|
||||
release: RC_RELEASE,
|
||||
sourceRoot,
|
||||
suiteRoot,
|
||||
skipProbe: true,
|
||||
generatedAt: '2026-05-19T00:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.strictEqual(report.release, RC_RELEASE);
|
||||
assert.strictEqual(report.ready, true);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
cleanup(sourceRoot);
|
||||
cleanup(suiteRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('publish candidate videos require visual blank-frame QA', () => {
|
||||
const publishVideos = REQUIRED_PUBLISH_CANDIDATES.filter(candidate => candidate.kind === 'video');
|
||||
|
||||
assert.ok(publishVideos.length > 0);
|
||||
assert.ok(publishVideos.every(candidate => candidate.noBlackFrames === true));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('missing local roots keep the release video gate blocked', () => {
|
||||
const rootDir = createTempDir('release-video-missing-roots-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
|
||||
const report = buildReport({
|
||||
root: rootDir,
|
||||
skipProbe: true,
|
||||
generatedAt: '2026-05-19T00:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.top_actions.some(action => action.includes('ECC_VIDEO_SOURCE_ROOT')));
|
||||
assert.ok(report.top_actions.some(action => action.includes('ECC_VIDEO_RELEASE_SUITE_ROOT')));
|
||||
assert.ok(report.checks.some(check => check.id === 'video-source-assets-present' && check.status === 'fail'));
|
||||
assert.ok(report.checks.some(check => check.id === 'video-release-artifacts-present' && check.status === 'fail'));
|
||||
assert.ok(report.checks.some(check => check.id === 'video-primary-render-self-eval' && check.status === 'fail'));
|
||||
assert.ok(report.checks.some(check => check.id === 'video-publish-candidates-present' && check.status === 'fail'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('private media paths in public docs fail sanitization', () => {
|
||||
const rootDir = createTempDir('release-video-private-path-');
|
||||
const sourceRoot = createTempDir('release-video-source-');
|
||||
const suiteRoot = createTempDir('release-video-suite-');
|
||||
|
||||
try {
|
||||
const releaseDir = releaseDirFor(CURRENT_RELEASE);
|
||||
seedRepo(rootDir, {
|
||||
[`${releaseDir}/video-suite-production.md`]: videoManifestContent('/Users/affoon/private-media'),
|
||||
});
|
||||
seedMedia(sourceRoot, suiteRoot);
|
||||
|
||||
const report = buildReport({
|
||||
root: rootDir,
|
||||
sourceRoot,
|
||||
suiteRoot,
|
||||
skipProbe: true,
|
||||
generatedAt: '2026-05-19T00:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.strictEqual(report.ready, false);
|
||||
assert.ok(report.checks.some(check => check.id === 'video-suite-public-sanitization' && check.status === 'fail'));
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
cleanup(sourceRoot);
|
||||
cleanup(suiteRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI emits JSON and exits successfully for complete fixture', () => {
|
||||
const rootDir = createTempDir('release-video-cli-');
|
||||
const sourceRoot = createTempDir('release-video-source-');
|
||||
const suiteRoot = createTempDir('release-video-suite-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
seedMedia(sourceRoot, suiteRoot);
|
||||
|
||||
const output = run([
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
`--source-root=${sourceRoot}`,
|
||||
`--suite-root=${suiteRoot}`,
|
||||
'--skip-probe',
|
||||
'--summary',
|
||||
], { cwd: rootDir });
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.release, CURRENT_RELEASE);
|
||||
assert.strictEqual(parsed.sourceRootConfigured, true);
|
||||
assert.strictEqual(parsed.suiteRootConfigured, true);
|
||||
assert.strictEqual(parsed.sourceAssetSummary.present, REQUIRED_SOURCE_ASSETS.length);
|
||||
assert.strictEqual(parsed.suiteArtifactSummary.present, REQUIRED_SUITE_ARTIFACTS.length);
|
||||
assert.strictEqual(parsed.publishCandidateSummary.present, REQUIRED_PUBLISH_CANDIDATES.length);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
cleanup(sourceRoot);
|
||||
cleanup(suiteRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI exits nonzero when media roots are missing', () => {
|
||||
const rootDir = createTempDir('release-video-cli-blocked-');
|
||||
|
||||
try {
|
||||
seedRepo(rootDir);
|
||||
|
||||
const result = runProcess([
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
'--skip-probe',
|
||||
'--summary',
|
||||
], { cwd: rootDir });
|
||||
|
||||
assert.strictEqual(result.status, 1);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.ready, false);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Source-level tests for scripts/release.sh
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'release.sh');
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
const releaseWorkflowPath = path.join(__dirname, '..', '..', '.github', 'workflows', 'release.yml');
|
||||
const reusableReleaseWorkflowPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'.github',
|
||||
'workflows',
|
||||
'reusable-release.yml'
|
||||
);
|
||||
const ciWorkflowPath = path.join(__dirname, '..', '..', '.github', 'workflows', 'ci.yml');
|
||||
const releaseWorkflowSource = fs.readFileSync(releaseWorkflowPath, 'utf8');
|
||||
const reusableReleaseWorkflowSource = fs.readFileSync(reusableReleaseWorkflowPath, 'utf8');
|
||||
const ciWorkflowSource = fs.readFileSync(ciWorkflowPath, 'utf8');
|
||||
const normalizedCiWorkflowSource = ciWorkflowSource.replace(/\r\n/g, '\n');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing release.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('release script rejects untracked files when checking cleanliness', () => {
|
||||
assert.ok(
|
||||
source.includes('git status --porcelain --untracked-files=all'),
|
||||
'release.sh should use git status --porcelain --untracked-files=all for cleanliness checks'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release script reruns release metadata sync validation before commit/tag', () => {
|
||||
const syncCheckIndex = source.lastIndexOf('node tests/plugin-manifest.test.js');
|
||||
const commitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"');
|
||||
|
||||
assert.ok(syncCheckIndex >= 0, 'release.sh should run plugin-manifest.test.js');
|
||||
assert.ok(commitIndex >= 0, 'release.sh should create the release commit');
|
||||
assert.ok(
|
||||
syncCheckIndex < commitIndex,
|
||||
'plugin-manifest.test.js should run before the release commit is created'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release script verifies npm pack payload after version updates and before commit/tag', () => {
|
||||
const updateIndex = source.indexOf('update_version "$ROOT_PACKAGE_JSON"');
|
||||
const packCheckIndex = source.indexOf('node tests/scripts/build-opencode.test.js');
|
||||
const commitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"');
|
||||
|
||||
assert.ok(updateIndex >= 0, 'release.sh should update package version fields');
|
||||
assert.ok(packCheckIndex >= 0, 'release.sh should run build-opencode.test.js');
|
||||
assert.ok(commitIndex >= 0, 'release.sh should create the release commit');
|
||||
assert.ok(
|
||||
updateIndex < packCheckIndex,
|
||||
'build-opencode.test.js should run after versioned files are updated'
|
||||
);
|
||||
assert.ok(
|
||||
packCheckIndex < commitIndex,
|
||||
'build-opencode.test.js should run before the release commit is created'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release script supports prerelease semver and release heading sync', () => {
|
||||
assert.ok(
|
||||
source.includes('2.0.0-rc.1'),
|
||||
'release.sh should document an accepted prerelease semver example'
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('(-[0-9A-Za-z.-]+)?'),
|
||||
'release.sh should allow prerelease semver suffixes'
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('update_latest_release_heading "$ROOT_ZH_CN_README_FILE"'),
|
||||
'release.sh should update localized latest-release headings that plugin-manifest.test.js verifies'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release workflows mark prerelease tags as GitHub prereleases', () => {
|
||||
assert.ok(
|
||||
releaseWorkflowSource.includes('prerelease: ${{ contains(github.ref_name, \'-\') }}'),
|
||||
'release.yml should mark hyphenated tag pushes as GitHub prereleases'
|
||||
);
|
||||
assert.ok(
|
||||
releaseWorkflowSource.includes('make_latest: ${{ contains(github.ref_name, \'-\') && \'false\' || \'true\' }}'),
|
||||
'release.yml should avoid making hyphenated prereleases the latest GitHub release'
|
||||
);
|
||||
assert.ok(
|
||||
reusableReleaseWorkflowSource.includes('prerelease: ${{ contains(inputs.tag, \'-\') }}'),
|
||||
'reusable-release.yml should mark hyphenated manual tags as GitHub prereleases'
|
||||
);
|
||||
assert.ok(
|
||||
reusableReleaseWorkflowSource.includes('make_latest: ${{ contains(inputs.tag, \'-\') && \'false\' || \'true\' }}'),
|
||||
'reusable-release.yml should avoid making hyphenated prereleases the latest GitHub release'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reusable release checks out the requested tag before validating and publishing', () => {
|
||||
const checkoutIndex = reusableReleaseWorkflowSource.indexOf('uses: actions/checkout@');
|
||||
const refIndex = reusableReleaseWorkflowSource.indexOf('ref: ${{ inputs.tag }}');
|
||||
const validateIndex = reusableReleaseWorkflowSource.indexOf('name: Validate version tag');
|
||||
|
||||
assert.ok(checkoutIndex >= 0, 'reusable-release.yml should check out repository content');
|
||||
assert.ok(refIndex >= 0, 'reusable-release.yml checkout should use inputs.tag as ref');
|
||||
assert.ok(validateIndex >= 0, 'reusable-release.yml should validate requested tag');
|
||||
assert.ok(
|
||||
checkoutIndex < refIndex && refIndex < validateIndex,
|
||||
'reusable release should check out inputs.tag before tag validation and publish steps'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CI runs for release branches and version tags before release workflows execute', () => {
|
||||
const pushBlockMatch = normalizedCiWorkflowSource.match(/on:\n\s+push:\n([\s\S]*?)\n\s+pull_request:/);
|
||||
const pushBlock = pushBlockMatch ? pushBlockMatch[1] : '';
|
||||
|
||||
assert.ok(pushBlock, 'ci.yml should define a push trigger block');
|
||||
assert.match(
|
||||
pushBlock,
|
||||
/branches:\s*\[[^\]]*main[^\]]*['"]release\/\*\*['"][^\]]*\]/,
|
||||
'ci.yml push branches should include release/**'
|
||||
);
|
||||
assert.match(
|
||||
pushBlock,
|
||||
/tags:\s*\[[^\]]*['"]v\*['"][^\]]*\]/,
|
||||
'ci.yml push tags should include v*'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Tests for scripts/repair.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const DOCTOR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js');
|
||||
const REPAIR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'repair.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CLI_TIMEOUT_MS = 30000;
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function runNode(scriptPath, args = [], options = {}) {
|
||||
const homeDir = options.homeDir || process.env.HOME;
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [scriptPath, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: options.timeout || CLI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || error.message || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeComparablePath(filePath) {
|
||||
const normalized = path.normalize(filePath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function pathListIncludes(paths, expectedPath) {
|
||||
const normalizedExpected = normalizeComparablePath(expectedPath);
|
||||
return paths.some(filePath => normalizeComparablePath(filePath) === normalizedExpected);
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing repair.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('repairs drifted files from a real install-apply state', () => {
|
||||
const homeDir = createTempDir('repair-home-');
|
||||
const projectRoot = createTempDir('repair-project-');
|
||||
|
||||
try {
|
||||
const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', 'typescript'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(installResult.code, 0, installResult.stderr);
|
||||
|
||||
const normalizedProjectRoot = fs.realpathSync(projectRoot);
|
||||
const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks', 'session-start.js');
|
||||
const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||
const expectedContent = fs.readFileSync(
|
||||
path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(managedPath, '// drifted\n');
|
||||
|
||||
const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(doctorBefore.code, 1);
|
||||
assert.ok(JSON.parse(doctorBefore.stdout).results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
|
||||
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||
|
||||
const parsed = JSON.parse(repairResult.stdout);
|
||||
assert.strictEqual(parsed.results[0].status, 'repaired');
|
||||
assert.ok(pathListIncludes(parsed.results[0].repairedPaths, managedPath));
|
||||
assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), expectedContent);
|
||||
assert.ok(fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repairs drifted non-copy managed operations and refreshes install-state', () => {
|
||||
const homeDir = createTempDir('repair-home-');
|
||||
const projectRoot = createTempDir('repair-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const jsonPath = path.join(normalizedTargetRoot, 'hooks.json');
|
||||
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||
const removedPath = path.join(normalizedTargetRoot, 'legacy-note.txt');
|
||||
fs.writeFileSync(jsonPath, JSON.stringify({ existing: true, managed: false }, null, 2));
|
||||
fs.writeFileSync(renderedPath, '# drifted\n');
|
||||
fs.writeFileSync(removedPath, 'stale\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: jsonPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
managed: true,
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/generated.md.template',
|
||||
destinationPath: renderedPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '# generated\n',
|
||||
},
|
||||
{
|
||||
kind: 'remove',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/legacy-note.txt',
|
||||
destinationPath: removedPath,
|
||||
strategy: 'remove',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(doctorBefore.code, 1);
|
||||
assert.ok(JSON.parse(doctorBefore.stdout).results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
|
||||
const installedAtBefore = JSON.parse(fs.readFileSync(statePath, 'utf8')).installedAt;
|
||||
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||
|
||||
const parsed = JSON.parse(repairResult.stdout);
|
||||
assert.strictEqual(parsed.results[0].status, 'repaired');
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(jsonPath));
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(renderedPath));
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(removedPath));
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(jsonPath, 'utf8')), {
|
||||
existing: true,
|
||||
managed: true,
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# generated\n');
|
||||
assert.ok(!fs.existsSync(removedPath));
|
||||
|
||||
const repairedState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.strictEqual(repairedState.installedAt, installedAtBefore);
|
||||
assert.ok(repairedState.lastValidatedAt);
|
||||
|
||||
const doctorAfter = runNode(DOCTOR_SCRIPT, ['--target', 'cursor'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(doctorAfter.code, 0, doctorAfter.stderr);
|
||||
assert.ok(doctorAfter.stdout.includes('Status: OK'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports dry-run without mutating drifted non-copy operations', () => {
|
||||
const homeDir = createTempDir('repair-home-');
|
||||
const projectRoot = createTempDir('repair-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||
fs.writeFileSync(renderedPath, '# drifted\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/generated.md.template',
|
||||
destinationPath: renderedPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '# generated\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--dry-run', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||
const parsed = JSON.parse(repairResult.stdout);
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.ok(parsed.results[0].plannedRepairs.includes(renderedPath));
|
||||
assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# drifted\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Tests for scripts/session-inspect.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { getFallbackSessionRecordingPath } = require('../../scripts/lib/session-adapters/canonical-session');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const envOverrides = {
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
|
||||
envOverrides.USERPROFILE = envOverrides.HOME;
|
||||
}
|
||||
|
||||
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
|
||||
envOverrides.HOME = envOverrides.USERPROFILE;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides
|
||||
}
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing session-inspect.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows usage when no target is provided', () => {
|
||||
const result = run();
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists registered adapters', () => {
|
||||
const result = run(['--list-adapters']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(payload.adapters));
|
||||
assert.ok(payload.adapters.some(adapter => adapter.id === 'claude-history'));
|
||||
assert.ok(payload.adapters.some(adapter => adapter.id === 'dmux-tmux'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints canonical JSON for claude history targets', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-recordings-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# Inspect Session\n\n**Branch:** feat/session-inspect\n'
|
||||
);
|
||||
|
||||
const result = run(['claude:latest'], {
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ECC_SESSION_RECORDING_DIR: recordingDir
|
||||
}
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const recordingPath = getFallbackSessionRecordingPath(payload, { recordingDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||
assert.strictEqual(payload.adapterId, 'claude-history');
|
||||
assert.strictEqual(payload.session.kind, 'history');
|
||||
assert.strictEqual(payload.workers[0].branch, 'feat/session-inspect');
|
||||
assert.strictEqual(persisted.latest.adapterId, 'claude-history');
|
||||
assert.strictEqual(persisted.history.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports explicit target types for structured registry routing', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# Inspect Session\n\n**Branch:** feat/typed-inspect\n'
|
||||
);
|
||||
|
||||
const result = run(['latest', '--target-type', 'claude-history'], {
|
||||
env: { HOME: homeDir }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.adapterId, 'claude-history');
|
||||
assert.strictEqual(payload.session.sourceTarget.type, 'claude-history');
|
||||
assert.strictEqual(payload.workers[0].branch, 'feat/typed-inspect');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes snapshot JSON to disk when --write is provided', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
||||
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-out-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const outputPath = path.join(outputDir, 'snapshot.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# Inspect Session\n\n**Branch:** feat/session-inspect\n'
|
||||
);
|
||||
|
||||
const result = run(['claude:latest', '--write', outputPath], {
|
||||
env: { HOME: homeDir }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(outputPath));
|
||||
const written = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
assert.strictEqual(written.adapterId, 'claude-history');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('inspects skill health from recorded observations', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-skills-'));
|
||||
const observationsDir = path.join(projectRoot, '.claude', 'ecc', 'skills');
|
||||
fs.mkdirSync(observationsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(observationsDir, 'observations.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-1',
|
||||
timestamp: '2026-03-14T12:00:00.000Z',
|
||||
task: 'Review auth middleware',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: 'missing csrf guidance', feedback: 'Need CSRF coverage' },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-1' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-2',
|
||||
timestamp: '2026-03-14T12:05:00.000Z',
|
||||
task: 'Review auth middleware',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: 'missing csrf guidance', feedback: null },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-2' }
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = run(['skills:health'], { cwd: projectRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.skill-health.v1');
|
||||
assert.ok(payload.skills.some(skill => skill.skill.id === 'security-review'));
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('proposes skill amendments through session-inspect', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-amend-'));
|
||||
const observationsDir = path.join(projectRoot, '.claude', 'ecc', 'skills');
|
||||
fs.mkdirSync(observationsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(observationsDir, 'observations.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-1',
|
||||
timestamp: '2026-03-14T12:00:00.000Z',
|
||||
task: 'Add rate limiting',
|
||||
skill: { id: 'api-design', path: 'skills/api-design/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: 'missing rate limiting guidance', feedback: 'Need rate limiting examples' },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-1' }
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = run(['skills:amendify', '--skill', 'api-design'], { cwd: projectRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.skill-amendment-proposal.v1');
|
||||
assert.strictEqual(payload.skill.id, 'api-design');
|
||||
assert.ok(payload.patch.preview.includes('Failure-Driven Amendments'));
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('builds skill evaluation scaffolding through session-inspect', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-eval-'));
|
||||
const observationsDir = path.join(projectRoot, '.claude', 'ecc', 'skills');
|
||||
fs.mkdirSync(observationsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(observationsDir, 'observations.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-1',
|
||||
timestamp: '2026-03-14T12:00:00.000Z',
|
||||
task: 'Fix flaky login test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: null, feedback: null },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-1' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-2',
|
||||
timestamp: '2026-03-14T12:10:00.000Z',
|
||||
task: 'Fix flaky checkout test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: true, status: 'success', error: null, feedback: null },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-2' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-3',
|
||||
timestamp: '2026-03-14T12:20:00.000Z',
|
||||
task: 'Fix flaky login test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: true, status: 'success', error: null, feedback: null },
|
||||
run: { variant: 'amended', amendmentId: 'amend-1', sessionId: 'sess-3' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-4',
|
||||
timestamp: '2026-03-14T12:30:00.000Z',
|
||||
task: 'Fix flaky checkout test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: true, status: 'success', error: null, feedback: null },
|
||||
run: { variant: 'amended', amendmentId: 'amend-1', sessionId: 'sess-4' }
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = run(['skills:evaluate', '--skill', 'e2e-testing', '--amendment-id', 'amend-1'], { cwd: projectRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.skill-evaluation.v1');
|
||||
assert.strictEqual(payload.recommendation, 'promote-amendment');
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Tests for scripts/setup-package-manager.js
|
||||
*
|
||||
* Tests CLI argument parsing and output via subprocess invocation.
|
||||
*
|
||||
* Run with: node tests/scripts/setup-package-manager.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'setup-package-manager.js');
|
||||
|
||||
// Run the script with given args, return { stdout, stderr, code }
|
||||
function run(args = [], env = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...env },
|
||||
timeout: 10000
|
||||
});
|
||||
return { stdout, stderr: '', code: 0 };
|
||||
} catch (err) {
|
||||
return {
|
||||
stdout: err.stdout || '',
|
||||
stderr: err.stderr || '',
|
||||
code: err.status || 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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 runTests() {
|
||||
console.log('\n=== Testing setup-package-manager.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --help flag
|
||||
console.log('--help:');
|
||||
|
||||
if (test('shows help with --help flag', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Setup'));
|
||||
assert.ok(result.stdout.includes('--detect'));
|
||||
assert.ok(result.stdout.includes('--global'));
|
||||
assert.ok(result.stdout.includes('--project'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help with -h flag', () => {
|
||||
const result = run(['-h']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Setup'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help with no arguments', () => {
|
||||
const result = run([]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Setup'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --detect flag
|
||||
console.log('\n--detect:');
|
||||
|
||||
if (test('detects current package manager', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Detection'));
|
||||
assert.ok(result.stdout.includes('Current selection'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows detection sources', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.ok(result.stdout.includes('From package.json'));
|
||||
assert.ok(result.stdout.includes('From lock file'));
|
||||
assert.ok(result.stdout.includes('Environment var'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows available managers in detection output', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.ok(result.stdout.includes('npm'));
|
||||
assert.ok(result.stdout.includes('pnpm'));
|
||||
assert.ok(result.stdout.includes('yarn'));
|
||||
assert.ok(result.stdout.includes('bun'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --list flag
|
||||
console.log('\n--list:');
|
||||
|
||||
if (test('lists available package managers', () => {
|
||||
const result = run(['--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Available Package Managers'));
|
||||
assert.ok(result.stdout.includes('npm'));
|
||||
assert.ok(result.stdout.includes('Lock file'));
|
||||
assert.ok(result.stdout.includes('Install'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --global flag
|
||||
console.log('\n--global:');
|
||||
|
||||
if (test('rejects --global without package manager name', () => {
|
||||
const result = run(['--global']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --global with unknown package manager', () => {
|
||||
const result = run(['--global', 'unknown-pm']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown package manager'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --project flag
|
||||
console.log('\n--project:');
|
||||
|
||||
if (test('rejects --project without package manager name', () => {
|
||||
const result = run(['--project']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --project with unknown package manager', () => {
|
||||
const result = run(['--project', 'unknown-pm']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown package manager'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Positional argument
|
||||
console.log('\npositional argument:');
|
||||
|
||||
if (test('rejects unknown positional argument', () => {
|
||||
const result = run(['not-a-pm']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown option or package manager'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Environment variable
|
||||
console.log('\nenvironment variable:');
|
||||
|
||||
if (test('detects env var override', () => {
|
||||
const result = run(['--detect'], { CLAUDE_PACKAGE_MANAGER: 'pnpm' });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('pnpm'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --detect output completeness
|
||||
console.log('\n--detect output completeness:');
|
||||
|
||||
if (test('shows all three command types in detection output', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install:'), 'Should show Install command');
|
||||
assert.ok(result.stdout.includes('Run script:'), 'Should show Run script command');
|
||||
assert.ok(result.stdout.includes('Execute binary:'), 'Should show Execute binary command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows current marker for active package manager', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.ok(result.stdout.includes('(current)'), 'Should mark current PM');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 31: flag-as-PM-name rejection ──
|
||||
// Note: --help, --detect, --list are checked BEFORE --global/--project in argv
|
||||
// parsing, so passing e.g. --global --list triggers the --list handler first.
|
||||
// The startsWith('-') fix protects against flags that AREN'T caught earlier,
|
||||
// like --global --project or --project --unknown-flag.
|
||||
console.log('\n--global flag validation (Round 31):');
|
||||
|
||||
if (test('rejects --global --project (flag not caught by earlier checks)', () => {
|
||||
const result = run(['--global', '--project']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --global --unknown-flag (arbitrary flag as PM name)', () => {
|
||||
const result = run(['--global', '--foo-bar']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --global -x (single-dash flag as PM name)', () => {
|
||||
const result = run(['--global', '-x']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--global --list is handled by --list check first (exit 0)', () => {
|
||||
// --list is checked before --global in the parsing order
|
||||
const result = run(['--global', '--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Available Package Managers'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--project flag validation (Round 31):');
|
||||
|
||||
if (test('rejects --project --global (cross-flag confusion)', () => {
|
||||
// --global handler runs before --project, catches it first
|
||||
const result = run(['--project', '--global']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --project --unknown-flag', () => {
|
||||
const result = run(['--project', '--bar']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --project -z (single-dash flag)', () => {
|
||||
const result = run(['--project', '-z']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 45: output completeness and marker uniqueness ──
|
||||
console.log('\n--detect marker uniqueness (Round 45):');
|
||||
|
||||
if (test('--detect output shows exactly one (current) marker', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const lines = result.stdout.split('\n');
|
||||
const currentLines = lines.filter(l => l.includes('(current)'));
|
||||
assert.strictEqual(currentLines.length, 1, `Expected exactly 1 "(current)" marker, found ${currentLines.length}`);
|
||||
// The (current) marker should be on a line with a PM name
|
||||
assert.ok(/\b(npm|pnpm|yarn|bun)\b/.test(currentLines[0]), 'Current marker should be on a PM line');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--list output completeness (Round 45):');
|
||||
|
||||
if (test('--list shows all four supported package managers', () => {
|
||||
const result = run(['--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
for (const pm of ['npm', 'pnpm', 'yarn', 'bun']) {
|
||||
assert.ok(result.stdout.includes(pm), `Should list ${pm}`);
|
||||
}
|
||||
// Each PM should show Lock file and Install info
|
||||
const lockFileCount = (result.stdout.match(/Lock file:/g) || []).length;
|
||||
assert.strictEqual(lockFileCount, 4, `Expected 4 "Lock file:" entries, found ${lockFileCount}`);
|
||||
const installCount = (result.stdout.match(/Install:/g) || []).length;
|
||||
assert.strictEqual(installCount, 4, `Expected 4 "Install:" entries, found ${installCount}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 62: --global success path and bare PM name ──
|
||||
console.log('\n--global success path (Round 62):');
|
||||
|
||||
if (test('--global npm writes config and succeeds', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-global-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = run(['--global', 'npm'], { HOME: tmpDir, USERPROFILE: tmpDir });
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Global preference set to'), 'Should show success message');
|
||||
assert.ok(result.stdout.includes('npm'), 'Should mention npm');
|
||||
// Verify config file was created
|
||||
const configPath = path.join(tmpDir, '.claude', 'package-manager.json');
|
||||
assert.ok(fs.existsSync(configPath), 'Config file should be created');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.packageManager, 'npm', 'Config should contain npm');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nbare PM name success (Round 62):');
|
||||
|
||||
if (test('bare npm sets global preference and succeeds', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-bare-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = run(['npm'], { HOME: tmpDir, USERPROFILE: tmpDir });
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Global preference set to'), 'Should show success message');
|
||||
// Verify config file was created
|
||||
const configPath = path.join(tmpDir, '.claude', 'package-manager.json');
|
||||
assert.ok(fs.existsSync(configPath), 'Config file should be created');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.packageManager, 'npm', 'Config should contain npm');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--detect source label (Round 62):');
|
||||
|
||||
if (test('--detect with env var shows source as environment', () => {
|
||||
const result = run(['--detect'], { CLAUDE_PACKAGE_MANAGER: 'pnpm' });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Source: environment'), 'Should show environment as source');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 68: --project success path and --list (current) marker ──
|
||||
console.log('\n--project success path (Round 68):');
|
||||
|
||||
if (test('--project npm writes project config and succeeds', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-project-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = require('child_process').spawnSync('node', [SCRIPT, '--project', 'npm'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
timeout: 10000,
|
||||
cwd: tmpDir
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Project preference set to'), 'Should show project success message');
|
||||
assert.ok(result.stdout.includes('npm'), 'Should mention npm');
|
||||
// Verify config file was created in the project CWD
|
||||
const configPath = path.join(tmpDir, '.claude', 'package-manager.json');
|
||||
assert.ok(fs.existsSync(configPath), 'Project config file should be created in CWD');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.packageManager, 'npm', 'Config should contain npm');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--list (current) marker (Round 68):');
|
||||
|
||||
if (test('--list output includes (current) marker for active PM', () => {
|
||||
const result = run(['--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('(current)'), '--list should mark the active PM with (current)');
|
||||
// The (current) marker should appear exactly once
|
||||
const currentCount = (result.stdout.match(/\(current\)/g) || []).length;
|
||||
assert.strictEqual(currentCount, 1, `Expected exactly 1 "(current)" in --list, found ${currentCount}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 74: setGlobal catch — setPreferredPackageManager throws ──
|
||||
console.log('\nRound 74: setGlobal catch (save failure):');
|
||||
|
||||
if (test('--global npm fails when HOME is not a directory', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// HOME=/dev/null causes ensureDir to throw ENOTDIR when creating ~/.claude/
|
||||
const result = run(['--global', 'npm'], { HOME: '/dev/null', USERPROFILE: '/dev/null' });
|
||||
assert.strictEqual(result.code, 1, `Expected exit 1, got ${result.code}`);
|
||||
assert.ok(result.stderr.includes('Error:'),
|
||||
`stderr should contain Error:, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 74: setProject catch — setProjectPackageManager throws ──
|
||||
console.log('\nRound 74: setProject catch (save failure):');
|
||||
|
||||
if (test('--project npm fails when CWD is read-only', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
console.log(' (skipped — chmod ineffective on Windows/root)');
|
||||
return;
|
||||
}
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-ro-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
// Make CWD read-only so .claude/ dir creation fails with EACCES
|
||||
fs.chmodSync(tmpDir, 0o555);
|
||||
const result = require('child_process').spawnSync('node', [SCRIPT, '--project', 'npm'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
timeout: 10000,
|
||||
cwd: tmpDir
|
||||
});
|
||||
assert.strictEqual(result.status, 1,
|
||||
`Expected exit 1, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('Error:'),
|
||||
`stderr should contain Error:, got: ${result.stderr}`);
|
||||
} finally {
|
||||
try { fs.chmodSync(tmpDir, 0o755); } catch { /* best-effort */ }
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Tests for scripts/skill-create-output.js
|
||||
*
|
||||
* Tests the SkillCreateOutput class and helper functions.
|
||||
*
|
||||
* Run with: node tests/scripts/skill-create-output.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
// Import the module
|
||||
const { SkillCreateOutput } = require('../../scripts/skill-create-output');
|
||||
|
||||
// We also need to test the un-exported helpers by requiring the source
|
||||
// and extracting them from the module scope. Since they're not exported,
|
||||
// we test them indirectly through the class methods, plus test the
|
||||
// exported class directly.
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip ANSI escape sequences for assertions
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
// Capture console.log output
|
||||
function captureLog(fn) {
|
||||
const logs = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args) => logs.push(args.join(' '));
|
||||
try {
|
||||
fn();
|
||||
return logs;
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing skill-create-output.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Constructor tests
|
||||
console.log('SkillCreateOutput constructor:');
|
||||
|
||||
if (test('creates instance with repo name', () => {
|
||||
const output = new SkillCreateOutput('test-repo');
|
||||
assert.strictEqual(output.repoName, 'test-repo');
|
||||
assert.strictEqual(output.width, 70); // default width
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts custom width option', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 100 });
|
||||
assert.strictEqual(output.width, 100);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// header() tests
|
||||
console.log('\nheader():');
|
||||
|
||||
if (test('outputs header with repo name', () => {
|
||||
const output = new SkillCreateOutput('my-project');
|
||||
const logs = captureLog(() => output.header());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Skill Creator'), 'Should include Skill Creator');
|
||||
assert.ok(combined.includes('my-project'), 'Should include repo name');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header handles long repo names without crash', () => {
|
||||
const output = new SkillCreateOutput('a-very-long-repository-name-that-exceeds-normal-width-limits');
|
||||
// Should not throw RangeError
|
||||
const logs = captureLog(() => output.header());
|
||||
assert.ok(logs.length > 0, 'Should produce output');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// analysisResults() tests
|
||||
console.log('\nanalysisResults():');
|
||||
|
||||
if (test('displays analysis data', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 150,
|
||||
timeRange: 'Jan 2026 - Feb 2026',
|
||||
contributors: 3,
|
||||
files: 200,
|
||||
}));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('150'), 'Should show commit count');
|
||||
assert.ok(combined.includes('Jan 2026'), 'Should show time range');
|
||||
assert.ok(combined.includes('200'), 'Should show file count');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// patterns() tests
|
||||
console.log('\npatterns():');
|
||||
|
||||
if (test('displays patterns with confidence bars', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Test Pattern', trigger: 'when testing', confidence: 0.9, evidence: 'Tests exist' },
|
||||
{ name: 'Another Pattern', trigger: 'when building', confidence: 0.5, evidence: 'Build exists' },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Test Pattern'), 'Should show pattern name');
|
||||
assert.ok(combined.includes('when testing'), 'Should show trigger');
|
||||
assert.ok(stripAnsi(combined).includes('90%'), 'Should show confidence as percentage');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles patterns with missing confidence', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
// Should default to 0.8 confidence
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'No Confidence', trigger: 'always', evidence: 'evidence' },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(stripAnsi(combined).includes('80%'), 'Should default to 80% confidence');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// instincts() tests
|
||||
console.log('\ninstincts():');
|
||||
|
||||
if (test('displays instincts in a box', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'instinct-1', confidence: 0.95 },
|
||||
{ name: 'instinct-2', confidence: 0.7 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('instinct-1'), 'Should show instinct name');
|
||||
assert.ok(combined.includes('95%'), 'Should show confidence percentage');
|
||||
assert.ok(combined.includes('70%'), 'Should show second confidence');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// output() tests
|
||||
console.log('\noutput():');
|
||||
|
||||
if (test('displays file paths', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.output(
|
||||
'/path/to/SKILL.md',
|
||||
'/path/to/instincts.yaml'
|
||||
));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('SKILL.md'), 'Should show skill path');
|
||||
assert.ok(combined.includes('instincts.yaml'), 'Should show instincts path');
|
||||
assert.ok(combined.includes('Complete'), 'Should show completion message');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// nextSteps() tests
|
||||
console.log('\nnextSteps():');
|
||||
|
||||
if (test('displays next steps with commands', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.nextSteps());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Next Steps'), 'Should show Next Steps title');
|
||||
assert.ok(combined.includes('/instinct-import'), 'Should show import command');
|
||||
assert.ok(combined.includes('/evolve'), 'Should show evolve command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// footer() tests
|
||||
console.log('\nfooter():');
|
||||
|
||||
if (test('displays footer with attribution', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.footer());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Everything Claude Code'), 'Should include project name');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// progressBar edge cases (tests the clamp fix)
|
||||
console.log('\nprogressBar edge cases:');
|
||||
|
||||
if (test('does not crash with confidence > 1.0 (percent > 100)', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
// confidence 1.5 => percent 150 — previously crashed with RangeError
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Overconfident', trigger: 'always', confidence: 1.5, evidence: 'too much' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('150%'), 'Should show 150%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders 0% confidence bar without crash', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Zero Confidence', trigger: 'never', confidence: 0.0, evidence: 'none' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('0%'), 'Should show 0%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders 100% confidence bar without crash', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Perfect', trigger: 'always', confidence: 1.0, evidence: 'certain' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('100%'), 'Should show 100%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Empty array edge cases
|
||||
console.log('\nempty array edge cases:');
|
||||
|
||||
if (test('patterns() with empty array produces header but no entries', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Patterns'), 'Should show header');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('instincts() with empty array produces box but no entries', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.instincts([]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Instincts'), 'Should show box title');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Box drawing crash fix (regression test)
|
||||
console.log('\nbox() crash prevention:');
|
||||
|
||||
if (test('box does not crash on title longer than width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 20 });
|
||||
// The instincts() method calls box() internally with a title
|
||||
// that could exceed the narrow width
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'a-very-long-instinct-name', confidence: 0.9 },
|
||||
]));
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('analysisResults does not crash with very narrow width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 10 });
|
||||
// box() is called with a title that exceeds width=10
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 1, timeRange: 'today', contributors: 1, files: 1,
|
||||
}));
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// box() alignment regression test
|
||||
console.log('\nbox() alignment:');
|
||||
|
||||
if (test('top, middle, and bottom lines have equal visual width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 40 });
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'test', confidence: 0.9 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => stripAnsi(l).trim().length > 0);
|
||||
// Find lines that start with box-drawing characters
|
||||
const boxDrawn = boxLines.filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
if (boxDrawn.length >= 3) {
|
||||
const widths = boxDrawn.map(l => stripAnsi(l).length);
|
||||
const firstWidth = widths[0];
|
||||
widths.forEach((w, i) => {
|
||||
assert.strictEqual(w, firstWidth,
|
||||
`Line ${i} width ${w} should match first line width ${firstWidth}`);
|
||||
});
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 27: box and progressBar edge cases ──
|
||||
console.log('\nbox() content overflow:');
|
||||
|
||||
if (test('box does not crash when content line exceeds width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 30 });
|
||||
// Force a very long instinct name that exceeds width
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'this-is-an-extremely-long-instinct-name-that-clearly-exceeds-width', confidence: 0.9 },
|
||||
]));
|
||||
// Math.max(0, padding) should prevent RangeError
|
||||
assert.ok(logs.length > 0, 'Should produce output without RangeError');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('patterns renders negative confidence without crash', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
// confidence -0.1 => percent -10 — Math.max(0, ...) should clamp filled to 0
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Negative', trigger: 'never', confidence: -0.1, evidence: 'impossible' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('-10%'), 'Should show -10%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header does not crash with very long repo name', () => {
|
||||
const longRepo = 'A'.repeat(100);
|
||||
const output = new SkillCreateOutput(longRepo);
|
||||
// Math.max(0, 55 - stripAnsi(subtitle).length) protects against negative repeat
|
||||
const logs = captureLog(() => output.header());
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stripAnsi handles nested ANSI codes with multi-digit params', () => {
|
||||
// Simulate bold + color + reset
|
||||
const ansiStr = '\x1b[1m\x1b[36mBold Cyan\x1b[0m\x1b[0m';
|
||||
const stripped = stripAnsi(ansiStr);
|
||||
assert.strictEqual(stripped, 'Bold Cyan', 'Should strip all nested ANSI sequences');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('footer produces output', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.footer());
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('Powered by'), 'Should include attribution text');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 34: header width alignment ──
|
||||
console.log('\nheader() width alignment (Round 34):');
|
||||
|
||||
if (test('header subtitle line matches border width', () => {
|
||||
const output = new SkillCreateOutput('test-repo');
|
||||
const logs = captureLog(() => output.header());
|
||||
// Find the border and subtitle lines
|
||||
const lines = logs.map(l => stripAnsi(l));
|
||||
const borderLine = lines.find(l => l.includes('═══'));
|
||||
const subtitleLine = lines.find(l => l.includes('Extracting patterns'));
|
||||
assert.ok(borderLine, 'Should find border line');
|
||||
assert.ok(subtitleLine, 'Should find subtitle line');
|
||||
// Both lines should have the same visible width
|
||||
assert.strictEqual(subtitleLine.length, borderLine.length,
|
||||
`Subtitle width (${subtitleLine.length}) should match border width (${borderLine.length})`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header all lines have consistent width for short repo name', () => {
|
||||
const output = new SkillCreateOutput('abc');
|
||||
const logs = captureLog(() => output.header());
|
||||
const lines = logs.map(l => stripAnsi(l)).filter(l => l.includes('║') || l.includes('╔') || l.includes('╚'));
|
||||
assert.ok(lines.length >= 4, 'Should have at least 4 box lines');
|
||||
const widths = lines.map(l => l.length);
|
||||
const first = widths[0];
|
||||
widths.forEach((w, i) => {
|
||||
assert.strictEqual(w, first,
|
||||
`Line ${i} width (${w}) should match first line (${first})`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header subtitle has correct content area width of 64 chars', () => {
|
||||
const output = new SkillCreateOutput('myrepo');
|
||||
const logs = captureLog(() => output.header());
|
||||
const lines = logs.map(l => stripAnsi(l));
|
||||
const subtitleLine = lines.find(l => l.includes('Extracting patterns'));
|
||||
assert.ok(subtitleLine, 'Should find subtitle line');
|
||||
// Content between ║ and ║ should be 64 chars (border is 66 total)
|
||||
// Format: ║ + content(64) + ║ = 66
|
||||
assert.strictEqual(subtitleLine.length, 66,
|
||||
`Total subtitle line width should be 66, got ${subtitleLine.length}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header subtitle line does not truncate with medium-length repo name', () => {
|
||||
const output = new SkillCreateOutput('my-medium-repo-name');
|
||||
const logs = captureLog(() => output.header());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('my-medium-repo-name'), 'Should include full repo name');
|
||||
const lines = logs.map(l => stripAnsi(l));
|
||||
const subtitleLine = lines.find(l => l.includes('Extracting patterns'));
|
||||
assert.ok(subtitleLine, 'Should have subtitle line');
|
||||
// Should still be 66 chars even with a longer name
|
||||
assert.strictEqual(subtitleLine.length, 66,
|
||||
`Subtitle line should be 66 chars, got ${subtitleLine.length}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 35: box() width accuracy ──
|
||||
console.log('\nbox() width accuracy (Round 35):');
|
||||
|
||||
if (test('box lines in instincts() match the default box width of 60', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'test-instinct', confidence: 0.85 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
// The box() default width is 60 — each line should be exactly 60 chars
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, 60,
|
||||
`Box line ${i} should be 60 chars wide, got ${w}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('box lines with custom width match the requested width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 40 });
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'short', confidence: 0.9 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
// instincts() calls box() with no explicit width, so it uses the default 60
|
||||
// regardless of this.width — verify self-consistency at least
|
||||
const firstWidth = stripAnsi(boxLines[0]).length;
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, firstWidth,
|
||||
`Box line ${i} width ${w} should match first line ${firstWidth}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('analysisResults box lines are all 60 chars wide', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 50, timeRange: 'Jan 2026', contributors: 2, files: 100,
|
||||
}));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, 60,
|
||||
`Analysis box line ${i} should be 60 chars, got ${w}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('nextSteps box lines are all 60 chars wide', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.nextSteps());
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, 60,
|
||||
`NextSteps box line ${i} should be 60 chars, got ${w}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 54: analysisResults with zero values ──
|
||||
console.log('\nanalysisResults zero values (Round 54):');
|
||||
|
||||
if (test('analysisResults handles zero values for all data fields', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 0, timeRange: '', contributors: 0, files: 0,
|
||||
}));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('0'), 'Should display zero values');
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
// Box lines should still be 60 chars wide
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should render a complete box');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 68: demo function export ──
|
||||
console.log('\ndemo export (Round 68):');
|
||||
|
||||
if (test('module exports demo function alongside SkillCreateOutput', () => {
|
||||
const mod = require('../../scripts/skill-create-output');
|
||||
assert.ok(mod.demo, 'Should export demo function');
|
||||
assert.strictEqual(typeof mod.demo, 'function', 'demo should be a function');
|
||||
assert.ok(mod.SkillCreateOutput, 'Should also export SkillCreateOutput');
|
||||
assert.strictEqual(typeof mod.SkillCreateOutput, 'function', 'SkillCreateOutput should be a constructor');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 85: patterns() confidence=0 uses ?? (not ||) ──
|
||||
console.log('\nRound 85: patterns() confidence=0 nullish coalescing:');
|
||||
|
||||
if (test('patterns() with confidence=0 shows 0%, not 80% (nullish coalescing fix)', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Zero Confidence', trigger: 'never', confidence: 0, evidence: 'none' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
// With ?? operator: 0 ?? 0.8 = 0 → Math.round(0 * 100) = 0 → shows "0%"
|
||||
// With || operator (bug): 0 || 0.8 = 0.8 → shows "80%"
|
||||
assert.ok(combined.includes('0%'), 'Should show 0% for zero confidence');
|
||||
assert.ok(!combined.includes('80%'),
|
||||
'Should NOT show 80% — confidence=0 is explicitly provided, not missing');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 87: analyzePhase() async method (untested) ──
|
||||
console.log('\nRound 87: analyzePhase() async method:');
|
||||
|
||||
if (test('analyzePhase completes without error and writes to stdout', () => {
|
||||
const output = new SkillCreateOutput('test-repo');
|
||||
// analyzePhase is async and calls animateProgress which uses sleep() and
|
||||
// process.stdout.write/clearLine/cursorTo. In non-TTY environments clearLine
|
||||
// and cursorTo are undefined, but the code uses optional chaining (?.) to
|
||||
// handle this safely. We verify it resolves without throwing.
|
||||
// Capture stdout.write to verify output was produced.
|
||||
const writes = [];
|
||||
const origWrite = process.stdout.write;
|
||||
process.stdout.write = function(str) { writes.push(String(str)); return true; };
|
||||
try {
|
||||
// Call synchronously by accessing the returned promise — we just need to
|
||||
// verify it doesn't throw during setup. The sleeps total 1.9s so we
|
||||
// verify the promise is a thenable (async function returns Promise).
|
||||
const promise = output.analyzePhase({ commits: 42 });
|
||||
assert.ok(promise && typeof promise.then === 'function',
|
||||
'analyzePhase should return a Promise');
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
}
|
||||
// Verify that process.stdout.write was called (the header line is written synchronously)
|
||||
assert.ok(writes.length > 0, 'Should have written output via process.stdout.write');
|
||||
assert.ok(writes.some(w => w.includes('Analyzing')), 'Should include "Analyzing" label');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Source-level tests for scripts/sync-ecc-to-codex.sh
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'sync-ecc-to-codex.sh');
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
const normalizedSource = source.replace(/\r\n/g, '\n');
|
||||
const runOrEchoSource = (() => {
|
||||
const start = normalizedSource.indexOf('run_or_echo() {');
|
||||
if (start < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let bodyStart = normalizedSource.indexOf('{', start);
|
||||
if (bodyStart < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let i = bodyStart; i < normalizedSource.length; i++) {
|
||||
const char = normalizedSource[i];
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
} else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return normalizedSource.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
})();
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing sync-ecc-to-codex.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('run_or_echo does not use eval', () => {
|
||||
assert.ok(runOrEchoSource, 'Expected to locate run_or_echo function body');
|
||||
assert.ok(!runOrEchoSource.includes('eval "$@"'), 'run_or_echo should not execute through eval');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('run_or_echo executes argv directly', () => {
|
||||
assert.ok(runOrEchoSource.includes(' "$@"'), 'run_or_echo should execute the argv vector directly');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run output shell-escapes argv', () => {
|
||||
assert.ok(runOrEchoSource.includes(`printf ' %q' "$@"`), 'Dry-run mode should print shell-escaped argv');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filesystem-changing calls use argv-form run_or_echo invocations', () => {
|
||||
assert.ok(source.includes('run_or_echo mkdir -p "$BACKUP_DIR"'), 'mkdir should use argv form');
|
||||
// Skills sync rm/cp calls were removed — Codex reads from ~/.agents/skills/ natively
|
||||
assert.ok(!source.includes('run_or_echo rm -rf "$dest"'), 'skill sync rm should be removed');
|
||||
assert.ok(!source.includes('run_or_echo cp -R "$skill_dir" "$dest"'), 'skill sync cp should be removed');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sync script avoids GNU-only grep -P parsing', () => {
|
||||
assert.ok(!source.includes('grep -oP'), 'sync-ecc-to-codex.sh should remain portable across BSD and GNU environments');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('extract_context7_key uses a portable parser', () => {
|
||||
assert.ok(source.includes('extract_context7_key() {'), 'Expected extract_context7_key helper');
|
||||
assert.ok(source.includes('node - "$file"'), 'extract_context7_key should use Node-based parsing');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for .trae/install.sh and .trae/uninstall.sh
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const INSTALL_SCRIPT = path.join(REPO_ROOT, '.trae', 'install.sh');
|
||||
const UNINSTALL_SCRIPT = path.join(REPO_ROOT, '.trae', 'uninstall.sh');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runInstall(options = {}) {
|
||||
return execFileSync('bash', [INSTALL_SCRIPT, ...(options.args || [])], {
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 300000,
|
||||
});
|
||||
}
|
||||
|
||||
function runUninstall(options = {}) {
|
||||
return execFileSync('bash', [UNINSTALL_SCRIPT, ...(options.args || [])], {
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
input: options.input || 'y\n',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 300000,
|
||||
});
|
||||
}
|
||||
|
||||
function readManifestLines(projectRoot) {
|
||||
const manifestPath = path.join(projectRoot, '.trae', '.ecc-manifest');
|
||||
return fs.readFileSync(manifestPath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing Trae install/uninstall scripts ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - skipped on Windows; Trae shell scripts are Unix-only');
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (test('does not claim ownership of preexisting target files', () => {
|
||||
const homeDir = createTempDir('trae-home-');
|
||||
const projectRoot = createTempDir('trae-project-');
|
||||
|
||||
try {
|
||||
const preexistingCommandPath = path.join(projectRoot, '.trae', 'commands', 'quality-gate.md');
|
||||
fs.mkdirSync(path.dirname(preexistingCommandPath), { recursive: true });
|
||||
fs.writeFileSync(preexistingCommandPath, 'user owned command\n');
|
||||
|
||||
runInstall({ cwd: projectRoot, homeDir });
|
||||
|
||||
const manifestLines = readManifestLines(projectRoot);
|
||||
assert.ok(!manifestLines.includes('commands/quality-gate.md'), 'Preexisting file should not be recorded in manifest');
|
||||
|
||||
runUninstall({ cwd: projectRoot, homeDir });
|
||||
|
||||
assert.strictEqual(fs.readFileSync(preexistingCommandPath, 'utf8'), 'user owned command\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('records nested skill files and the full rules tree in the manifest', () => {
|
||||
const homeDir = createTempDir('trae-home-');
|
||||
const projectRoot = createTempDir('trae-project-');
|
||||
|
||||
try {
|
||||
runInstall({ cwd: projectRoot, homeDir });
|
||||
|
||||
const manifestLines = readManifestLines(projectRoot);
|
||||
assert.ok(manifestLines.includes('skills/skill-comply/pyproject.toml'));
|
||||
assert.ok(manifestLines.includes('rules/common/code-review.md'));
|
||||
assert.ok(manifestLines.includes('rules/python/coding-style.md'));
|
||||
assert.ok(manifestLines.includes('rules/web/performance.md'));
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, '.trae', 'skills', 'skill-comply', 'pyproject.toml')));
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, '.trae', 'rules', 'python', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, '.trae', 'rules', 'web', 'performance.md')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reinstall preserves managed manifest coverage without duplicate entries', () => {
|
||||
const homeDir = createTempDir('trae-home-');
|
||||
const projectRoot = createTempDir('trae-project-');
|
||||
|
||||
try {
|
||||
runInstall({ cwd: projectRoot, homeDir });
|
||||
|
||||
const managedCommandPath = path.join(projectRoot, '.trae', 'commands', 'quality-gate.md');
|
||||
fs.rmSync(managedCommandPath);
|
||||
|
||||
runInstall({ cwd: projectRoot, homeDir });
|
||||
|
||||
const manifestLines = readManifestLines(projectRoot);
|
||||
const entryCount = manifestLines.filter((line) => line === 'commands/quality-gate.md').length;
|
||||
|
||||
assert.strictEqual(entryCount, 1, 'Managed file should appear once in manifest after reinstall');
|
||||
assert.ok(fs.existsSync(managedCommandPath), 'Managed file should be recreated on reinstall');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall rejects manifest entries that escape the Trae root via symlink traversal', () => {
|
||||
const homeDir = createTempDir('trae-home-');
|
||||
const projectRoot = createTempDir('trae-project-');
|
||||
const externalRoot = createTempDir('trae-outside-');
|
||||
|
||||
try {
|
||||
const traeRoot = path.join(projectRoot, '.trae');
|
||||
fs.mkdirSync(traeRoot, { recursive: true });
|
||||
|
||||
const outsideSecretPath = path.join(externalRoot, 'secret.txt');
|
||||
fs.writeFileSync(outsideSecretPath, 'do not remove\n');
|
||||
fs.symlinkSync(externalRoot, path.join(traeRoot, 'escape-link'));
|
||||
fs.writeFileSync(path.join(traeRoot, '.ecc-manifest'), 'escape-link/secret.txt\n.ecc-manifest\n');
|
||||
|
||||
const stdout = runUninstall({ cwd: projectRoot, homeDir });
|
||||
|
||||
assert.ok(stdout.includes('Skipped: escape-link/secret.txt (invalid manifest entry)'));
|
||||
assert.strictEqual(fs.readFileSync(outsideSecretPath, 'utf8'), 'do not remove\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(externalRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Tests for scripts/uninstall.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'uninstall.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const CLI_TIMEOUT_MS = 30000;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: CLI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing uninstall.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('uninstalls files from a real install-apply state and preserves unrelated files', () => {
|
||||
const homeDir = createTempDir('uninstall-home-');
|
||||
const projectRoot = createTempDir('uninstall-project-');
|
||||
|
||||
try {
|
||||
const installStdout = execFileSync('node', [INSTALL_SCRIPT, '--target', 'cursor', 'typescript'], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: CLI_TIMEOUT_MS,
|
||||
});
|
||||
assert.ok(installStdout.includes('Done. Install-state written'));
|
||||
|
||||
const normalizedProjectRoot = fs.realpathSync(projectRoot);
|
||||
const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks.json');
|
||||
const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||
const unrelatedPath = path.join(normalizedProjectRoot, '.cursor', 'custom-user-note.txt');
|
||||
fs.writeFileSync(unrelatedPath, 'leave me alone');
|
||||
|
||||
const uninstallResult = run(['--target', 'cursor'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
|
||||
assert.ok(uninstallResult.stdout.includes('Uninstall summary'));
|
||||
assert.ok(!fs.existsSync(managedPath));
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
assert.ok(fs.existsSync(unrelatedPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reverses non-copy operations and keeps unrelated files', () => {
|
||||
const homeDir = createTempDir('uninstall-home-');
|
||||
const projectRoot = createTempDir('uninstall-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const copiedPath = path.join(normalizedTargetRoot, 'managed-rule.md');
|
||||
const mergedPath = path.join(normalizedTargetRoot, 'hooks.json');
|
||||
const removedPath = path.join(normalizedTargetRoot, 'legacy-note.txt');
|
||||
const unrelatedPath = path.join(normalizedTargetRoot, 'custom-user-note.txt');
|
||||
fs.writeFileSync(copiedPath, 'managed\n');
|
||||
fs.writeFileSync(mergedPath, JSON.stringify({
|
||||
existing: true,
|
||||
managed: true,
|
||||
}, null, 2));
|
||||
fs.writeFileSync(unrelatedPath, 'leave me alone');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
destinationPath: copiedPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: mergedPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
managed: true,
|
||||
},
|
||||
previousContent: JSON.stringify({
|
||||
existing: true,
|
||||
}, null, 2),
|
||||
},
|
||||
{
|
||||
kind: 'remove',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/legacy-note.txt',
|
||||
destinationPath: removedPath,
|
||||
strategy: 'remove',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
previousContent: 'restore me\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallResult = run(['--target', 'cursor'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
|
||||
assert.ok(uninstallResult.stdout.includes('Uninstall summary'));
|
||||
assert.ok(!fs.existsSync(copiedPath));
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(mergedPath, 'utf8')), {
|
||||
existing: true,
|
||||
});
|
||||
assert.strictEqual(fs.readFileSync(removedPath, 'utf8'), 'restore me\n');
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
assert.ok(fs.existsSync(unrelatedPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports dry-run without mutating managed files', () => {
|
||||
const homeDir = createTempDir('uninstall-home-');
|
||||
const projectRoot = createTempDir('uninstall-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||
fs.writeFileSync(renderedPath, '# generated\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/generated.md.template',
|
||||
destinationPath: renderedPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '# generated\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallResult = run(['--target', 'cursor', '--dry-run', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
|
||||
|
||||
const parsed = JSON.parse(uninstallResult.stdout);
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.ok(parsed.results[0].plannedRemovals.includes(renderedPath));
|
||||
assert.ok(fs.existsSync(renderedPath));
|
||||
assert.ok(fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,152 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for scripts/work-items.js — focused on the `claim` JIT pickup command.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { createStateStore } = require('../../scripts/lib/state-store');
|
||||
|
||||
const CLI = path.join(__dirname, '..', '..', 'scripts', 'work-items.js');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function runClaim(dbPath, args) {
|
||||
const result = spawnSync('node', [CLI, 'claim', '--db', dbPath, '--json', ...args], {
|
||||
encoding: 'utf8'
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function seed(dbPath) {
|
||||
const store = await createStateStore({ dbPath });
|
||||
try {
|
||||
// High-priority, unassigned, open — the JIT pickup target.
|
||||
store.upsertWorkItem({
|
||||
id: 'wi-unassigned-high',
|
||||
source: 'github-issue',
|
||||
title: 'Fix the gate bypass',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
owner: null,
|
||||
metadata: {}
|
||||
});
|
||||
// Low-priority, unassigned, open — should be picked only after the high one.
|
||||
store.upsertWorkItem({
|
||||
id: 'wi-unassigned-low',
|
||||
source: 'manual',
|
||||
title: 'Tidy docs',
|
||||
status: 'open',
|
||||
priority: 'low',
|
||||
owner: null,
|
||||
metadata: {}
|
||||
});
|
||||
// Already owned — must never be auto-claimed.
|
||||
store.upsertWorkItem({
|
||||
id: 'wi-owned',
|
||||
source: 'manual',
|
||||
title: 'In progress',
|
||||
status: 'running',
|
||||
priority: 'high',
|
||||
owner: 'codex',
|
||||
metadata: {}
|
||||
});
|
||||
// Done — must never be claimed.
|
||||
store.upsertWorkItem({
|
||||
id: 'wi-done',
|
||||
source: 'manual',
|
||||
title: 'Shipped',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
owner: null,
|
||||
metadata: {}
|
||||
});
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('\n=== Testing work-items.js claim ===\n');
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'work-items-claim-'));
|
||||
const dbPath = path.join(dir, 'state.db');
|
||||
|
||||
try {
|
||||
await seed(dbPath);
|
||||
|
||||
await test('claim picks the highest-priority unassigned open item and sets owner + kind', async () => {
|
||||
const result = runClaim(dbPath, ['--owner', 'alice', '--as', 'human']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.claimed, true);
|
||||
assert.strictEqual(payload.item.id, 'wi-unassigned-high', 'high-priority item claimed first');
|
||||
assert.strictEqual(payload.item.owner, 'alice');
|
||||
assert.strictEqual(payload.item.status, 'running', 'claim moves the card to running');
|
||||
assert.strictEqual(payload.item.metadata.assigneeKind, 'human');
|
||||
});
|
||||
|
||||
await test('a second claim takes the next unassigned item, not an owned or done one', async () => {
|
||||
const result = runClaim(dbPath, ['--owner', 'bot-7', '--as', 'agent']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.claimed, true);
|
||||
assert.strictEqual(payload.item.id, 'wi-unassigned-low');
|
||||
assert.strictEqual(payload.item.metadata.assigneeKind, 'agent');
|
||||
});
|
||||
|
||||
await test('claim reports nothing to do once the queue is drained', async () => {
|
||||
const result = runClaim(dbPath, ['--owner', 'alice']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.claimed, false);
|
||||
assert.strictEqual(payload.reason, 'no-unassigned-open-items');
|
||||
});
|
||||
|
||||
await test('claim of a specific id works and rejects a missing id', async () => {
|
||||
const owned = runClaim(dbPath, ['wi-owned', '--owner', 'carol']);
|
||||
assert.strictEqual(owned.status, 0, owned.stderr);
|
||||
assert.strictEqual(JSON.parse(owned.stdout).item.owner, 'carol', 'explicit id can be re-claimed');
|
||||
|
||||
const missing = runClaim(dbPath, ['nope-404', '--owner', 'carol']);
|
||||
assert.notStrictEqual(missing.status, 0, 'missing id should fail');
|
||||
assert.ok(/not found/i.test(missing.stderr), 'reports not found');
|
||||
});
|
||||
|
||||
await test('claim requires --owner and validates --as', async () => {
|
||||
const noOwner = runClaim(dbPath, ['wi-done']);
|
||||
assert.notStrictEqual(noOwner.status, 0);
|
||||
assert.ok(/requires --owner/i.test(noOwner.stderr));
|
||||
|
||||
const badKind = runClaim(dbPath, ['wi-owned', '--owner', 'x', '--as', 'robot']);
|
||||
assert.notStrictEqual(badKind.status, 0);
|
||||
assert.ok(/agent.*human/i.test(badKind.stderr));
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
Reference in New Issue
Block a user