mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
The test job ran the entire suite on 3 OSes x 3 Node lines x 4 package managers, 33 full runs per push. Windows lanes took 28 to 39 minutes and macOS 14 to 19, so one push queued about 1.5 hours of wall clock behind runner concurrency, and timing-sensitive tests got 33 chances to flake. The package-manager axis only ever changed one environment variable that nine suites read. Now there are 13 lanes. Five run the full suite: Linux on Node 18, 20 and 22, plus Windows and macOS on Node 22. Eight are package-manager lanes on Node 22 that run only the suites which read CLAUDE_CODE_PACKAGE_MANAGER or CLAUDE_PACKAGE_MANAGER; the file list is grepped at run time so it cannot drift as tests are added. Full lanes use the runner's 4-process pool on Linux and macOS; Windows stays serial because its process spawn and temp-dir cleanup are the flaky part.
420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const { spawnSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const repoRoot = path.join(__dirname, '..', '..');
|
|
const harnessRoot = path.join(repoRoot, 'docker', 'plugin-setup');
|
|
const SUBPROCESS_TIMEOUT_MS = 30_000;
|
|
const files = {
|
|
ci: path.join(repoRoot, '.github', 'workflows', 'ci.yml'),
|
|
compose: path.join(harnessRoot, 'compose.yaml'),
|
|
dockerfile: path.join(harnessRoot, 'Dockerfile'),
|
|
fixtureProject: path.join(
|
|
repoRoot,
|
|
'tests',
|
|
'fixtures',
|
|
'docker-plugin-project',
|
|
'package.json'
|
|
),
|
|
fixtureRunner: path.join(harnessRoot, 'run-fixture-tests.sh'),
|
|
interactivePlan: path.join(harnessRoot, 'interactive-plan.js'),
|
|
packageJson: path.join(repoRoot, 'package.json'),
|
|
packedCliPreparer: path.join(harnessRoot, 'prepare-packed-cli.js'),
|
|
platformRunner: path.join(harnessRoot, 'run-platform-tests.js'),
|
|
planValidator: path.join(harnessRoot, 'verify-install-plan.js'),
|
|
projectDirResolver: path.join(harnessRoot, 'resolve-project-dir.js'),
|
|
realRunner: path.join(harnessRoot, 'run-real-cli.sh'),
|
|
};
|
|
|
|
let passed = 0;
|
|
let failed = 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 read(filePath) {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
function runNode(argv, options = {}) {
|
|
const result = spawnSync(process.execPath, argv, {
|
|
...options,
|
|
shell: false,
|
|
timeout: SUBPROCESS_TIMEOUT_MS,
|
|
});
|
|
assert.ifError(result.error);
|
|
return result;
|
|
}
|
|
|
|
console.log('\n=== Docker plugin setup harness tests ===\n');
|
|
|
|
test('ships the focused Docker harness and default fixture project', () => {
|
|
for (const filePath of Object.values(files)) {
|
|
assert.ok(
|
|
fs.existsSync(filePath),
|
|
`Missing ${path.relative(repoRoot, filePath)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('builds pinned Debian and Ubuntu images as a non-root user', () => {
|
|
const dockerfile = read(files.dockerfile);
|
|
const compose = read(files.compose);
|
|
assert.match(dockerfile, /node:22-bookworm-slim@sha256:[a-f0-9]{64}/);
|
|
assert.match(dockerfile, /ARG OS_IMAGE=/);
|
|
assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS node-runtime/);
|
|
assert.match(dockerfile, /FROM \$\{OS_IMAGE\}/);
|
|
assert.match(dockerfile, /COPY --from=node-runtime \/usr\/local\/ \/usr\/local\//);
|
|
assert.match(dockerfile, /ARG CLAUDE_CODE_VERSION=\d+\.\d+\.\d+/);
|
|
assert.match(dockerfile, /@anthropic-ai\/claude-code@\$\{CLAUDE_CODE_VERSION\}/);
|
|
assert.match(dockerfile, /@iarna\/toml@2\.2\.5/);
|
|
assert.match(dockerfile, /ajv@8\.20\.0/);
|
|
assert.match(dockerfile, /sql\.js@1\.14\.1/);
|
|
assert.match(dockerfile, /--ignore-scripts/);
|
|
assert.match(
|
|
dockerfile,
|
|
/@anthropic-ai\/claude-code\/install\.cjs/
|
|
);
|
|
assert.match(dockerfile, /ENV DISABLE_AUTOUPDATER=1/);
|
|
assert.match(dockerfile, /ENV HOME=\/tmp\/ecc-home/);
|
|
assert.match(dockerfile, /ENV NODE_PATH=\/usr\/local\/lib\/node_modules/);
|
|
assert.match(dockerfile, /chown 1000:1000 \/workspace/);
|
|
assert.match(dockerfile, /USER 1000:1000/);
|
|
assert.doesNotMatch(dockerfile, /:latest/);
|
|
assert.match(compose, /image:\s*ecc-plugin-setup:debian/);
|
|
assert.match(compose, /image:\s*ecc-plugin-setup:ubuntu/);
|
|
assert.match(compose, /ubuntu:24\.04@sha256:[a-f0-9]{64}/);
|
|
assert.match(compose, /real-cli-ubuntu:/);
|
|
assert.match(
|
|
compose,
|
|
/fixture-tests:[\s\S]*?user:\s*["']1000:1000["']/
|
|
);
|
|
assert.strictEqual(
|
|
(compose.match(/node:22-bookworm-slim@sha256:[a-f0-9]{64}/g) || []).length,
|
|
1,
|
|
'The pinned Node image must have one source of truth in Compose'
|
|
);
|
|
assert.match(compose, /x-node-image:\s*&node-image/);
|
|
assert.match(compose, /image:\s*\*node-image/);
|
|
assert.match(compose, /NODE_IMAGE:\s*\*node-image/);
|
|
assert.match(compose, /OS_IMAGE:\s*\*node-image/);
|
|
});
|
|
|
|
test('keeps checkout and source project read-only with hardened defaults', () => {
|
|
const compose = read(files.compose);
|
|
assert.match(compose, /network_mode:\s*none/);
|
|
assert.match(compose, /x-real-cli:[\s\S]*?network_mode:\s*none[\s\S]*?services:/);
|
|
assert.match(
|
|
compose,
|
|
/real-cli-networked:[\s\S]*?profiles:[\s\S]*?-\s*networked[\s\S]*?network_mode:\s*default/
|
|
);
|
|
assert.match(compose, /read_only:\s*true/);
|
|
assert.match(compose, /no-new-privileges:true/);
|
|
assert.match(compose, /cap_drop:\s*\n\s*-\s*ALL/);
|
|
assert.match(compose, /pids_limit:\s*256/);
|
|
assert.match(compose, /target:\s*\/ecc\s*\n\s*read_only:\s*true/);
|
|
assert.match(compose, /target:\s*\/source-project\s*\n\s*read_only:\s*true/);
|
|
assert.match(compose, /CLAUDE_CONFIG_DIR:\s*\/tmp\/ecc-claude-config/);
|
|
assert.match(
|
|
compose,
|
|
/\/tmp:rw,nosuid,nodev,exec,size=\$\{ECC_TMPFS_SIZE:-2g\},uid=1000,gid=1000,mode=0700/
|
|
);
|
|
assert.match(
|
|
compose,
|
|
/\/workspace:rw,nosuid,nodev,noexec,size=\$\{ECC_WORKSPACE_SIZE:-1g\},uid=1000,gid=1000,mode=0700/
|
|
);
|
|
assert.match(compose, /NPM_CONFIG_CACHE:\s*\/tmp\/npm-cache/);
|
|
assert.doesNotMatch(
|
|
compose,
|
|
/ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN|env_file:/
|
|
);
|
|
});
|
|
|
|
test('real runner copies into tmpfs and exposes only explicit safe modes', () => {
|
|
const runner = read(files.realRunner);
|
|
assert.match(runner, /ECC_PROJECT_DIR:-\/workspace\/project/);
|
|
assert.match(runner, /mkdir -p "\$HOME" "\$CLAUDE_CONFIG_DIR" "\$NPM_CONFIG_CACHE"/);
|
|
assert.match(runner, /dry-run\|install\|plugin\|shell/);
|
|
assert.match(runner, /--target claude-project/);
|
|
assert.match(runner, /--dry-run/);
|
|
assert.match(runner, /verify-install-plan\.js.*--dry-run/);
|
|
assert.match(runner, /resolve-project-dir\.js/);
|
|
assert.match(
|
|
runner,
|
|
/project_dir="\$\([\s\S]*?resolve-project-dir\.js[\s\S]*?\)"\s*\nreadonly project_dir/
|
|
);
|
|
assert.doesNotMatch(runner, /readonly project_dir="\$\(/);
|
|
assert.match(runner, /prepare-packed-cli\.js/);
|
|
assert.match(runner, /run_ecc install/);
|
|
assert.match(runner, /run_ecc list-installed --json/);
|
|
assert.match(runner, /run_ecc doctor --target claude-project/);
|
|
assert.match(runner, /\[\[ -e "\$project_dir\/\.claude" \]\]/);
|
|
assert.doesNotMatch(
|
|
runner,
|
|
/scripts\/ecc\.js" setup|--move-scope|\bmigrate\b/
|
|
);
|
|
assert.doesNotMatch(runner, /scripts\/ecc\.js" install/);
|
|
assert.doesNotMatch(runner, /\beval\b|rm\s+-rf/);
|
|
});
|
|
|
|
test('prepares a local npm artifact through the confined public bin contract', () => {
|
|
const preparer = read(files.packedCliPreparer);
|
|
assert.match(preparer, /spawnSync\(executable, argv/);
|
|
assert.match(preparer, /run\(['"]npm['"]/);
|
|
assert.match(preparer, /['"]pack['"]/);
|
|
assert.match(preparer, /['"]--ignore-scripts['"]/);
|
|
assert.match(preparer, /npm_config_offline:\s*['"]true['"]/);
|
|
assert.match(preparer, /run\(['"]tar['"]/);
|
|
assert.match(preparer, /shell:\s*false/g);
|
|
assert.match(
|
|
preparer,
|
|
/const CHILD_PROCESS_TIMEOUT_MS\s*=\s*5 \* 60 \* 1000;/
|
|
);
|
|
assert.match(preparer, /timeout:\s*CHILD_PROCESS_TIMEOUT_MS/);
|
|
assert.doesNotMatch(preparer, /execSync\(|\beval\b/);
|
|
|
|
const { validatePackedPackage } = require(files.packedCliPreparer);
|
|
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-cli-'));
|
|
|
|
function createFixture(name, options = {}) {
|
|
const packageRoot = path.join(fixtureRoot, name);
|
|
fs.mkdirSync(path.join(packageRoot, 'scripts'), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, 'manifests'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, 'package.json'),
|
|
JSON.stringify({
|
|
name: options.packageName || 'ecc-universal',
|
|
version: '2.1.0',
|
|
bin: options.bin === undefined ? { ecc: 'scripts/ecc.js' } : options.bin,
|
|
})
|
|
);
|
|
fs.writeFileSync(path.join(packageRoot, 'scripts', 'ecc.js'), '#!/usr/bin/env node\n');
|
|
fs.chmodSync(path.join(packageRoot, 'scripts', 'ecc.js'), 0o755);
|
|
for (const manifest of [
|
|
'install-components.json',
|
|
'install-modules.json',
|
|
'install-profiles.json',
|
|
]) {
|
|
if (manifest !== options.omitManifest) {
|
|
fs.writeFileSync(path.join(packageRoot, 'manifests', manifest), '{}\n');
|
|
}
|
|
}
|
|
return packageRoot;
|
|
}
|
|
|
|
try {
|
|
const validRoot = createFixture('valid');
|
|
assert.strictEqual(
|
|
validatePackedPackage(validRoot),
|
|
path.join(validRoot, 'scripts', 'ecc.js')
|
|
);
|
|
|
|
for (const [name, options, pattern] of [
|
|
['wrong-name', { packageName: 'not-ecc' }, /package name/i],
|
|
['missing-bin', { bin: {} }, /bin\.ecc/i],
|
|
['escaping-bin', { bin: { ecc: '../escape.js' } }, /bin\.ecc/i],
|
|
['missing-manifest', { omitManifest: 'install-profiles.json' }, /missing/i],
|
|
]) {
|
|
assert.throws(() => validatePackedPackage(createFixture(name, options)), pattern);
|
|
}
|
|
} finally {
|
|
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('normalizes the isolated project path before enforcing workspace containment', () => {
|
|
const valid = runNode([
|
|
files.projectDirResolver,
|
|
'/workspace/nested/../project',
|
|
], { encoding: 'utf8' });
|
|
assert.strictEqual(valid.status, 0, valid.stderr);
|
|
assert.strictEqual(valid.stdout.trim(), '/workspace/project');
|
|
|
|
for (const candidate of [
|
|
'/workspace',
|
|
'/workspace/../tmp/project',
|
|
'/tmp/project',
|
|
'workspace/project',
|
|
]) {
|
|
const invalid = runNode([
|
|
files.projectDirResolver,
|
|
candidate,
|
|
], { encoding: 'utf8' });
|
|
assert.strictEqual(invalid.status, 2, `${candidate}: ${invalid.stderr}`);
|
|
assert.match(invalid.stderr, /within \/workspace/i);
|
|
}
|
|
});
|
|
|
|
test('fixture runner delegates to the cross-platform test entry point', () => {
|
|
const runner = read(files.fixtureRunner);
|
|
assert.match(runner, /id -u/);
|
|
assert.match(runner, /id -g/);
|
|
assert.match(runner, /must run as uid\/gid 1000:1000/i);
|
|
assert.match(
|
|
runner,
|
|
/exec node docker\/plugin-setup\/run-platform-tests\.js/
|
|
);
|
|
});
|
|
|
|
test('uses one shell-free focused runner across Linux, macOS, and Windows', () => {
|
|
const ci = read(files.ci);
|
|
const packageJson = read(files.packageJson);
|
|
const platformRunner = read(files.platformRunner);
|
|
|
|
for (const os of ['ubuntu-latest', 'windows-latest', 'macos-latest']) {
|
|
assert.match(ci, new RegExp(`os:\\s*${os},[^}]*scope:\\s*full`), `${os} must run the full suite`);
|
|
}
|
|
assert.match(
|
|
packageJson,
|
|
/"test:plugin-setup-platform":\s*"node docker\/plugin-setup\/run-platform-tests\.js"/
|
|
);
|
|
assert.match(platformRunner, /spawnSync\(/);
|
|
assert.match(platformRunner, /shell:\s*false/);
|
|
assert.match(
|
|
platformRunner,
|
|
/const CHILD_PROCESS_TIMEOUT_MS\s*=\s*5 \* 60 \* 1000;/
|
|
);
|
|
assert.match(platformRunner, /timeout:\s*CHILD_PROCESS_TIMEOUT_MS/);
|
|
assert.match(platformRunner, /Object\.fromEntries\(/);
|
|
assert.match(platformRunner, /Object\.entries\(process\.env\)\.filter/);
|
|
assert.doesNotMatch(platformRunner, /delete childEnv\[/);
|
|
assert.match(platformRunner, /tests\/lib\/install-manifests\.test\.js/);
|
|
assert.match(platformRunner, /tests\/lib\/install-targets\.test\.js/);
|
|
assert.match(platformRunner, /tests\/lib\/install-executor\.test\.js/);
|
|
assert.doesNotMatch(platformRunner, /\beval\b|execSync\(/);
|
|
});
|
|
|
|
test('emits docker exec as an executable plus argv integration contract', () => {
|
|
const result = runNode([
|
|
files.interactivePlan,
|
|
'--container', 'ecc-plugin-shell',
|
|
'--workdir', '/workspace/project',
|
|
'--json',
|
|
'--',
|
|
'node',
|
|
'-p',
|
|
'process.stdin.isTTY',
|
|
], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
|
contractVersion: 1,
|
|
executable: 'docker',
|
|
argv: [
|
|
'exec',
|
|
'-it',
|
|
'-w',
|
|
'/workspace/project',
|
|
'ecc-plugin-shell',
|
|
'node',
|
|
'-p',
|
|
'process.stdin.isTTY',
|
|
],
|
|
});
|
|
});
|
|
|
|
test('keeps Docker session values as argv entries and validates boundaries', () => {
|
|
const literalArgument = '$(touch should-not-run)';
|
|
const result = runNode([
|
|
files.interactivePlan,
|
|
'--container', 'ecc.plugin-shell_1',
|
|
'--workdir', '/workspace/project with spaces',
|
|
'--json',
|
|
'--',
|
|
'printf',
|
|
'%s',
|
|
literalArgument,
|
|
], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(JSON.parse(result.stdout).argv.slice(-3), [
|
|
'printf',
|
|
'%s',
|
|
literalArgument,
|
|
]);
|
|
|
|
for (const args of [
|
|
['--container', '../escape', '--json'],
|
|
['--container', 'valid-name', '--workdir', 'relative/path', '--json'],
|
|
['--container', 'valid-name', '--workdir', '/workspace/../tmp', '--json'],
|
|
]) {
|
|
const invalid = runNode([files.interactivePlan, ...args], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
});
|
|
assert.strictEqual(invalid.status, 2);
|
|
assert.match(invalid.stderr, /invalid/i);
|
|
}
|
|
});
|
|
|
|
test('validates dry-run target confinement and nonempty operations', () => {
|
|
const projectDir = path.join(repoRoot, 'workspace-project');
|
|
const installRoot = path.join(projectDir, '.claude');
|
|
const safePlan = {
|
|
dryRun: true,
|
|
plan: {
|
|
target: 'claude-project',
|
|
installRoot,
|
|
operations: [
|
|
{ destinationPath: path.join(installRoot, 'rules', 'ecc', 'base.md') },
|
|
],
|
|
},
|
|
};
|
|
const safe = runNode(
|
|
[files.planValidator, projectDir, '--dry-run'],
|
|
{ encoding: 'utf8', input: JSON.stringify(safePlan) }
|
|
);
|
|
assert.strictEqual(safe.status, 0, safe.stderr);
|
|
|
|
const unsafePlan = {
|
|
...safePlan,
|
|
plan: {
|
|
...safePlan.plan,
|
|
operations: [{ destinationPath: '/tmp/escape.md' }],
|
|
},
|
|
};
|
|
const unsafe = runNode(
|
|
[files.planValidator, projectDir, '--dry-run'],
|
|
{ encoding: 'utf8', input: JSON.stringify(unsafePlan) }
|
|
);
|
|
assert.strictEqual(unsafe.status, 1);
|
|
assert.match(unsafe.stderr, /outside/i);
|
|
|
|
for (const installRootValue of [undefined, 42, { path: installRoot }]) {
|
|
const invalidRootPlan = {
|
|
...safePlan,
|
|
plan: {
|
|
...safePlan.plan,
|
|
installRoot: installRootValue,
|
|
},
|
|
};
|
|
const invalidRoot = runNode(
|
|
[files.planValidator, projectDir, '--dry-run'],
|
|
{ encoding: 'utf8', input: JSON.stringify(invalidRootPlan) }
|
|
);
|
|
assert.strictEqual(invalidRoot.status, 1);
|
|
assert.match(invalidRoot.stderr, /install root is not confined/i);
|
|
assert.doesNotMatch(invalidRoot.stderr, /ERR_INVALID_ARG_TYPE|TypeError/);
|
|
}
|
|
});
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|