* fix(db): remove unique constraint on folder to support multi-channel agents * ci: implement automated skill drift detection and self-healing PRs * fix: align registration logic with Gavriel's feedback and fix build/test issues from Daniel Mi * style: conform to prettier standards for CI validation * test: fix branch naming inconsistency in CI (master vs main) * fix(ci): robust module resolution by removing file extensions in scripts * refactor(ci): simplify skill validation by removing redundant combination tests * style: conform skills-engine to prettier, unify logging in index.ts and cleanup unused imports * refactor: extract multi-channel DB changes to separate branch Move channel column, folder suffix logic, and related migrations to feat/multi-channel-db-v2 for independent review. This PR now contains only CI/CD optimizations, Prettier formatting, and logging improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
7.1 KiB
TypeScript
244 lines
7.1 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import {
|
|
areRangesCompatible,
|
|
mergeNpmDependencies,
|
|
mergeEnvAdditions,
|
|
mergeDockerComposeServices,
|
|
} from '../structured.js';
|
|
import { createTempDir, cleanup } from './test-helpers.js';
|
|
|
|
describe('structured', () => {
|
|
let tmpDir: string;
|
|
const originalCwd = process.cwd();
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
process.chdir(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
describe('areRangesCompatible', () => {
|
|
it('identical versions are compatible', () => {
|
|
const result = areRangesCompatible('^1.0.0', '^1.0.0');
|
|
expect(result.compatible).toBe(true);
|
|
});
|
|
|
|
it('compatible ^ ranges resolve to higher', () => {
|
|
const result = areRangesCompatible('^1.0.0', '^1.1.0');
|
|
expect(result.compatible).toBe(true);
|
|
expect(result.resolved).toBe('^1.1.0');
|
|
});
|
|
|
|
it('incompatible major ^ ranges', () => {
|
|
const result = areRangesCompatible('^1.0.0', '^2.0.0');
|
|
expect(result.compatible).toBe(false);
|
|
});
|
|
|
|
it('compatible ~ ranges', () => {
|
|
const result = areRangesCompatible('~1.0.0', '~1.0.3');
|
|
expect(result.compatible).toBe(true);
|
|
expect(result.resolved).toBe('~1.0.3');
|
|
});
|
|
|
|
it('mismatched prefixes are incompatible', () => {
|
|
const result = areRangesCompatible('^1.0.0', '~1.0.0');
|
|
expect(result.compatible).toBe(false);
|
|
});
|
|
|
|
it('handles double-digit version parts numerically', () => {
|
|
// ^1.9.0 vs ^1.10.0 — 10 > 9 numerically, but "9" > "10" as strings
|
|
const result = areRangesCompatible('^1.9.0', '^1.10.0');
|
|
expect(result.compatible).toBe(true);
|
|
expect(result.resolved).toBe('^1.10.0');
|
|
});
|
|
|
|
it('handles double-digit patch versions', () => {
|
|
const result = areRangesCompatible('~1.0.9', '~1.0.10');
|
|
expect(result.compatible).toBe(true);
|
|
expect(result.resolved).toBe('~1.0.10');
|
|
});
|
|
});
|
|
|
|
describe('mergeNpmDependencies', () => {
|
|
it('adds new dependencies', () => {
|
|
const pkgPath = path.join(tmpDir, 'package.json');
|
|
fs.writeFileSync(
|
|
pkgPath,
|
|
JSON.stringify(
|
|
{
|
|
name: 'test',
|
|
dependencies: { existing: '^1.0.0' },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
mergeNpmDependencies(pkgPath, { newdep: '^2.0.0' });
|
|
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
expect(pkg.dependencies.newdep).toBe('^2.0.0');
|
|
expect(pkg.dependencies.existing).toBe('^1.0.0');
|
|
});
|
|
|
|
it('resolves compatible ^ ranges', () => {
|
|
const pkgPath = path.join(tmpDir, 'package.json');
|
|
fs.writeFileSync(
|
|
pkgPath,
|
|
JSON.stringify(
|
|
{
|
|
name: 'test',
|
|
dependencies: { dep: '^1.0.0' },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
mergeNpmDependencies(pkgPath, { dep: '^1.1.0' });
|
|
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
expect(pkg.dependencies.dep).toBe('^1.1.0');
|
|
});
|
|
|
|
it('sorts devDependencies after merge', () => {
|
|
const pkgPath = path.join(tmpDir, 'package.json');
|
|
fs.writeFileSync(
|
|
pkgPath,
|
|
JSON.stringify(
|
|
{
|
|
name: 'test',
|
|
dependencies: {},
|
|
devDependencies: { zlib: '^1.0.0', acorn: '^2.0.0' },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
mergeNpmDependencies(pkgPath, { middle: '^1.0.0' });
|
|
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
const devKeys = Object.keys(pkg.devDependencies);
|
|
expect(devKeys).toEqual(['acorn', 'zlib']);
|
|
});
|
|
|
|
it('throws on incompatible major versions', () => {
|
|
const pkgPath = path.join(tmpDir, 'package.json');
|
|
fs.writeFileSync(
|
|
pkgPath,
|
|
JSON.stringify(
|
|
{
|
|
name: 'test',
|
|
dependencies: { dep: '^1.0.0' },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
expect(() => mergeNpmDependencies(pkgPath, { dep: '^2.0.0' })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('mergeEnvAdditions', () => {
|
|
it('adds new variables', () => {
|
|
const envPath = path.join(tmpDir, '.env.example');
|
|
fs.writeFileSync(envPath, 'EXISTING_VAR=value\n');
|
|
|
|
mergeEnvAdditions(envPath, ['NEW_VAR']);
|
|
|
|
const content = fs.readFileSync(envPath, 'utf-8');
|
|
expect(content).toContain('NEW_VAR=');
|
|
expect(content).toContain('EXISTING_VAR=value');
|
|
});
|
|
|
|
it('skips existing variables', () => {
|
|
const envPath = path.join(tmpDir, '.env.example');
|
|
fs.writeFileSync(envPath, 'MY_VAR=original\n');
|
|
|
|
mergeEnvAdditions(envPath, ['MY_VAR']);
|
|
|
|
const content = fs.readFileSync(envPath, 'utf-8');
|
|
// Should not add duplicate - only 1 occurrence of MY_VAR=
|
|
const matches = content.match(/MY_VAR=/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
|
|
it('recognizes lowercase and mixed-case env vars as existing', () => {
|
|
const envPath = path.join(tmpDir, '.env.example');
|
|
fs.writeFileSync(envPath, 'my_lower_var=value\nMixed_Case=abc\n');
|
|
|
|
mergeEnvAdditions(envPath, ['my_lower_var', 'Mixed_Case']);
|
|
|
|
const content = fs.readFileSync(envPath, 'utf-8');
|
|
// Should not add duplicates
|
|
const lowerMatches = content.match(/my_lower_var=/g);
|
|
expect(lowerMatches).toHaveLength(1);
|
|
const mixedMatches = content.match(/Mixed_Case=/g);
|
|
expect(mixedMatches).toHaveLength(1);
|
|
});
|
|
|
|
it('creates file if it does not exist', () => {
|
|
const envPath = path.join(tmpDir, '.env.example');
|
|
mergeEnvAdditions(envPath, ['NEW_VAR']);
|
|
|
|
expect(fs.existsSync(envPath)).toBe(true);
|
|
const content = fs.readFileSync(envPath, 'utf-8');
|
|
expect(content).toContain('NEW_VAR=');
|
|
});
|
|
});
|
|
|
|
describe('mergeDockerComposeServices', () => {
|
|
it('adds new services', () => {
|
|
const composePath = path.join(tmpDir, 'docker-compose.yaml');
|
|
fs.writeFileSync(
|
|
composePath,
|
|
'version: "3"\nservices:\n web:\n image: nginx\n',
|
|
);
|
|
|
|
mergeDockerComposeServices(composePath, {
|
|
redis: { image: 'redis:7' },
|
|
});
|
|
|
|
const content = fs.readFileSync(composePath, 'utf-8');
|
|
expect(content).toContain('redis');
|
|
});
|
|
|
|
it('skips existing services', () => {
|
|
const composePath = path.join(tmpDir, 'docker-compose.yaml');
|
|
fs.writeFileSync(
|
|
composePath,
|
|
'version: "3"\nservices:\n web:\n image: nginx\n',
|
|
);
|
|
|
|
mergeDockerComposeServices(composePath, {
|
|
web: { image: 'apache' },
|
|
});
|
|
|
|
const content = fs.readFileSync(composePath, 'utf-8');
|
|
expect(content).toContain('nginx');
|
|
});
|
|
|
|
it('throws on port collision', () => {
|
|
const composePath = path.join(tmpDir, 'docker-compose.yaml');
|
|
fs.writeFileSync(
|
|
composePath,
|
|
'version: "3"\nservices:\n web:\n image: nginx\n ports:\n - "8080:80"\n',
|
|
);
|
|
|
|
expect(() =>
|
|
mergeDockerComposeServices(composePath, {
|
|
api: { image: 'node', ports: ['8080:3000'] },
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
});
|