mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-03 01:46:55 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
708 lines
50 KiB
TypeScript
708 lines
50 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { ALL_HOST_CONFIGS } from '../hosts';
|
|
import { runGeneration, type GenerationResult } from '../scripts/gen-skill-docs';
|
|
import { discoverSectionTemplates } from '../scripts/discover-skills';
|
|
import { SECTION, SECTION_INDEX, sectionPath, usesLazySections } from '../scripts/resolvers/sections';
|
|
import { generateQAMethodReads, generateQAResource } from '../scripts/resolvers/qa';
|
|
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
|
import { runBashScript } from './helpers/bash-script';
|
|
import { PARITY_INVARIANTS, runParityChecks } from './helpers/parity-harness';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const owned = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-qa-sections-'));
|
|
const rendered = path.join(owned, 'rendered');
|
|
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
|
|
const QA_SKILLS = ['qa', 'qa-only'];
|
|
const REPORT_TEMPLATE = fs.readFileSync(path.join(ROOT, 'qa/templates/functional-report-template.md'), 'utf8');
|
|
const GENERATED_REPORT = '<!-- AUTO-GENERATED from qa/templates/functional-report-template.md — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n' + REPORT_TEMPLATE;
|
|
let generated: GenerationResult;
|
|
let fixture: typeof import('../scripts/resolvers/sections');
|
|
|
|
function put(file: string, content: string) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, content);
|
|
}
|
|
|
|
function context(host: string, skillName: string): TemplateContext {
|
|
return { host, skillName, tmplPath: '', paths: HOST_PATHS[host] };
|
|
}
|
|
|
|
function setupFunction(name: string): string {
|
|
const start = setup.indexOf(`${name}() {`);
|
|
const end = setup.indexOf('\n}\n', start);
|
|
if (start < 0 || end < 0) throw new Error(`Missing setup function ${name}`);
|
|
return setup.slice(start, end + 2);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
generated = await runGeneration({ host: 'all', outputRoot: rendered });
|
|
expect(generated.exitCode, JSON.stringify(generated.diagnostics)).toBe(0);
|
|
const fixtureRoot = path.join(owned, 'fixture');
|
|
put(path.join(fixtureRoot, 'scripts/resolvers/sections.ts'), fs.readFileSync(path.join(ROOT, 'scripts/resolvers/sections.ts'), 'utf8'));
|
|
for (const skill of [...QA_SKILLS, 'ship']) {
|
|
put(path.join(fixtureRoot, skill, 'sections/manifest.json'), JSON.stringify({
|
|
skill, sections: [{ id: 'native', file: 'contract-probes.md', title: 'Native probes', trigger: 'probing a native contract' }],
|
|
}));
|
|
put(path.join(fixtureRoot, skill, 'sections/contract-probes.md.tmpl'), 'PRIVATE_SECTION_BODY\n{{INVOKE_SKILL:investigate}}\n');
|
|
}
|
|
fixture = await import(path.join(fixtureRoot, 'scripts/resolvers/sections.ts'));
|
|
}, 120_000);
|
|
|
|
afterAll(() => fs.rmSync(owned, { recursive: true, force: true }));
|
|
|
|
describe('QA-only cross-host lazy rendering', () => {
|
|
test('Codex review defines omitted-Army records without waiving native or QA completion', () => {
|
|
const codex = ALL_HOST_CONFIGS.find(host => host.name === 'codex')!;
|
|
const text = fs.readFileSync(path.join(rendered, codex.hostSubdir, 'skills/gstack-review/SKILL.md'), 'utf8');
|
|
const record = text.slice(text.indexOf('## Step 5.8: Persist Eng Review result')).replace(/\s+/g, ' ');
|
|
expect(text).not.toContain('### Dispatch specialists');
|
|
expect(text).not.toContain('### Step 4.6: Collect and merge findings');
|
|
expect(text).not.toContain('SPECIALIST REVIEW: N findings');
|
|
expect(record).toContain('If this host omits Review Army, use `specialists: {}` without claiming specialist coverage');
|
|
expect(record).toContain('`10.0` when small-diff specialists were skipped or this host omits Review Army');
|
|
expect(record).toContain('This default is not completion evidence');
|
|
expect(record).toContain('native Step 4.8 adversarial pass finish, and every required Step 4.7 probe passes');
|
|
expect(record).toContain('Any failed, blocked, inconclusive or not-run required probe means false, as does a failed native review');
|
|
expect(record).toContain('`/ship` named-risk acceptance cannot complete `/review`');
|
|
expect(record).toContain('unresolved non-advisory core defects still count in `issues_found`');
|
|
expect(record).toContain('State INCOMPLETE if `COMPLETED` is false, even when N=0');
|
|
});
|
|
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
test(`${host.name} review loads QA methods before static review and reuses them before probes`, () => {
|
|
const directory = host.name === 'claude' ? 'review' : `${host.hostSubdir}/skills/gstack-review`;
|
|
const text = fs.readFileSync(path.join(rendered, directory, 'SKILL.md'), 'utf8');
|
|
const start = text.indexOf('## Step 4: Critical pass (core review)');
|
|
const core = text.indexOf('Apply both checklist passes in order', start);
|
|
const exploration = text.indexOf('### Step 4.7: Exploratory QA', core);
|
|
expect(start).toBeGreaterThan(-1);
|
|
expect(core).toBeGreaterThan(start);
|
|
expect(exploration).toBeGreaterThan(core);
|
|
const preparation = text.slice(start, core);
|
|
const loop = preparation.indexOf('sections/exploratory.md');
|
|
expect(loop).toBeGreaterThan(-1);
|
|
expect(preparation).toContain('complete the ordered scope/method Reads below');
|
|
expect(preparation).toContain('Step 4 is read-only: defer charters, setup and probes to Step 4.7');
|
|
expect(preparation).not.toContain('sections/system-functional.md');
|
|
const qaDirectory = host.name === 'claude' ? 'qa' : `${host.hostSubdir}/skills/gstack-qa`;
|
|
const shared = fs.readFileSync(path.join(rendered, qaDirectory, 'sections/exploratory.md'), 'utf8');
|
|
const scope = shared.indexOf('Read `sections/scope.md`');
|
|
const selection = shared.indexOf('in full and select the surfaces');
|
|
const methods = shared.indexOf('Read `sections/system-functional.md`');
|
|
expect(scope).toBeGreaterThan(-1);
|
|
expect(selection).toBeGreaterThan(scope);
|
|
expect(methods).toBeGreaterThan(selection);
|
|
expect(shared).toContain('**Browser surfaces only:**');
|
|
expect(preparation).not.toContain('sections/browser-setup.md');
|
|
expect(shared).toContain('sections/qa-patterns.md');
|
|
expect(preparation.replace(/\s+/g, ' ')).toContain('Step 4 is read-only: defer charters, setup and probes to Step 4.7');
|
|
expect(shared.indexOf('Write a **charter**')).toBeGreaterThan(methods);
|
|
expect(shared).toContain('Do not repeat a Read already completed in this invocation');
|
|
const qa = text.slice(exploration, text.indexOf('## Step 5: Fix-First Review', exploration));
|
|
const charter = qa.indexOf('**1. Set the charter and isolation.**');
|
|
const readiness = qa.indexOf('**2. Check readiness and list required checks.**');
|
|
const setup = qa.indexOf("Read QA's `sections/browser-setup.md`");
|
|
const probes = qa.indexOf('**3. Run smoke and plan checks.**');
|
|
expect(charter).toBeGreaterThan(-1);
|
|
expect(readiness).toBeGreaterThan(charter);
|
|
expect(setup).toBeGreaterThan(readiness);
|
|
expect(probes).toBeGreaterThan(setup);
|
|
expect(qa.slice(charter, readiness).replace(/\s+/g, ' ')).toContain('complete the shared isolation/permission preflight before setup');
|
|
const flat = qa.replace(/\s+/g, ' ');
|
|
expect(flat).toContain('Reuse setup only with verified tools/session/target/ownership; otherwise recheck');
|
|
expect(flat).toContain('Never install, import cookies or bootstrap tests');
|
|
expect(flat).toContain('Functional-only skips browser setup');
|
|
if (usesLazySections(host.name, 'review')) {
|
|
const index = text.slice(text.indexOf('## Section index'), text.indexOf('## Step 1:'));
|
|
expect(index).toContain('Inline in [Step 4](#step-4-critical-pass-core-review); setup and probes run in Step 4.7');
|
|
expect(index.indexOf('Select surfaces and read QA methods')).toBeLessThan(index.indexOf('sections/review-army.md'));
|
|
} else {
|
|
expect(text).not.toContain('## Section index');
|
|
}
|
|
});
|
|
|
|
for (const skill of QA_SKILLS) {
|
|
test(`${host.name} ${skill} resolves a passive manifest without inlining its body`, () => {
|
|
const ctx = context(host.name, skill);
|
|
expect(usesLazySections(host.name, skill)).toBe(true);
|
|
const reference = fixture.sectionPath(ctx, skill, 'native');
|
|
expect(reference).toContain('`sections/contract-probes.md`');
|
|
expect(reference).toContain('installed');
|
|
expect(reference).toContain('SKILL.md directory');
|
|
expect(reference).toContain(host.name === 'claude' ? `\`${skill}\`` : `\`gstack-${skill}\``);
|
|
const pointer = fixture.SECTION(ctx, ['native']);
|
|
expect(pointer).toContain(reference);
|
|
expect(pointer).toContain('probing a native contract');
|
|
expect(pointer).toContain('never the product working directory');
|
|
expect(pointer).toContain('missing or unreadable');
|
|
expect(pointer).toContain('QA setup blocker');
|
|
expect(pointer).not.toContain('PRIVATE_SECTION_BODY');
|
|
expect(pointer).not.toContain('$GSTACK_ROOT');
|
|
expect(fixture.SECTION_INDEX(ctx)).toContain(reference);
|
|
expect(fixture.SECTION_INDEX(context(host.name, 'ship'), [skill])).toContain(reference);
|
|
expect(fixture.sectionPath(context(host.name, 'review'), skill, 'native')).toBe(reference);
|
|
expect(fixture.sectionPath(context(host.name, 'ship'), skill, 'native')).toBe(reference);
|
|
});
|
|
}
|
|
|
|
test(`${host.name} generates all QA section templates and preserves other skills' section policy`, () => {
|
|
const sections = discoverSectionTemplates(ROOT);
|
|
const targets = sections.filter(section => QA_SKILLS.includes(section.skillDir));
|
|
expect(targets.length).toBeGreaterThan(0);
|
|
for (const section of targets) {
|
|
const dir = host.name === 'claude' ? section.skillDir : `${host.hostSubdir}/skills/gstack-${section.skillDir}`;
|
|
const file = `${dir}/sections/${path.basename(section.output)}`;
|
|
expect(generated.artifacts).toContainEqual({ relativePath: file, kind: 'section', host: host.name });
|
|
const body = fs.readFileSync(path.join(rendered, file), 'utf8');
|
|
expect(body).toContain('AUTO-GENERATED');
|
|
expect(body).not.toMatch(/\{\{[A-Z_]+(?::[^}]*)?\}\}/);
|
|
expect(fs.readFileSync(path.join(rendered, dir, 'SKILL.md'), 'utf8')).not.toContain(body.split('\n').slice(2).join('\n').trim());
|
|
}
|
|
const outside = generated.artifacts.filter(artifact => artifact.host === host.name && artifact.kind === 'section'
|
|
&& !/^(?:qa|qa-only)\//.test(artifact.relativePath)
|
|
&& !/\/gstack-qa(?:-only)?\//.test(artifact.relativePath));
|
|
expect(outside.length > 0).toBe(host.name === 'claude');
|
|
const ctx = context(host.name, 'ship');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'ship/sections/manifest.json'), 'utf8'));
|
|
const entry = manifest.sections[0];
|
|
expect(usesLazySections(host.name, 'ship')).toBe(host.name === 'claude');
|
|
if (host.name === 'claude') {
|
|
expect(SECTION(ctx, [entry.id])).toBe(`> **STOP.** Before ${entry.trigger}, Read \`~/.claude/skills/gstack/ship/sections/${entry.file}\` and execute it\n> in full. Do not work from memory — that section is the source of truth for this step.`);
|
|
} else {
|
|
expect(SECTION(ctx, [entry.id])).toBe(fs.readFileSync(path.join(ROOT, 'ship/sections', `${entry.file}.tmpl`), 'utf8').trimEnd());
|
|
expect(SECTION_INDEX(ctx)).toBe('');
|
|
}
|
|
});
|
|
|
|
test(`${host.name} packages the authored functional report beside the QA entrypoint`, () => {
|
|
const dir = host.name === 'claude' ? 'qa' : `${host.hostSubdir}/skills/gstack-qa`;
|
|
const relativePath = `${dir}/templates/functional-report-template.md`;
|
|
expect(generated.artifacts.find(artifact => artifact.relativePath === relativePath))
|
|
.toEqual({ relativePath, kind: 'asset', host: host.name });
|
|
expect(fs.readFileSync(path.join(rendered, relativePath), 'utf8'))
|
|
.toBe(host.name === 'claude' ? REPORT_TEMPLATE : GENERATED_REPORT);
|
|
});
|
|
|
|
test(`${host.name} qa-only reads shared browser setup directly without a redirect section`, () => {
|
|
const dir = host.name === 'claude' ? 'qa-only' : `${host.hostSubdir}/skills/gstack-qa-only`;
|
|
const entry = fs.readFileSync(path.join(rendered, dir, 'SKILL.md'), 'utf8');
|
|
const browserRead = generateQAResource(context(host.name, 'qa-only'), ['browser-setup']);
|
|
expect(entry).toContain(browserRead);
|
|
expect(entry.indexOf('**Browser surface only:**')).toBeLessThan(entry.indexOf(browserRead));
|
|
expect(browserRead).toContain(sectionPath(context(host.name, 'qa-only'), 'qa', 'browser-setup'));
|
|
expect(browserRead).toContain("this host's installed caller skill");
|
|
expect(browserRead).toContain('No product-directory or cross-host substitutes');
|
|
expect(browserRead).toContain('Missing/unreadable assets block required QA');
|
|
expect(browserRead).toContain('continue other safe probes');
|
|
expect(generated.artifacts.some(artifact => artifact.relativePath === `${dir}/sections/browser-setup.md`)).toBe(false);
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'qa-only/sections/manifest.json'), 'utf8'));
|
|
expect(manifest.sections.some((section: { id: string }) => section.id === 'browser-setup')).toBe(false);
|
|
for (const name of ['browser-setup.md.tmpl', 'browser-setup.md']) {
|
|
expect(fs.existsSync(path.join(ROOT, 'qa-only/sections', name))).toBe(false);
|
|
}
|
|
});
|
|
|
|
test(`${host.name} both QA entrypoints reach shared functional modes before probes`, () => {
|
|
const qaDirectory = host.name === 'claude' ? 'qa' : `${host.hostSubdir}/skills/gstack-qa`;
|
|
const functional = fs.readFileSync(path.join(rendered, qaDirectory, 'sections/system-functional.md'), 'utf8');
|
|
const modes = functional.slice(functional.indexOf('## Functional modes'), functional.indexOf('## Contract map'));
|
|
for (const contract of [
|
|
'**Full** (default)', 'every applicable documented contract', '**Quick** (`--quick`)',
|
|
'success and the highest-risk changed edge', 'contracts not run',
|
|
'**Regression** (`--regression <previous-report>`)', 'before probes, read the supplied',
|
|
'functional report and linked replay evidence', 'missing, unreadable or wrong-target',
|
|
'baseline blocks regression mode', 'browser-only `baseline.json` is not a functional',
|
|
'Re-establish owned setup', 'replay prior failed probes against the documented',
|
|
'never recorded buggy output', 'changed adjacent behavior', 'Preserve the prior report',
|
|
'fixed, still failing and new findings', 'Missing safe replay inputs block affected probes',
|
|
'never count as passes', "Mixed runs apply each surface's mode separately",
|
|
"bounded smoke and explicit plan checks, not Full exploration",
|
|
]) expect(modes).toContain(contract);
|
|
expect(functional.indexOf('## Functional modes')).toBeLessThan(functional.indexOf('## Execute and retain evidence'));
|
|
for (const skill of QA_SKILLS) {
|
|
const dir = host.name === 'claude' ? skill : `${host.hostSubdir}/skills/gstack-${skill}`;
|
|
const entry = fs.readFileSync(path.join(rendered, dir, 'SKILL.md'), 'utf8');
|
|
expect(entry).toContain('| Mode | full | `--quick`, `--regression <previous-report-or-baseline>` |');
|
|
expect(entry).toContain(sectionPath(context(host.name, skill), skill, 'exploratory'));
|
|
expect(entry).not.toContain('## Functional modes');
|
|
const explorer = fs.readFileSync(path.join(rendered, dir, 'sections/exploratory.md'), 'utf8');
|
|
const functionalRead = 'Read `sections/system-functional.md` in full.';
|
|
expect(entry).not.toContain(functionalRead);
|
|
expect(entry).toContain(skill === 'qa' ? "Follow the shared section's ordered preparation" : 'Load the shared preparation gate now');
|
|
expect(explorer).toContain(generateQAMethodReads(context(host.name, skill)));
|
|
const stages = ['1. Read `sections/scope.md`', 'in full and select the surfaces', functionalRead,
|
|
'Read `sections/qa-patterns.md` in full.', 'Write a **charter**', '1. First demonstrate success'];
|
|
const positions = stages.map(stage => explorer.indexOf(stage));
|
|
expect(positions.every(position => position >= 0)).toBe(true);
|
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
|
expect(explorer.split(functionalRead)).toHaveLength(2);
|
|
expect(explorer).toContain('Do not repeat a Read already completed in this invocation');
|
|
if (skill === 'qa') {
|
|
expect(entry).toContain('`--quick` also selects Quick exploration; `--exhaustive` changes only the fix tier.');
|
|
expect(entry).toContain('Regression mode preserves the selected fix tier.');
|
|
for (const tier of ['**Quick:** Fix critical + high severity only', '**Standard:** + medium severity (default)', '**Exhaustive:** + low/cosmetic severity']) expect(entry).toContain(tier);
|
|
} else {
|
|
expect(entry).toContain('Never fix bugs or write product tests');
|
|
const browserSetup = entry.indexOf('## Browser Setup (conditional)');
|
|
const selectedChecks = entry.indexOf('## Run the Selected Checks');
|
|
expect(browserSetup).toBeGreaterThan(0);
|
|
expect(selectedChecks).toBeGreaterThan(browserSetup);
|
|
expect(entry.lastIndexOf(sectionPath(context(host.name, skill), skill, 'exploratory'))).toBeLessThan(browserSetup);
|
|
expect(entry.slice(browserSetup, selectedChecks)).not.toContain(functionalRead);
|
|
expect(entry).toContain('Use the shared section already loaded above; do not restart its preparation');
|
|
expect(entry).toContain('Defer charters, clocks and probes to');
|
|
expect(explorer).toContain('Complete these Reads in order before writing charters or probing');
|
|
expect(entry).toContain('For mixed Regression, the argument is the prior combined report');
|
|
expect(entry).toContain('use separate browser and functional sections in this same report');
|
|
expect(explorer).toContain('## 3. Parent handoff');
|
|
expect(explorer).toContain('Return test_stub proposals');
|
|
expect(explorer).toContain('never create tests or freeze buggy output');
|
|
expect(explorer).not.toMatch(/before repair|For \/review and \/ship|every new \/ship/);
|
|
}
|
|
}
|
|
const browser = fs.readFileSync(path.join(rendered, qaDirectory, 'sections/qa-patterns.md'), 'utf8');
|
|
expect(browser).toContain('### Regression (`--regression <baseline>`)');
|
|
expect(browser).toContain('Run Full; append fixed/new issues and score delta. Preserve the supplied prior baseline.');
|
|
expect(browser).not.toContain('## Functional modes');
|
|
});
|
|
|
|
test(`${host.name} caller-relative QA resources resolve within the generated installation`, () => {
|
|
const base = host.name === 'claude' ? rendered : path.join(rendered, host.hostSubdir, 'skills');
|
|
const prefix = host.name === 'claude' ? '' : 'gstack-';
|
|
for (const caller of ['review', 'ship']) {
|
|
const dir = path.join(base, `${prefix}${caller}`);
|
|
const body = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')
|
|
+ (host.name === 'claude' && caller === 'ship'
|
|
? fs.readFileSync(path.join(dir, 'sections/review-army.md'), 'utf8') : '');
|
|
expect(body).toContain(`From the installed /${caller} SKILL.md's directory`);
|
|
expect(body).toContain(`Read \`../${prefix}qa/sections/exploratory.md\` in full`);
|
|
expect(body).toContain('complete the ordered scope/method Reads below');
|
|
const scopeTarget = path.resolve(dir, `../${prefix}qa/sections/scope.md`);
|
|
expect(fs.realpathSync(scopeTarget)).toBe(path.join(base, `${prefix}qa/sections/scope.md`));
|
|
const target = path.resolve(dir, `../${prefix}qa/sections/exploratory.md`);
|
|
expect(fs.realpathSync(target)).toBe(path.join(base, `${prefix}qa/sections/exploratory.md`));
|
|
expect(fs.readFileSync(target, 'utf8')).toContain('# Shared exploratory QA');
|
|
expect(fs.readFileSync(target, 'utf8')).toContain(sectionPath(context(host.name, 'qa'), 'qa', 'scope'));
|
|
expect(fs.readFileSync(scopeTarget, 'utf8')).toContain('Select **browser**, **functional**');
|
|
if (host.name === 'claude') {
|
|
expect(body).toContain(`If the caller directory is prefixed \`gstack-${caller}\``);
|
|
expect(body).toContain('use `../gstack-qa/sections/exploratory.md` instead');
|
|
if (caller === 'review') {
|
|
expect(body).toContain('If neither layout applies, report an unresolved QA installation as a setup blocker; do not guess another path');
|
|
expect(body).toContain("Use this host's installation, never the product tree");
|
|
expect(body).toContain('its affected probes as blocked; continue other safe probes');
|
|
expect(body).toContain('Missing/unreadable assets block required QA');
|
|
}
|
|
const registry = path.join(owned, 'prefixed-callers', caller);
|
|
fs.mkdirSync(path.join(registry, `gstack-${caller}`), { recursive: true });
|
|
fs.cpSync(path.join(base, 'qa'), path.join(registry, 'gstack-qa'), { recursive: true });
|
|
const prefixedTarget = path.resolve(registry, `gstack-${caller}`, '../gstack-qa/sections/exploratory.md');
|
|
const prefixedScope = path.resolve(registry, `gstack-${caller}`, '../gstack-qa/sections/scope.md');
|
|
expect(fs.readFileSync(prefixedScope, 'utf8')).toBe(fs.readFileSync(scopeTarget, 'utf8'));
|
|
expect(fs.readFileSync(prefixedTarget, 'utf8')).toBe(fs.readFileSync(target, 'utf8'));
|
|
expect(fs.existsSync(path.resolve(registry, `gstack-${caller}`, '../qa/sections/exploratory.md'))).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
test('freshly rendered QA modes retain the fixed parity and prompt-size limits', () => {
|
|
const baseline = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/parity-baseline-v1.64.1.0.json'), 'utf8'));
|
|
const report = runParityChecks({ repoRoot: rendered, baseline,
|
|
invariants: PARITY_INVARIANTS.filter(invariant => QA_SKILLS.includes(invariant.skill)) });
|
|
expect(report.totalChecks).toBe(2);
|
|
expect(report.details.filter(detail => !detail.passed)).toEqual([]);
|
|
});
|
|
|
|
test('invalid IDs and missing source assets fail rather than producing a usable pointer', () => {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
expect(() => fixture.SECTION(context(host.name, 'qa'), [])).toThrow('requires a section id');
|
|
expect(() => fixture.sectionPath(context(host.name, 'ship'), 'qa', 'unknown')).toThrow('no section "unknown"');
|
|
}
|
|
const template = path.join(owned, 'fixture/qa-only/sections/contract-probes.md.tmpl');
|
|
fs.unlinkSync(template);
|
|
try {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
expect(() => fixture.SECTION(context(host.name, 'qa-only'), ['native'])).toThrow('contract-probes.md.tmpl');
|
|
}
|
|
} finally {
|
|
fs.writeFileSync(template, 'PRIVATE_SECTION_BODY\n');
|
|
}
|
|
});
|
|
|
|
test('dry-run reports missing QA sections and authored report assets without recreating them', async () => {
|
|
const artifacts = generated.artifacts.filter(artifact =>
|
|
artifact.kind === 'section' && /^(?:\.[^/]+\/skills\/gstack-)?qa\/sections\//.test(artifact.relativePath)
|
|
|| artifact.kind === 'asset' && artifact.relativePath.endsWith('/templates/functional-report-template.md'));
|
|
expect(artifacts.length).toBeGreaterThanOrEqual(ALL_HOST_CONFIGS.length);
|
|
const originals = artifacts.map(artifact => ({ ...artifact, body: fs.readFileSync(path.join(rendered, artifact.relativePath)) }));
|
|
for (const artifact of originals) fs.unlinkSync(path.join(rendered, artifact.relativePath));
|
|
try {
|
|
const result = await runGeneration({ host: 'all', outputRoot: rendered, dryRun: true });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.diagnostics.filter(diagnostic => diagnostic.kind === 'error')).toEqual([]);
|
|
expect(result.diagnostics.filter(diagnostic => diagnostic.kind === 'stale').map(diagnostic => diagnostic.relativePath).sort())
|
|
.toEqual(artifacts.map(artifact => artifact.relativePath).sort());
|
|
for (const artifact of artifacts) expect(fs.existsSync(path.join(rendered, artifact.relativePath))).toBe(false);
|
|
} finally {
|
|
for (const artifact of originals) fs.writeFileSync(path.join(rendered, artifact.relativePath), artifact.body);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('installed QA pointers', () => {
|
|
test('QA-only reads methods and finalization before their dependent writes', () => {
|
|
const source = fs.readFileSync(path.join(ROOT, 'qa-only/SKILL.md.tmpl'), 'utf8');
|
|
const stages = ['Load the shared preparation gate now', '{{SECTION:exploratory}}',
|
|
'## Prepare Report Artifacts', '## Run the Selected Checks', 'With its required Reads complete and report ownership resolved, Write the charters',
|
|
'### Assemble the report', '{{SECTION:reporting}}', '### Write the checked report'];
|
|
const positions = stages.map(stage => source.indexOf(stage));
|
|
expect(positions.every(position => position >= 0)).toBe(true);
|
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
|
expect(source.indexOf('Write identical content')).toBeGreaterThan(source.indexOf('{{SECTION:reporting}}'));
|
|
expect(source).not.toContain('{{SLUG_SETUP}}');
|
|
expect(source).not.toContain('{{SLUG_EVAL}}');
|
|
expect(source).toContain('The no-repeat rule covers preparation Reads, not this finalization Read');
|
|
expect(source).toContain('To recover from an accidental early Read');
|
|
expect(source).toContain('Preserve the initial charters under **Charters** after that metadata, before findings');
|
|
});
|
|
|
|
test('QA-only defines standalone permissions, mixed modes and outside changes', () => {
|
|
const source = ['qa-only/SKILL.md.tmpl', 'qa-only/sections/reporting.md.tmpl']
|
|
.map(file => fs.readFileSync(path.join(ROOT, file), 'utf8')).join('\n').replace(/\s+/g, ' ');
|
|
expect(source).toContain('**caller** means this /qa-only workflow');
|
|
expect(source).toContain('**Owned** means created for this run or explicitly assigned to it, not merely writable');
|
|
expect(source).toContain('the user or invoking workflow explicitly permitted that learning-store path');
|
|
expect(source).toContain('Invoking /qa-only alone does not grant this permission');
|
|
expect(source).toContain('Do not create a forbidden second copy');
|
|
expect(source).toContain('A mode flag applies to all selected surfaces unless the request names one surface; the others default to Full');
|
|
expect(source).toContain('source/diff reads only map changes to pages and flows');
|
|
expect(source).toContain('read `TODOS.md` if present to identify known bugs');
|
|
expect(source).toContain('defaulting to functional then browser');
|
|
expect(source).toContain('Do not reset a clock when switching surfaces');
|
|
expect(source).toContain('CLI executable basename');
|
|
expect(source).toContain('**No explicit permission:** skip learning-store writes and continue to the report');
|
|
expect(source).toContain('**Explicit permission:** Read the named store first');
|
|
expect(source).toContain('Do not run logging helpers');
|
|
expect(source).not.toContain('{{LEARNINGS_LOG}}');
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const directory = host.name === 'claude' ? 'qa-only' : `${host.hostSubdir}/skills/gstack-qa-only`;
|
|
const explorer = fs.readFileSync(path.join(rendered, directory, 'sections/exploratory.md'), 'utf8').replace(/\s+/g, ' ');
|
|
expect(explorer).toContain('If the user or another process changes source, commands or fixtures');
|
|
expect(explorer).toContain('Do not make product changes yourself');
|
|
expect(explorer).toContain('Keep the original limits/notes');
|
|
}
|
|
});
|
|
|
|
test('report-only scope, modes and output overrides precede browser setup', () => {
|
|
const source = fs.readFileSync(path.join(ROOT, 'qa-only/SKILL.md.tmpl'), 'utf8');
|
|
const stages = ['## Request Parameters', '## Test Plan Context', '{{LEARNINGS_SEARCH}}',
|
|
'## Select Surfaces and Isolation', '{{SECTION:exploratory}}', '## Prepare Report Artifacts',
|
|
'## Browser Setup (conditional)', '{{QA_RESOURCE:browser-setup}}', '## Run the Selected Checks'];
|
|
const positions = stages.map(stage => source.indexOf(stage));
|
|
for (const position of positions) expect(position).toBeGreaterThan(-1);
|
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
|
expect(source).not.toContain('{{SECTION:browser-setup}}');
|
|
expect(source).not.toContain('{{QA_METHOD_READS}}');
|
|
expect(source).toContain('Load the shared preparation gate now');
|
|
expect(source).toContain('Parsing records the request; it does not start browser setup');
|
|
expect(source).toContain('If both `--quick` and\n`--regression` are supplied, ask the user to choose one mode before setup or probes');
|
|
expect(source).toContain("Each surface's method defines Full, Quick and Regression");
|
|
expect(source).toContain('All local reports, baselines and evidence use this directory');
|
|
expect(source).toContain('$REPORT_DIR/qa-report-{target}-{YYYY-MM-DD}.md');
|
|
expect(source).not.toContain('qa-report-{domain}');
|
|
expect(source.indexOf('Set `REPORT_FILE`')).toBeLessThan(source.indexOf('## Browser Setup (conditional)'));
|
|
expect(source).toContain("Set `REPORT_FILE` to the caller\'s final report filename");
|
|
expect(source.replace(/\s+/g, ' ')).toContain('Charters and final findings use this same file, not a sidecar');
|
|
const browser = fs.readFileSync(path.join(ROOT, 'qa/sections/browser-setup.md.tmpl'), 'utf8');
|
|
expect(browser).toContain('do not run the fallback\'s setup/install or cookie-import workflow');
|
|
expect(browser).toContain('scope section\'s ownership rules apply even to LOCAL browser targets');
|
|
});
|
|
|
|
test('QA entrypoints preserve previous artifacts before browser setup without expanding caller authority', () => {
|
|
for (const skill of QA_SKILLS) {
|
|
const source = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md.tmpl'), 'utf8');
|
|
const browser = skill === 'qa' ? '{{SECTION:browser-setup}}' : '{{QA_RESOURCE:browser-setup}}';
|
|
const setup = source.slice(0, source.indexOf(browser)).replace(/\s+/g, ' ');
|
|
expect(setup).toContain('prior report');
|
|
expect(setup).toMatch(/baseline paths.*before writing/);
|
|
expect(setup).toContain('only when it is empty; otherwise choose a fresh owned run subdirectory');
|
|
if (skill === 'qa-only') {
|
|
expect(setup).toContain('Never overwrite artifacts from earlier runs');
|
|
expect(setup).toContain("Preserve this run's baselines, screenshots and exploration notes when finalizing its report");
|
|
} else {
|
|
expect(setup).toContain('Never overwrite previous reports, baselines, screenshots or exploration notes');
|
|
}
|
|
expect(setup).toContain("caller\'s fixed artifact paths and permissions take precedence");
|
|
expect(setup).toMatch(/impossible.*(?:output blocker|blocker)/);
|
|
expect(setup).toMatch(/(?:rather than expanding|do not expand) write authority/);
|
|
}
|
|
const source = fs.readFileSync(path.join(ROOT, 'qa-only/SKILL.md.tmpl'), 'utf8').replace(/\s+/g, ' ');
|
|
expect(source).toContain('existing empty directory already established as owned by the caller needs no new shell commands to revalidate it');
|
|
expect(source).toContain("use the caller\'s supported interface and fixed destinations");
|
|
expect(source).toContain('If that destination exists, choose a fresh suffixed filename; never replace a prior report');
|
|
});
|
|
|
|
test('mixed report labels, metadata and baselines have one explicit assembly rule', () => {
|
|
const source = fs.readFileSync(path.join(ROOT, 'qa-only/SKILL.md.tmpl'), 'utf8').replace(/\s+/g, ' ');
|
|
for (const contract of [
|
|
'`mixed-{project-label}`', 'sanitizing the repository name', '`mixed-target`',
|
|
'List the individual targets', 'common metadata once', '**Browser:**', '**Functional:**',
|
|
'`templates/qa-report-template.md`', '`templates/functional-report-template.md`',
|
|
'without duplicating the shared title or metadata',
|
|
'Browser scores apply only to browser coverage; never combine them with functional outcomes',
|
|
'current baseline or replay evidence and checkpoints',
|
|
'Regression also links the prior input baseline/report',
|
|
'Prior baselines are not applicable to Full/Quick',
|
|
'for functional regression the report plus replay evidence is the baseline',
|
|
'Report-only repair/test fields contain proposals or not-run status, never claims of edits',
|
|
]) expect(source).toContain(contract);
|
|
});
|
|
|
|
test('QA-only distinguishes shared browser artifacts from per-surface clocks and checkpoints', () => {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const directory = host.name === 'claude' ? 'qa-only' : `${host.hostSubdir}/skills/gstack-qa-only`;
|
|
const source = fs.readFileSync(path.join(rendered, directory, 'SKILL.md'), 'utf8');
|
|
const start = source.indexOf('### Output Structure');
|
|
expect(start).toBeGreaterThan(-1);
|
|
const layout = source.slice(start, source.indexOf('\n---', start));
|
|
expect(layout).toContain('`REPORT_DIR` stays the report root throughout the run');
|
|
expect(layout.replace(/\s+/g, ' ')).toContain('For browser-only and mixed runs, keep screenshots in `$REPORT_DIR/screenshots/` and the browser baseline in `$REPORT_DIR/baseline.json`');
|
|
expect(layout).toContain("mixed-surface split applies only to clocks and checkpoints");
|
|
expect(layout).toContain('| One surface (browser or functional) | `$REPORT_DIR` |');
|
|
expect(layout).toContain('| Mixed: browser probes | `$REPORT_DIR/browser` |');
|
|
expect(layout).toContain('| Mixed: functional probes | `$REPORT_DIR/functional` |');
|
|
expect(layout.replace(/\s+/g, ' ')).toContain('only when timed, `deadline.json`');
|
|
expect(layout).toContain('Caller-fixed paths override this layout');
|
|
expect(layout.replace(/\s+/g, ' ')).toContain('Do not reassign `REPORT_DIR` to a surface directory');
|
|
}
|
|
});
|
|
|
|
test('QA-only persists its plan before probes and separates observed facts from hypotheses', () => {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const directory = host.name === 'claude' ? 'qa-only' : `${host.hostSubdir}/skills/gstack-qa-only`;
|
|
const entry = fs.readFileSync(path.join(rendered, directory, 'SKILL.md'), 'utf8');
|
|
const reporting = fs.readFileSync(path.join(rendered, directory, 'sections/reporting.md'), 'utf8');
|
|
const directive = SECTION(context(host.name, 'qa-only'), ['reporting']);
|
|
expect(entry).toContain(directive);
|
|
expect(entry.indexOf(directive)).toBeGreaterThan(entry.indexOf('### Assemble the report'));
|
|
expect(entry).toContain('After probing stops, load the finalization procedure below');
|
|
expect(entry).toContain('this step does not authorize more probes or restart an expired clock');
|
|
expect(entry).toContain('Do not preload reporting');
|
|
expect(entry.replace(/\s+/g, ' ')).toContain('If already read, issue another Read now and await its acknowledgement, even if the tool reports unchanged content');
|
|
expect(directive).toContain('finalizing the report after probing stops');
|
|
expect(directive).toContain('Read `sections/reporting.md`');
|
|
expect(directive).toContain('Missing/unreadable assets block required QA');
|
|
expect(entry).not.toContain('## 1. Establish each finding once');
|
|
const source = (entry + '\n' + reporting).replace(/\s+/g, ' ');
|
|
expect(source).toContain('With its required Reads complete and report ownership resolved, Write the charters into the owned report and wait for the successful Write result before starting any probe clock or baseline');
|
|
expect(source).toContain('A failed baseline contract stays failed');
|
|
expect(source).toContain('distinguish the observed result, the expected contract and any untested causal hypothesis');
|
|
expect(source).toContain('Link the supporting command/result or screenshot; unknown impact remains unknown');
|
|
expect(source).toContain('A console error message does not establish an uncaught exception, failed payload or missing UI');
|
|
expect(source).toContain('Missing text in a page-text extract does not establish an absent attribute or inaccessible element');
|
|
expect(source).toContain('leave them unconfirmed when time expires');
|
|
expect(source).toContain('guard start to child launch as pre-launch elapsed time, and child start to finish as command duration');
|
|
expect(source).toContain("not a component's latency without its own measurement");
|
|
expect(source).toContain('**Probe budget** (configured limit)');
|
|
expect(source).toContain('**Guarded command time** (sum of measured child spans)');
|
|
expect(source).toContain('**Total session elapsed**: `unmeasured` for the invocation whose report is being written');
|
|
expect(source).toContain('A deadline window is not total run time');
|
|
expect(source).toContain('Gaps between receipts do not measure status/Write overhead or prove how many probes fit');
|
|
expect(source).toContain('if late, say only that this run dispatched its follow-up after the deadline');
|
|
expect(source).toContain('Apply these evidence limits to proposed tests and learnings too');
|
|
expect(source).toContain('final report Write, acknowledgement and cleanup are not finished yet');
|
|
expect(source).toContain('An optional **Measured interval** must cite its actual start/end receipts and name the work outside those boundaries');
|
|
expect(source).toContain('A logged exception-shaped string proves a logged message, not that the named operation executed');
|
|
expect(source).toContain("Build headlines, Top 3, summaries and completion text from each finding's Observed and Confirmation fields, not its Hypothesis");
|
|
expect(source).toContain('Choose one conservative factual sentence per finding and reuse it verbatim in those locations; do not introduce a new causal paraphrase');
|
|
const exploratory = fs.readFileSync(path.join(rendered, directory, 'sections/exploratory.md'), 'utf8').replace(/\s+/g, ' ');
|
|
expect(exploratory).toContain("copy the complete span between the guard's started and finished receipt lines");
|
|
expect(exploratory).toContain('Keep its whitespace and content fences verbatim');
|
|
expect(exploratory).toContain('that separator is not child text');
|
|
expect(exploratory).toContain('If capture is incomplete, report that limit instead of reconstructing it');
|
|
expect(source).toContain('For a logged console error, capture console errors; exception-only hooks do not detect a console-only message');
|
|
expect(source).toContain('Write the report only after this consistency check');
|
|
expect(source).toContain("Run the learning step below only if its destination is caller-authorized");
|
|
expect(source).toContain('keep notes in `REPORT_FILE`; do not write learning stores or automatic memory');
|
|
expect(source).toContain('one observation proves neither recurrence nor an unexecuted check');
|
|
expect(source).toContain('After the final Write, respond briefly with its path and verified coverage/limits');
|
|
const stages = ['## 1. Establish each finding once', '## 2. Fill timing fields', '## 3. Assemble and check'];
|
|
const positions = stages.map(stage => reporting.indexOf(stage));
|
|
expect(positions.every(position => position >= 0)).toBe(true);
|
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
|
}
|
|
});
|
|
|
|
const installers = new Set(['claude', 'codex', 'factory', 'kiro', 'opencode', 'cursor']);
|
|
const helpers = [
|
|
'_link_or_copy', '_print_windows_copy_note_once', '_link_skill_runtime_assets', '_gstack_link_target_abs', '_gstack_target_is_ours',
|
|
'_gstack_generated_header', '_claude_entry_owned_strongly', '_claude_entry_is_ours', '_write_owned_marker',
|
|
'_backup_skill_md', '_cleanup_weak_dir', '_gstack_dir_only_links', '_cleanup_linked_dir',
|
|
'_owned_for_windows_refresh', '_sidecar_root_user_owned', '_prune_stale_generated', '_skill_source_exists',
|
|
].map(setupFunction).join('\n');
|
|
const kiroStart = setup.indexOf('# 6. Install for Kiro CLI');
|
|
const kiroBlock = setup.slice(kiroStart, setup.indexOf('# 6b.', kiroStart));
|
|
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
for (const copy of process.platform === 'win32' ? [true] : [false, true]) {
|
|
test(`${host.name} ${copy ? 'copies' : 'links'} resolve in global/local ${installers.has(host.name) ? 'setup installs' : 'rendered layouts (no setup arm)'}`, () => {
|
|
const base = fs.mkdtempSync(path.join(owned, `${host.name}-`));
|
|
const source = path.join(base, 'payload');
|
|
const native = host.name === 'claude' ? source : path.join(source, host.hostSubdir, 'skills');
|
|
const names = QA_SKILLS.filter(skill => fs.existsSync(path.join(ROOT, skill, 'sections/manifest.json')));
|
|
expect(names).toContain('qa');
|
|
for (const skill of names) {
|
|
const name = host.name === 'claude' ? skill : `gstack-${skill}`;
|
|
const original = host.name === 'claude' ? path.join(rendered, skill) : path.join(rendered, host.hostSubdir, 'skills', name);
|
|
fs.cpSync(original, path.join(native, name), { recursive: true });
|
|
put(path.join(source, skill, 'SKILL.md.tmpl'), `---\nname: ${skill}\n---\n`);
|
|
}
|
|
const homes = [path.join(base, 'home', path.dirname(host.globalRoot)), path.join(base, 'repo', path.dirname(host.localSkillRoot))];
|
|
if (host.name === 'codex') homes.push(path.join(base, 'custom-codex-home/skills'));
|
|
for (const registry of homes) {
|
|
for (const prefix of host.name === 'claude' ? [0, 1] : [1]) {
|
|
fs.mkdirSync(registry, { recursive: true });
|
|
let install: string;
|
|
if (host.name === 'kiro') {
|
|
for (const file of ['bin/tool', 'lib/helper', 'browse/dist/browse', 'browse/bin/helper']) put(path.join(source, file), 'fixture');
|
|
for (const name of ['gstack', 'gstack-upgrade']) put(path.join(native, name, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n<!-- Regenerate: bun run gen:skill-docs -->\n');
|
|
install = `INSTALL_KIRO=1\nKIRO_SKILLS="$REGISTRY"\n${kiroBlock}`;
|
|
} else if (installers.has(host.name)) {
|
|
const name = `link_${host.name}_skill_dirs`;
|
|
install = `${setupFunction(name)}\n${name} "$SOURCE_GSTACK_DIR" "$REGISTRY"`;
|
|
} else {
|
|
install = names.map(skill => `_link_or_copy "$NATIVE/gstack-${skill}" "$REGISTRY/gstack-${skill}"`).join('\n');
|
|
}
|
|
const script = [
|
|
'set -e', `IS_WINDOWS=${copy ? 1 : 0}`, `SKILL_PREFIX=${prefix}`, 'QUIET=1',
|
|
'_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/backups"',
|
|
'GSTACK_USER_RENDER_DIR="$HOME/absent-render"', helpers,
|
|
'log() { :; }', '_browser_hint() { :; }', 'bun_cmd() { :; }', install,
|
|
].join('\n');
|
|
const runInstall = () => runBashScript(script, {
|
|
cwd: base, timeout: 30_000,
|
|
env: { ...process.env, HOME: path.join(base, 'isolated-home'), REGISTRY: registry, SOURCE_GSTACK_DIR: source, NATIVE: native },
|
|
});
|
|
const result = runInstall();
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stderr).not.toContain('command not found');
|
|
for (const skill of names) {
|
|
const name = host.name === 'claude' && !prefix ? skill : `gstack-${skill}`;
|
|
const entry = path.join(registry, name, 'SKILL.md');
|
|
const body = fs.readFileSync(entry, 'utf8');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections/manifest.json'), 'utf8'));
|
|
for (const section of manifest.sections) {
|
|
const ref = sectionPath(context(host.name, skill), skill, section.id);
|
|
expect(body).toContain(ref);
|
|
const relative = ref.match(/^`([^`]+)`/)![1];
|
|
const installed = path.resolve(path.dirname(entry), relative);
|
|
const resolved = fs.realpathSync(installed);
|
|
expect(resolved.startsWith(fs.realpathSync(base) + path.sep)).toBe(true);
|
|
expect(fs.readFileSync(installed, 'utf8')).toBe(fs.readFileSync(path.join(native, host.name === 'claude' ? skill : `gstack-${skill}`, relative), 'utf8'));
|
|
expect(fs.existsSync(path.resolve(base, relative))).toBe(false);
|
|
fs.renameSync(resolved, `${resolved}.absent`);
|
|
try {
|
|
expect(() => fs.readFileSync(installed, 'utf8')).toThrow('ENOENT');
|
|
expect(body).toContain('missing or unreadable');
|
|
expect(body).toContain('QA setup blocker');
|
|
} finally {
|
|
fs.renameSync(`${resolved}.absent`, resolved);
|
|
}
|
|
}
|
|
const explorer = fs.readFileSync(path.join(path.dirname(entry), 'sections/exploratory.md'), 'utf8');
|
|
expect(body).not.toContain(generateQAMethodReads(context(host.name, skill)));
|
|
expect(body).toContain(skill === 'qa' ? "Follow the shared section's ordered preparation" : 'Load the shared preparation gate now');
|
|
expect(explorer).toContain(generateQAMethodReads(context(host.name, skill)));
|
|
expect(explorer.indexOf('in full and select the surfaces')).toBeLessThan(explorer.indexOf('Read `sections/system-functional.md`'));
|
|
expect(explorer.indexOf('Read `sections/system-functional.md`')).toBeLessThan(explorer.indexOf('Write a **charter**'));
|
|
const qaName = host.name === 'claude' && !prefix ? 'qa' : 'gstack-qa';
|
|
const qaDirectory = path.join(registry, qaName);
|
|
const functional = fs.readFileSync(path.join(qaDirectory, 'sections/system-functional.md'), 'utf8');
|
|
const reportReference = functional.match(/Use `([^`]+)` relative to the installed QA SKILL\.md\./);
|
|
expect(reportReference).not.toBeNull();
|
|
const report = path.resolve(qaDirectory, reportReference![1]);
|
|
const resolvedReport = fs.realpathSync(report);
|
|
expect(resolvedReport.startsWith(fs.realpathSync(base) + path.sep)).toBe(true);
|
|
expect(fs.readFileSync(report, 'utf8'))
|
|
.toBe(host.name === 'claude' ? REPORT_TEMPLATE : GENERATED_REPORT);
|
|
fs.renameSync(resolvedReport, `${resolvedReport}.absent`);
|
|
try {
|
|
expect(() => fs.readFileSync(report, 'utf8')).toThrow('ENOENT');
|
|
expect(explorer).toMatch(/Missing or unreadable assets, prerequisites or permission\s+block affected probes, not independent safe checks/);
|
|
expect(explorer).toContain('Pass requires all required current-input contracts to pass with no required remainder');
|
|
} finally {
|
|
fs.renameSync(`${resolvedReport}.absent`, resolvedReport);
|
|
}
|
|
}
|
|
if (host.name === 'kiro') {
|
|
const templates = path.join(registry, 'gstack-qa/templates');
|
|
const report = path.join(templates, 'functional-report-template.md');
|
|
const sourceReport = path.join(native, 'gstack-qa/templates/functional-report-template.md');
|
|
for (const candidate of [templates, report, sourceReport]) {
|
|
expect(fs.realpathSync(candidate).startsWith(fs.realpathSync(base) + path.sep)).toBe(true);
|
|
}
|
|
const original = fs.readFileSync(sourceReport, 'utf8');
|
|
const custom = path.join(templates, 'custom.md');
|
|
fs.writeFileSync(custom, 'unrelated user template');
|
|
try {
|
|
const updated = original + '\nUpdated report instructions.\n';
|
|
fs.writeFileSync(sourceReport, updated);
|
|
if (!copy) {
|
|
const previous = path.join(source, 'previous-report.md');
|
|
fs.writeFileSync(previous, original);
|
|
fs.unlinkSync(report);
|
|
fs.symlinkSync(previous, report);
|
|
}
|
|
const refreshed = runInstall();
|
|
expect(refreshed.status, refreshed.stderr).toBe(0);
|
|
expect(fs.readFileSync(report, 'utf8')).toBe(updated);
|
|
expect(fs.readFileSync(custom, 'utf8')).toBe('unrelated user template');
|
|
|
|
fs.unlinkSync(report);
|
|
fs.writeFileSync(report, 'foreign authored report');
|
|
const fileCollision = runInstall();
|
|
expect(fileCollision.status, fileCollision.stderr).toBe(0);
|
|
expect(fileCollision.stderr).toContain('existing file is not gstack-managed');
|
|
expect(fs.readFileSync(report, 'utf8')).toBe('foreign authored report');
|
|
|
|
const foreign = fs.mkdtempSync(path.join(base, 'foreign-'));
|
|
const foreignReport = path.join(foreign, 'functional-report-template.md');
|
|
fs.writeFileSync(foreignReport, 'foreign link target');
|
|
fs.unlinkSync(report);
|
|
fs.symlinkSync(foreignReport, report);
|
|
const fileLinkCollision = runInstall();
|
|
expect(fileLinkCollision.status, fileLinkCollision.stderr).toBe(0);
|
|
expect(fileLinkCollision.stderr).toContain('file link is not gstack-managed');
|
|
expect(fs.readlinkSync(report)).toBe(foreignReport);
|
|
expect(fs.readFileSync(foreignReport, 'utf8')).toBe('foreign link target');
|
|
|
|
fs.renameSync(templates, `${templates}.saved`);
|
|
fs.symlinkSync(foreign, templates);
|
|
const directoryLinkCollision = runInstall();
|
|
expect(directoryLinkCollision.status, directoryLinkCollision.stderr).toBe(0);
|
|
expect(directoryLinkCollision.stderr).toContain('directory link is not gstack-managed');
|
|
expect(fs.readlinkSync(templates)).toBe(foreign);
|
|
expect(fs.readFileSync(foreignReport, 'utf8')).toBe('foreign link target');
|
|
expect(fs.readFileSync(`${templates}.saved/custom.md`, 'utf8')).toBe('unrelated user template');
|
|
|
|
fs.unlinkSync(templates);
|
|
fs.writeFileSync(templates, 'foreign file at templates directory');
|
|
const directoryFileCollision = runInstall();
|
|
expect(directoryFileCollision.status, directoryFileCollision.stderr).toBe(0);
|
|
expect(directoryFileCollision.stderr).toContain('existing entry is not a directory');
|
|
expect(fs.readFileSync(templates, 'utf8')).toBe('foreign file at templates directory');
|
|
} finally {
|
|
fs.writeFileSync(sourceReport, original);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, 60_000);
|
|
}
|
|
}
|
|
});
|