Files
gstack/test/claude-code-migration.test.ts
T
Garry Tan 92cfd07a79 v1.91.45.0 feat: configurable model tiers and advisory freshness (#3073)
* 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
2026-10-08 08:00:08 +00:00

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');
});
});