Files
ECC/tests/lib/copilot-install-target.test.js
T
haelyra 131c6186c3 feat: integrate native Copilot skill and agent distribution
Preserve contributor histories for PR #2960 and #2963. Resolve current-main conflicts without dropping OpenCode behavior. Reject traversal source paths, keep volatile inventory counts observational, and apply the shared ownership guard during every adapter repair while retaining adapter-derived roots. RED ownership and traversal regressions now pass, 73 lifecycle tests pass, and native Copilot observes 288 enabled project skills. Disposable install, missing-file repair, and uninstall preserve user-authored agents.
2026-10-09 15:53:38 -04:00

207 lines
8.1 KiB
JavaScript

/**
* Contract tests for the GitHub Copilot install target.
*
* Covers the frontmatter transform in isolation and the adapter's operation
* planning, including the containment guarantee that nothing is written
* outside the project's .github/ directory.
*/
'use strict';
const assert = require('assert');
const path = require('path');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const { adaptCopilotAgent } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install', 'copilot-agent'));
const copilotAdapter = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'copilot-project'));
const { getInstallTargetAdapter } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'registry'));
const { SUPPORTED_INSTALL_TARGETS } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-manifests'));
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
passed += 1;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
const CLAUDE_AGENT = [
'---',
'name: architect',
'description: Software architecture specialist.',
'tools: Read, Grep, Glob',
'model: opus',
'color: teal',
'---',
'',
'## Role',
'',
'You are an architect.',
'',
].join('\n');
function planFor(modules) {
return copilotAdapter.planOperations({
repoRoot: '/repo',
projectRoot: '/project',
homeDir: '/home/user',
modules,
});
}
console.log('=== Testing Copilot install target ===\n');
test('registry resolves the copilot target and adapter id', () => {
assert.strictEqual(getInstallTargetAdapter('copilot').id, 'copilot-project');
assert.strictEqual(getInstallTargetAdapter('copilot-project').id, 'copilot-project');
assert.strictEqual(copilotAdapter.target, 'copilot');
assert.strictEqual(copilotAdapter.kind, 'project');
});
test('copilot is a supported install target', () => {
assert.ok(SUPPORTED_INSTALL_TARGETS.includes('copilot'));
});
test('transform keeps only name and description', () => {
const adapted = adaptCopilotAgent(CLAUDE_AGENT, 'agents/architect.md');
assert.match(adapted, /^---\n/);
assert.match(adapted, /name: architect/);
assert.match(adapted, /description: Software architecture specialist\./);
assert.doesNotMatch(adapted, /^model:/m);
assert.doesNotMatch(adapted, /^tools:/m);
assert.doesNotMatch(adapted, /^color:/m);
});
test('transform preserves the agent body verbatim', () => {
const adapted = adaptCopilotAgent(CLAUDE_AGENT, 'agents/architect.md');
assert.ok(adapted.includes('## Role'));
assert.ok(adapted.includes('You are an architect.'));
});
test('transform rejects a file without frontmatter', () => {
assert.throws(
() => adaptCopilotAgent('# no frontmatter\n', 'agents/broken.md'),
/missing YAML frontmatter/
);
});
test('transform rejects frontmatter that is not an object', () => {
assert.throws(
() => adaptCopilotAgent('---\n- a\n- b\n---\nbody\n', 'agents/broken.md'),
/frontmatter must be an object/
);
});
test('transform requires name and description', () => {
assert.throws(
() => adaptCopilotAgent('---\nname: only-name\n---\nbody\n', 'agents/broken.md'),
/missing required frontmatter "description"/
);
assert.throws(
() => adaptCopilotAgent('---\ndescription: only description\n---\nbody\n', 'agents/broken.md'),
/missing required frontmatter "name"/
);
});
test('agents are planned into .github/agents with the frontmatter transform', () => {
const operations = planFor([{ id: 'agents-core', paths: ['agents'] }]);
assert.strictEqual(operations.length, 1);
assert.strictEqual(operations[0].contentTransform, 'copilot-agent-frontmatter');
assert.strictEqual(operations[0].destinationPath, path.join('/project', '.github', 'agents'));
});
test('skills are planned into .github/skills without a transform', () => {
const operations = planFor([{ id: 'workflow-quality', paths: ['skills/tdd-workflow'] }]);
assert.strictEqual(operations.length, 1);
assert.strictEqual(operations[0].contentTransform, undefined);
assert.strictEqual(
operations[0].destinationPath,
path.join('/project', '.github', 'skills', 'tdd-workflow')
);
});
test('unsupported source paths produce no operations', () => {
const operations = planFor([
{ id: 'rules-core', paths: ['rules'] },
{ id: 'commands-core', paths: ['commands', 'scripts/harness-audit.js'] },
{ id: 'hooks-runtime', paths: ['hooks', 'scripts/hooks'] },
{ id: 'platform-configs', paths: ['.claude-plugin', '.codex', 'mcp-configs'] },
]);
assert.strictEqual(operations.length, 0);
});
test('supportsModule stays permissive so dependency anchors still resolve', () => {
// rules-core, commands-core and platform-configs ship no agents or skills but
// are dependencies of the skill modules. Gating them out here would cascade
// and skip every module that depends on them.
assert.strictEqual(copilotAdapter.supportsModule({ id: 'rules-core', paths: ['rules'] }), true);
assert.strictEqual(copilotAdapter.supportsModule({ id: 'empty', paths: [] }), false);
});
test('every planned operation is contained within .github', () => {
const operations = planFor([
{ id: 'agents-core', paths: ['agents', '.agents', 'AGENTS.md'] },
{ id: 'framework-language', paths: ['skills/api-design', 'skills/react-patterns'] },
{ id: 'rules-core', paths: ['rules'] },
]);
const root = path.join('/project', '.github');
assert.ok(operations.length > 0);
for (const operation of operations) {
const relative = path.relative(root, operation.destinationPath);
assert.ok(
relative && !relative.startsWith('..') && !path.isAbsolute(relative),
`operation escapes .github: ${operation.destinationPath}`
);
}
});
test('AGENTS.md and .agents are not installed by this target', () => {
const operations = planFor([{ id: 'agents-core', paths: ['.agents', 'AGENTS.md'] }]);
assert.strictEqual(operations.length, 0);
});
test('traversal segments produce no planned operations', () => {
for (const value of ['agents/../../escape.md', 'skills/../escape.md', 'agents\\..\\..\\escape.md']) {
assert.deepStrictEqual(planFor([{ id: 'agents-core', paths: [value] }]), []);
}
});
test('repair and uninstall preserve user-owned agents across project adapters', () => {
const fs = require('fs');
const os = require('os');
const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor');
const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
for (const [target, segments] of [['copilot', ['.github', 'agents']], ['antigravity', ['.agents', 'agents']]]) {
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-ownership-'));
try {
const agent = path.join(project, ...segments, 'architect.md');
fs.mkdirSync(path.dirname(agent), { recursive: true });
fs.writeFileSync(agent, 'USER OWNED SENTINEL\n');
const homeDir = path.join(project, 'home');
const plan = createManifestInstallPlan({ target, moduleIds: ['agents-core'], projectRoot: project, homeDir, env: {} });
applyInstallPlan(plan);
assert.strictEqual(fs.readFileSync(agent, 'utf8'), 'USER OWNED SENTINEL\n', 'install must preserve user file');
const options = { repoRoot: REPO_ROOT, projectRoot: project, homeDir, targets: [target], env: {} };
const repair = repairInstalledStates(options);
assert.strictEqual(repair.summary.errorCount, 0, JSON.stringify(repair));
assert.strictEqual(fs.readFileSync(agent, 'utf8'), 'USER OWNED SENTINEL\n', 'repair must preserve user file');
const state = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
assert.ok(!state.operations.some(operation => operation.destinationPath === agent), 'repair must not claim ownership');
uninstallInstalledStates(options);
assert.strictEqual(fs.readFileSync(agent, 'utf8'), 'USER OWNED SENTINEL\n', 'uninstall must preserve user file');
} finally {
fs.rmSync(project, { recursive: true, force: true });
}
}
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed === 0 ? 0 : 1);