mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* perf: batch-load integrity scan — 500 round-trips → 1 SQL query
doctor's integrity_sample check called getPage() sequentially for 500
pages through PgBouncer transaction-mode pooling. Each call required a
full connection acquire/release cycle, causing doctor to timeout (~90s+)
on production deployments.
Replace with a single SQL query that fetches slug, compiled_truth, and
frontmatter for all candidate pages at once. Falls back to the
sequential path for PGLite or when no DB connection is available.
Before: doctor timeout (killed at 60s)
After: doctor completes in ~6s (full run including all other checks)
143 existing minions tests pass unchanged.
* fix: skillpack acquireLock negative-age on Linux sub-ms fs timestamps
On Linux ext4, statSync().mtimeMs has sub-ms precision while Date.now() is
integer ms. A just-written lockfile can report an mtime ~0.3ms ahead of
Date.now(), making age negative. The acquireLock check `age >= staleMs`
then evaluated false on staleMs:0, falling through the forceUnlock branch
and throwing "Another skillpack install appears to be running" instead of
unlocking. macOS rounds to integer ms so this only surfaced on Linux CI.
Clamp age to zero and add a utimesSync-based regression test that pushes
the lock mtime 10ms into the future to deterministically reproduce the
negative-age case on any platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: scanIntegrity batch path scopes by unique slug + Postgres-only gate
Codex review caught that the batch SQL scanned raw (source_id, slug) rows
while sequential's getAllSlugs() returned a Set<string>. On multi-source
brains (UNIQUE(source_id, slug) since v0.18.0), the batch path overcounted
hits and exhausted the LIMIT before covering N distinct pages.
Three changes:
- SELECT DISTINCT ON (slug) ... ORDER BY slug mirrors Set<string>
semantics; multi-source brains now get exact unique-slug counts.
- engine.kind === 'postgres' gate at the call site so PGLite never
enters the batch branch (catch{} fallback was firing on every PGLite
doctor run, polluting the GBRAIN_DEBUG log signal).
- Replace bare catch{} with debug-gated console.error so real Postgres
errors (deadlock, connection drop, SQL bug) are diagnosable instead
of silently swallowed.
Plus inline comments explaining the WHY for DISTINCT ON, the engine.kind
gate, the GBRAIN_DEBUG fallback, and the validate filter divergence
(boolean is the documented contract; stringly-typed handled at lint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: scanIntegrity batch parity (dedup, hits, validate, topPages)
Real-Postgres E2E tests asserting the batch fast path returns identical
results to the sequential path on the four cases that matter:
- dedup: multi-source duplicate slugs scan once (regression guard for
the codex catch). Raw SQL fixture seeds the alt-source row since
engine.putPage doesn't take a source_id.
- hits: bareHits and externalHits arrays match between paths.
- validate: validate:false (boolean) page is skipped on both paths.
- topPages: ordering matches.
Skip when DATABASE_URL is not set (matches existing test/e2e/ pattern).
Per-test TRUNCATE keeps fixture state isolated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version, changelog, and CLAUDE.md (v0.22.7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate llms-full.txt for v0.22.7 CLAUDE.md updates
CLAUDE.md gained the integrity.ts inventory entry and the new
test/e2e/integrity-batch.test.ts test file in commit edd4329.
The committed llms-full.txt bundle inlines CLAUDE.md content,
so it needs to be regenerated to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump v0.22.7 → v0.22.8
Same content as v0.22.7 (doctor integrity batch-load + multi-source
correctness + skillpack Linux fs-timestamp fix), retitled to v0.22.8 to
slot above master's pending v0.22.7 if/when that releases first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
/**
|
|
* Tests for src/core/skillpack/bundle.ts + installer.ts (W5).
|
|
* Bundle enumeration, dependency closure, per-file diff, managed
|
|
* block, lockfile concurrency, atomic writes.
|
|
*/
|
|
|
|
import { describe, expect, it, afterEach } from 'bun:test';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
import {
|
|
bundledSkillSlugs,
|
|
findGbrainRoot,
|
|
loadBundleManifest,
|
|
enumerateBundle,
|
|
BundleError,
|
|
} from '../src/core/skillpack/bundle.ts';
|
|
import {
|
|
applyInstall,
|
|
buildManagedBlock,
|
|
diffSkill,
|
|
extractManagedSlugs,
|
|
planInstall,
|
|
updateManagedBlock,
|
|
InstallError,
|
|
} from '../src/core/skillpack/installer.ts';
|
|
|
|
const created: string[] = [];
|
|
|
|
function scratchGbrain(): { gbrainRoot: string; skillsDir: string } {
|
|
const root = mkdtempSync(join(tmpdir(), 'skillpack-gbrain-'));
|
|
created.push(root);
|
|
mkdirSync(join(root, 'src'), { recursive: true });
|
|
writeFileSync(join(root, 'src', 'cli.ts'), '// stub');
|
|
const skillsDir = join(root, 'skills');
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
|
|
// Two bundled skills + shared deps.
|
|
mkdirSync(join(skillsDir, 'alpha'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'alpha', 'SKILL.md'),
|
|
'---\nname: alpha\n---\n# alpha\n',
|
|
);
|
|
mkdirSync(join(skillsDir, 'alpha', 'scripts'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'alpha', 'scripts', 'alpha.mjs'),
|
|
'export function run() { return "alpha"; }\n',
|
|
);
|
|
|
|
mkdirSync(join(skillsDir, 'beta'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'beta', 'SKILL.md'),
|
|
'---\nname: beta\n---\n# beta\n',
|
|
);
|
|
|
|
// Shared deps.
|
|
mkdirSync(join(skillsDir, 'conventions'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'conventions', 'quality.md'),
|
|
'# quality conventions\n',
|
|
);
|
|
writeFileSync(join(skillsDir, '_output-rules.md'), '# output rules\n');
|
|
|
|
// RESOLVER.md (so find-resolver finds it).
|
|
writeFileSync(
|
|
join(skillsDir, 'RESOLVER.md'),
|
|
'# RESOLVER\n\n| Trigger | Skill |\n|---------|-------|\n| "alpha" | `skills/alpha/SKILL.md` |\n| "beta" | `skills/beta/SKILL.md` |\n',
|
|
);
|
|
|
|
// Plugin manifest.
|
|
writeFileSync(
|
|
join(root, 'openclaw.plugin.json'),
|
|
JSON.stringify(
|
|
{
|
|
name: 'gbrain-test',
|
|
version: '0.17.0-test',
|
|
skills: ['skills/alpha', 'skills/beta'],
|
|
shared_deps: ['skills/conventions', 'skills/_output-rules.md'],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
return { gbrainRoot: root, skillsDir };
|
|
}
|
|
|
|
function scratchTarget(): { workspace: string; skillsDir: string } {
|
|
const workspace = mkdtempSync(join(tmpdir(), 'skillpack-target-'));
|
|
created.push(workspace);
|
|
const skillsDir = join(workspace, 'skills');
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
// Seed a RESOLVER.md so managed block has a home.
|
|
writeFileSync(
|
|
join(skillsDir, 'RESOLVER.md'),
|
|
'# Target RESOLVER\n\n| Trigger | Skill |\n|---------|-------|\n',
|
|
);
|
|
return { workspace, skillsDir };
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (created.length) {
|
|
const d = created.pop();
|
|
if (d && existsSync(d)) rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('findGbrainRoot', () => {
|
|
it('walks up to find openclaw.plugin.json + src/cli.ts', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const nested = join(gbrainRoot, 'a', 'b', 'c');
|
|
mkdirSync(nested, { recursive: true });
|
|
expect(findGbrainRoot(nested)).toBe(gbrainRoot);
|
|
});
|
|
it('returns null when no gbrain root above', () => {
|
|
expect(findGbrainRoot('/tmp/definitely-not-a-gbrain-repo-XYZ')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('loadBundleManifest', () => {
|
|
it('loads + validates a valid manifest', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const m = loadBundleManifest(gbrainRoot);
|
|
expect(m.skills).toEqual(['skills/alpha', 'skills/beta']);
|
|
expect(m.shared_deps.length).toBe(2);
|
|
});
|
|
it('throws BundleError on missing manifest', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'skillpack-empty-'));
|
|
created.push(root);
|
|
expect(() => loadBundleManifest(root)).toThrow(BundleError);
|
|
});
|
|
it('throws BundleError on malformed JSON', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
writeFileSync(join(gbrainRoot, 'openclaw.plugin.json'), '{ nope');
|
|
expect(() => loadBundleManifest(gbrainRoot)).toThrow(BundleError);
|
|
});
|
|
});
|
|
|
|
describe('enumerateBundle (D-CX-10 dependency closure)', () => {
|
|
it('includes skill files AND shared_deps for a single-skill install', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const m = loadBundleManifest(gbrainRoot);
|
|
const entries = enumerateBundle({ gbrainRoot, skillSlug: 'alpha', manifest: m });
|
|
const targets = entries.map(e => e.relTarget).sort();
|
|
expect(targets).toContain('alpha/SKILL.md');
|
|
expect(targets).toContain('alpha/scripts/alpha.mjs');
|
|
// Shared deps pulled in despite single-skill scope.
|
|
expect(targets).toContain('conventions/quality.md');
|
|
expect(targets).toContain('_output-rules.md');
|
|
// beta NOT included.
|
|
expect(targets.find(t => t.startsWith('beta/'))).toBeUndefined();
|
|
});
|
|
it('throws BundleError for unknown skill slug', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const m = loadBundleManifest(gbrainRoot);
|
|
expect(() =>
|
|
enumerateBundle({ gbrainRoot, skillSlug: 'nope', manifest: m }),
|
|
).toThrow(BundleError);
|
|
});
|
|
it('enumerates everything when skillSlug is undefined (--all)', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const m = loadBundleManifest(gbrainRoot);
|
|
const entries = enumerateBundle({ gbrainRoot, manifest: m });
|
|
const targets = entries.map(e => e.relTarget).sort();
|
|
expect(targets.some(t => t.startsWith('alpha/'))).toBe(true);
|
|
expect(targets.some(t => t.startsWith('beta/'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('buildManagedBlock + updateManagedBlock', () => {
|
|
it('builds a block with all installed slugs as rows', () => {
|
|
const m = loadBundleManifest(scratchGbrain().gbrainRoot);
|
|
const block = buildManagedBlock(m, ['alpha', 'beta']);
|
|
expect(block).toContain('gbrain:skillpack:begin');
|
|
expect(block).toContain('gbrain:skillpack:end');
|
|
expect(block).toContain('`skills/alpha/SKILL.md`');
|
|
expect(block).toContain('`skills/beta/SKILL.md`');
|
|
});
|
|
it('appends block when none exists', () => {
|
|
const block = buildManagedBlock(loadBundleManifest(scratchGbrain().gbrainRoot), ['alpha']);
|
|
const updated = updateManagedBlock('# AGENTS\n\nSome prose.\n', block);
|
|
expect(updated).toContain('gbrain:skillpack:begin');
|
|
expect(updated).toContain('Some prose.');
|
|
});
|
|
it('replaces existing block in place, keeping surrounding content', () => {
|
|
const m = loadBundleManifest(scratchGbrain().gbrainRoot);
|
|
const original =
|
|
'# AGENTS\n\nBefore\n\n' +
|
|
buildManagedBlock(m, ['alpha']) +
|
|
'\n\nAfter\n';
|
|
const replaced = updateManagedBlock(original, buildManagedBlock(m, ['alpha', 'beta']));
|
|
expect(replaced).toContain('Before');
|
|
expect(replaced).toContain('After');
|
|
expect(replaced).toContain('`skills/beta/SKILL.md`');
|
|
});
|
|
it('extractManagedSlugs roundtrips with buildManagedBlock', () => {
|
|
const m = loadBundleManifest(scratchGbrain().gbrainRoot);
|
|
const block = buildManagedBlock(m, ['alpha', 'beta']);
|
|
expect(extractManagedSlugs(block).sort()).toEqual(['alpha', 'beta']);
|
|
});
|
|
it('extractManagedSlugs returns [] when no block present', () => {
|
|
expect(extractManagedSlugs('# hello\n\nno block here\n')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('planInstall + applyInstall', () => {
|
|
it('dry-run: plans file writes but does not touch target', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const plan = planInstall({
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
});
|
|
const result = applyInstall(plan, {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
dryRun: true,
|
|
});
|
|
expect(result.dryRun).toBe(true);
|
|
expect(result.summary.wroteNew).toBeGreaterThan(0);
|
|
expect(existsSync(join(skillsDir, 'alpha', 'SKILL.md'))).toBe(false);
|
|
});
|
|
|
|
it('installs a fresh skill and its shared deps', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const plan = planInstall({
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
});
|
|
const result = applyInstall(plan, {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
});
|
|
expect(result.summary.wroteNew).toBeGreaterThan(0);
|
|
expect(existsSync(join(skillsDir, 'alpha', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(skillsDir, 'alpha', 'scripts', 'alpha.mjs'))).toBe(true);
|
|
expect(existsSync(join(skillsDir, 'conventions', 'quality.md'))).toBe(true);
|
|
expect(result.managedBlock.applied).toBe(true);
|
|
});
|
|
|
|
it('re-install is idempotent (skipped_identical on unchanged files)', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
applyInstall(planInstall(opts), opts);
|
|
const second = applyInstall(planInstall(opts), opts);
|
|
expect(second.summary.wroteNew).toBe(0);
|
|
expect(second.summary.skippedIdentical).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('skips locally-modified files without --overwrite-local', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
applyInstall(planInstall(opts), opts);
|
|
// Locally edit the installed SKILL.md
|
|
const localFile = join(skillsDir, 'alpha', 'SKILL.md');
|
|
writeFileSync(localFile, readFileSync(localFile, 'utf-8') + '\n<!-- local edit -->\n');
|
|
|
|
const result = applyInstall(planInstall(opts), opts);
|
|
expect(result.summary.skippedLocallyModified).toBeGreaterThan(0);
|
|
// Local edit preserved.
|
|
expect(readFileSync(localFile, 'utf-8')).toContain('local edit');
|
|
});
|
|
|
|
it('--overwrite-local replaces locally-modified files', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
applyInstall(planInstall(opts), opts);
|
|
const localFile = join(skillsDir, 'alpha', 'SKILL.md');
|
|
writeFileSync(localFile, 'local garbage');
|
|
|
|
const overwriteOpts = { ...opts, overwriteLocal: true };
|
|
const result = applyInstall(planInstall(overwriteOpts), overwriteOpts);
|
|
expect(result.summary.wroteOverwrite).toBeGreaterThan(0);
|
|
expect(readFileSync(localFile, 'utf-8')).not.toContain('local garbage');
|
|
});
|
|
|
|
it('D-CX-11: concurrent install attempt fails with lock_held', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
// Simulate a peer holding the lock.
|
|
writeFileSync(join(workspace, '.gbrain-skillpack.lock'), '99999');
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
const plan = planInstall(opts);
|
|
expect(() => applyInstall(plan, opts)).toThrow(InstallError);
|
|
});
|
|
|
|
it('D-CX-11: --force-unlock overrides a stale lock', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
// Stale lock is handled by setting lockStaleMs small and sleeping
|
|
// — simulate by writing the lock and passing lockStaleMs=0 so
|
|
// any age looks stale.
|
|
writeFileSync(join(workspace, '.gbrain-skillpack.lock'), '99999');
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
forceUnlock: true,
|
|
lockStaleMs: 0,
|
|
};
|
|
const plan = planInstall(opts);
|
|
const result = applyInstall(plan, opts);
|
|
expect(result.summary.wroteNew).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('D-CX-11: --force-unlock works when lock mtime is sub-ms ahead of Date.now (Linux fs jitter)', () => {
|
|
// Regression guard: on Linux ext4, statSync().mtimeMs has sub-ms precision
|
|
// while Date.now() is integer ms, so a just-written lock can report a
|
|
// negative age. If acquireLock does not clamp, stale=false and the
|
|
// forceUnlock path is unreachable. Simulate deterministically by pushing
|
|
// the lock's mtime 10ms into the future.
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const lockFile = join(workspace, '.gbrain-skillpack.lock');
|
|
writeFileSync(lockFile, '99999');
|
|
const future = (Date.now() + 10) / 1000;
|
|
utimesSync(lockFile, future, future);
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
forceUnlock: true,
|
|
lockStaleMs: 0,
|
|
};
|
|
const plan = planInstall(opts);
|
|
const result = applyInstall(plan, opts);
|
|
expect(result.summary.wroteNew).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('managed block is written atomically (tmp then rename)', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: null,
|
|
};
|
|
applyInstall(planInstall(opts), opts);
|
|
const resolver = readFileSync(join(skillsDir, 'RESOLVER.md'), 'utf-8');
|
|
expect(resolver).toContain('gbrain:skillpack:begin');
|
|
expect(resolver).toContain('gbrain:skillpack:end');
|
|
expect(resolver).toContain('`skills/alpha/SKILL.md`');
|
|
expect(resolver).toContain('`skills/beta/SKILL.md`');
|
|
});
|
|
|
|
it('managed block accumulates across separate single-skill installs', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
|
|
const alphaOpts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
applyInstall(planInstall(alphaOpts), alphaOpts);
|
|
|
|
const betaOpts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'beta',
|
|
};
|
|
applyInstall(planInstall(betaOpts), betaOpts);
|
|
|
|
const resolver = readFileSync(join(skillsDir, 'RESOLVER.md'), 'utf-8');
|
|
expect(resolver).toContain('`skills/alpha/SKILL.md`');
|
|
expect(resolver).toContain('`skills/beta/SKILL.md`');
|
|
});
|
|
|
|
it('works against AGENTS.md-at-workspace-root layout', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const workspace = mkdtempSync(join(tmpdir(), 'skillpack-root-agents-'));
|
|
created.push(workspace);
|
|
const skillsDir = join(workspace, 'skills');
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
// No RESOLVER.md in skills — AGENTS.md at workspace root instead.
|
|
writeFileSync(
|
|
join(workspace, 'AGENTS.md'),
|
|
'# AGENTS\n\n| Trigger | Skill |\n|---------|-------|\n',
|
|
);
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
const result = applyInstall(planInstall(opts), opts);
|
|
expect(result.managedBlock.applied).toBe(true);
|
|
const agents = readFileSync(join(workspace, 'AGENTS.md'), 'utf-8');
|
|
expect(agents).toContain('`skills/alpha/SKILL.md`');
|
|
});
|
|
});
|
|
|
|
describe('diffSkill', () => {
|
|
it('reports missing files', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { skillsDir } = scratchTarget();
|
|
const diffs = diffSkill(gbrainRoot, 'alpha', skillsDir);
|
|
expect(diffs.every(d => !d.existing)).toBe(true);
|
|
});
|
|
it('reports identical after install', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
applyInstall(planInstall(opts), opts);
|
|
const diffs = diffSkill(gbrainRoot, 'alpha', skillsDir);
|
|
expect(diffs.every(d => d.existing && d.identical)).toBe(true);
|
|
});
|
|
it('reports differs after local edit', () => {
|
|
const { gbrainRoot } = scratchGbrain();
|
|
const { workspace, skillsDir } = scratchTarget();
|
|
const opts = {
|
|
gbrainRoot,
|
|
targetWorkspace: workspace,
|
|
targetSkillsDir: skillsDir,
|
|
skillSlug: 'alpha',
|
|
};
|
|
applyInstall(planInstall(opts), opts);
|
|
writeFileSync(join(skillsDir, 'alpha', 'SKILL.md'), 'edited locally');
|
|
const diffs = diffSkill(gbrainRoot, 'alpha', skillsDir);
|
|
expect(diffs.some(d => !d.identical && d.existing)).toBe(true);
|
|
});
|
|
});
|