Files
gstack/test/publish-dry-run.test.ts
T
Garry Tan 620f5dbaea test: /review hardening — NOT-READY env isolation, workdir cleanup, perf
Applied from the adversarial subagent pass during /review on this branch:

- test/benchmark-cli.test.ts — new "NOT READY path fires when auth env
  vars are stripped" test. The default dry-run test always showed OK on
  dev machines with auth, hiding regressions in the remediation-hint
  branch. Stripped env (no auth vars, HOME→empty tmpdir) now force-
  exercises gpt + gemini NOT READY paths and asserts every NOT READY
  line includes a concrete remediation hint (install/login/export).
  (claude adapter's os.homedir() call is Bun-cached; the 2-of-3 adapter
  coverage is sufficient to exercise the branch.)

- test/taste-engine.test.ts — session-cap test rewritten to seed the
  profile with 50 entries + one real CLI call, instead of 55 sequential
  subprocess spawns. Same coverage (FIFO eviction at the boundary), ~5s
  faster CI time. Also pins first-casing-wins on the Geist/GEIST merge
  assertion — bumpPref() keeps the first-arrival casing, so the test
  documents that policy.

- test/skill-e2e-benchmark-providers.test.ts — workdir creation moved
  from module-load into beforeAll, cleanup added in afterAll. Previous
  shape leaked a /tmp/bench-e2e-* dir every CI run.

- test/publish-dry-run.test.ts — removed unused empty test/helpers
  mkdirSync from the sandbox setup. The bin doesn't import from there,
  so the empty dir was a footgun for future maintainers.

- test/helpers/providers/gpt.ts — expanded the inline comment on
  `--skip-git-repo-check` to explicitly note that `-s read-only` is now
  load-bearing safety (the trust prompt was the secondary boundary;
  removing read-only while keeping skip-git-repo-check would be unsafe).

Net: 45 passing tests (was 44), session-cap test 5s faster, one real
regression surface covered that didn't exist before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 06:54:09 +08:00

281 lines
9.6 KiB
TypeScript

