mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
v1.91.5.0 feat: balanced free-suite shards, test:ubicloud, and faster PR eval lane (#2989)
* v1.91.5.0 feat: balanced free-suite shards, 16-way Linux runs, and bun run test:ubicloud * chore: regenerate agents digest for v1.91.5.0 * ci: serial flaky retry and flake ledger for the Windows free lane * ci: cancel superseded eval runs; relax LLM-judge clarity bar to 3
This commit is contained in:
1 parent
d2a0bbcf4c
commit
080a655791
30 files changed
+2474
-1784
No files matched your search
@@ -81,12 +81,19 @@ describe('paid CI coordination stays off the eval image', () => {
|
||||
expect(planner.steps.find(step => step.run?.includes('--emit-plan'))?.run).toContain('bun --no-install run');
|
||||
});
|
||||
|
||||
test(`${name}: cancel-in-progress can stop every job (no job-level always())`, () => {
|
||||
for (const [id, job] of Object.entries(jobs)) {
|
||||
const condition = String((job as { if?: unknown }).if ?? '');
|
||||
expect(`${id}: ${condition}`).not.toMatch(/(^|[^!])always\(\)/);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${name}: executors still require both prerequisites and consume the image`, () => {
|
||||
const executor = jobs['eval-slices'];
|
||||
expect(executor.needs).toEqual(['build-image', 'plan-slices']);
|
||||
expect(JSON.stringify(executor.container)).toContain('needs.build-image.outputs.image-tag');
|
||||
if (name === 'evals.yml') {
|
||||
expect(executor.if).toBe("always() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success'");
|
||||
expect(executor.if).toBe("${{ !cancelled() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success' }}");
|
||||
} else {
|
||||
expect(executor.if).toBeUndefined();
|
||||
}
|
||||
@@ -99,7 +106,7 @@ describe('paid CI coordination stays off the eval image', () => {
|
||||
expect(report.container).toBeUndefined();
|
||||
expect(report.needs).toContain('plan-slices');
|
||||
expect(report.needs).toContain('eval-slices');
|
||||
expect(report.if).toBe("always() && needs.plan-slices.result == 'success'");
|
||||
expect(report.if).toBe("${{ !cancelled() && needs.plan-slices.result == 'success' }}");
|
||||
expect(JSON.stringify(report.steps)).not.toMatch(/restore-deps|bun install/);
|
||||
expect(report.steps.find(step => step.run?.includes('--report'))?.run).toContain('bun --no-install run');
|
||||
if (name === 'evals.yml') expect(report.permissions).toEqual({ contents: 'read' });
|
||||
|
||||
@@ -149,6 +149,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
|
||||
'served-page JS talking to its own loopback server (same-origin relative fetch)',
|
||||
'design/src/compare.ts':
|
||||
'served-page JS talking to its own loopback server (relative ./api fetch)',
|
||||
'scripts/ubicloud':
|
||||
'developer-invoked test infrastructure: Ubicloud API calls (VM create/show/destroy) with the developer\'s own token, the checkout streamed over SSH to that developer\'s own ephemeral VM, and toolchain downloads run on the VM; no installed-gstack user state is sent',
|
||||
// Skill prose templates: these render agent-executed instructions (the
|
||||
// agent runs git in the USER\'S repo at the user\'s direction), they are
|
||||
// not gstack binaries. Includes the preamble-generated brain-sync block —
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, expect } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
export const ROOT = resolve(import.meta.dir, '../..');
|
||||
export const files = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 });
|
||||
if (files.status !== 0) throw new Error(files.stderr);
|
||||
export const owned: string[] = [];
|
||||
export const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
|
||||
export function assertFixtureWrite(file: string) {
|
||||
const root = owned.find(dir => file === dir || file.startsWith(dir + '/'));
|
||||
if (!root) throw new Error(`Fixture write outside owned roots: ${file}`);
|
||||
let existing = file;
|
||||
while (!existsSync(existing)) {
|
||||
if (lstatSync(existing, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Unresolvable fixture link: ${existing}`);
|
||||
existing = dirname(existing);
|
||||
}
|
||||
const target = realpathSync(existing);
|
||||
const physicalRoot = realpathSync(root);
|
||||
if (target !== physicalRoot && !target.startsWith(physicalRoot + '/')) throw new Error(`Fixture write escapes physical root: ${file} -> ${target}`);
|
||||
}
|
||||
export function fixtureWriteFileSync(...args: Parameters<typeof writeFileSync>) {
|
||||
assertFixtureWrite(String(args[0]));
|
||||
return writeFileSync(...args);
|
||||
}
|
||||
export function fixtureCopyFileSync(...args: Parameters<typeof copyFileSync>) {
|
||||
assertFixtureWrite(String(args[1]));
|
||||
return copyFileSync(...args);
|
||||
}
|
||||
export function fixtureMkdirSync(...args: Parameters<typeof mkdirSync>) {
|
||||
assertFixtureWrite(String(args[0]));
|
||||
return mkdirSync(...args);
|
||||
}
|
||||
export function fixtureUtimesSync(...args: Parameters<typeof utimesSync>) {
|
||||
assertFixtureWrite(String(args[0]));
|
||||
return utimesSync(...args);
|
||||
}
|
||||
|
||||
export function tree(dir: string): unknown {
|
||||
const stat = lstatSync(dir);
|
||||
if (stat.isSymbolicLink()) return { link: readlinkSync(dir) };
|
||||
if (stat.isDirectory()) return Object.fromEntries(readdirSync(dir).sort().map(name => [name, tree(join(dir, name))]));
|
||||
return createHash('sha256').update(readFileSync(dir)).digest('hex');
|
||||
}
|
||||
|
||||
export function fixture(layout: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-codex-scope-'));
|
||||
owned.push(dir);
|
||||
const home = join(dir, 'home');
|
||||
const project = join(dir, 'project-a');
|
||||
const other = join(dir, 'project-b');
|
||||
const source = layout === 'machine' ? join(home, '.claude/skills/gstack')
|
||||
: layout === 'ordinary' ? join(project, 'custom-checkout') : join(project, layout, 'skills/gstack');
|
||||
const commands = join(dir, 'commands');
|
||||
for (const d of [home, other, commands, source]) fixtureMkdirSync(d, { recursive: true });
|
||||
for (const rel of [...files.stdout.split('\0').filter(Boolean), 'scripts/external-skill-names.ts', 'scripts/preflight-codex-overlap.ts']) {
|
||||
if (/^(?:test|docs|browse\/test|\.github)\//.test(rel)) continue;
|
||||
const dest = join(source, rel);
|
||||
fixtureMkdirSync(dirname(dest), { recursive: true });
|
||||
if (lstatSync(join(ROOT, rel)).isSymbolicLink()) {
|
||||
const target = readlinkSync(join(ROOT, rel));
|
||||
expect(resolve(dirname(dest), target).startsWith(source + '/')).toBe(true);
|
||||
symlinkSync(target, dest);
|
||||
} else fixtureCopyFileSync(join(ROOT, rel), dest);
|
||||
}
|
||||
const write = (file: string, content: string) => {
|
||||
fixtureMkdirSync(dirname(file), { recursive: true });
|
||||
fixtureWriteFileSync(file, content, { mode: 0o755 });
|
||||
};
|
||||
for (const rel of ['browse/dist/browse', 'design/dist/design', 'make-pdf/dist/pdf', 'browse/dist/.build-complete']) {
|
||||
const file = join(source, rel);
|
||||
write(file, '#!/bin/sh\nexit 0\n');
|
||||
fixtureUtimesSync(file, new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
write(join(commands, 'bun'), `#!/usr/bin/env bash
|
||||
case "$*" in
|
||||
'install --frozen-lockfile') exit 0 ;;
|
||||
'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;;
|
||||
'run build') echo 'Unexpected build in registration fixture' >&2; exit 90 ;;
|
||||
*) exec ${quote(process.execPath)} "$@" ;;
|
||||
esac
|
||||
`);
|
||||
const realRm = Bun.which('rm');
|
||||
if (!realRm) throw new Error('rm is required');
|
||||
write(join(commands, 'rm'), `#!/usr/bin/env bash
|
||||
if [ "$#" -eq 2 ] && [ "$1" = -f ] && [ "$2" = /tmp/gstack-latest-version ]; then exit 0; fi
|
||||
exec ${quote(realRm)} "$@"
|
||||
`);
|
||||
for (const name of ['codex', 'claude']) write(join(commands, name), '#!/bin/sh\nexit 0\n');
|
||||
const global = join(home, '.codex/skills');
|
||||
const previous = join(dir, 'previous/gstack');
|
||||
write(join(previous, 'bin/gstack-autoplan-snapshot.ts'), readFileSync(join(ROOT, 'bin/gstack-autoplan-snapshot.ts'), 'utf8'));
|
||||
write(join(previous, 'lib/claude-bin.ts'), 'export {};\n');
|
||||
for (const name of ['gstack-review', 'gstack-claude', 'gstack-retired']) {
|
||||
write(join(previous, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior global skill.\n`);
|
||||
fixtureMkdirSync(global, { recursive: true });
|
||||
if (name === 'gstack-claude') {
|
||||
write(join(global, name, 'SKILL.md'), readFileSync(join(previous, name, 'SKILL.md'), 'utf8'));
|
||||
} else symlinkSync(join(previous, name), join(global, name), 'dir');
|
||||
}
|
||||
fixtureMkdirSync(join(global, 'gstack'), { recursive: true });
|
||||
symlinkSync(join(previous, 'bin'), join(global, 'gstack/bin'), 'dir');
|
||||
symlinkSync(join(previous, 'lib'), join(global, 'gstack/lib'), 'dir');
|
||||
write(join(global, 'gstack/SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nGlobal router.\n');
|
||||
write(join(global, 'custom/SKILL.md'), 'User-owned skill.\n');
|
||||
const env = {
|
||||
PATH: `${commands}:${process.env.PATH}`, HOME: home, USERPROFILE: home,
|
||||
CODEX_HOME: join(home, '.codex'), CLAUDE_CONFIG_DIR: join(home, '.claude'),
|
||||
GSTACK_HOME: join(home, '.gstack'), GSTACK_STATE_ROOT: join(home, '.gstack'),
|
||||
TMPDIR: dir, TMP: dir, TEMP: dir,
|
||||
GSTACK_SKIP_PLAYWRIGHT: '1', GSTACK_SKIP_FONTS: '1', GSTACK_SKIP_COREUTILS: '1', GSTACK_SKIP_ASIDE: '1', GSTACK_SKIP_GBRAIN_REGEN: '1',
|
||||
BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(tmpdir(), 'gstack-preflight-bun-cache'),
|
||||
};
|
||||
write(join(home, '.gstack/config.yaml'), 'telemetry: off\nartifacts_sync: off\n');
|
||||
return { dir, source, home, project, other, global, previous, env };
|
||||
}
|
||||
|
||||
export function install(f: ReturnType<typeof fixture>, args = '--host codex') {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), ...args.split(' '), '--no-plan-tune-hooks', '--no-timeline-stop-hook', '--no-team'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cleanupOwnedFixtures() { for (const dir of owned.splice(0)) rmSync(dir, { recursive: true, force: true }); }
|
||||
afterEach(cleanupOwnedFixtures);
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, lstatSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { owned, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup Codex destination follows recognized source scope', () => {
|
||||
for (const localLegacy of [false, true]) for (const marker of ['current', '1.85.0.0']) test(`excluded global legacy render survives local migration=${localLegacy}, marker=${marker} and generation`, () => {
|
||||
const f = fixture('.claude');
|
||||
const oldRender = join(f.source, '.agents/skills/gstack-claude');
|
||||
fixtureMkdirSync(oldRender, { recursive: true });
|
||||
const oldBytes = '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExisting legacy global workflow.\n';
|
||||
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), oldBytes);
|
||||
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
|
||||
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const target = join(f.global, 'gstack', rel);
|
||||
expect(lstatSync(target).isSymbolicLink()).toBe(true);
|
||||
rmSync(target);
|
||||
fixtureMkdirSync(target);
|
||||
fixtureWriteFileSync(join(target, 'prior'), 'Existing copied global runtime.\n');
|
||||
}
|
||||
const local = join(f.project, '.agents/skills');
|
||||
if (localLegacy) {
|
||||
fixtureMkdirSync(local, { recursive: true });
|
||||
symlinkSync(oldRender, join(local, 'gstack-claude'), 'dir');
|
||||
}
|
||||
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
|
||||
const before = tree(f.global);
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(before);
|
||||
expect(readFileSync(join(f.global, 'gstack-claude/SKILL.md'), 'utf8')).toBe(oldBytes);
|
||||
expect(realpathSync(join(local, 'gstack-claude-code/SKILL.md'))).toBe(join(f.source, '.agents/skills/gstack-claude-code/SKILL.md'));
|
||||
expect(lstatSync(join(local, 'gstack-claude'), { throwIfNoEntry: false })).toBeUndefined();
|
||||
}, 90_000);
|
||||
|
||||
for (const nested of [false, true]) for (const global of [false, true]) for (const windows of [false, true]) {
|
||||
test(`named ${nested ? 'ancestor' : 'source'} overlap: logical local=${!global}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const physical = join(f.project, '.agents/skills/gstack-review', ...(nested ? ['checkout'] : []));
|
||||
fixtureMkdirSync(physical, { recursive: true });
|
||||
cpSync(f.source, physical, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
|
||||
rmSync(f.source, { recursive: true });
|
||||
symlinkSync(physical, f.source, 'dir');
|
||||
if (nested) fixtureWriteFileSync(join(dirname(physical), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior render.\n');
|
||||
fixtureWriteFileSync(join(physical, 'uncommitted-proof'), 'Do not erase this checkout.\n');
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureCopyFileSync(join(physical, rel.replace('.exe', '')), join(physical, rel));
|
||||
fixtureUtimesSync(join(physical, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(global ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
if (windows) {
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('Codex skill copy replacement overlaps source');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
} else {
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(physical, 'uncommitted-proof'), 'utf8')).toBe('Do not erase this checkout.\n');
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const target of ['source', 'project', 'root-sidecar']) for (const host of target === 'root-sidecar' ? ['codex'] : ['codex', 'claude']) test(`generated namespace alias to ${target} refuses before writes, host=${host}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const render = join(f.source, '.agents/skills');
|
||||
fixtureMkdirSync(render, { recursive: true });
|
||||
const alias = join(render, target === 'root-sidecar' ? 'gstack' : 'gstack-review');
|
||||
const destination = target === 'project' ? join(f.project, 'ordinary-review') : f.source;
|
||||
if (target === 'project') {
|
||||
fixtureMkdirSync(destination);
|
||||
fixtureWriteFileSync(join(destination, 'SKILL.md'), 'Project-owned content.\n');
|
||||
}
|
||||
symlinkSync(destination, alias, 'dir');
|
||||
if (target === 'root-sidecar') fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(target === 'root-sidecar' ? 'Codex sidecar' : 'Codex generated skill write');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['absolute-generation-alias', 'occupied-relocation']) for (const windows of [false, true]) {
|
||||
test(`direct global checkout preflights ${target} before moving, Windows=${windows}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const direct = join(f.global, 'gstack');
|
||||
rmSync(direct, { recursive: true });
|
||||
cpSync(f.source, direct, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
|
||||
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep source checkout.\n');
|
||||
if (target === 'absolute-generation-alias') {
|
||||
const generated = join(direct, 'generated-codex');
|
||||
fixtureMkdirSync(generated);
|
||||
symlinkSync(generated, join(direct, '.agents'), 'dir');
|
||||
} else {
|
||||
fixtureMkdirSync(join(f.home, '.gstack/repos'), { recursive: true });
|
||||
symlinkSync(direct, join(f.home, '.gstack/repos/gstack'), 'dir');
|
||||
}
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(target === 'absolute-generation-alias' ? 'post-relocation generation namespace' : 'checkout relocation');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
test('Claude-only setup cannot prune a bannered stale skill inside its source checkout', () => {
|
||||
const f = fixture('ordinary');
|
||||
const skill = join(f.source, 'skills/gstack-obsolete/SKILL.md');
|
||||
fixtureMkdirSync(dirname(skill), { recursive: true });
|
||||
fixtureWriteFileSync(skill, '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPreserve source content.\n');
|
||||
const env = { ...f.env, CODEX_HOME: f.source };
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('Codex stale host cleanup');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
test('a dangling owned legacy Codex link reaches the rename migration', () => {
|
||||
const f = fixture('ordinary');
|
||||
const old = join(f.global, 'gstack-claude');
|
||||
rmSync(old, { recursive: true });
|
||||
symlinkSync(join(f.source, '.agents/skills/gstack-claude'), old, 'dir');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const target = join(f.global, 'gstack', rel);
|
||||
rmSync(target);
|
||||
symlinkSync(join(f.source, rel), target, 'dir');
|
||||
}
|
||||
install(f);
|
||||
expect(lstatSync(old, { throwIfNoEntry: false })).toBeUndefined();
|
||||
expect(readFileSync(join(f.global, 'gstack-claude-code/SKILL.md'), 'utf8')).toContain('name: claude-code');
|
||||
expect(readFileSync(join(f.global, 'custom/SKILL.md'), 'utf8')).toBe('User-owned skill.\n');
|
||||
}, 90_000);
|
||||
|
||||
for (const explicit of [false, true]) for (const windows of [false, true]) {
|
||||
test(`global handwritten runtime is refused before writes, explicit=${explicit}, Windows=${windows}`, () => {
|
||||
const f = fixture(explicit ? '.claude' : 'ordinary');
|
||||
const runtime = join(f.global, 'gstack');
|
||||
rmSync(runtime, { recursive: true });
|
||||
fixtureMkdirSync(runtime);
|
||||
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
|
||||
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(explicit ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(`global Codex runtime ${runtime} is a real user-owned skill`);
|
||||
expect(result.stderr).toContain('choose another CODEX_HOME');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const prior of ['managed', 'partial']) test(`global ${prior} runtime remains refreshable`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const runtime = join(f.global, 'gstack');
|
||||
rmSync(runtime, { recursive: true });
|
||||
fixtureMkdirSync(runtime);
|
||||
if (prior === 'managed') fixtureWriteFileSync(join(runtime, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior runtime.\n');
|
||||
fixtureWriteFileSync(join(runtime, 'prior-asset'), 'A previous runtime asset.\n');
|
||||
install(f);
|
||||
install(f);
|
||||
expect(existsSync(join(runtime, 'prior-asset'))).toBe(false);
|
||||
expect(readFileSync(join(runtime, 'SKILL.md'), 'utf8')).toContain('<!-- AUTO-GENERATED from');
|
||||
expect(realpathSync(join(runtime, 'bin'))).toBe(join(f.source, 'bin'));
|
||||
}, 90_000);
|
||||
|
||||
test('Claude-only setup leaves a global handwritten runtime untouched', () => {
|
||||
const f = fixture('ordinary');
|
||||
const runtime = join(f.global, 'gstack');
|
||||
rmSync(runtime, { recursive: true });
|
||||
fixtureMkdirSync(runtime);
|
||||
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
|
||||
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
|
||||
const before = tree(runtime);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(tree(runtime)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
test('Unix rename does not overwrite a source checkout registered as another skill', () => {
|
||||
const f = fixture('ordinary');
|
||||
const source = join(f.global, 'gstack-review');
|
||||
rmSync(source);
|
||||
cpSync(f.source, source, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
|
||||
fixtureWriteFileSync(join(source, 'uncommitted-proof'), 'Keep this source.\n');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const asset = join(f.global, 'gstack', rel);
|
||||
rmSync(asset);
|
||||
fixtureMkdirSync(asset);
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('Codex rename workflow write');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const alias of ['SKILL leaf', 'metadata parent']) test(`direct relocation refuses a generated absolute ${alias} before moving source`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const direct = join(f.global, 'gstack');
|
||||
rmSync(direct, { recursive: true });
|
||||
cpSync(f.source, direct, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
|
||||
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep this source.\n');
|
||||
const generated = join(direct, '.agents/skills/gstack-review');
|
||||
fixtureMkdirSync(generated, { recursive: true });
|
||||
if (alias === 'SKILL leaf') {
|
||||
fixtureWriteFileSync(join(generated, 'saved.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\nPrior render.\n');
|
||||
symlinkSync(join(generated, 'saved.md'), join(generated, 'SKILL.md'));
|
||||
} else {
|
||||
const prior = join(direct, '.agents/skills/gstack-office-hours/agents');
|
||||
fixtureMkdirSync(prior, { recursive: true });
|
||||
symlinkSync(prior, join(generated, 'agents'), 'dir');
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('post-relocation generated alias');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
test('managed rename detaches a metadata parent link without touching its source', () => {
|
||||
const f = fixture('ordinary');
|
||||
const installed = join(f.global, 'gstack-review');
|
||||
rmSync(installed);
|
||||
fixtureMkdirSync(installed);
|
||||
fixtureWriteFileSync(join(installed, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior installed review.\n');
|
||||
const sourceAgents = join(f.source, '.agents/skills/gstack-review/agents');
|
||||
fixtureMkdirSync(sourceAgents, { recursive: true });
|
||||
fixtureWriteFileSync(join(dirname(sourceAgents), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior canonical review.\n');
|
||||
fixtureWriteFileSync(join(sourceAgents, 'user-notes'), 'Preserve source metadata.\n');
|
||||
symlinkSync(sourceAgents, join(installed, 'agents'), 'dir');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const asset = join(f.global, 'gstack', rel);
|
||||
rmSync(asset);
|
||||
symlinkSync(join(f.source, rel), asset, 'dir');
|
||||
}
|
||||
const result = install(f);
|
||||
expect(result.stderr).toContain('migrated 1 installed skill');
|
||||
expect(lstatSync(join(installed, 'agents')).isDirectory()).toBe(true);
|
||||
expect(readFileSync(join(sourceAgents, 'user-notes'), 'utf8')).toBe('Preserve source metadata.\n');
|
||||
expect(existsSync(join(installed, 'agents/openai.yaml'))).toBe(true);
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup Codex destination follows recognized source scope', () => {
|
||||
for (const global of [false, true]) for (const windows of [false, true]) test(`a real runtime containing the source is refused before writes, global=${global}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const runtime = join(f.project, '.agents/skills/gstack');
|
||||
const nested = join(runtime, 'checkout');
|
||||
cpSync(f.source, nested, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
|
||||
fixtureWriteFileSync(join(nested, 'uncommitted-work'), 'Preserve the nested checkout.\n');
|
||||
rmSync(f.source, { recursive: true });
|
||||
symlinkSync(nested, f.source, 'dir');
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
|
||||
const before = tree(f.dir);
|
||||
for (const host of ['codex', 'auto']) for (let run = 0; run < 2; run++) {
|
||||
const args = [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'];
|
||||
if (global) args.push('--global');
|
||||
const result = spawnSync('bash', args, { cwd: f.other, env, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('runtime directory contains the source checkout');
|
||||
expect(result.stderr).toContain(runtime);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test('a distinct sibling source checkout is refused before any mutation', () => {
|
||||
const f = fixture('.claude');
|
||||
const sibling = join(f.project, '.agents/skills/gstack');
|
||||
cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
|
||||
fixtureWriteFileSync(join(sibling, 'uncommitted-work'), 'Keep sibling checkout edits.\n');
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`existing source checkout at ${sibling}`);
|
||||
}, 90_000);
|
||||
|
||||
test('a project destination aliased to global skills is refused before any mutation', () => {
|
||||
const f = fixture('.claude');
|
||||
fixtureMkdirSync(join(f.project, '.agents'));
|
||||
symlinkSync(f.global, join(f.project, '.agents/skills'), 'dir');
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('project-local Codex destination resolves outside the project');
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['root', 'rendered', 'missing-tail', 'project-root']) for (const marker of ['current', '1.85.0.0']) test(`another payload cannot own the local namespace: ${target}, marker=${marker}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const sibling = target === 'project-root' ? f.project : join(f.project, 'other-checkout');
|
||||
if (target === 'project-root') {
|
||||
for (const rel of ['setup', 'VERSION', 'bin/gstack-relink']) {
|
||||
fixtureMkdirSync(dirname(join(sibling, rel)), { recursive: true });
|
||||
fixtureCopyFileSync(join(f.source, rel), join(sibling, rel));
|
||||
}
|
||||
} else cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
|
||||
const rendered = join(sibling, '.agents/skills');
|
||||
const root = join(rendered, 'gstack');
|
||||
const review = join(rendered, 'gstack-review');
|
||||
for (const dir of [root, review]) {
|
||||
fixtureMkdirSync(dir, { recursive: true });
|
||||
fixtureWriteFileSync(join(dir, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nKeep the sibling workflow.\n');
|
||||
}
|
||||
symlinkSync(join(sibling, 'bin'), join(root, 'bin'), 'dir');
|
||||
for (const [name, source] of [['gstack', root], ['gstack-review', review]]) {
|
||||
rmSync(join(f.global, name!), { recursive: true });
|
||||
symlinkSync(source!, join(f.global, name!), 'dir');
|
||||
}
|
||||
if (target === 'missing-tail') {
|
||||
expect(existsSync(join(sibling, 'skills'))).toBe(false);
|
||||
symlinkSync(sibling, join(f.project, '.agents'), 'dir');
|
||||
} else if (target !== 'project-root') {
|
||||
fixtureMkdirSync(join(f.project, '.agents'));
|
||||
symlinkSync(target === 'root' ? sibling : rendered, join(f.project, '.agents/skills'), 'dir');
|
||||
}
|
||||
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('overlaps the source tree');
|
||||
expect(result.stderr).toContain(sibling);
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const preexisting of [false, true]) test(`generated runtime leaf alias survives setup, preexisting=${preexisting}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const renderedSkills = join(f.source, '.agents/skills');
|
||||
const renderedRoot = join(renderedSkills, 'gstack');
|
||||
const local = join(f.project, '.agents/skills');
|
||||
if (preexisting) {
|
||||
fixtureMkdirSync(renderedRoot, { recursive: true });
|
||||
fixtureWriteFileSync(join(renderedRoot, 'SKILL.md'), readFileSync(join(f.source, 'SKILL.md')));
|
||||
}
|
||||
fixtureMkdirSync(local, { recursive: true });
|
||||
symlinkSync(renderedRoot, join(local, 'gstack'), 'dir');
|
||||
const globalRoot = join(f.global, 'gstack/SKILL.md');
|
||||
rmSync(globalRoot);
|
||||
symlinkSync(join(renderedRoot, 'SKILL.md'), globalRoot);
|
||||
const before = tree(f.global);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(before);
|
||||
expect(existsSync(join(renderedRoot, 'SKILL.md'))).toBe(true);
|
||||
expect(realpathSync(join(local, 'gstack/SKILL.md'))).toBe(join(renderedRoot, 'SKILL.md'));
|
||||
expect(readFileSync(globalRoot)).toEqual(readFileSync(join(renderedRoot, 'SKILL.md')));
|
||||
expect(realpathSync(join(local, 'gstack/bin'))).toBe(join(f.source, 'bin'));
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['source', 'rendered', 'missing-rendered']) for (const windows of [false, true]) test(`source-backed namespace is refused without mutation: ${target}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const renderedSkills = join(f.source, '.agents/skills');
|
||||
const oldRender = join(renderedSkills, 'gstack-claude');
|
||||
if (target !== 'missing-rendered') {
|
||||
fixtureMkdirSync(oldRender, { recursive: true });
|
||||
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExcluded global workflow.\n');
|
||||
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
|
||||
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
|
||||
}
|
||||
fixtureMkdirSync(join(f.project, '.agents'));
|
||||
symlinkSync(target === 'source' ? f.source : renderedSkills, join(f.project, '.agents/skills'), 'dir');
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(target === 'missing-rendered' ? 'unresolvable directory' : 'overlaps the source tree');
|
||||
expect(result.stderr).toContain('Use a separate project-local skills directory');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`outward generation namespace is refused before writes: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const agents = join(f.project, '.agents');
|
||||
const local = join(agents, 'skills');
|
||||
fixtureMkdirSync(agents);
|
||||
if (preexisting || level === 'skills') fixtureMkdirSync(local);
|
||||
if (level === 'agents') symlinkSync(agents, join(f.source, '.agents'), 'dir');
|
||||
else {
|
||||
fixtureMkdirSync(join(f.source, '.agents'));
|
||||
symlinkSync(local, join(f.source, '.agents/skills'), 'dir');
|
||||
}
|
||||
if (preexisting) for (const name of ['gstack', 'gstack-review']) {
|
||||
fixtureMkdirSync(join(local, name));
|
||||
fixtureWriteFileSync(join(local, name, 'SKILL.md'), `Handwritten ${name} workflow.\n`);
|
||||
fixtureWriteFileSync(join(local, name, 'user-notes'), 'Preserve unrelated user notes.\n');
|
||||
}
|
||||
if (!preexisting && level === 'skills') rmSync(local, { recursive: true });
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), '1.85.0.0');
|
||||
const before = tree(f.dir);
|
||||
for (const host of ['codex', 'claude', 'auto']) for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('generation namespace');
|
||||
expect(result.stderr).toContain('Use a separate project-local skills directory');
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['dangling-agents', 'dangling-skills', 'cyclic-agents', 'cyclic-skills', 'file-agents', 'file-skills', 'source-root']) test(`unresolvable generation namespace is refused without mutation: ${target}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const agents = join(f.source, '.agents');
|
||||
const component = target.endsWith('skills') || target === 'source-root' ? join(agents, 'skills') : agents;
|
||||
if (component !== agents) fixtureMkdirSync(agents);
|
||||
if (target.startsWith('file')) fixtureWriteFileSync(component, 'Preserve namespace file.\n');
|
||||
else symlinkSync(target === 'source-root' ? f.source : target.startsWith('cyclic') ? component : join(f.source, 'missing-generation'), component, 'dir');
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('generation namespace');
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`internal generation namespace alias remains supported: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const internal = join(f.source, 'generated-codex');
|
||||
fixtureMkdirSync(internal);
|
||||
if (level === 'agents') symlinkSync(internal, join(f.source, '.agents'), 'dir');
|
||||
else {
|
||||
fixtureMkdirSync(join(f.source, '.agents'));
|
||||
symlinkSync(internal, join(f.source, '.agents/skills'), 'dir');
|
||||
}
|
||||
const generated = join(f.source, '.agents/skills');
|
||||
if (preexisting) {
|
||||
fixtureMkdirSync(join(generated, 'gstack-review'), { recursive: true });
|
||||
fixtureWriteFileSync(join(generated, 'gstack-review/SKILL.md'), 'Prior internal render.\n');
|
||||
}
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
fixtureWriteFileSync(join(f.source, 'uncommitted-work'), 'Preserve source edits.\n');
|
||||
const excluded = tree(f.global), sibling = tree(f.other);
|
||||
const sourceBefore = Object.fromEntries(['SKILL.md', 'bin', 'lib', 'uncommitted-work'].map(rel => [rel, tree(join(f.source, rel))]));
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(excluded);
|
||||
expect(tree(f.other)).toEqual(sibling);
|
||||
for (const [rel, before] of Object.entries(sourceBefore)) expect(tree(join(f.source, rel))).toEqual(before);
|
||||
expect(readFileSync(join(f.project, '.agents/skills/gstack-review/SKILL.md'))).toEqual(readFileSync(join(generated, 'gstack-review/SKILL.md')));
|
||||
expect(existsSync(join(f.source, 'bin/bin'))).toBe(false);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const windows of [false, true]) test(`individual generated skill leaf alias remains supported, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const rendered = join(f.source, '.agents/skills/gstack-review');
|
||||
const local = join(f.project, '.agents/skills');
|
||||
fixtureMkdirSync(rendered, { recursive: true });
|
||||
fixtureMkdirSync(local, { recursive: true });
|
||||
fixtureWriteFileSync(join(rendered, 'user-notes'), 'Keep notes beside the source render.\n');
|
||||
symlinkSync(rendered, join(local, 'gstack-review'), 'dir');
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
const excluded = tree(f.global);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(excluded);
|
||||
expect(readFileSync(join(rendered, 'user-notes'), 'utf8')).toBe('Keep notes beside the source render.\n');
|
||||
expect(readFileSync(join(local, 'gstack-review/SKILL.md'))).toEqual(readFileSync(join(rendered, 'SKILL.md')));
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['dangling-skills', 'dangling-agents', 'cyclic-skills', 'file-skills']) test(`unresolvable namespace is refused without mutation: ${target}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const agents = join(f.project, '.agents');
|
||||
if (target === 'dangling-agents') symlinkSync(join(f.dir, 'missing-agents'), agents, 'dir');
|
||||
else {
|
||||
fixtureMkdirSync(agents);
|
||||
const local = join(agents, 'skills');
|
||||
if (target === 'file-skills') fixtureWriteFileSync(local, 'Preserve this file.\n');
|
||||
else symlinkSync(target === 'cyclic-skills' ? 'skills' : join(f.dir, 'missing-skills'), local, 'dir');
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('unresolvable directory');
|
||||
expect(result.stderr).toContain('Use a separate project-local skills directory');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('F13 independent review boundaries', () => {
|
||||
for (const target of ['legacy', 'replacement', 'legacy-skill', 'runtime', 'runtime-bin', 'runtime-lib']) {
|
||||
test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
rmSync(join(f.global, 'gstack-retired'));
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const asset = join(f.global, 'gstack', rel);
|
||||
rmSync(asset);
|
||||
symlinkSync(join(f.source, rel), asset, 'dir');
|
||||
}
|
||||
const old = join(f.global, 'gstack-claude');
|
||||
rmSync(old, { recursive: true });
|
||||
const oldRender = join(f.source, '.agents/skills/gstack-claude');
|
||||
fixtureMkdirSync(oldRender, { recursive: true });
|
||||
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior legacy render.\n');
|
||||
symlinkSync(oldRender, old, 'dir');
|
||||
const link = target === 'legacy' ? old
|
||||
: target === 'replacement' ? join(f.global, 'gstack-claude-code')
|
||||
: target === 'legacy-skill' ? join(old, 'SKILL.md')
|
||||
: target === 'runtime' ? join(f.global, 'gstack')
|
||||
: join(f.global, 'gstack', target === 'runtime-bin' ? 'bin' : 'lib');
|
||||
if (target === 'legacy-skill') {
|
||||
rmSync(old);
|
||||
fixtureMkdirSync(old);
|
||||
}
|
||||
if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) rmSync(link, { recursive: true });
|
||||
symlinkSync(link, link);
|
||||
const before = tree(f.global);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f, '--host claude');
|
||||
expect(tree(f.global)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const alias of [false, true]) for (const windows of [false, true]) {
|
||||
test(`global generation namespace is refused before mutation: alias=${alias}, Windows=${windows}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const agents = join(f.source, '.agents');
|
||||
fixtureMkdirSync(agents, { recursive: true });
|
||||
const codexHome = alias ? join(f.home, 'generation-alias') : agents;
|
||||
if (alias) symlinkSync(agents, codexHome, 'dir');
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureCopyFileSync(join(f.source, rel.replace('.exe', '')), join(f.source, rel));
|
||||
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: { ...f.env, CODEX_HOME: codexHome }, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('host namespace');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const leaf of ['SKILL.md', 'agents']) {
|
||||
test(`selected generated cyclic write remains fail-closed: ${leaf}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const generated = join(f.source, '.agents/skills/gstack-review');
|
||||
fixtureMkdirSync(generated, { recursive: true });
|
||||
const link = join(generated, leaf);
|
||||
symlinkSync(link, link);
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('ELOOP');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,136 +1,12 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { cpSync, existsSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { generateAutoplanSnapshotTool } from '../scripts/resolvers/composition';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const files = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 });
|
||||
if (files.status !== 0) throw new Error(files.stderr);
|
||||
const owned: string[] = [];
|
||||
afterEach(() => { for (const dir of owned.splice(0)) rmSync(dir, { recursive: true, force: true }); });
|
||||
const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
|
||||
function assertFixtureWrite(file: string) {
|
||||
const root = owned.find(dir => file === dir || file.startsWith(dir + '/'));
|
||||
if (!root) throw new Error(`Fixture write outside owned roots: ${file}`);
|
||||
let existing = file;
|
||||
while (!existsSync(existing)) {
|
||||
if (lstatSync(existing, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Unresolvable fixture link: ${existing}`);
|
||||
existing = dirname(existing);
|
||||
}
|
||||
const target = realpathSync(existing);
|
||||
const physicalRoot = realpathSync(root);
|
||||
if (target !== physicalRoot && !target.startsWith(physicalRoot + '/')) throw new Error(`Fixture write escapes physical root: ${file} -> ${target}`);
|
||||
}
|
||||
function fixtureWriteFileSync(...args: Parameters<typeof writeFileSync>) {
|
||||
assertFixtureWrite(String(args[0]));
|
||||
return writeFileSync(...args);
|
||||
}
|
||||
function fixtureCopyFileSync(...args: Parameters<typeof copyFileSync>) {
|
||||
assertFixtureWrite(String(args[1]));
|
||||
return copyFileSync(...args);
|
||||
}
|
||||
function fixtureMkdirSync(...args: Parameters<typeof mkdirSync>) {
|
||||
assertFixtureWrite(String(args[0]));
|
||||
return mkdirSync(...args);
|
||||
}
|
||||
function fixtureUtimesSync(...args: Parameters<typeof utimesSync>) {
|
||||
assertFixtureWrite(String(args[0]));
|
||||
return utimesSync(...args);
|
||||
}
|
||||
|
||||
function tree(dir: string): unknown {
|
||||
const stat = lstatSync(dir);
|
||||
if (stat.isSymbolicLink()) return { link: readlinkSync(dir) };
|
||||
if (stat.isDirectory()) return Object.fromEntries(readdirSync(dir).sort().map(name => [name, tree(join(dir, name))]));
|
||||
return createHash('sha256').update(readFileSync(dir)).digest('hex');
|
||||
}
|
||||
|
||||
function fixture(layout: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-codex-scope-'));
|
||||
owned.push(dir);
|
||||
const home = join(dir, 'home');
|
||||
const project = join(dir, 'project-a');
|
||||
const other = join(dir, 'project-b');
|
||||
const source = layout === 'machine' ? join(home, '.claude/skills/gstack')
|
||||
: layout === 'ordinary' ? join(project, 'custom-checkout') : join(project, layout, 'skills/gstack');
|
||||
const commands = join(dir, 'commands');
|
||||
for (const d of [home, other, commands, source]) fixtureMkdirSync(d, { recursive: true });
|
||||
for (const rel of [...files.stdout.split('\0').filter(Boolean), 'scripts/external-skill-names.ts', 'scripts/preflight-codex-overlap.ts']) {
|
||||
if (/^(?:test|docs|browse\/test|\.github)\//.test(rel)) continue;
|
||||
const dest = join(source, rel);
|
||||
fixtureMkdirSync(dirname(dest), { recursive: true });
|
||||
if (lstatSync(join(ROOT, rel)).isSymbolicLink()) {
|
||||
const target = readlinkSync(join(ROOT, rel));
|
||||
expect(resolve(dirname(dest), target).startsWith(source + '/')).toBe(true);
|
||||
symlinkSync(target, dest);
|
||||
} else fixtureCopyFileSync(join(ROOT, rel), dest);
|
||||
}
|
||||
const write = (file: string, content: string) => {
|
||||
fixtureMkdirSync(dirname(file), { recursive: true });
|
||||
fixtureWriteFileSync(file, content, { mode: 0o755 });
|
||||
};
|
||||
for (const rel of ['browse/dist/browse', 'design/dist/design', 'make-pdf/dist/pdf', 'browse/dist/.build-complete']) {
|
||||
const file = join(source, rel);
|
||||
write(file, '#!/bin/sh\nexit 0\n');
|
||||
fixtureUtimesSync(file, new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
write(join(commands, 'bun'), `#!/usr/bin/env bash
|
||||
case "$*" in
|
||||
'install --frozen-lockfile') exit 0 ;;
|
||||
'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;;
|
||||
'run build') echo 'Unexpected build in registration fixture' >&2; exit 90 ;;
|
||||
*) exec ${quote(process.execPath)} "$@" ;;
|
||||
esac
|
||||
`);
|
||||
const realRm = Bun.which('rm');
|
||||
if (!realRm) throw new Error('rm is required');
|
||||
write(join(commands, 'rm'), `#!/usr/bin/env bash
|
||||
if [ "$#" -eq 2 ] && [ "$1" = -f ] && [ "$2" = /tmp/gstack-latest-version ]; then exit 0; fi
|
||||
exec ${quote(realRm)} "$@"
|
||||
`);
|
||||
for (const name of ['codex', 'claude']) write(join(commands, name), '#!/bin/sh\nexit 0\n');
|
||||
const global = join(home, '.codex/skills');
|
||||
const previous = join(dir, 'previous/gstack');
|
||||
write(join(previous, 'bin/gstack-autoplan-snapshot.ts'), readFileSync(join(ROOT, 'bin/gstack-autoplan-snapshot.ts'), 'utf8'));
|
||||
write(join(previous, 'lib/claude-bin.ts'), 'export {};\n');
|
||||
for (const name of ['gstack-review', 'gstack-claude', 'gstack-retired']) {
|
||||
write(join(previous, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior global skill.\n`);
|
||||
fixtureMkdirSync(global, { recursive: true });
|
||||
if (name === 'gstack-claude') {
|
||||
write(join(global, name, 'SKILL.md'), readFileSync(join(previous, name, 'SKILL.md'), 'utf8'));
|
||||
} else symlinkSync(join(previous, name), join(global, name), 'dir');
|
||||
}
|
||||
fixtureMkdirSync(join(global, 'gstack'), { recursive: true });
|
||||
symlinkSync(join(previous, 'bin'), join(global, 'gstack/bin'), 'dir');
|
||||
symlinkSync(join(previous, 'lib'), join(global, 'gstack/lib'), 'dir');
|
||||
write(join(global, 'gstack/SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nGlobal router.\n');
|
||||
write(join(global, 'custom/SKILL.md'), 'User-owned skill.\n');
|
||||
const env = {
|
||||
PATH: `${commands}:${process.env.PATH}`, HOME: home, USERPROFILE: home,
|
||||
CODEX_HOME: join(home, '.codex'), CLAUDE_CONFIG_DIR: join(home, '.claude'),
|
||||
GSTACK_HOME: join(home, '.gstack'), GSTACK_STATE_ROOT: join(home, '.gstack'),
|
||||
TMPDIR: dir, TMP: dir, TEMP: dir,
|
||||
GSTACK_SKIP_PLAYWRIGHT: '1', GSTACK_SKIP_FONTS: '1', GSTACK_SKIP_COREUTILS: '1', GSTACK_SKIP_ASIDE: '1', GSTACK_SKIP_GBRAIN_REGEN: '1',
|
||||
BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(tmpdir(), 'gstack-preflight-bun-cache'),
|
||||
};
|
||||
write(join(home, '.gstack/config.yaml'), 'telemetry: off\nartifacts_sync: off\n');
|
||||
return { dir, source, home, project, other, global, previous, env };
|
||||
}
|
||||
|
||||
function install(f: ReturnType<typeof fixture>, args = '--host codex') {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), ...args.split(' '), '--no-plan-tune-hooks', '--no-timeline-stop-hook', '--no-team'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
return result;
|
||||
}
|
||||
import { ROOT, owned, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
|
||||
|
||||
test.skipIf(process.platform === 'win32')('fixture writes reject physical escapes and allow aliased temporary roots', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-fixture-guard-'));
|
||||
@@ -335,608 +211,4 @@ describe.skipIf(process.platform === 'win32')('setup Codex destination follows r
|
||||
expect(realpathSync(join(f.project, '.agents/skills/gstack/bin'))).toBe(join(f.source, 'bin'));
|
||||
}, 90_000);
|
||||
|
||||
for (const global of [false, true]) for (const windows of [false, true]) test(`a real runtime containing the source is refused before writes, global=${global}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const runtime = join(f.project, '.agents/skills/gstack');
|
||||
const nested = join(runtime, 'checkout');
|
||||
cpSync(f.source, nested, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
|
||||
fixtureWriteFileSync(join(nested, 'uncommitted-work'), 'Preserve the nested checkout.\n');
|
||||
rmSync(f.source, { recursive: true });
|
||||
symlinkSync(nested, f.source, 'dir');
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
|
||||
const before = tree(f.dir);
|
||||
for (const host of ['codex', 'auto']) for (let run = 0; run < 2; run++) {
|
||||
const args = [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'];
|
||||
if (global) args.push('--global');
|
||||
const result = spawnSync('bash', args, { cwd: f.other, env, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('runtime directory contains the source checkout');
|
||||
expect(result.stderr).toContain(runtime);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test('a distinct sibling source checkout is refused before any mutation', () => {
|
||||
const f = fixture('.claude');
|
||||
const sibling = join(f.project, '.agents/skills/gstack');
|
||||
cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
|
||||
fixtureWriteFileSync(join(sibling, 'uncommitted-work'), 'Keep sibling checkout edits.\n');
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`existing source checkout at ${sibling}`);
|
||||
}, 90_000);
|
||||
|
||||
test('a project destination aliased to global skills is refused before any mutation', () => {
|
||||
const f = fixture('.claude');
|
||||
fixtureMkdirSync(join(f.project, '.agents'));
|
||||
symlinkSync(f.global, join(f.project, '.agents/skills'), 'dir');
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('project-local Codex destination resolves outside the project');
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['root', 'rendered', 'missing-tail', 'project-root']) for (const marker of ['current', '1.85.0.0']) test(`another payload cannot own the local namespace: ${target}, marker=${marker}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const sibling = target === 'project-root' ? f.project : join(f.project, 'other-checkout');
|
||||
if (target === 'project-root') {
|
||||
for (const rel of ['setup', 'VERSION', 'bin/gstack-relink']) {
|
||||
fixtureMkdirSync(dirname(join(sibling, rel)), { recursive: true });
|
||||
fixtureCopyFileSync(join(f.source, rel), join(sibling, rel));
|
||||
}
|
||||
} else cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
|
||||
const rendered = join(sibling, '.agents/skills');
|
||||
const root = join(rendered, 'gstack');
|
||||
const review = join(rendered, 'gstack-review');
|
||||
for (const dir of [root, review]) {
|
||||
fixtureMkdirSync(dir, { recursive: true });
|
||||
fixtureWriteFileSync(join(dir, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nKeep the sibling workflow.\n');
|
||||
}
|
||||
symlinkSync(join(sibling, 'bin'), join(root, 'bin'), 'dir');
|
||||
for (const [name, source] of [['gstack', root], ['gstack-review', review]]) {
|
||||
rmSync(join(f.global, name!), { recursive: true });
|
||||
symlinkSync(source!, join(f.global, name!), 'dir');
|
||||
}
|
||||
if (target === 'missing-tail') {
|
||||
expect(existsSync(join(sibling, 'skills'))).toBe(false);
|
||||
symlinkSync(sibling, join(f.project, '.agents'), 'dir');
|
||||
} else if (target !== 'project-root') {
|
||||
fixtureMkdirSync(join(f.project, '.agents'));
|
||||
symlinkSync(target === 'root' ? sibling : rendered, join(f.project, '.agents/skills'), 'dir');
|
||||
}
|
||||
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('overlaps the source tree');
|
||||
expect(result.stderr).toContain(sibling);
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const preexisting of [false, true]) test(`generated runtime leaf alias survives setup, preexisting=${preexisting}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const renderedSkills = join(f.source, '.agents/skills');
|
||||
const renderedRoot = join(renderedSkills, 'gstack');
|
||||
const local = join(f.project, '.agents/skills');
|
||||
if (preexisting) {
|
||||
fixtureMkdirSync(renderedRoot, { recursive: true });
|
||||
fixtureWriteFileSync(join(renderedRoot, 'SKILL.md'), readFileSync(join(f.source, 'SKILL.md')));
|
||||
}
|
||||
fixtureMkdirSync(local, { recursive: true });
|
||||
symlinkSync(renderedRoot, join(local, 'gstack'), 'dir');
|
||||
const globalRoot = join(f.global, 'gstack/SKILL.md');
|
||||
rmSync(globalRoot);
|
||||
symlinkSync(join(renderedRoot, 'SKILL.md'), globalRoot);
|
||||
const before = tree(f.global);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(before);
|
||||
expect(existsSync(join(renderedRoot, 'SKILL.md'))).toBe(true);
|
||||
expect(realpathSync(join(local, 'gstack/SKILL.md'))).toBe(join(renderedRoot, 'SKILL.md'));
|
||||
expect(readFileSync(globalRoot)).toEqual(readFileSync(join(renderedRoot, 'SKILL.md')));
|
||||
expect(realpathSync(join(local, 'gstack/bin'))).toBe(join(f.source, 'bin'));
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['source', 'rendered', 'missing-rendered']) for (const windows of [false, true]) test(`source-backed namespace is refused without mutation: ${target}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const renderedSkills = join(f.source, '.agents/skills');
|
||||
const oldRender = join(renderedSkills, 'gstack-claude');
|
||||
if (target !== 'missing-rendered') {
|
||||
fixtureMkdirSync(oldRender, { recursive: true });
|
||||
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExcluded global workflow.\n');
|
||||
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
|
||||
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
|
||||
}
|
||||
fixtureMkdirSync(join(f.project, '.agents'));
|
||||
symlinkSync(target === 'source' ? f.source : renderedSkills, join(f.project, '.agents/skills'), 'dir');
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(target === 'missing-rendered' ? 'unresolvable directory' : 'overlaps the source tree');
|
||||
expect(result.stderr).toContain('Use a separate project-local skills directory');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`outward generation namespace is refused before writes: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const agents = join(f.project, '.agents');
|
||||
const local = join(agents, 'skills');
|
||||
fixtureMkdirSync(agents);
|
||||
if (preexisting || level === 'skills') fixtureMkdirSync(local);
|
||||
if (level === 'agents') symlinkSync(agents, join(f.source, '.agents'), 'dir');
|
||||
else {
|
||||
fixtureMkdirSync(join(f.source, '.agents'));
|
||||
symlinkSync(local, join(f.source, '.agents/skills'), 'dir');
|
||||
}
|
||||
if (preexisting) for (const name of ['gstack', 'gstack-review']) {
|
||||
fixtureMkdirSync(join(local, name));
|
||||
fixtureWriteFileSync(join(local, name, 'SKILL.md'), `Handwritten ${name} workflow.\n`);
|
||||
fixtureWriteFileSync(join(local, name, 'user-notes'), 'Preserve unrelated user notes.\n');
|
||||
}
|
||||
if (!preexisting && level === 'skills') rmSync(local, { recursive: true });
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), '1.85.0.0');
|
||||
const before = tree(f.dir);
|
||||
for (const host of ['codex', 'claude', 'auto']) for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('generation namespace');
|
||||
expect(result.stderr).toContain('Use a separate project-local skills directory');
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['dangling-agents', 'dangling-skills', 'cyclic-agents', 'cyclic-skills', 'file-agents', 'file-skills', 'source-root']) test(`unresolvable generation namespace is refused without mutation: ${target}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const agents = join(f.source, '.agents');
|
||||
const component = target.endsWith('skills') || target === 'source-root' ? join(agents, 'skills') : agents;
|
||||
if (component !== agents) fixtureMkdirSync(agents);
|
||||
if (target.startsWith('file')) fixtureWriteFileSync(component, 'Preserve namespace file.\n');
|
||||
else symlinkSync(target === 'source-root' ? f.source : target.startsWith('cyclic') ? component : join(f.source, 'missing-generation'), component, 'dir');
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('generation namespace');
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`internal generation namespace alias remains supported: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const internal = join(f.source, 'generated-codex');
|
||||
fixtureMkdirSync(internal);
|
||||
if (level === 'agents') symlinkSync(internal, join(f.source, '.agents'), 'dir');
|
||||
else {
|
||||
fixtureMkdirSync(join(f.source, '.agents'));
|
||||
symlinkSync(internal, join(f.source, '.agents/skills'), 'dir');
|
||||
}
|
||||
const generated = join(f.source, '.agents/skills');
|
||||
if (preexisting) {
|
||||
fixtureMkdirSync(join(generated, 'gstack-review'), { recursive: true });
|
||||
fixtureWriteFileSync(join(generated, 'gstack-review/SKILL.md'), 'Prior internal render.\n');
|
||||
}
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
fixtureWriteFileSync(join(f.source, 'uncommitted-work'), 'Preserve source edits.\n');
|
||||
const excluded = tree(f.global), sibling = tree(f.other);
|
||||
const sourceBefore = Object.fromEntries(['SKILL.md', 'bin', 'lib', 'uncommitted-work'].map(rel => [rel, tree(join(f.source, rel))]));
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(excluded);
|
||||
expect(tree(f.other)).toEqual(sibling);
|
||||
for (const [rel, before] of Object.entries(sourceBefore)) expect(tree(join(f.source, rel))).toEqual(before);
|
||||
expect(readFileSync(join(f.project, '.agents/skills/gstack-review/SKILL.md'))).toEqual(readFileSync(join(generated, 'gstack-review/SKILL.md')));
|
||||
expect(existsSync(join(f.source, 'bin/bin'))).toBe(false);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const windows of [false, true]) test(`individual generated skill leaf alias remains supported, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const rendered = join(f.source, '.agents/skills/gstack-review');
|
||||
const local = join(f.project, '.agents/skills');
|
||||
fixtureMkdirSync(rendered, { recursive: true });
|
||||
fixtureMkdirSync(local, { recursive: true });
|
||||
fixtureWriteFileSync(join(rendered, 'user-notes'), 'Keep notes beside the source render.\n');
|
||||
symlinkSync(rendered, join(local, 'gstack-review'), 'dir');
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
const excluded = tree(f.global);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(excluded);
|
||||
expect(readFileSync(join(rendered, 'user-notes'), 'utf8')).toBe('Keep notes beside the source render.\n');
|
||||
expect(readFileSync(join(local, 'gstack-review/SKILL.md'))).toEqual(readFileSync(join(rendered, 'SKILL.md')));
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['dangling-skills', 'dangling-agents', 'cyclic-skills', 'file-skills']) test(`unresolvable namespace is refused without mutation: ${target}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const agents = join(f.project, '.agents');
|
||||
if (target === 'dangling-agents') symlinkSync(join(f.dir, 'missing-agents'), agents, 'dir');
|
||||
else {
|
||||
fixtureMkdirSync(agents);
|
||||
const local = join(agents, 'skills');
|
||||
if (target === 'file-skills') fixtureWriteFileSync(local, 'Preserve this file.\n');
|
||||
else symlinkSync(target === 'cyclic-skills' ? 'skills' : join(f.dir, 'missing-skills'), local, 'dir');
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('unresolvable directory');
|
||||
expect(result.stderr).toContain('Use a separate project-local skills directory');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const localLegacy of [false, true]) for (const marker of ['current', '1.85.0.0']) test(`excluded global legacy render survives local migration=${localLegacy}, marker=${marker} and generation`, () => {
|
||||
const f = fixture('.claude');
|
||||
const oldRender = join(f.source, '.agents/skills/gstack-claude');
|
||||
fixtureMkdirSync(oldRender, { recursive: true });
|
||||
const oldBytes = '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExisting legacy global workflow.\n';
|
||||
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), oldBytes);
|
||||
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
|
||||
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const target = join(f.global, 'gstack', rel);
|
||||
expect(lstatSync(target).isSymbolicLink()).toBe(true);
|
||||
rmSync(target);
|
||||
fixtureMkdirSync(target);
|
||||
fixtureWriteFileSync(join(target, 'prior'), 'Existing copied global runtime.\n');
|
||||
}
|
||||
const local = join(f.project, '.agents/skills');
|
||||
if (localLegacy) {
|
||||
fixtureMkdirSync(local, { recursive: true });
|
||||
symlinkSync(oldRender, join(local, 'gstack-claude'), 'dir');
|
||||
}
|
||||
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
|
||||
const before = tree(f.global);
|
||||
install(f);
|
||||
expect(tree(f.global)).toEqual(before);
|
||||
expect(readFileSync(join(f.global, 'gstack-claude/SKILL.md'), 'utf8')).toBe(oldBytes);
|
||||
expect(realpathSync(join(local, 'gstack-claude-code/SKILL.md'))).toBe(join(f.source, '.agents/skills/gstack-claude-code/SKILL.md'));
|
||||
expect(lstatSync(join(local, 'gstack-claude'), { throwIfNoEntry: false })).toBeUndefined();
|
||||
}, 90_000);
|
||||
|
||||
for (const nested of [false, true]) for (const global of [false, true]) for (const windows of [false, true]) {
|
||||
test(`named ${nested ? 'ancestor' : 'source'} overlap: logical local=${!global}, Windows=${windows}`, () => {
|
||||
const f = fixture('.claude');
|
||||
const physical = join(f.project, '.agents/skills/gstack-review', ...(nested ? ['checkout'] : []));
|
||||
fixtureMkdirSync(physical, { recursive: true });
|
||||
cpSync(f.source, physical, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
|
||||
rmSync(f.source, { recursive: true });
|
||||
symlinkSync(physical, f.source, 'dir');
|
||||
if (nested) fixtureWriteFileSync(join(dirname(physical), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior render.\n');
|
||||
fixtureWriteFileSync(join(physical, 'uncommitted-proof'), 'Do not erase this checkout.\n');
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureCopyFileSync(join(physical, rel.replace('.exe', '')), join(physical, rel));
|
||||
fixtureUtimesSync(join(physical, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(global ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
if (windows) {
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('Codex skill copy replacement overlaps source');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
} else {
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(physical, 'uncommitted-proof'), 'utf8')).toBe('Do not erase this checkout.\n');
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const target of ['source', 'project', 'root-sidecar']) for (const host of target === 'root-sidecar' ? ['codex'] : ['codex', 'claude']) test(`generated namespace alias to ${target} refuses before writes, host=${host}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const render = join(f.source, '.agents/skills');
|
||||
fixtureMkdirSync(render, { recursive: true });
|
||||
const alias = join(render, target === 'root-sidecar' ? 'gstack' : 'gstack-review');
|
||||
const destination = target === 'project' ? join(f.project, 'ordinary-review') : f.source;
|
||||
if (target === 'project') {
|
||||
fixtureMkdirSync(destination);
|
||||
fixtureWriteFileSync(join(destination, 'SKILL.md'), 'Project-owned content.\n');
|
||||
}
|
||||
symlinkSync(destination, alias, 'dir');
|
||||
if (target === 'root-sidecar') fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(target === 'root-sidecar' ? 'Codex sidecar' : 'Codex generated skill write');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const target of ['absolute-generation-alias', 'occupied-relocation']) for (const windows of [false, true]) {
|
||||
test(`direct global checkout preflights ${target} before moving, Windows=${windows}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const direct = join(f.global, 'gstack');
|
||||
rmSync(direct, { recursive: true });
|
||||
cpSync(f.source, direct, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
|
||||
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep source checkout.\n');
|
||||
if (target === 'absolute-generation-alias') {
|
||||
const generated = join(direct, 'generated-codex');
|
||||
fixtureMkdirSync(generated);
|
||||
symlinkSync(generated, join(direct, '.agents'), 'dir');
|
||||
} else {
|
||||
fixtureMkdirSync(join(f.home, '.gstack/repos'), { recursive: true });
|
||||
symlinkSync(direct, join(f.home, '.gstack/repos/gstack'), 'dir');
|
||||
}
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(target === 'absolute-generation-alias' ? 'post-relocation generation namespace' : 'checkout relocation');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
test('Claude-only setup cannot prune a bannered stale skill inside its source checkout', () => {
|
||||
const f = fixture('ordinary');
|
||||
const skill = join(f.source, 'skills/gstack-obsolete/SKILL.md');
|
||||
fixtureMkdirSync(dirname(skill), { recursive: true });
|
||||
fixtureWriteFileSync(skill, '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPreserve source content.\n');
|
||||
const env = { ...f.env, CODEX_HOME: f.source };
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('Codex stale host cleanup');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
test('a dangling owned legacy Codex link reaches the rename migration', () => {
|
||||
const f = fixture('ordinary');
|
||||
const old = join(f.global, 'gstack-claude');
|
||||
rmSync(old, { recursive: true });
|
||||
symlinkSync(join(f.source, '.agents/skills/gstack-claude'), old, 'dir');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const target = join(f.global, 'gstack', rel);
|
||||
rmSync(target);
|
||||
symlinkSync(join(f.source, rel), target, 'dir');
|
||||
}
|
||||
install(f);
|
||||
expect(lstatSync(old, { throwIfNoEntry: false })).toBeUndefined();
|
||||
expect(readFileSync(join(f.global, 'gstack-claude-code/SKILL.md'), 'utf8')).toContain('name: claude-code');
|
||||
expect(readFileSync(join(f.global, 'custom/SKILL.md'), 'utf8')).toBe('User-owned skill.\n');
|
||||
}, 90_000);
|
||||
|
||||
for (const explicit of [false, true]) for (const windows of [false, true]) {
|
||||
test(`global handwritten runtime is refused before writes, explicit=${explicit}, Windows=${windows}`, () => {
|
||||
const f = fixture(explicit ? '.claude' : 'ordinary');
|
||||
const runtime = join(f.global, 'gstack');
|
||||
rmSync(runtime, { recursive: true });
|
||||
fixtureMkdirSync(runtime);
|
||||
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
|
||||
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
|
||||
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(explicit ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(`global Codex runtime ${runtime} is a real user-owned skill`);
|
||||
expect(result.stderr).toContain('choose another CODEX_HOME');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const prior of ['managed', 'partial']) test(`global ${prior} runtime remains refreshable`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const runtime = join(f.global, 'gstack');
|
||||
rmSync(runtime, { recursive: true });
|
||||
fixtureMkdirSync(runtime);
|
||||
if (prior === 'managed') fixtureWriteFileSync(join(runtime, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior runtime.\n');
|
||||
fixtureWriteFileSync(join(runtime, 'prior-asset'), 'A previous runtime asset.\n');
|
||||
install(f);
|
||||
install(f);
|
||||
expect(existsSync(join(runtime, 'prior-asset'))).toBe(false);
|
||||
expect(readFileSync(join(runtime, 'SKILL.md'), 'utf8')).toContain('<!-- AUTO-GENERATED from');
|
||||
expect(realpathSync(join(runtime, 'bin'))).toBe(join(f.source, 'bin'));
|
||||
}, 90_000);
|
||||
|
||||
test('Claude-only setup leaves a global handwritten runtime untouched', () => {
|
||||
const f = fixture('ordinary');
|
||||
const runtime = join(f.global, 'gstack');
|
||||
rmSync(runtime, { recursive: true });
|
||||
fixtureMkdirSync(runtime);
|
||||
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
|
||||
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
|
||||
const before = tree(runtime);
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(tree(runtime)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
test('Unix rename does not overwrite a source checkout registered as another skill', () => {
|
||||
const f = fixture('ordinary');
|
||||
const source = join(f.global, 'gstack-review');
|
||||
rmSync(source);
|
||||
cpSync(f.source, source, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
|
||||
fixtureWriteFileSync(join(source, 'uncommitted-proof'), 'Keep this source.\n');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const asset = join(f.global, 'gstack', rel);
|
||||
rmSync(asset);
|
||||
fixtureMkdirSync(asset);
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('Codex rename workflow write');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
for (const alias of ['SKILL leaf', 'metadata parent']) test(`direct relocation refuses a generated absolute ${alias} before moving source`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const direct = join(f.global, 'gstack');
|
||||
rmSync(direct, { recursive: true });
|
||||
cpSync(f.source, direct, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
|
||||
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep this source.\n');
|
||||
const generated = join(direct, '.agents/skills/gstack-review');
|
||||
fixtureMkdirSync(generated, { recursive: true });
|
||||
if (alias === 'SKILL leaf') {
|
||||
fixtureWriteFileSync(join(generated, 'saved.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\nPrior render.\n');
|
||||
symlinkSync(join(generated, 'saved.md'), join(generated, 'SKILL.md'));
|
||||
} else {
|
||||
const prior = join(direct, '.agents/skills/gstack-office-hours/agents');
|
||||
fixtureMkdirSync(prior, { recursive: true });
|
||||
symlinkSync(prior, join(generated, 'agents'), 'dir');
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('post-relocation generated alias');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}, 90_000);
|
||||
|
||||
test('managed rename detaches a metadata parent link without touching its source', () => {
|
||||
const f = fixture('ordinary');
|
||||
const installed = join(f.global, 'gstack-review');
|
||||
rmSync(installed);
|
||||
fixtureMkdirSync(installed);
|
||||
fixtureWriteFileSync(join(installed, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior installed review.\n');
|
||||
const sourceAgents = join(f.source, '.agents/skills/gstack-review/agents');
|
||||
fixtureMkdirSync(sourceAgents, { recursive: true });
|
||||
fixtureWriteFileSync(join(dirname(sourceAgents), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior canonical review.\n');
|
||||
fixtureWriteFileSync(join(sourceAgents, 'user-notes'), 'Preserve source metadata.\n');
|
||||
symlinkSync(sourceAgents, join(installed, 'agents'), 'dir');
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const asset = join(f.global, 'gstack', rel);
|
||||
rmSync(asset);
|
||||
symlinkSync(join(f.source, rel), asset, 'dir');
|
||||
}
|
||||
const result = install(f);
|
||||
expect(result.stderr).toContain('migrated 1 installed skill');
|
||||
expect(lstatSync(join(installed, 'agents')).isDirectory()).toBe(true);
|
||||
expect(readFileSync(join(sourceAgents, 'user-notes'), 'utf8')).toBe('Preserve source metadata.\n');
|
||||
expect(existsSync(join(installed, 'agents/openai.yaml'))).toBe(true);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('F13 independent review boundaries', () => {
|
||||
for (const target of ['legacy', 'replacement', 'legacy-skill', 'runtime', 'runtime-bin', 'runtime-lib']) {
|
||||
test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
rmSync(join(f.global, 'gstack-retired'));
|
||||
for (const rel of ['bin', 'lib']) {
|
||||
const asset = join(f.global, 'gstack', rel);
|
||||
rmSync(asset);
|
||||
symlinkSync(join(f.source, rel), asset, 'dir');
|
||||
}
|
||||
const old = join(f.global, 'gstack-claude');
|
||||
rmSync(old, { recursive: true });
|
||||
const oldRender = join(f.source, '.agents/skills/gstack-claude');
|
||||
fixtureMkdirSync(oldRender, { recursive: true });
|
||||
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior legacy render.\n');
|
||||
symlinkSync(oldRender, old, 'dir');
|
||||
const link = target === 'legacy' ? old
|
||||
: target === 'replacement' ? join(f.global, 'gstack-claude-code')
|
||||
: target === 'legacy-skill' ? join(old, 'SKILL.md')
|
||||
: target === 'runtime' ? join(f.global, 'gstack')
|
||||
: join(f.global, 'gstack', target === 'runtime-bin' ? 'bin' : 'lib');
|
||||
if (target === 'legacy-skill') {
|
||||
rmSync(old);
|
||||
fixtureMkdirSync(old);
|
||||
}
|
||||
if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) rmSync(link, { recursive: true });
|
||||
symlinkSync(link, link);
|
||||
const before = tree(f.global);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
install(f, '--host claude');
|
||||
expect(tree(f.global)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const alias of [false, true]) for (const windows of [false, true]) {
|
||||
test(`global generation namespace is refused before mutation: alias=${alias}, Windows=${windows}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const agents = join(f.source, '.agents');
|
||||
fixtureMkdirSync(agents, { recursive: true });
|
||||
const codexHome = alias ? join(f.home, 'generation-alias') : agents;
|
||||
if (alias) symlinkSync(agents, codexHome, 'dir');
|
||||
if (windows) {
|
||||
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\n"\n', { mode: 0o755 });
|
||||
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
|
||||
fixtureCopyFileSync(join(f.source, rel.replace('.exe', '')), join(f.source, rel));
|
||||
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
|
||||
}
|
||||
}
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: { ...f.env, CODEX_HOME: codexHome }, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('host namespace');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
for (const leaf of ['SKILL.md', 'agents']) {
|
||||
test(`selected generated cyclic write remains fail-closed: ${leaf}`, () => {
|
||||
const f = fixture('ordinary');
|
||||
const generated = join(f.source, '.agents/skills/gstack-review');
|
||||
fixtureMkdirSync(generated, { recursive: true });
|
||||
const link = join(generated, leaf);
|
||||
symlinkSync(link, link);
|
||||
const before = tree(f.dir);
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
|
||||
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain('ELOOP');
|
||||
expect(tree(f.dir)).toEqual(before);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
});
|
||||
+11
-11
@@ -110,14 +110,14 @@ describeIfSelected('LLM-as-judge quality evals', [
|
||||
name: 'command reference table',
|
||||
suite: 'LLM-as-judge quality evals',
|
||||
tier: 'llm-judge',
|
||||
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4,
|
||||
passed: scores.clarity >= 3 && scores.completeness >= 3 && scores.actionability >= 4,
|
||||
duration_ms: Date.now() - t0,
|
||||
cost_usd: 0.02,
|
||||
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
||||
judge_reasoning: scores.reasoning,
|
||||
});
|
||||
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(3);
|
||||
expect(scores.completeness).toBeGreaterThanOrEqual(3);
|
||||
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
||||
}, JUDGE_MS);
|
||||
@@ -136,14 +136,14 @@ describeIfSelected('LLM-as-judge quality evals', [
|
||||
name: 'snapshot flags reference',
|
||||
suite: 'LLM-as-judge quality evals',
|
||||
tier: 'llm-judge',
|
||||
passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
|
||||
passed: scores.clarity >= 3 && scores.completeness >= 4 && scores.actionability >= 4,
|
||||
duration_ms: Date.now() - t0,
|
||||
cost_usd: 0.02,
|
||||
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
||||
judge_reasoning: scores.reasoning,
|
||||
});
|
||||
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(3);
|
||||
expect(scores.completeness).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
||||
}, JUDGE_MS);
|
||||
@@ -160,14 +160,14 @@ describeIfSelected('LLM-as-judge quality evals', [
|
||||
name: 'browse/SKILL.md reference',
|
||||
suite: 'LLM-as-judge quality evals',
|
||||
tier: 'llm-judge',
|
||||
passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
|
||||
passed: scores.clarity >= 3 && scores.completeness >= 4 && scores.actionability >= 4,
|
||||
duration_ms: Date.now() - t0,
|
||||
cost_usd: 0.02,
|
||||
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
||||
judge_reasoning: scores.reasoning,
|
||||
});
|
||||
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(3);
|
||||
expect(scores.completeness).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
||||
}, JUDGE_MS);
|
||||
@@ -349,14 +349,14 @@ ${section}`);
|
||||
name: 'qa/SKILL.md workflow',
|
||||
suite: 'QA skill quality evals',
|
||||
tier: 'llm-judge',
|
||||
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4,
|
||||
passed: scores.clarity >= 3 && scores.completeness >= 3 && scores.actionability >= 4,
|
||||
duration_ms: Date.now() - t0,
|
||||
cost_usd: 0.02,
|
||||
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
||||
judge_reasoning: scores.reasoning,
|
||||
});
|
||||
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(3);
|
||||
// Completeness scores 3 when judge notes the health rubric is in a separate
|
||||
// section (the eval only passes the Workflow section, not the full document).
|
||||
expect(scores.completeness).toBeGreaterThanOrEqual(3);
|
||||
@@ -391,14 +391,14 @@ ${section}`);
|
||||
name: 'qa/SKILL.md health rubric',
|
||||
suite: 'QA skill quality evals',
|
||||
tier: 'llm-judge',
|
||||
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4,
|
||||
passed: scores.clarity >= 3 && scores.completeness >= 3 && scores.actionability >= 4,
|
||||
duration_ms: Date.now() - t0,
|
||||
cost_usd: 0.02,
|
||||
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
||||
judge_reasoning: scores.reasoning,
|
||||
});
|
||||
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
||||
expect(scores.clarity).toBeGreaterThanOrEqual(3);
|
||||
expect(scores.completeness).toBeGreaterThanOrEqual(3);
|
||||
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
||||
}, JUDGE_MS);
|
||||
@@ -664,7 +664,7 @@ async function runWorkflowJudge(opts: {
|
||||
// Timeout/retry finalizes once; late provider continuations cannot publish evidence.
|
||||
const work = async () => {
|
||||
checkActive();
|
||||
const thresholds = { clarity: 4, completeness: 3, actionability: 4, ...opts.thresholds };
|
||||
const thresholds = { clarity: 3, completeness: 3, actionability: 4, ...opts.thresholds };
|
||||
const input = opts.readInput ? opts.readInput() : readWorkflowJudgeInput({ root: ROOT, skillPath: opts.skillPath,
|
||||
startMarker: opts.startMarker, endMarker: opts.endMarker });
|
||||
checkActive();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* main() — these tests pin its two load-bearing inputs:
|
||||
*
|
||||
* 1. fullSuiteJobs(): env override wins, is deliberately UNclamped by
|
||||
* MAX_FULL_SUITE_JOBS, and rejects garbage loudly (a silent fallback to
|
||||
* the per-platform cap, and rejects garbage loudly (a silent fallback to
|
||||
* the default would saturate the sandbox's seccomp supervisor — the
|
||||
* exact failure the knob exists to prevent).
|
||||
* 2. FreeShardOutcome.failingFiles: empty on pass, attributed files on
|
||||
@@ -20,6 +20,8 @@ import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
fullSuiteJobs,
|
||||
MAX_FULL_SUITE_JOBS,
|
||||
MAX_LINUX_FULL_SUITE_JOBS,
|
||||
maxFullSuiteJobs,
|
||||
runFreeShard,
|
||||
} from '../scripts/test-free-shards';
|
||||
|
||||
@@ -38,20 +40,31 @@ function withJobsEnv<T>(value: string | undefined, fn: () => T): T {
|
||||
|
||||
describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
|
||||
test('unset and empty string both take the computed default — available CPUs, capped, floor 1', () => {
|
||||
const expected = Math.max(1, Math.min(MAX_FULL_SUITE_JOBS, os.availableParallelism?.() ?? os.cpus().length));
|
||||
const expected = Math.max(1, Math.min(maxFullSuiteJobs(), os.availableParallelism?.() ?? os.cpus().length));
|
||||
expect(withJobsEnv(undefined, fullSuiteJobs)).toBe(expected);
|
||||
// A stray `export GSTACK_FREE_JOBS=` must not throw.
|
||||
expect(withJobsEnv('', fullSuiteJobs)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([[0, 1], [1, 1], [2, 2], [4, 4], [8, 6]])(
|
||||
'%i available CPUs default to %i serial shard processes regardless of host CPU count',
|
||||
(availableCpus, expected) => {
|
||||
test('Linux caps at 16 shard processes; macOS and Windows keep the cap of 6', () => {
|
||||
expect(maxFullSuiteJobs('linux')).toBe(MAX_LINUX_FULL_SUITE_JOBS);
|
||||
expect(MAX_LINUX_FULL_SUITE_JOBS).toBe(16);
|
||||
expect(maxFullSuiteJobs('darwin')).toBe(MAX_FULL_SUITE_JOBS);
|
||||
expect(maxFullSuiteJobs('win32')).toBe(MAX_FULL_SUITE_JOBS);
|
||||
expect(MAX_FULL_SUITE_JOBS).toBe(6);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['darwin', 0, 1], ['darwin', 1, 1], ['darwin', 2, 2], ['darwin', 4, 4], ['darwin', 8, 6],
|
||||
['linux', 0, 1], ['linux', 4, 4], ['linux', 8, 8], ['linux', 16, 16], ['linux', 64, 16],
|
||||
] as const)(
|
||||
'%s: %i available CPUs default to %i serial shard processes regardless of host CPU count',
|
||||
(platform, availableCpus, expected) => {
|
||||
const available = spyOn(os, 'availableParallelism').mockReturnValue(availableCpus);
|
||||
const cpus = spyOn(os, 'cpus').mockReturnValue(Array<os.CpuInfo>(16));
|
||||
const cpus = spyOn(os, 'cpus').mockReturnValue(Array<os.CpuInfo>(64));
|
||||
try {
|
||||
expect(withJobsEnv(undefined, fullSuiteJobs)).toBe(expected);
|
||||
expect(withJobsEnv('', fullSuiteJobs)).toBe(expected);
|
||||
expect(withJobsEnv(undefined, () => fullSuiteJobs(platform))).toBe(expected);
|
||||
expect(withJobsEnv('', () => fullSuiteJobs(platform))).toBe(expected);
|
||||
expect(cpus).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
available.mockRestore();
|
||||
@@ -73,14 +86,14 @@ describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
|
||||
}));
|
||||
const { fullSuiteJobs } = await import(${JSON.stringify(import.meta.resolve('../scripts/test-free-shards'))});
|
||||
delete process.env.GSTACK_FREE_JOBS;
|
||||
console.log(JSON.stringify([0, 1, 2, 4, 8].map(count => {
|
||||
console.log(JSON.stringify([0, 1, 2, 4, 8, 32].map(count => {
|
||||
cpuCount = count;
|
||||
return fullSuiteJobs();
|
||||
return [fullSuiteJobs('darwin'), fullSuiteJobs('linux')];
|
||||
})));
|
||||
`], { timeout: 10_000 });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr.toString()).toBe('');
|
||||
expect(JSON.parse(result.stdout.toString())).toEqual([1, 1, 2, 4, 6]);
|
||||
expect(JSON.parse(result.stdout.toString())).toEqual([[1, 1], [1, 1], [2, 2], [4, 4], [6, 8], [6, 16]]);
|
||||
});
|
||||
|
||||
test('a positive integer override is honored exactly (the sandbox recipe sets 2)', () => {
|
||||
@@ -100,6 +113,20 @@ describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
|
||||
expect(withJobsEnv(step?.env?.GSTACK_FREE_JOBS, fullSuiteJobs)).toBe(2);
|
||||
});
|
||||
|
||||
test('Windows CI retries attributed flakes serially and uploads every flaky pass', () => {
|
||||
const source = readFileSync(new URL('../.github/workflows/windows-free-tests.yml', import.meta.url), 'utf8');
|
||||
const workflow = Bun.YAML.parse(source) as {
|
||||
jobs: Record<string, { steps: Array<{ name?: string; if?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }> }>;
|
||||
};
|
||||
const steps = workflow.jobs['windows-free-tests'].steps;
|
||||
const suite = steps.find(step => step.run === 'bun run test:windows');
|
||||
expect(suite?.env?.GSTACK_FREE_RETRY_FLAKY).toBe('1');
|
||||
expect(suite?.env?.GSTACK_FLAKE_LEDGER).toBe('${{ runner.temp }}/flake-ledger.jsonl');
|
||||
const upload = steps.find(step => step.name === 'Upload flake ledger');
|
||||
expect(upload?.if).toBe('always()');
|
||||
expect(upload?.with?.path).toBe('${{ runner.temp }}/flake-ledger.jsonl');
|
||||
});
|
||||
|
||||
test('an explicit override does not probe the available CPUs', () => {
|
||||
const available = spyOn(os, 'availableParallelism').mockImplementation(() => {
|
||||
throw new Error('CPU detection must not run for an explicit override');
|
||||
@@ -118,8 +145,8 @@ describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('override is deliberately NOT clamped by MAX_FULL_SUITE_JOBS (beefy boxes may raise it)', () => {
|
||||
const above = MAX_FULL_SUITE_JOBS + 6;
|
||||
test('override is deliberately NOT clamped by the platform cap (beefy boxes may raise it)', () => {
|
||||
const above = MAX_LINUX_FULL_SUITE_JOBS + 6;
|
||||
expect(withJobsEnv(String(above), fullSuiteJobs)).toBe(above);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
verifyFreeCiResults,
|
||||
eligibleFreeRetryFiles,
|
||||
selectQuickFreeFiles,
|
||||
unseededFreeFiles,
|
||||
QUICK_CORE,
|
||||
type FreeCiResult,
|
||||
DEFAULT_WALL_TIMEOUT_MS as WALL_BASE_MS,
|
||||
@@ -846,6 +847,22 @@ describe('test-free-shards: duration-aware packing (full-suite LPT)', () => {
|
||||
expect(() => packShardsByDuration(files, 0, {})).toThrow();
|
||||
});
|
||||
|
||||
test('files missing from the seed are named, and --ci-plan warns on stderr without touching the matrix', () => {
|
||||
expect(unseededFreeFiles(files, { 'test/a.test.ts': 1, 'test/c.test.ts': 1 })).toEqual(['test/b.test.ts', 'test/d.test.ts']);
|
||||
expect(unseededFreeFiles(files, Object.fromEntries(files.map(f => [f, 1])))).toEqual([]);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'free-seed-drift-'));
|
||||
try {
|
||||
const seedPath = path.join(dir, 'seed.json');
|
||||
fs.writeFileSync(seedPath, JSON.stringify({ version: 1, durations: { 'test/strict-output.test.ts': 1_000 } }));
|
||||
const planned = Bun.spawnSync([process.execPath, path.join(ROOT, 'scripts/test-free-shards.ts'), '--ci-plan', path.join(dir, 'plan.json'), '--shards', '2'], {
|
||||
env: { ...process.env, GSTACK_FREE_TEST_DURATIONS: seedPath }, timeout: 10_000,
|
||||
});
|
||||
expect(planned.exitCode, planned.stderr.toString()).toBe(0);
|
||||
expect(JSON.parse(planned.stdout.toString())).toEqual({ shard: [1, 2] });
|
||||
expect(planned.stderr.toString()).toMatch(/\d+ file\(s\) have no recorded duration .*bun run test:ubicloud --record-durations/);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('corrupt seed falls back to null (hash sharding), never throws', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'durations-seed-'));
|
||||
const seedPath = path.join(dir, 'seed.json');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Static and offline pins for `bun run test:ubicloud` (scripts/ubicloud/).
|
||||
* The VM path needs a Ubicloud token and costs money, so it is exercised by
|
||||
* hand; these checks keep its environment from drifting away from the
|
||||
* required CI free lane it mirrors, and prove it fails before any network
|
||||
* call when the token is absent.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const DIR = join(ROOT, 'scripts/ubicloud');
|
||||
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
type Step = { uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, string> };
|
||||
const workflow = Bun.YAML.parse(read('.github/workflows/free-tests.yml')) as { jobs: Record<string, { steps: Step[] }> };
|
||||
const freeSuite = workflow.jobs['free-suite'].steps;
|
||||
|
||||
describe('ubicloud free-suite runner', () => {
|
||||
test('setup pins the same Bun version as the CI free-suite job', () => {
|
||||
const ciBun = freeSuite.find(step => step.uses?.startsWith('oven-sh/setup-bun'))?.with?.['bun-version'];
|
||||
expect(ciBun).toBeDefined();
|
||||
expect(read('scripts/ubicloud/setup-free-suite.sh')).toContain(`BUN_VERSION=${ciBun}\n`);
|
||||
});
|
||||
|
||||
test('setup performs the CI job’s build steps', () => {
|
||||
const setup = read('scripts/ubicloud/setup-free-suite.sh');
|
||||
for (const command of ['bun install --frozen-lockfile', 'bun run gen:skill-docs --host all', 'bun run vendor:xterm',
|
||||
'bash browse/scripts/build-node-server.sh', 'bun run build:gates', 'bun run build:cso']) {
|
||||
expect(freeSuite.some(step => step.run?.includes(command))).toBe(true);
|
||||
expect(setup).toContain(command);
|
||||
}
|
||||
expect(setup).toContain('chrome-sandbox');
|
||||
expect(setup).toContain('kernel.apparmor_restrict_unprivileged_userns=0');
|
||||
});
|
||||
|
||||
test('the wrapper runs the suite under Xvfb with the CI lane’s strictness knobs', () => {
|
||||
const ciEnv = freeSuite.find(step => step.run?.includes('bun run test:free'))?.env ?? {};
|
||||
const wrapper = read('scripts/ubicloud/test-free.sh');
|
||||
expect(ciEnv.GSTACK_EXPECT_BINARIES).toBe('1');
|
||||
expect(ciEnv.GSTACK_FREE_RETRY_FLAKY).toBe('1');
|
||||
expect(wrapper).toContain('--env GSTACK_EXPECT_BINARIES=1');
|
||||
expect(wrapper).toContain('--env GSTACK_FREE_RETRY_FLAKY=1');
|
||||
expect(wrapper).toContain('xvfb-run -a bun run test:free');
|
||||
expect(JSON.parse(read('package.json')).scripts['test:ubicloud']).toBe('bash scripts/ubicloud/test-free.sh');
|
||||
});
|
||||
|
||||
test('remote commands force umask 022 and teardown is trapped on exit', () => {
|
||||
const runner = read('scripts/ubicloud/ubi-runner.sh');
|
||||
expect(runner).toContain('"umask 022; $*"');
|
||||
expect(runner).toMatch(/trap 'cmd_down "\$RUN_VM"[^']*' EXIT/);
|
||||
});
|
||||
|
||||
test('refuses to start without UBICLOUD_API_KEY, before any network call', () => {
|
||||
const env = { ...process.env, UBICLOUD_API_URL: 'http://127.0.0.1:9' } as Record<string, string | undefined>;
|
||||
delete env.UBICLOUD_API_KEY;
|
||||
const result = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'list'], { env, timeout: 10_000 });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr.toString()).toContain('UBICLOUD_API_KEY is not set');
|
||||
expect(result.stdout.toString()).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user