Files
gstack/test/env-host-runtime-assets.test.ts
T
Garry Tanandcapy-ai[bot] 6417abf7a6 Move the tools only Capy crews use into a crew/ folder (GST-1833) (#4145)
* Load the Capy crew skills only on Capy machines (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add changelog entry for Capy-only crew skills (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Move the Capy-only crew tooling into crew/ and run its tests only when needed (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Test that the Capy launch file puts crew/bin on PATH

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Count any ~/.capy as a Capy machine, so a Mac running Capy keeps /captain

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Fix the tests and fixtures that broke when crew tooling moved into crew/

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Plan every crew test when a pull request's merge-base can't be read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add a test that checks each crew-only module left the root tree for crew/

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Run crew tests on pull requests that change bin/ commands or workflows they drive

Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep running every crew test on every pull request; the skip comes in a later change

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep copied installs and older release bisects working after the crew/ move

Setup now copies crew/lib into every host's runtime root beside bin and lib, so Windows installs (which copy instead of linking) can still start gstack-ship-receipt, gstack-pregate and the other commands that import it. The release bisect runs the train-gate script from wherever the checked-out commit has it, so a bisect over commits from before the move still gets a verdict.

Review-Finding: p1/outside-voice#1 reader=outside-voice
Review-Finding: p1/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the changelog that Windows installs keep working after the crew/ move

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Point the harness guide's merge-queue rows at crew/lib/ where those modules moved

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Point the plain-title test at the wave module's new home in crew/ (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-08 14:26:14 +00:00

152 lines
8.5 KiB
TypeScript

/**
* Env-var hosts reach gstack's runtime files only through their installed
* runtime root (found in Codex integration, lane W, 2026-10-04).
*
* Templates that quoted "$HOME/.claude/skills/gstack/..." were rewritten to
* "$HOME/.agents/skills/gstack/..." on Codex, a directory a global install
* does not have, and the runtime roots setup builds had no freeze/bin, so
* /freeze, /guard, /unfreeze and /investigate's edit boundary failed on every
* env-var host. Fences now use $GSTACK_ROOT (with the runtime prelude) and
* setup links freeze/bin plus the careful/bin helper it sources.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ALL_HOST_CONFIGS } from '../hosts';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { runGeneration } from '../scripts/gen-skill-docs';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const ENV_HOSTS = ALL_HOST_CONFIGS.filter(h => h.usesEnvVars);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-runtime-assets-'));
const renderDir = path.join(tmp, 'render');
// Lookups that probe Claude-layout installs on purpose and fall through when
// absent: /gstack-upgrade's install-type detection and the optional Chrome
// extension path. design-html's vendored pretext.js is an optional probe too.
const PROBE_FILES = /[\\/]gstack-(?:upgrade|open-gstack-browser|connect-chrome)[\\/]SKILL\.md$/;
const OPTIONAL_ROOT_PATHS = ['design-html/vendor'];
function walk(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e =>
e.isDirectory() ? walk(path.join(dir, e.name)) : e.name.endsWith('.md') ? [path.join(dir, e.name)] : []);
}
// Indentation-aware: an indented fence (inside a list item) closes with its own indent,
// so an unindented regex would span from it to the next fence's opener.
const fences = (text: string) => [...text.matchAll(/^([ \t]*)```bash\n([\s\S]*?)\n\1```[ \t]*$/gm)].map(m => m[2]!);
function extractFunction(name: string): string {
const start = SETUP_SRC.indexOf(`\n${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
return SETUP_SRC.slice(start + 1, end + 2);
}
beforeAll(async () => {
const result = await runGeneration({ host: 'all', outputRoot: renderDir });
if (result.exitCode !== 0) throw new Error(result.diagnostics.filter(d => d.kind === 'error').map(d => d.message).join('\n'));
});
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
describe('env-var host fences reach runtime files through their runtime root', () => {
test('no fence guesses a $HOME/<dir>/skills/gstack path', () => {
const problems: string[] = [];
for (const host of ENV_HOSTS) {
const base = path.join(renderDir, host.hostSubdir, 'skills');
for (const file of walk(base).filter(f => !PROBE_FILES.test(f))) {
for (const body of fences(fs.readFileSync(file, 'utf8'))) {
for (const m of body.matchAll(/\$(?:HOME|\{HOME\})\/\.[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/skills\/gstack\/[^\s"']*/g)) {
problems.push(`${host.name}: ${path.relative(renderDir, file)}: ${m[0]}`);
}
}
}
}
expect(problems.slice(0, 20)).toEqual([]);
});
test("every $GSTACK_ROOT path a fence uses is in the host's runtime root", () => {
const problems: string[] = [];
for (const host of ENV_HOSTS) {
const provided = [...host.runtimeRoot.globalSymlinks,
...Object.entries(host.runtimeRoot.globalFiles ?? {}).flatMap(([dir, files]) => files.map(f => `${dir}/${f}`))];
const base = path.join(renderDir, host.hostSubdir, 'skills');
for (const file of walk(base)) {
for (const body of fences(fs.readFileSync(file, 'utf8'))) {
for (const m of body.matchAll(/\$\{?GSTACK_ROOT\}?\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g)) {
const rel = m[1]!;
const covered = [...provided, ...OPTIONAL_ROOT_PATHS].some(p => rel === p || rel.startsWith(`${p}/`));
if (!covered) problems.push(`${host.name}: ${path.relative(renderDir, file)}: $GSTACK_ROOT/${rel}`);
}
}
}
}
expect([...new Set(problems)].slice(0, 20)).toEqual([]);
});
});
describe.skipIf(process.platform === 'win32')('Codex global install: the freeze boundary works from a fresh shell', () => {
test('setup links freeze/bin and careful/bin, and /freeze then /unfreeze run as installed', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-freeze-'));
try {
const home = path.join(sandbox, 'home');
const codexHome = path.join(home, '.codex');
const rootDir = path.join(codexHome, 'skills', 'gstack');
const project = path.join(sandbox, 'project', 'src');
fs.mkdirSync(project, { recursive: true });
const functions = ['_link_or_copy', '_link_runtime_dists', '_copy_runtime_skill_refs', '_copy_skill_md', '_gstack_generated_header', 'create_codex_runtime_root']
.filter(name => SETUP_SRC.includes(`\n${name}() {`)).map(extractFunction).join('\n');
const built = spawnSync('bash', ['-c', `IS_WINDOWS=0\n_CODEX_RENDER_ROOT="${renderDir}"\n${functions}\ncreate_codex_runtime_root "${ROOT}" "${rootDir}"`],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home } });
expect(built.stderr).toBe('');
expect(built.status).toBe(0);
for (const rel of ['freeze/bin/freeze-state.sh', 'freeze/bin/check-freeze.sh', 'careful/bin/hook-extract.sh']) {
expect(fs.existsSync(path.join(rootDir, rel)), rel).toBe(true);
}
expect(fs.existsSync(path.join(rootDir, 'freeze', 'SKILL.md'))).toBe(false);
const skill = (name: string) => fs.readFileSync(path.join(renderDir, '.agents', 'skills', name, 'SKILL.md'), 'utf8');
const fence = (name: string, marker: string) => fences(skill(name)).find(body => body.includes(marker))!;
const run = (script: string) => spawnSync('bash', ['-c', script], {
cwd: path.dirname(project), encoding: 'utf8', timeout: 30_000,
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, CODEX_HOME: codexHome },
});
const set = run(fence('gstack-freeze', 'freeze-state.sh" set').replace('<user-provided-path>', project));
expect(set.stderr).toBe('');
expect(set.status).toBe(0);
expect(set.stdout).toContain(`FREEZE_DIR=${fs.realpathSync(project)}`);
const clear = run(fence('gstack-unfreeze', 'freeze-state.sh" clear'));
expect(clear.status, clear.stderr).toBe(0);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
// protects: bin and lib import crew/lib since the crew/ move, so a root whose bin and lib are
// copies (Windows) must carry crew/lib too. fails_when: crew/lib leaves _link_runtime_dists and
// the copied gstack-ship-receipt and gstack-pregate die on "Cannot find module '../crew/lib/...'".
test('a copied runtime root carries crew/lib, so its root bins still start', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-copy-'));
try {
const home = path.join(sandbox, 'home');
const rootDir = path.join(home, '.codex', 'skills', 'gstack');
const functions = ['_link_or_copy', '_link_runtime_dists', '_copy_runtime_skill_refs', '_copy_skill_md', '_gstack_generated_header', 'create_codex_runtime_root']
.filter(name => SETUP_SRC.includes(`\n${name}() {`)).map(extractFunction).join('\n');
const built = spawnSync('bash', ['-c', `IS_WINDOWS=1\n_CODEX_RENDER_ROOT="${renderDir}"\n${functions}\ncreate_codex_runtime_root "${ROOT}" "${rootDir}"`],
{ encoding: 'utf8', timeout: 60_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home } });
expect(built.status, built.stderr).toBe(0);
expect(fs.lstatSync(path.join(rootDir, 'lib')).isSymbolicLink()).toBe(false);
for (const bin of ['gstack-ship-receipt', 'gstack-pregate']) {
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(rootDir, 'bin', bin), '--help'],
{ cwd: sandbox, encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home } });
expect(r.stderr, bin).not.toContain('Cannot find module');
expect(r.status, `${bin}: ${r.stderr}`).toBe(0);
}
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
});