mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554) bunfig.toml's legacy-embedding-preload pins the gateway once at process start to openai:text-embedding-3-large @ 1536, but resetGateway() wiped that pin to _config = null. The next test file's beforeAll engine-connect then reconfigured from the SHIPPED default (zembed-1 @ 1280) before the preload's per-test beforeEach could restore anything, and every 1536-d fixture in that file failed with `expected 1280 dimensions, not 1536`. Which file pairs collided depended on shard bin-packing, so adding ANY test file reshuffled the mines (this is what blocks #3545). Fix: the preload registers its config as a reset baseline via a new test-only seam (__setGatewayResetBaselineForTests); resetGateway() clears all module state as before, then re-applies the baseline. All 93 existing resetGateway() call sites get the correct behavior with zero edits. Production is untouched: nothing in src/ calls resetGateway() or the setter, so the baseline is never registered outside tests and resetGateway() still fully unconfigures there. Five tests genuinely need an unconfigured gateway (no_gateway_config diagnosis, isAvailable=false, the #2590 cold-gateway path, the registry builtin-default tier); they switch to the new __unconfigureGatewayForTests. Two files' hand-rolled "restore the legacy pin in afterAll/finally" workarounds for this exact bug are now redundant and simplified away. Guard test (test/ai/gateway-reset-baseline.test.ts) pins the contract: 1536/openai immediately after resetGateway(), transports still cleared, hard-unconfigure still available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): restore preload's GBRAIN_AUDIT_DIR instead of deleting it (#3554 sibling) Same bug class as the gateway fix in this PR: state set once by a bunfig preload (audit-dir-preload's scratch GBRAIN_AUDIT_DIR), wiped by one file's cleanup, blast radius decided by shard bin-packing. In shard 6, test/minions-shell.test.ts (position 12) unconditionally deleted the var in afterAll; test/audit/audit-dir-preload.test.ts (position 93) then found it undefined and failed 3 tests — and every file in between wrote audit fixtures toward the operator's real ~/.gbrain/audit/. Fix: capture the prior value at file load and conditionally restore it, the same inline save/restore pattern 11 sibling files already use. test/e2e/skill-brain-first.test.ts had the identical unconditional delete in afterEach; fixed the same way. Sweep of every `delete process.env.GBRAIN_AUDIT_DIR` in test/ confirms all remaining sites are conditional restores. Ordered-pair proof (minions-shell.test.ts then audit/audit-dir-preload.test.ts, one process): 3 fail on master, 43/43 pass with this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
/**
|
|
* E2E for the v0.36.x skill_brain_first wave (T10 from /plan-eng-review).
|
|
*
|
|
* Two layers:
|
|
* 1. **Fixture-corpus shape assertions (always runs in CI):** assemble a
|
|
* synthetic skills dir under tempdir using the fixture corpus and the
|
|
* `manifest.json` shape `loadOrDeriveManifest` accepts. Run the
|
|
* `skillBrainFirstCheck()` doctor check + the auto-fix INSERT path
|
|
* and assert structural invariants (T1 — shape, not count):
|
|
* - structurally-exempt skills MUST be ok
|
|
* - canonical-callout skills MUST be ok
|
|
* - skills with `brain_first: exempt` MUST be ok
|
|
* - external + no compliance + no exempt MUST be warn
|
|
* - --fix materializes the callout at the correct insertion site
|
|
* - re-running doctor after --fix reports 0 violations
|
|
*
|
|
* 2. **Live OpenClaw shape (opt-in, $OPENCLAW_WORKSPACE-gated):** lives
|
|
* at `scripts/live-brain-first-check.ts`. NOT in `bun run verify`.
|
|
* Manual run during dev/QA to validate the wave against the real
|
|
* deployment.
|
|
*
|
|
* No DATABASE_URL needed — entirely filesystem. Auto-fix path requires a
|
|
* git repo (the safety gate refuses writes outside one), so the test
|
|
* inits a throwaway git repo in the tempdir and commits the fixtures
|
|
* before exercising --fix.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { execFileSync } from 'child_process';
|
|
|
|
import { skillBrainFirstCheck } from '../../src/commands/doctor.ts';
|
|
import { autoFixDryViolations } from '../../src/core/dry-fix.ts';
|
|
|
|
const FIXTURE_SOURCE = join(import.meta.dir, '..', 'fixtures', 'brain-first-skills');
|
|
|
|
interface Workspace {
|
|
dir: string;
|
|
skillsDir: string;
|
|
cleanup: () => void;
|
|
}
|
|
|
|
function copyFixturesIntoTempWorkspace(): Workspace {
|
|
const root = join(
|
|
tmpdir(),
|
|
`brain-first-e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
);
|
|
const skills = join(root, 'skills');
|
|
mkdirSync(skills, { recursive: true });
|
|
|
|
// Mirror every fixture skill into the tempdir.
|
|
for (const name of readdirSync(FIXTURE_SOURCE)) {
|
|
const srcDir = join(FIXTURE_SOURCE, name);
|
|
const dstDir = join(skills, name);
|
|
mkdirSync(dstDir, { recursive: true });
|
|
const src = readFileSync(join(srcDir, 'SKILL.md'), 'utf-8');
|
|
writeFileSync(join(dstDir, 'SKILL.md'), src);
|
|
}
|
|
|
|
// Init git so the dry-fix safety gate sees "clean tracked file" not
|
|
// "not a repo." The auto-fix REFUSES writes when the file isn't under
|
|
// git (would destroy the only copy with no rollback).
|
|
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
execFileSync('git', ['add', '-A'], { cwd: root });
|
|
execFileSync(
|
|
'git',
|
|
['-c', 'user.email=e2e@test', '-c', 'user.name=e2e', 'commit', '-q', '-m', 'fixtures'],
|
|
{ cwd: root },
|
|
);
|
|
|
|
return {
|
|
dir: root,
|
|
skillsDir: skills,
|
|
cleanup: () => {
|
|
try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
},
|
|
};
|
|
}
|
|
|
|
let workspace: Workspace;
|
|
|
|
// Restore (not delete) after each test: the audit-dir preload sets
|
|
// GBRAIN_AUDIT_DIR once at process start, and deleting it leaks the
|
|
// operator's real ~/.gbrain/audit/ to every later file in the shard.
|
|
const priorAuditDir = process.env.GBRAIN_AUDIT_DIR;
|
|
|
|
beforeEach(() => {
|
|
workspace = copyFixturesIntoTempWorkspace();
|
|
// Redirect audit dir to the tempdir so the snapshot file doesn't pollute
|
|
// the real $GBRAIN_AUDIT_DIR.
|
|
process.env.GBRAIN_AUDIT_DIR = join(workspace.dir, 'audit');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (priorAuditDir === undefined) delete process.env.GBRAIN_AUDIT_DIR;
|
|
else process.env.GBRAIN_AUDIT_DIR = priorAuditDir;
|
|
workspace.cleanup();
|
|
});
|
|
|
|
describe('skill_brain_first E2E (fixture corpus)', () => {
|
|
test('SHAPE: warn surfaces non-compliant fixtures by name', () => {
|
|
const result = skillBrainFirstCheck(workspace.skillsDir);
|
|
expect(result.status).toBe('warn');
|
|
const violatorSlugs = (result.issues ?? []).map(i => i.skill).sort();
|
|
// SHAPE: exactly the deliberately-bad fixtures must be in violators.
|
|
expect(violatorSlugs).toContain('missing-brain-first');
|
|
expect(violatorSlugs).toContain('multi-pattern');
|
|
expect(violatorSlugs).toContain('typo-frontmatter');
|
|
});
|
|
|
|
test('SHAPE: compliant fixtures MUST NOT appear in violators', () => {
|
|
const result = skillBrainFirstCheck(workspace.skillsDir);
|
|
const violatorSlugs = new Set((result.issues ?? []).map(i => i.skill));
|
|
expect(violatorSlugs.has('compliant-callout')).toBe(false);
|
|
expect(violatorSlugs.has('compliant-phase')).toBe(false);
|
|
expect(violatorSlugs.has('compliant-position')).toBe(false);
|
|
expect(violatorSlugs.has('exempt-frontmatter')).toBe(false);
|
|
expect(violatorSlugs.has('no-external')).toBe(false);
|
|
expect(violatorSlugs.has('negation-prose')).toBe(false);
|
|
});
|
|
|
|
test('SHAPE: typo-frontmatter surfaces typo_hint in fix payload', () => {
|
|
const result = skillBrainFirstCheck(workspace.skillsDir);
|
|
const typoIssue = (result.issues ?? []).find(i => i.skill === 'typo-frontmatter');
|
|
expect(typoIssue).toBeDefined();
|
|
expect(typoIssue!.fix.typo_hint).toBeDefined();
|
|
expect(typoIssue!.fix.typo_hint).toContain('snake_case');
|
|
});
|
|
|
|
test('--fix INSERT path: dry-run reports proposed callouts without writing', () => {
|
|
const initialContent = readFileSync(
|
|
join(workspace.skillsDir, 'missing-brain-first', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
const report = autoFixDryViolations(workspace.skillsDir, { dryRun: true });
|
|
const proposed = report.fixed.filter(f => f.status === 'proposed' && f.patternLabel === 'brain-first compliance');
|
|
expect(proposed.length).toBeGreaterThanOrEqual(2); // missing-brain-first + multi-pattern at least
|
|
// File MUST NOT have been written during dry-run.
|
|
const afterDryRun = readFileSync(
|
|
join(workspace.skillsDir, 'missing-brain-first', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(afterDryRun).toBe(initialContent);
|
|
});
|
|
|
|
test('--fix INSERT path: applied write inserts canonical callout at correct site', () => {
|
|
const before = readFileSync(
|
|
join(workspace.skillsDir, 'missing-brain-first', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
const report = autoFixDryViolations(workspace.skillsDir);
|
|
const applied = report.fixed.filter(f => f.status === 'applied' && f.patternLabel === 'brain-first compliance');
|
|
expect(applied.length).toBeGreaterThanOrEqual(2);
|
|
const after = readFileSync(
|
|
join(workspace.skillsDir, 'missing-brain-first', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(after).not.toBe(before);
|
|
// The callout must be present.
|
|
expect(after).toMatch(/^>\s*\*\*Convention:\*\*[^\n]*brain-first/im);
|
|
// It must land AFTER frontmatter close + AFTER first H1.
|
|
const frontmatterClose = after.indexOf('---\n', 4); // second `---`
|
|
const h1Index = after.indexOf('# missing-brain-first');
|
|
const calloutIndex = after.search(/^>\s*\*\*Convention:\*\*[^\n]*brain-first/im);
|
|
expect(calloutIndex).toBeGreaterThan(frontmatterClose);
|
|
expect(calloutIndex).toBeGreaterThan(h1Index);
|
|
});
|
|
|
|
test('--fix INSERT path: second run is idempotent (already_delegated)', () => {
|
|
autoFixDryViolations(workspace.skillsDir);
|
|
const report2 = autoFixDryViolations(workspace.skillsDir);
|
|
const applied = report2.fixed.filter(f => f.status === 'applied' && f.patternLabel === 'brain-first compliance');
|
|
expect(applied.length).toBe(0);
|
|
// The skipped reasons for any brain-first attempts should be `already_delegated`.
|
|
const skippedBf = report2.skipped.filter(f => f.patternLabel === 'brain-first compliance');
|
|
for (const sk of skippedBf) {
|
|
expect(sk.reason).toBe('already_delegated');
|
|
}
|
|
});
|
|
|
|
test('--fix INSERT path: re-running doctor after fix reports 0 brain-first violators (excluding typo case which still has invalid frontmatter)', () => {
|
|
autoFixDryViolations(workspace.skillsDir);
|
|
const result = skillBrainFirstCheck(workspace.skillsDir);
|
|
const violatorSlugs = (result.issues ?? []).map(i => i.skill).sort();
|
|
// After --fix, missing-brain-first and multi-pattern should be resolved
|
|
// via the inserted callout. typo-frontmatter ALSO gets the callout
|
|
// inserted (because the typo'd brain_first didn't exempt it, so the
|
|
// analyzer flagged it, so --fix inserted). So it should also flip to ok.
|
|
// (The typo hint goes away too because it's only surfaced for warn cases.)
|
|
expect(violatorSlugs).not.toContain('missing-brain-first');
|
|
expect(violatorSlugs).not.toContain('multi-pattern');
|
|
expect(violatorSlugs).not.toContain('typo-frontmatter');
|
|
});
|
|
|
|
test('SHAPE: structural-exemption-by-no-external-pattern is honored (no-external fixture)', () => {
|
|
const result = skillBrainFirstCheck(workspace.skillsDir);
|
|
const issue = (result.issues ?? []).find(i => i.skill === 'no-external');
|
|
expect(issue).toBeUndefined();
|
|
});
|
|
|
|
test('SHAPE: declarative opt-out is honored (exempt-frontmatter fixture)', () => {
|
|
const result = skillBrainFirstCheck(workspace.skillsDir);
|
|
const issue = (result.issues ?? []).find(i => i.skill === 'exempt-frontmatter');
|
|
expect(issue).toBeUndefined();
|
|
});
|
|
|
|
test('AUDIT: first doctor run bootstraps snapshot + writes detected events for current violators', () => {
|
|
skillBrainFirstCheck(workspace.skillsDir);
|
|
const auditDir = process.env.GBRAIN_AUDIT_DIR!;
|
|
expect(existsSync(join(auditDir, 'skill-brain-first-snapshot.json'))).toBe(true);
|
|
// Read the audit file and confirm detected events are present.
|
|
const files = readdirSync(auditDir).filter(f => f.startsWith('skill-brain-first-') && f.endsWith('.jsonl'));
|
|
expect(files.length).toBe(1);
|
|
const audit = readFileSync(join(auditDir, files[0]), 'utf-8');
|
|
expect(audit).toContain('"event":"detected"');
|
|
expect(audit).toContain('missing-brain-first');
|
|
});
|
|
|
|
test('AUDIT: second doctor run with no transitions produces zero new audit lines (A2 contract)', () => {
|
|
skillBrainFirstCheck(workspace.skillsDir); // first run bootstraps
|
|
const auditDir = process.env.GBRAIN_AUDIT_DIR!;
|
|
const files = readdirSync(auditDir).filter(f => f.endsWith('.jsonl'));
|
|
const initialLength = files.length > 0
|
|
? readFileSync(join(auditDir, files[0]), 'utf-8').split('\n').filter(l => l.length > 0).length
|
|
: 0;
|
|
skillBrainFirstCheck(workspace.skillsDir); // second run — no transitions
|
|
const afterLength = files.length > 0
|
|
? readFileSync(join(auditDir, files[0]), 'utf-8').split('\n').filter(l => l.length > 0).length
|
|
: 0;
|
|
expect(afterLength).toBe(initialLength);
|
|
});
|
|
|
|
test('AUDIT: applied --fix shifts skills from warn → ok across runs (transition signal)', () => {
|
|
skillBrainFirstCheck(workspace.skillsDir); // bootstrap
|
|
autoFixDryViolations(workspace.skillsDir); // resolve violations
|
|
skillBrainFirstCheck(workspace.skillsDir); // post-fix scan
|
|
const auditDir = process.env.GBRAIN_AUDIT_DIR!;
|
|
const files = readdirSync(auditDir).filter(f => f.endsWith('.jsonl'));
|
|
const audit = readFileSync(join(auditDir, files[0]), 'utf-8');
|
|
// The post-fix run should have written resolved events for the
|
|
// skills that flipped from warn → ok.
|
|
expect(audit).toContain('"event":"resolved"');
|
|
});
|
|
});
|