/**
* gstack-publish end-to-end tests via --dry-run.
*
* Verifies manifest parsing, schema validation, marketplace auth checks, per-skill
* error isolation, and command building — all without touching real marketplaces.
*
* --dry-run does NOT run execSync on publish commands. Auth checks still run
* against real binaries; we use fake marketplaces whose `auth_check` commands
* are always-succeed (`true`) or always-fail (`false`) so the test is hermetic.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-publish');
let sandbox: string;
let binCopy: string;
beforeEach(() => {
// gstack-publish reads skills.json relative to the binary's dir (import.meta.dir/..).
// To isolate each test's manifest, we create a sandbox repo that mirrors the real
// structure: copy the bin into sandbox/bin/, write a controlled skills.json at the root.
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-sandbox-'));
fs.mkdirSync(path.join(sandbox, 'bin'));
binCopy = path.join(sandbox, 'bin', 'gstack-publish');
fs.copyFileSync(BIN, binCopy);
fs.chmodSync(binCopy, 0o755);
});
afterEach(() => {
fs.rmSync(sandbox, { recursive: true, force: true });
});
function writeManifest(manifest: object): void {
fs.writeFileSync(path.join(sandbox, 'skills.json'), JSON.stringify(manifest, null, 2));
}
function writeSkillFile(relPath: string, content = '# Test Skill\n'): void {
const full = path.join(sandbox, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync('bun', ['run', binCopy, ...args], {
cwd: sandbox,
encoding: 'utf-8',
timeout: 15000,
});
return {
status: result.status,
stdout: result.stdout?.toString() ?? '',
stderr: result.stderr?.toString() ?? '',
};
}
const VALID_MARKETPLACES = {
fakestore_ok: {
cli: 'true', // binary that always succeeds
login_cmd: 'fakestore_ok login',
publish_cmd_template: 'echo publish {slug} {version}',
docs: 'https://fakestore.example',
auth_check: 'true', // always-authenticated
},
fakestore_noauth: {
cli: 'true',
login_cmd: 'fakestore_noauth login',
publish_cmd_template: 'echo publish {slug} {version}',
docs: 'https://fakestore.example',
auth_check: 'false', // always-fails auth
},
fakestore_missing: {
cli: 'nonexistent-binary-xyz',
login_cmd: 'fakestore_missing login',
publish_cmd_template: 'echo publish {slug} {version}',
docs: 'https://fakestore.example',
auth_check: 'nonexistent-binary-xyz whoami',
},
};
function validSkill(slug: string, sourceRel: string, marketplaces: string[] = ['fakestore_ok']) {
const m: Record<string, { slug: string; publish: boolean }> = {};
for (const name of marketplaces) m[name] = { slug, publish: true };
return {
slug,
source: sourceRel,
name: `Skill ${slug}`,
version: '1.0.0',
category: 'test',
description: 'A test skill',
marketplaces: m,
standalone: true,
compatible_hosts: ['claude-code'],
};
}
describe('gstack-publish: manifest loading', () => {
test('--list prints every skill and marketplace', () => {
writeSkillFile('skills/alpha/SKILL.md');
writeSkillFile('skills/beta/SKILL.md');
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('alpha', 'skills/alpha/SKILL.md'), validSkill('beta', 'skills/beta/SKILL.md')],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--list']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('alpha');
expect(r.stdout).toContain('beta');
expect(r.stdout).toContain('fakestore_ok');
});
test('missing manifest exits non-zero', () => {
// Delete any manifest
const r = run(['--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('skills.json');
});
test('malformed JSON exits non-zero', () => {
fs.writeFileSync(path.join(sandbox, 'skills.json'), '{ not json');
const r = run(['--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('parse');
});
});
describe('gstack-publish: validation', () => {
test('missing source file reports validation error and exits 1', () => {
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('ghost', 'skills/ghost/DOES_NOT_EXIST.md')],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('source file missing');
expect(r.stderr).toContain('ghost');
});
test('missing slug reports validation error', () => {
writeSkillFile('skills/x/SKILL.md');
const s = validSkill('temp', 'skills/x/SKILL.md');
delete (s as Partial<typeof s>).slug;
writeManifest({
version: '1.0.0',
description: 't',
skills: [s],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('missing slug');
});
test('missing version reports validation error', () => {
writeSkillFile('skills/x/SKILL.md');
const s = validSkill('x', 'skills/x/SKILL.md');
delete (s as Partial<typeof s>).version;
writeManifest({
version: '1.0.0',
description: 't',
skills: [s],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('missing version');
});
test('no marketplaces configured reports validation error', () => {
writeSkillFile('skills/x/SKILL.md');
const s = { ...validSkill('x', 'skills/x/SKILL.md'), marketplaces: {} };
writeManifest({ version: '1.0.0', description: 't', skills: [s], marketplaces: VALID_MARKETPLACES });
const r = run(['--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('no marketplaces configured');
});
});
describe('gstack-publish: dry-run execution', () => {
test('happy path reports DRY-RUN tag and templated command', () => {
writeSkillFile('skills/alpha/SKILL.md');
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('alpha', 'skills/alpha/SKILL.md')],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--dry-run']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('DRY-RUN');
expect(r.stdout).toContain('alpha');
expect(r.stdout).toContain('Published: 1');
expect(r.stdout).toContain('Failed: 0');
});
test('per-skill filter publishes only the requested slug', () => {
writeSkillFile('skills/alpha/SKILL.md');
writeSkillFile('skills/beta/SKILL.md');
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('alpha', 'skills/alpha/SKILL.md'), validSkill('beta', 'skills/beta/SKILL.md')],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['alpha', '--dry-run']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Publishing alpha');
expect(r.stdout).not.toContain('Publishing beta');
expect(r.stdout).toContain('Published: 1');
});
test('unknown skill filter exits non-zero', () => {
writeSkillFile('skills/alpha/SKILL.md');
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('alpha', 'skills/alpha/SKILL.md')],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['nonexistent', '--dry-run']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('skill not found');
});
});
describe('gstack-publish: auth check isolation', () => {
test('failing auth for one marketplace does NOT abort the batch in dry-run', () => {
writeSkillFile('skills/alpha/SKILL.md');
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('alpha', 'skills/alpha/SKILL.md', ['fakestore_ok', 'fakestore_noauth'])],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--dry-run']);
// In dry-run, auth failures are reported but don't block dispatch
expect(r.status).toBe(0);
expect(r.stdout).toContain('fakestore_ok: OK');
expect(r.stdout).toContain('fakestore_noauth: NOT READY');
});
test('missing binary reported as not-ready with docs link', () => {
writeSkillFile('skills/alpha/SKILL.md');
writeManifest({
version: '1.0.0',
description: 't',
skills: [validSkill('alpha', 'skills/alpha/SKILL.md', ['fakestore_missing'])],
marketplaces: VALID_MARKETPLACES,
});
const r = run(['--dry-run']);
expect(r.stdout).toContain('fakestore_missing: NOT READY');
expect(r.stdout).toContain('not on PATH');
});
});
describe('gstack-publish: real manifest sanity', () => {
test('the real repo skills.json passes --dry-run validation', () => {
// This uses the actual bin against the actual manifest (ROOT/skills.json).
// If auth to any real marketplace isn't set up it just reports NOT READY;
// --dry-run still exits 0 because it doesn't require auth to pass.
const real = spawnSync('bun', ['run', path.join(ROOT, 'bin', 'gstack-publish'), '--dry-run'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 20000,
});
expect(real.status).toBe(0);
expect(real.stdout).toContain('Validating manifest');
// Every skill in the real manifest should pass validation
expect(real.stderr).not.toContain('Manifest validation failed');
});
});