mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat: add configurable model tiers and advisory freshness * fix: disclose role selections before Claude review execution * fix: reject malformed model policies and keep model pins portable * fix: separate readiness diagnostics and bound version checks * fix: preserve actionable model failures and validate review inputs before probes * fix: retry released probe locks and retain late-rejection repair context * feat: finalize configurable model tiers and verified release repairs * fix: preserve strict model policy reads on Windows * fix: keep fixture probes read-only and clarify pending CEO decisions * fix: verify native terminal closure before bounded descriptor settlement
226 lines
14 KiB
TypeScript
226 lines
14 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { CLAUDE_CODE_RUNTIME_FILES as runtimeFiles, migrateClaudeCodeSkills } from '../lib/claude-code-migration';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
|
|
const skill = (name: string, host = '') => `---\nname: ${name}\n---\n${banner}\n${host}\n`;
|
|
function put(file: string, text: string): void {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text);
|
|
}
|
|
function fixture() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-rename-test-'));
|
|
const root = path.join(dir, 'checkout'); const home = path.join(dir, 'home');
|
|
const codex = path.join(home, 'custom-codex', 'skills');
|
|
const kiro = path.join(home, '.kiro', 'skills');
|
|
for (const rel of runtimeFiles) put(path.join(root, rel), rel);
|
|
const oldRender = path.join(root, '.agents', 'skills', 'gstack-claude');
|
|
put(path.join(oldRender, 'SKILL.md'), skill('gstack-claude'));
|
|
fs.mkdirSync(codex, { recursive: true }); fs.mkdirSync(kiro, { recursive: true });
|
|
const calls: string[] = []; const messages: string[] = [];
|
|
const render = (host: string, out: string) => {
|
|
calls.push(host);
|
|
const subdir = host === 'codex' ? '.agents' : `.${host}`;
|
|
// Native generation keeps the unprefixed frontmatter name even though
|
|
// the installed directory is namespaced (gstack-claude-code).
|
|
put(path.join(out, subdir, 'skills', 'gstack-claude-code', 'SKILL.md'), skill('claude-code', host));
|
|
put(path.join(out, subdir, 'skills', 'gstack-review', 'SKILL.md'), skill('gstack-review', `${host} native outside provider`));
|
|
put(path.join(out, subdir, 'skills', 'gstack-review', 'sections', 'gate.md'), `${banner}\n${host} native gate`);
|
|
put(path.join(out, subdir, 'skills', 'gstack', 'SKILL.md'), skill('gstack', host));
|
|
put(path.join(out, subdir, 'skills', 'gstack-office-hours', 'SKILL.md'), skill('gstack-office-hours', `${host} native consultation`));
|
|
put(path.join(out, subdir, 'skills', 'gstack-upgrade', 'SKILL.md'), skill('gstack-upgrade', `./setup --host ${host}`));
|
|
};
|
|
const run = (overrides: Partial<Parameters<typeof migrateClaudeCodeSkills>[0]> = {}) => migrateClaudeCodeSkills({
|
|
installDir: root, home, env: { CODEX_HOME: path.dirname(codex) }, render,
|
|
log: line => messages.push(line), ...overrides,
|
|
});
|
|
return { dir, root, home, codex, kiro, oldRender, calls, messages, render, run };
|
|
}
|
|
|
|
describe('Claude wrapper installed-name migration', () => {
|
|
test('copied policy tools and wrapper load without the source checkout', () => {
|
|
const f = fixture();
|
|
try {
|
|
for (const rel of runtimeFiles) fs.copyFileSync(path.join(ROOT, rel), path.join(f.root, rel));
|
|
put(path.join(f.codex, 'gstack-claude', 'SKILL.md'), skill('gstack-claude'));
|
|
expect(f.run({ copy: true })).toEqual({ migrated: 1, pending: [] });
|
|
fs.renameSync(f.root, `${f.root}-unavailable`);
|
|
const runtime = path.join(f.codex, 'gstack');
|
|
const env = {
|
|
...process.env, HOME: f.home, USERPROFILE: f.home,
|
|
GSTACK_STATE_ROOT: path.join(f.home, 'state'), CODEX_HOME: path.dirname(f.codex),
|
|
CLAUDE_CONFIG_DIR: path.join(f.home, '.claude'),
|
|
GSTACK_CLAUDE_MODEL: '', GSTACK_CODEX_MODEL: '', ANTHROPIC_BASE_URL: '', OPENAI_BASE_URL: '',
|
|
CLAUDE_CODE_USE_BEDROCK: '', CLAUDE_CODE_USE_VERTEX: '', CLAUDE_CODE_USE_FOUNDRY: '',
|
|
BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0',
|
|
};
|
|
const set = spawnSync('bash', [path.join(runtime, 'bin/gstack-config'), 'set', 'plan_review_tier', 'smart'], {
|
|
cwd: f.dir, env, encoding: 'utf8', timeout: 30_000,
|
|
});
|
|
expect(set.status).toBe(0);
|
|
const inspect = spawnSync(process.execPath, [path.join(runtime, 'bin/gstack-models'), 'resolve', '--role', 'plan-review', '--provider', 'anthropic', '--json'], {
|
|
cwd: f.dir, env, encoding: 'utf8', timeout: 30_000,
|
|
});
|
|
expect(inspect.status).toBe(0);
|
|
expect(JSON.parse(inspect.stdout).selections[0].requestedModel).toBe('claude-opus-5-5');
|
|
const wrapper = spawnSync(process.execPath, [path.join(runtime, 'bin/gstack-claude-code'), '--help'], {
|
|
cwd: f.dir, env, encoding: 'utf8', timeout: 30_000,
|
|
});
|
|
expect(wrapper.status).toBe(1);
|
|
expect(JSON.parse(wrapper.stdout).error.code).toBe('arguments');
|
|
const gate = spawnSync(process.execPath, ['-e', 'const {classifyOutsideReview} = await import(process.argv[1]); console.log(classifyOutsideReview({text:"",gate:"review",exit:1}).verdict)', path.join(runtime, 'lib/outside-review-result.ts')], {
|
|
cwd: f.dir, env, encoding: 'utf8', timeout: 30_000,
|
|
});
|
|
expect(gate.status).toBe(0);
|
|
expect(gate.stdout.trim()).toBe('unavailable');
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('migrates existing Codex and Kiro installs independently of the selected setup host', () => {
|
|
const f = fixture();
|
|
try {
|
|
fs.symlinkSync(f.oldRender, path.join(f.codex, 'gstack-claude'));
|
|
put(path.join(f.kiro, 'gstack-claude', 'SKILL.md'), skill('gstack-claude'));
|
|
put(path.join(f.kiro, 'gstack-claude', 'notes.md'), 'user notes');
|
|
put(path.join(f.kiro, 'gstack-review', 'SKILL.md'), skill('gstack-review', 'old Codex-shaped Kiro output'));
|
|
put(path.join(f.kiro, 'gstack-review', 'sections', 'gate.md'), `${banner}\nold Codex gate`);
|
|
put(path.join(f.kiro, 'gstack-review', 'sections', 'notes.md'), 'user section notes');
|
|
put(path.join(f.kiro, 'gstack', 'office-hours', 'SKILL.md'), skill('gstack-office-hours', 'old consultation'));
|
|
put(path.join(f.kiro, 'gstack', 'gstack-upgrade', 'SKILL.md'), skill('gstack-upgrade', 'old upgrade'));
|
|
const result = f.run({ copy: true, render: (host, out) => {
|
|
// Both old installations remain readable until that replacement renders.
|
|
const old = path.join(host === 'codex' ? f.codex : f.kiro, 'gstack-claude', 'SKILL.md');
|
|
expect(fs.existsSync(old)).toBe(true);
|
|
f.render(host, out);
|
|
} });
|
|
expect(result).toEqual({ migrated: 2, pending: [] });
|
|
expect(f.calls).toEqual(['codex', 'kiro']);
|
|
for (const [host, dir] of [['codex', f.codex], ['kiro', f.kiro]]) {
|
|
expect(fs.readFileSync(path.join(dir, 'gstack-claude-code', 'SKILL.md'), 'utf8')).toContain(host);
|
|
expect(fs.existsSync(path.join(dir, 'gstack-claude', 'SKILL.md'))).toBe(false);
|
|
expect(fs.readFileSync(path.join(dir, 'gstack', 'bin', 'gstack-claude-code'), 'utf8')).toBe('bin/gstack-claude-code');
|
|
}
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack-claude', 'notes.md'), 'utf8')).toBe('user notes');
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack-review', 'SKILL.md'), 'utf8')).toContain('kiro native outside provider');
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack-review', 'SKILL.md.before-claude-code'), 'utf8')).toContain('old Codex-shaped');
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack-review', 'sections', 'gate.md'), 'utf8')).toContain('kiro native gate');
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack-review', 'sections', 'notes.md'), 'utf8')).toBe('user section notes');
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack', 'office-hours', 'SKILL.md'), 'utf8')).toContain('kiro native consultation');
|
|
expect(fs.readFileSync(path.join(f.kiro, 'gstack', 'gstack-upgrade', 'SKILL.md'), 'utf8')).toContain('./setup --host kiro');
|
|
expect(fs.existsSync(path.join(f.codex, 'gstack-review'))).toBe(false); // no new installs
|
|
expect(fs.existsSync(f.oldRender)).toBe(false);
|
|
expect(f.messages).toHaveLength(1);
|
|
expect(f.run()).toEqual({ migrated: 0, pending: [] });
|
|
expect(f.messages).toHaveLength(1); // notice is emitted only for a migration
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('repairs dangling owned links after a standalone build and leaves unrelated Codex output unchanged', () => {
|
|
const f = fixture();
|
|
try {
|
|
fs.symlinkSync(f.oldRender, path.join(f.codex, 'gstack-claude'));
|
|
fs.rmSync(f.oldRender, { recursive: true });
|
|
const other = path.join(f.root, '.agents', 'skills', 'gstack-review', 'SKILL.md');
|
|
put(other, 'Codex Sol profile');
|
|
expect(f.run().migrated).toBe(1);
|
|
expect(fs.lstatSync(path.join(f.codex, 'gstack-claude-code')).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync(other, 'utf8')).toBe('Codex Sol profile');
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('deferred pruning still migrates selected registrations but retains their shared old render', () => {
|
|
const f = fixture();
|
|
try {
|
|
fs.symlinkSync(f.oldRender, path.join(f.codex, 'gstack-claude'));
|
|
const before = fs.readFileSync(path.join(f.oldRender, 'SKILL.md'));
|
|
const result = f.run({ env: { CODEX_HOME: path.dirname(f.codex), GSTACK_DEFER_CLAUDE_RENAME_PRUNE: '1' } });
|
|
expect(result).toEqual({ migrated: 1, pending: [] });
|
|
expect(fs.existsSync(path.join(f.codex, 'gstack-claude/SKILL.md'))).toBe(false);
|
|
expect(fs.readFileSync(path.join(f.codex, 'gstack-claude-code/SKILL.md'), 'utf8')).toContain('name: claude-code');
|
|
expect(fs.readFileSync(path.join(f.oldRender, 'SKILL.md'))).toEqual(before);
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('failed generation preserves the old installed skill and shared render for retry', () => {
|
|
const f = fixture();
|
|
try {
|
|
fs.symlinkSync(f.oldRender, path.join(f.codex, 'gstack-claude'));
|
|
const result = f.run({ render: () => { throw new Error('fixture generation failure'); } });
|
|
expect(result.pending).toEqual([f.codex]);
|
|
expect(fs.readFileSync(path.join(f.codex, 'gstack-claude', 'SKILL.md'), 'utf8')).toContain('gstack-claude');
|
|
expect(fs.existsSync(path.join(f.codex, 'gstack-claude-code'))).toBe(false);
|
|
expect(f.run().migrated).toBe(1);
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('foreign old/replacement skills, links, runtime directories and render links are preserved', () => {
|
|
for (const conflict of ['old', 'old-one-line-banner', 'old-escaped-link', 'replacement', 'replacement-link', 'runtime', 'render-link']) {
|
|
const f = fixture();
|
|
try {
|
|
const foreign = path.join(f.dir, 'foreign');
|
|
put(path.join(foreign, 'SKILL.md'), 'my skill');
|
|
const old = path.join(f.codex, 'gstack-claude');
|
|
if (conflict === 'old') fs.symlinkSync(foreign, old);
|
|
else if (conflict === 'old-one-line-banner') put(path.join(old, 'SKILL.md'), '<!-- AUTO-GENERATED from my own tool -->');
|
|
else if (conflict === 'old-escaped-link') {
|
|
fs.rmSync(f.oldRender, { recursive: true });
|
|
fs.symlinkSync(foreign, f.oldRender);
|
|
fs.symlinkSync(f.oldRender, old);
|
|
}
|
|
else fs.symlinkSync(f.oldRender, old);
|
|
if (conflict === 'replacement') put(path.join(f.codex, 'gstack-claude-code', 'SKILL.md'), 'my skill');
|
|
if (conflict === 'replacement-link') fs.symlinkSync(foreign, path.join(f.codex, 'gstack-claude-code'));
|
|
if (conflict === 'runtime') put(path.join(f.codex, 'gstack', 'SKILL.md'), 'my skill');
|
|
if (conflict === 'render-link') fs.symlinkSync(foreign, path.join(f.root, '.agents', 'skills', 'gstack-claude-code'));
|
|
const result = f.run();
|
|
expect(result.migrated).toBe(0);
|
|
expect(fs.existsSync(path.join(old, 'SKILL.md'))).toBe(true);
|
|
expect(fs.readFileSync(path.join(foreign, 'SKILL.md'), 'utf8')).toBe('my skill');
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
}
|
|
});
|
|
|
|
test('a missing runtime or malformed replacement cannot retire the original', () => {
|
|
for (const failure of ['runtime', 'render']) {
|
|
const f = fixture();
|
|
try {
|
|
fs.symlinkSync(f.oldRender, path.join(f.codex, 'gstack-claude'));
|
|
if (failure === 'runtime') fs.rmSync(path.join(f.root, 'lib/claude-code.ts'));
|
|
const result = f.run(failure === 'render' ? { render: (_host, out) => put(path.join(out, '.agents/skills/gstack-claude-code/SKILL.md'), skill('wrong')) } : {});
|
|
expect(result.pending).toEqual([f.codex]);
|
|
expect(fs.existsSync(path.join(f.codex, 'gstack-claude', 'SKILL.md'))).toBe(true);
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
}
|
|
});
|
|
|
|
test('retirement preserves customized copied content without retaining the old command', () => {
|
|
const f = fixture();
|
|
try {
|
|
const old = path.join(f.kiro, 'gstack-claude');
|
|
const customized = skill('gstack-claude', 'my extra instructions');
|
|
put(path.join(old, 'SKILL.md'), customized);
|
|
put(path.join(old, 'SKILL.md.before-claude-code'), 'earlier backup');
|
|
expect(f.run().migrated).toBe(1);
|
|
expect(fs.existsSync(path.join(old, 'SKILL.md'))).toBe(false);
|
|
expect(fs.readFileSync(path.join(old, 'SKILL.md.before-claude-code'), 'utf8')).toBe('earlier backup');
|
|
expect(fs.readFileSync(path.join(old, 'SKILL.md.before-claude-code.1'), 'utf8')).toBe(customized);
|
|
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('setup runs migration before its first build and defers only the retired Claude render', () => {
|
|
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
|
|
const migration = setup.indexOf('GSTACK_RENAME_COPY="$IS_WINDOWS" bun_cmd');
|
|
expect(migration).toBeGreaterThan(-1);
|
|
expect(migration).toBeLessThan(setup.indexOf('bun_cmd run build'));
|
|
expect(setup).toContain('export GSTACK_DEFER_CLAUDE_RENAME_PRUNE=1');
|
|
expect(setup).toContain('unset GSTACK_DEFER_CLAUDE_RENAME_PRUNE');
|
|
expect(setup).toContain('[ "$n" = "gstack-claude" ]');
|
|
const version = path.join(ROOT, 'gstack-upgrade/migrations/v1.86.0.0.sh');
|
|
expect(fs.statSync(version).mode & 0o111).not.toBe(0);
|
|
expect(fs.readFileSync(version, 'utf8')).toContain('gstack-migrate-claude-code');
|
|
});
|
|
});
|