mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 16:08:59 +02:00
* fix: default cross-model workflows to frontier models * chore: bump version and changelog (v1.82.1.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix: repair frontier eval budgets and workflow instructions Preserve frontier models and quality thresholds while fixing truncated judge output, ordered section expansion, consent checks, QA scoring, and ship audit gates. Add regression coverage and refresh generated docs. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix: resolve workflow gaps exposed by frontier evals Clarify plan-review ordering and fallback modes, preserve deploy readiness gates, honor configured merge methods, correct benchmark and canary contracts, and restore vendored installs on setup failure. Cover recovery with real-shell regressions. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix: use agent capture budgets for deploy evals Multi-turn deploy and benchmark sessions were incorrectly limited to the single-call judge timeout. Use the existing capture tier and leave outer-test cleanup headroom, with a free policy regression test. Keep all behavioral assertions and frontier models unchanged. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix: clarify retro workflow and evaluate compare instructions Include compare mode in the frontier judge excerpt, define metric sources and snapshot ordering, and preserve the existing prompt-size budget. Co-authored-by: OpenAI Codex <noreply@openai.com> * fix: make documentation release review and publication consistent Review before commit, clarify changelog safeguards and unavailable reviewer modes, and preserve raw PR bodies across separate shell calls. Keep title sync in one shell and add regression coverage. Co-authored-by: OpenAI Codex <noreply@openai.com> --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
612 lines
23 KiB
TypeScript
612 lines
23 KiB
TypeScript
/**
|
|
* Host config system tests — 100% coverage of host-config.ts, hosts/index.ts,
|
|
* host-config-export.ts, and golden-file regression checks.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { validateHostConfig, validateAllConfigs, type HostConfig } from '../scripts/host-config';
|
|
import {
|
|
ALL_HOST_CONFIGS,
|
|
ALL_HOST_NAMES,
|
|
HOST_CONFIG_MAP,
|
|
getHostConfig,
|
|
resolveHostArg,
|
|
getExternalHosts,
|
|
claude,
|
|
codex,
|
|
factory,
|
|
kiro,
|
|
opencode,
|
|
slate,
|
|
cursor,
|
|
openclaw,
|
|
} from '../hosts/index';
|
|
import { HOST_PATHS } from '../scripts/resolvers/types';
|
|
import { RESOLVERS } from '../scripts/resolvers';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
|
|
|
|
// ─── hosts/index.ts ─────────────────────────────────────────
|
|
|
|
describe('hosts/index.ts', () => {
|
|
test('ALL_HOST_CONFIGS has 10 hosts', () => {
|
|
expect(ALL_HOST_CONFIGS.length).toBe(10);
|
|
});
|
|
|
|
test('ALL_HOST_NAMES matches config names', () => {
|
|
expect(ALL_HOST_NAMES).toEqual(ALL_HOST_CONFIGS.map(c => c.name));
|
|
});
|
|
|
|
test('HOST_CONFIG_MAP keys match names', () => {
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
expect(HOST_CONFIG_MAP[config.name]).toBe(config);
|
|
}
|
|
});
|
|
|
|
test('individual config re-exports match registry', () => {
|
|
expect(claude.name).toBe('claude');
|
|
expect(codex.name).toBe('codex');
|
|
expect(factory.name).toBe('factory');
|
|
expect(kiro.name).toBe('kiro');
|
|
expect(opencode.name).toBe('opencode');
|
|
expect(slate.name).toBe('slate');
|
|
expect(cursor.name).toBe('cursor');
|
|
expect(openclaw.name).toBe('openclaw');
|
|
});
|
|
|
|
test('getHostConfig returns correct config', () => {
|
|
const c = getHostConfig('codex');
|
|
expect(c.name).toBe('codex');
|
|
expect(c.displayName).toBe('OpenAI Codex CLI');
|
|
});
|
|
|
|
test('getHostConfig throws on unknown host', () => {
|
|
expect(() => getHostConfig('nonexistent')).toThrow('Unknown host');
|
|
});
|
|
|
|
test('resolveHostArg resolves direct names', () => {
|
|
for (const name of ALL_HOST_NAMES) {
|
|
expect(resolveHostArg(name)).toBe(name);
|
|
}
|
|
});
|
|
|
|
test('resolveHostArg resolves aliases', () => {
|
|
expect(resolveHostArg('agents')).toBe('codex');
|
|
expect(resolveHostArg('droid')).toBe('factory');
|
|
});
|
|
|
|
test('resolveHostArg throws on unknown alias', () => {
|
|
expect(() => resolveHostArg('nonexistent')).toThrow('Unknown host');
|
|
});
|
|
|
|
test('getExternalHosts excludes claude', () => {
|
|
const external = getExternalHosts();
|
|
expect(external.find(c => c.name === 'claude')).toBeUndefined();
|
|
expect(external.length).toBe(ALL_HOST_CONFIGS.length - 1);
|
|
});
|
|
|
|
test('every host has a unique name', () => {
|
|
const names = new Set(ALL_HOST_NAMES);
|
|
expect(names.size).toBe(ALL_HOST_NAMES.length);
|
|
});
|
|
|
|
test('every host has a unique hostSubdir', () => {
|
|
const subdirs = new Set(ALL_HOST_CONFIGS.map(c => c.hostSubdir));
|
|
expect(subdirs.size).toBe(ALL_HOST_CONFIGS.length);
|
|
});
|
|
|
|
test('every host has a unique globalRoot', () => {
|
|
const roots = new Set(ALL_HOST_CONFIGS.map(c => c.globalRoot));
|
|
expect(roots.size).toBe(ALL_HOST_CONFIGS.length);
|
|
});
|
|
});
|
|
|
|
// ─── validateHostConfig ─────────────────────────────────────
|
|
|
|
describe('validateHostConfig', () => {
|
|
function makeValid(): HostConfig {
|
|
return {
|
|
name: 'test-host',
|
|
displayName: 'Test Host',
|
|
cliCommand: 'testcli',
|
|
defaultModel: 'claude',
|
|
globalRoot: '.test/skills/gstack',
|
|
localSkillRoot: '.test/skills/gstack',
|
|
hostSubdir: '.test',
|
|
usesEnvVars: true,
|
|
frontmatter: { mode: 'allowlist', keepFields: ['name', 'description'] },
|
|
generation: { generateMetadata: false },
|
|
pathRewrites: [],
|
|
runtimeRoot: { globalSymlinks: ['bin'] },
|
|
install: { linkingStrategy: 'symlink-generated' },
|
|
};
|
|
}
|
|
|
|
test('valid config passes', () => {
|
|
expect(validateHostConfig(makeValid())).toEqual([]);
|
|
});
|
|
|
|
test('invalid name is caught', () => {
|
|
const c = makeValid();
|
|
c.name = 'UPPER_CASE';
|
|
const errors = validateHostConfig(c);
|
|
expect(errors.some(e => e.includes('name'))).toBe(true);
|
|
});
|
|
|
|
test('name with special chars is caught', () => {
|
|
const c = makeValid();
|
|
c.name = 'has spaces';
|
|
expect(validateHostConfig(c).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('empty displayName is caught', () => {
|
|
const c = makeValid();
|
|
c.displayName = '';
|
|
expect(validateHostConfig(c).some(e => e.includes('displayName'))).toBe(true);
|
|
});
|
|
|
|
test('invalid cliCommand is caught', () => {
|
|
const c = makeValid();
|
|
c.cliCommand = 'has spaces';
|
|
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
|
|
});
|
|
|
|
test('invalid cliAlias is caught', () => {
|
|
const c = makeValid();
|
|
c.cliAliases = ['good', 'BAD!'];
|
|
expect(validateHostConfig(c).some(e => e.includes('cliAlias'))).toBe(true);
|
|
});
|
|
|
|
test('valid cliAliases pass', () => {
|
|
const c = makeValid();
|
|
c.cliAliases = ['alias-one', 'alias-two'];
|
|
expect(validateHostConfig(c)).toEqual([]);
|
|
});
|
|
|
|
test('invalid defaultModel is caught', () => {
|
|
const c = makeValid();
|
|
(c as any).defaultModel = 'llama-local';
|
|
expect(validateHostConfig(c).some(e => e.includes('defaultModel'))).toBe(true);
|
|
});
|
|
|
|
test('invalid globalRoot is caught', () => {
|
|
const c = makeValid();
|
|
c.globalRoot = 'path with spaces';
|
|
expect(validateHostConfig(c).some(e => e.includes('globalRoot'))).toBe(true);
|
|
});
|
|
|
|
test('invalid localSkillRoot is caught', () => {
|
|
const c = makeValid();
|
|
c.localSkillRoot = 'invalid<path>';
|
|
expect(validateHostConfig(c).some(e => e.includes('localSkillRoot'))).toBe(true);
|
|
});
|
|
|
|
test('invalid hostSubdir is caught', () => {
|
|
const c = makeValid();
|
|
c.hostSubdir = 'no spaces allowed';
|
|
expect(validateHostConfig(c).some(e => e.includes('hostSubdir'))).toBe(true);
|
|
});
|
|
|
|
test('invalid frontmatter.mode is caught', () => {
|
|
const c = makeValid();
|
|
(c.frontmatter as any).mode = 'invalid';
|
|
expect(validateHostConfig(c).some(e => e.includes('frontmatter.mode'))).toBe(true);
|
|
});
|
|
|
|
test('invalid linkingStrategy is caught', () => {
|
|
const c = makeValid();
|
|
(c.install as any).linkingStrategy = 'invalid';
|
|
expect(validateHostConfig(c).some(e => e.includes('linkingStrategy'))).toBe(true);
|
|
});
|
|
|
|
test('paths with $ and ~ are valid', () => {
|
|
const c = makeValid();
|
|
c.globalRoot = '$HOME/.test/skills/gstack';
|
|
c.localSkillRoot = '~/.test/skills/gstack';
|
|
expect(validateHostConfig(c)).toEqual([]);
|
|
});
|
|
|
|
test('shell injection attempt in cliCommand is caught', () => {
|
|
const c = makeValid();
|
|
c.cliCommand = 'opencode;rm -rf /';
|
|
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
|
|
});
|
|
|
|
test('valid suppressedResolvers pass when resolver names provided', () => {
|
|
const c = makeValid();
|
|
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY'];
|
|
expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]);
|
|
});
|
|
|
|
test('unknown suppressedResolvers entry is caught', () => {
|
|
const c = makeValid();
|
|
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER'];
|
|
const errors = validateHostConfig(c, RESOLVER_NAMES);
|
|
expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true);
|
|
});
|
|
|
|
test('suppressedResolvers unchecked when resolver names omitted', () => {
|
|
const c = makeValid();
|
|
c.suppressedResolvers = ['TYPO_RESOLVER'];
|
|
expect(validateHostConfig(c)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── validateAllConfigs ─────────────────────────────────────
|
|
|
|
describe('validateAllConfigs', () => {
|
|
test('real configs all pass validation', () => {
|
|
const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('duplicate name detected', () => {
|
|
const dup = { ...codex, name: 'claude' } as HostConfig;
|
|
const errors = validateAllConfigs([claude, dup]);
|
|
expect(errors.some(e => e.includes('Duplicate name'))).toBe(true);
|
|
});
|
|
|
|
test('duplicate hostSubdir detected', () => {
|
|
const dup = { ...codex, name: 'dup-host', hostSubdir: '.claude', globalRoot: '.dup/skills/gstack' } as HostConfig;
|
|
const errors = validateAllConfigs([claude, dup]);
|
|
expect(errors.some(e => e.includes('Duplicate hostSubdir'))).toBe(true);
|
|
});
|
|
|
|
test('duplicate globalRoot detected', () => {
|
|
const dup = { ...codex, name: 'dup-host', hostSubdir: '.dup', globalRoot: '.claude/skills/gstack' } as HostConfig;
|
|
const errors = validateAllConfigs([claude, dup]);
|
|
expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true);
|
|
});
|
|
|
|
test('unknown suppressedResolvers entry surfaces with host-name prefix', () => {
|
|
const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig;
|
|
const errors = validateAllConfigs([bad], RESOLVER_NAMES);
|
|
expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true);
|
|
});
|
|
|
|
test('per-config validation errors are prefixed with host name', () => {
|
|
const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig;
|
|
const errors = validateAllConfigs([bad]);
|
|
expect(errors.every(e => e.startsWith('[BAD]'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── HOST_PATHS derivation ──────────────────────────────────
|
|
|
|
describe('HOST_PATHS derivation from configs', () => {
|
|
test('Claude uses literal home paths (no env vars)', () => {
|
|
expect(HOST_PATHS.claude.skillRoot).toBe('~/.claude/skills/gstack');
|
|
expect(HOST_PATHS.claude.binDir).toBe('~/.claude/skills/gstack/bin');
|
|
expect(HOST_PATHS.claude.browseDir).toBe('~/.claude/skills/gstack/browse/dist');
|
|
expect(HOST_PATHS.claude.designDir).toBe('~/.claude/skills/gstack/design/dist');
|
|
});
|
|
|
|
test('Codex uses $GSTACK_ROOT env vars', () => {
|
|
expect(HOST_PATHS.codex.skillRoot).toBe('$GSTACK_ROOT');
|
|
expect(HOST_PATHS.codex.binDir).toBe('$GSTACK_BIN');
|
|
expect(HOST_PATHS.codex.browseDir).toBe('$GSTACK_BROWSE');
|
|
expect(HOST_PATHS.codex.designDir).toBe('$GSTACK_DESIGN');
|
|
});
|
|
|
|
test('every host with usesEnvVars=true gets env var paths', () => {
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
if (config.usesEnvVars) {
|
|
expect(HOST_PATHS[config.name].skillRoot).toBe('$GSTACK_ROOT');
|
|
expect(HOST_PATHS[config.name].binDir).toBe('$GSTACK_BIN');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('every host with usesEnvVars=false gets literal paths', () => {
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
if (!config.usesEnvVars) {
|
|
expect(HOST_PATHS[config.name].skillRoot).toContain('~/');
|
|
expect(HOST_PATHS[config.name].binDir).toContain('/bin');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('localSkillRoot matches config for every host', () => {
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
expect(HOST_PATHS[config.name].localSkillRoot).toBe(config.localSkillRoot);
|
|
}
|
|
});
|
|
|
|
test('HOST_PATHS has entry for every registered host', () => {
|
|
for (const name of ALL_HOST_NAMES) {
|
|
expect(HOST_PATHS[name]).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── host-config-export.ts CLI ──────────────────────────────
|
|
|
|
describe('host-config-export.ts CLI', () => {
|
|
const EXPORT_SCRIPT = path.join(ROOT, 'scripts', 'host-config-export.ts');
|
|
|
|
function run(...args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
|
const result = Bun.spawnSync(['bun', 'run', EXPORT_SCRIPT, ...args], {
|
|
cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 30_000,
|
|
});
|
|
return {
|
|
stdout: result.stdout.toString().trim(),
|
|
stderr: result.stderr.toString().trim(),
|
|
exitCode: result.exitCode,
|
|
};
|
|
}
|
|
|
|
test('list prints all host names', () => {
|
|
const { stdout, exitCode } = run('list');
|
|
expect(exitCode).toBe(0);
|
|
const names = stdout.split('\n');
|
|
expect(names).toEqual(ALL_HOST_NAMES);
|
|
});
|
|
|
|
test('get returns string field', () => {
|
|
const { stdout, exitCode } = run('get', 'codex', 'globalRoot');
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toBe('.codex/skills/gstack');
|
|
});
|
|
|
|
test('get returns boolean as 1/0', () => {
|
|
const { stdout: t } = run('get', 'claude', 'usesEnvVars');
|
|
expect(t).toBe('0');
|
|
const { stdout: f } = run('get', 'codex', 'usesEnvVars');
|
|
expect(f).toBe('1');
|
|
});
|
|
|
|
test('get with missing args exits 1', () => {
|
|
const { exitCode } = run('get', 'codex');
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
|
|
test('get with unknown field exits 1', () => {
|
|
const { exitCode } = run('get', 'codex', 'nonexistent');
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
|
|
test('get with unknown host exits 1', () => {
|
|
const { exitCode } = run('get', 'nonexistent', 'name');
|
|
expect(exitCode).not.toBe(0);
|
|
});
|
|
|
|
test('validate passes for real configs', () => {
|
|
const { stdout, exitCode } = run('validate');
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('configs valid');
|
|
});
|
|
|
|
test('symlinks returns asset list', () => {
|
|
const { stdout, exitCode } = run('symlinks', 'codex');
|
|
expect(exitCode).toBe(0);
|
|
const lines = stdout.split('\n');
|
|
expect(lines).toContain('bin');
|
|
expect(lines).toContain('ETHOS.md');
|
|
expect(lines).toContain('review/checklist.md');
|
|
});
|
|
|
|
test('opencode symlinks returns nested runtime assets', () => {
|
|
const { stdout, exitCode } = run('symlinks', 'opencode');
|
|
expect(exitCode).toBe(0);
|
|
const lines = stdout.split('\n');
|
|
expect(lines).toContain('bin');
|
|
expect(lines).toContain('browse/dist');
|
|
expect(lines).toContain('browse/bin');
|
|
expect(lines).toContain('review/design-checklist.md');
|
|
expect(lines).toContain('review/greptile-triage.md');
|
|
expect(lines).toContain('review/specialists');
|
|
expect(lines).toContain('qa/templates');
|
|
expect(lines).toContain('qa/references');
|
|
expect(lines).toContain('plan-devex-review/dx-hall-of-fame.md');
|
|
});
|
|
|
|
test('symlinks with missing host exits 1', () => {
|
|
const { exitCode } = run('symlinks');
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
|
|
// Gated: the secretless free-tests CI lane deliberately installs no claude
|
|
// CLI, so "we are running in claude" is false there by design.
|
|
test.skipIf(!Bun.which('claude'))('detect finds claude (since we are running in claude)', () => {
|
|
const { stdout, exitCode } = run('detect');
|
|
expect(exitCode).toBe(0);
|
|
// claude binary should be on PATH in this environment
|
|
expect(stdout).toContain('claude');
|
|
});
|
|
|
|
test('unknown command exits 1', () => {
|
|
const { exitCode } = run('badcommand');
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── Golden-file regression ─────────────────────────────────
|
|
|
|
describe('golden-file regression', () => {
|
|
const GOLDEN_DIR = path.join(ROOT, 'test', 'fixtures', 'golden');
|
|
|
|
// #2532 successor: the codex/factory goldens used to read gitignored
|
|
// .agents/ and .factory/ artifacts "produced by gen-skill-docs.test.ts" —
|
|
// an inter-test ordering dependency that failed with ENOENT on a clean
|
|
// clone or when this file ran in isolation. Severed: this describe
|
|
// UNCONDITIONALLY renders both hosts into its own --out-dir in beforeAll
|
|
// and reads its goldens only from that render — no when-missing check, no
|
|
// live-tree reads for the gitignored artifacts, no dependence on what any
|
|
// other test left on disk. Comparing a FRESH render to the golden is also
|
|
// strictly deterministic: a stale on-disk artifact can no longer mask (or
|
|
// fake) a generator regression.
|
|
const GOLDEN_OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-golden-out-'));
|
|
|
|
beforeAll(() => {
|
|
for (const host of ['codex', 'factory']) {
|
|
const result = Bun.spawnSync(
|
|
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', GOLDEN_OUT],
|
|
{ cwd: ROOT, timeout: 120_000 },
|
|
);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(
|
|
`golden-file beforeAll: gen-skill-docs --host ${host} --out-dir failed (exit ${result.exitCode}):\n`
|
|
+ result.stderr.toString(),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(GOLDEN_OUT, { recursive: true, force: true });
|
|
});
|
|
|
|
test('every Claude outside-voice invocation selects the overridable frontier model', () => {
|
|
const rendered = fs.readFileSync(path.join(GOLDEN_OUT, '.agents/skills/gstack-claude/SKILL.md'), 'utf8');
|
|
const calls = rendered.split('\n').filter(line => line.includes('"$CLAUDE_BIN" -p'));
|
|
expect(calls).toHaveLength(4);
|
|
for (const call of calls) {
|
|
expect(call).toContain('--model "${GSTACK_CLAUDE_MODEL:-claude-fable-5-1}"');
|
|
}
|
|
});
|
|
|
|
test('Claude ship skill matches golden baseline', () => {
|
|
// Deliberately reads the TRACKED ship/SKILL.md (a read, not a write):
|
|
// the claude golden pins the committed render. Freshness of the tracked
|
|
// tree vs the templates is enforced by gen-skill-docs.test.ts. (An
|
|
// out-dir claude render would NOT byte-match this golden — --out-dir
|
|
// repoints section-base paths into the render by design.)
|
|
const golden = fs.readFileSync(path.join(GOLDEN_DIR, 'claude-ship-SKILL.md'), 'utf-8');
|
|
const current = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
|
expect(current).toBe(golden);
|
|
});
|
|
|
|
test('Codex ship skill matches golden baseline', () => {
|
|
const golden = fs.readFileSync(path.join(GOLDEN_DIR, 'codex-ship-SKILL.md'), 'utf-8');
|
|
const current = fs.readFileSync(path.join(GOLDEN_OUT, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
|
expect(current).toBe(golden);
|
|
});
|
|
|
|
test('Factory ship skill matches golden baseline', () => {
|
|
const golden = fs.readFileSync(path.join(GOLDEN_DIR, 'factory-ship-SKILL.md'), 'utf-8');
|
|
const current = fs.readFileSync(path.join(GOLDEN_OUT, '.factory', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
|
expect(current).toBe(golden);
|
|
});
|
|
});
|
|
|
|
// ─── Individual host config correctness ─────────────────────
|
|
|
|
describe('host config correctness', () => {
|
|
test('Codex host renders with generic GPT overlay while existing hosts retain Claude overlay', () => {
|
|
expect(codex.defaultModel).toBe('gpt');
|
|
for (const host of ALL_HOST_CONFIGS.filter(h => h.name !== 'codex')) {
|
|
expect(host.defaultModel).toBe('claude');
|
|
}
|
|
});
|
|
|
|
test('claude is the only host with real-dir-symlink strategy', () => {
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
if (config.name === 'claude') {
|
|
expect(config.install.linkingStrategy).toBe('real-dir-symlink');
|
|
} else {
|
|
expect(config.install.linkingStrategy).toBe('symlink-generated');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('claude does not use env vars', () => {
|
|
expect(claude.usesEnvVars).toBe(false);
|
|
});
|
|
|
|
test('all external hosts use env vars', () => {
|
|
for (const config of getExternalHosts()) {
|
|
expect(config.usesEnvVars).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('codex has 1024-char description limit with error behavior', () => {
|
|
expect(codex.frontmatter.descriptionLimit).toBe(1024);
|
|
expect(codex.frontmatter.descriptionLimitBehavior).toBe('error');
|
|
});
|
|
|
|
test('codex generates metadata (openai.yaml, format hardcoded in gen-skill-docs)', () => {
|
|
expect(codex.generation.generateMetadata).toBe(true);
|
|
});
|
|
|
|
test('codex rewrites CLAUDE.md to AGENTS.md', () => {
|
|
expect(codex.pathRewrites).toContainEqual({ from: 'CLAUDE.md', to: 'AGENTS.md' });
|
|
});
|
|
|
|
test('factory has tool rewrites', () => {
|
|
expect(factory.toolRewrites).toBeDefined();
|
|
expect(Object.keys(factory.toolRewrites!).length).toBeGreaterThan(0);
|
|
expect(factory.toolRewrites!['use the Bash tool']).toBe('run this command');
|
|
});
|
|
|
|
test('factory has conditional disable-model-invocation field', () => {
|
|
expect(factory.frontmatter.conditionalFields).toBeDefined();
|
|
expect(factory.frontmatter.conditionalFields!.length).toBe(1);
|
|
expect(factory.frontmatter.conditionalFields![0].if).toEqual({ sensitive: true });
|
|
expect(factory.frontmatter.conditionalFields![0].add).toEqual({ 'disable-model-invocation': true });
|
|
});
|
|
|
|
test('codex has suppressedResolvers for self-invocation prevention', () => {
|
|
expect(codex.suppressedResolvers).toBeDefined();
|
|
expect(codex.suppressedResolvers).toContain('CODEX_SECOND_OPINION');
|
|
expect(codex.suppressedResolvers).toContain('ADVERSARIAL_STEP');
|
|
expect(codex.suppressedResolvers).toContain('REVIEW_ARMY');
|
|
});
|
|
|
|
test('codex has boundary instruction', () => {
|
|
expect(codex.boundaryInstruction).toBeDefined();
|
|
expect(codex.boundaryInstruction).toContain('Do NOT read');
|
|
});
|
|
|
|
test('openclaw has tool rewrites for exec/read/write', () => {
|
|
expect(openclaw.toolRewrites).toBeDefined();
|
|
expect(openclaw.toolRewrites!['use the Bash tool']).toBe('use the exec tool');
|
|
expect(openclaw.toolRewrites!['use the Read tool']).toBe('use the read tool');
|
|
});
|
|
|
|
test('openclaw has CLAUDE.md→AGENTS.md path rewrite', () => {
|
|
expect(openclaw.pathRewrites.some(r => r.from === 'CLAUDE.md' && r.to === 'AGENTS.md')).toBe(true);
|
|
});
|
|
|
|
test('no host carries a no-op empty includeSkills allowlist', () => {
|
|
// includeSkills: [] was a no-op (the generator's `?.length` guard treats an
|
|
// empty allowlist as absent), so configs omit the field instead of
|
|
// shipping a lie about "no skills generated".
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
expect(config.generation.includeSkills).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test('every host has coAuthorTrailer or undefined', () => {
|
|
// Claude, Codex, Factory, OpenClaw have explicit trailers
|
|
expect(claude.coAuthorTrailer).toContain('Claude');
|
|
expect(codex.coAuthorTrailer).toContain('Codex');
|
|
expect(factory.coAuthorTrailer).toContain('Factory');
|
|
expect(openclaw.coAuthorTrailer).toContain('OpenClaw');
|
|
});
|
|
|
|
test('every external host skips the codex skill', () => {
|
|
for (const config of getExternalHosts()) {
|
|
expect(config.generation.skipSkills).toContain('codex');
|
|
}
|
|
});
|
|
|
|
test('every host has at least one pathRewrite (except claude)', () => {
|
|
for (const config of getExternalHosts()) {
|
|
expect(config.pathRewrites.length).toBeGreaterThan(0);
|
|
}
|
|
expect(claude.pathRewrites.length).toBe(0);
|
|
});
|
|
|
|
test('every host has runtimeRoot.globalSymlinks', () => {
|
|
for (const config of ALL_HOST_CONFIGS) {
|
|
expect(config.runtimeRoot.globalSymlinks.length).toBeGreaterThan(0);
|
|
expect(config.runtimeRoot.globalSymlinks).toContain('bin');
|
|
expect(config.runtimeRoot.globalSymlinks).toContain('ETHOS.md');
|
|
}
|
|
});
|
|
});
|