From a919a416787d27232a8d0ef06ac48aaafc76b290 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 16:53:11 +0000 Subject: [PATCH] test(modules): cover bin/gstack-modules and the loadouts schema; allow modules/ in pregate strays Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .gstack/pregate.json | 2 +- test/module-loadouts.test.ts | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.gstack/pregate.json b/.gstack/pregate.json index f9cb2832c..67a9a5625 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -20,5 +20,5 @@ "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, "warn": [], - "strays": { "allow": [] } + "strays": { "allow": ["modules/**"] } } diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts index 5507ab1fc..a1e0f54c8 100644 --- a/test/module-loadouts.test.ts +++ b/test/module-loadouts.test.ts @@ -5,9 +5,11 @@ * only exclusion coverage; committed bytes are covered by Skill Docs Freshness. */ import { afterAll, 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 { bunIsolationArgs } from '../lib/bun-isolation'; import { MODEL_CATALOG } from '../lib/model-catalog'; import { runGeneration } from '../scripts/gen-skill-docs'; import { ModuleError, excludedSkillDirs, loadLoadouts, moduleCatalog, normalizeModelId, rungFor, selectModules, validateLoadouts, type ModuleErrorCode } from '../scripts/modules'; @@ -217,3 +219,58 @@ describe('gstack-modules render', () => { expect(result.diagnostics.filter(d => d.kind === 'stale' || d.kind === 'error')).toEqual([]); }); }); + +describe('modules/loadouts.schema.json', () => { + const schema = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.schema.json'), 'utf-8')); + const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.json'), 'utf-8')); + + test('the manifest carries every required key and no key the schema lacks', () => { + for (const key of schema.required) expect(raw).toHaveProperty(key); + for (const key of Object.keys(raw)) expect(Object.keys(schema.properties)).toContain(key); + expect(raw.version).toBe(schema.properties.version.const); + }); + + test('rung names and patterns agree between the schema and the manifest', () => { + const rungs = schema.properties.rungs.required as string[]; + expect(Object.keys(raw.rungs).sort()).toEqual([...rungs].sort()); + expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort()); + const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern); + for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern); + }); +}); + +describe('bin/gstack-modules', () => { + const run = (...args: string[]) => { + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 60_000 }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; + }; + + test('validate and list read the committed manifest', () => { + expect(run('validate')).toMatchObject({ code: 0, stdout: expect.stringContaining('LOADOUTS: ok') }); + const listed = JSON.parse(run('list', '--json').stdout); + expect(listed.modules.map((m: { id: string }) => m.id)).toEqual(catalog.map(e => e.id)); + const pinned = run('list', '--pinned').stdout; + expect(pinned).toContain('preamble:bash'); + expect(pinned).not.toContain('skill:design-shotgun'); + }); + + test('refused inputs exit 2 with their code; unknown commands print usage', () => { + const out = path.join(tmp, 'cli-refused'); + expect(run('render', '--host', 'claude', '--modules', 'full,-skill:nope', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_UNKNOWN') }); + expect(run('render', '--host', 'claude', '--modules', 'full,-preamble:bash', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_PINNED') }); + expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--plan-mode', 'maybe')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--model', 'no-such-model')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(run('render', '--host', 'claude', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(fs.existsSync(out)).toBe(false); + expect(run('bogus')).toMatchObject({ code: 2, stderr: expect.stringContaining('Usage:') }); + expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') }); + }); + + test('render prints one RENDERED line and writes the lock', () => { + const out = path.join(tmp, 'cli-render'); + const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/^RENDERED: loadout=adhoc host=claude files=\d+ bytes=\d+ excluded=1 lock=/); + expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8')).excluded).toEqual(['skill:design-shotgun']); + }); +});