mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
// Unit tests for gstack-upgrade/migrations/v1.38.1.0.sh (#1452).
|
|
// Verifies idempotent in-place repair of .brain-allowlist,
|
|
// .brain-privacy-map.json, and .gitattributes.
|
|
|
|
import { describe, expect, test, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
|
|
const REPO_ROOT = new URL('..', import.meta.url).pathname;
|
|
const MIGRATION = join(REPO_ROOT, 'gstack-upgrade', 'migrations', 'v1.38.1.0.sh');
|
|
|
|
function setupFakeHome(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'mig-v1340-'));
|
|
mkdirSync(join(dir, '.gstack'), { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function runMigration(fakeHome: string): { code: number; stdout: string; stderr: string } {
|
|
const proc = Bun.spawnSync({
|
|
cmd: ['bash', MIGRATION],
|
|
env: { ...process.env, HOME: fakeHome },
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
code: proc.exitCode ?? -1,
|
|
stdout: new TextDecoder().decode(proc.stdout),
|
|
stderr: new TextDecoder().decode(proc.stderr),
|
|
};
|
|
}
|
|
|
|
describe('v1.38.1.0 migration', () => {
|
|
test('adds patterns to allowlist before USER ADDITIONS marker', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
|
'projects/*/learnings.jsonl',
|
|
'projects/*/designs/*.md',
|
|
'# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)',
|
|
'projects/*/my-custom.txt',
|
|
].join('\n') + '\n');
|
|
|
|
const r = runMigration(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const content = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
|
expect(content).toContain('projects/*/*-design-*.md');
|
|
expect(content).toContain('projects/*/*-test-plan-*.md');
|
|
// New patterns above the user marker
|
|
const designIdx = content.indexOf('projects/*/*-design-*.md');
|
|
const markerIdx = content.indexOf('# ---- USER ADDITIONS BELOW');
|
|
expect(designIdx).toBeLessThan(markerIdx);
|
|
// User customizations below the marker preserved
|
|
expect(content).toContain('projects/*/my-custom.txt');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('adds entries to privacy-map.json via jq (preserves JSON validity)', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
|
{ pattern: 'projects/*/learnings.jsonl', class: 'artifact' },
|
|
{ pattern: 'projects/*/designs/*.md', class: 'artifact' },
|
|
], null, 2));
|
|
|
|
const r = runMigration(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const raw = readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8');
|
|
// Valid JSON (would throw if jq emitted malformed output)
|
|
const parsed = JSON.parse(raw);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
const patterns = parsed.map((e: any) => e.pattern);
|
|
expect(patterns).toContain('projects/*/*-design-*.md');
|
|
expect(patterns).toContain('projects/*/*-test-plan-*.md');
|
|
// Class preserved on new entries
|
|
expect(parsed.find((e: any) => e.pattern === 'projects/*/*-design-*.md').class).toBe('artifact');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('adds union-merge rules to gitattributes', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.gitattributes'), [
|
|
'*.jsonl merge=jsonl-append',
|
|
'projects/*/designs/**/*.md merge=union',
|
|
].join('\n') + '\n');
|
|
|
|
const r = runMigration(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const content = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
|
expect(content).toContain('projects/*/*-design-*.md merge=union');
|
|
expect(content).toContain('projects/*/*-test-plan-*.md merge=union');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('is idempotent: re-running on already-patched files is a no-op', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
|
'projects/*/learnings.jsonl',
|
|
'# ---- USER ADDITIONS BELOW',
|
|
].join('\n') + '\n');
|
|
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
|
{ pattern: 'projects/*/learnings.jsonl', class: 'artifact' },
|
|
]));
|
|
writeFileSync(join(home, '.gstack', '.gitattributes'), '*.jsonl merge=jsonl-append\n');
|
|
|
|
runMigration(home);
|
|
// Remove the done marker so re-run actually executes
|
|
rmSync(join(home, '.gstack', '.migrations'), { recursive: true, force: true });
|
|
|
|
const beforeAllowlist = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
|
const beforePrivacy = readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8');
|
|
const beforeAttrs = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
|
|
|
runMigration(home);
|
|
|
|
const afterAllowlist = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
|
const afterPrivacy = readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8');
|
|
const afterAttrs = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
|
|
|
expect(afterAllowlist).toBe(beforeAllowlist);
|
|
// jq may re-emit JSON with different whitespace but the parsed content
|
|
// must be identical
|
|
expect(JSON.parse(afterPrivacy)).toEqual(JSON.parse(beforePrivacy));
|
|
expect(afterAttrs).toBe(beforeAttrs);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('repairs privacy-map even when allowlist is missing (per-file independence)', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
// No .brain-allowlist; only privacy-map present
|
|
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
|
{ pattern: 'projects/*/learnings.jsonl', class: 'artifact' },
|
|
]));
|
|
|
|
const r = runMigration(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
// Privacy-map still patched
|
|
const parsed = JSON.parse(readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8'));
|
|
const patterns = parsed.map((e: any) => e.pattern);
|
|
expect(patterns).toContain('projects/*/*-design-*.md');
|
|
// Allowlist remains absent (we don't create files that weren't there)
|
|
expect(existsSync(join(home, '.gstack', '.brain-allowlist'))).toBe(false);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('migration marker prevents re-running', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'), '# ---- USER ADDITIONS BELOW\n');
|
|
runMigration(home);
|
|
// Confirm marker file exists
|
|
expect(existsSync(join(home, '.gstack', '.migrations', 'v1.38.1.0.done'))).toBe(true);
|
|
|
|
// Modify allowlist so we can detect if the migration would re-run
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'), '# minimal\n');
|
|
|
|
runMigration(home);
|
|
|
|
// With the marker present, the migration short-circuits, so the file
|
|
// we just wrote stays unmodified
|
|
expect(readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8')).toBe('# minimal\n');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('handles allowlist without USER ADDITIONS marker (fallback to append)', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
|
'projects/*/learnings.jsonl',
|
|
'projects/*/designs/*.md',
|
|
// no USER ADDITIONS marker
|
|
].join('\n') + '\n');
|
|
|
|
const r = runMigration(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const content = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
|
expect(content).toContain('projects/*/*-design-*.md');
|
|
expect(content).toContain('projects/*/*-test-plan-*.md');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// v1.40.0.0 — `projects/*/*-eng-review-test-plan-*.md` follow-on for #1452.
|
|
// v1.38.1.0 shipped the design + test-plan patterns but missed
|
|
// /plan-eng-review's filename. Codex review #5 flagged that
|
|
// v1.38.1.0's done-marker prevents users who already upgraded from picking
|
|
// up #1465's allowlist edit, so v1.40.0.0 needs its own migration.
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
const MIGRATION_V1_40 = join(REPO_ROOT, 'gstack-upgrade', 'migrations', 'v1.40.0.0.sh');
|
|
|
|
function runMigrationV140(fakeHome: string): { code: number; stdout: string; stderr: string } {
|
|
const proc = Bun.spawnSync({
|
|
cmd: ['bash', MIGRATION_V1_40],
|
|
env: { ...process.env, HOME: fakeHome },
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
code: proc.exitCode ?? -1,
|
|
stdout: new TextDecoder().decode(proc.stdout),
|
|
stderr: new TextDecoder().decode(proc.stderr),
|
|
};
|
|
}
|
|
|
|
describe('v1.40.0.0 migration', () => {
|
|
test('adds eng-review-test-plan pattern to allowlist on top of an installed v1.38.1.0 state', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
// Simulate post-v1.38.1.0 state: design + test-plan patterns present,
|
|
// done-marker set so the v1.38.1.0 migration wouldn't re-run.
|
|
mkdirSync(join(home, '.gstack', '.migrations'), { recursive: true });
|
|
writeFileSync(join(home, '.gstack', '.migrations', 'v1.38.1.0.done'), '');
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'), [
|
|
'projects/*/learnings.jsonl',
|
|
'projects/*/designs/*.md',
|
|
'projects/*/*-design-*.md',
|
|
'projects/*/*-test-plan-*.md',
|
|
'# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)',
|
|
'projects/*/my-custom.txt',
|
|
].join('\n') + '\n');
|
|
|
|
const r = runMigrationV140(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const content = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
|
expect(content).toContain('projects/*/*-eng-review-test-plan-*.md');
|
|
// New pattern above the user marker.
|
|
const engRevIdx = content.indexOf('projects/*/*-eng-review-test-plan-*.md');
|
|
const markerIdx = content.indexOf('# ---- USER ADDITIONS BELOW');
|
|
expect(engRevIdx).toBeLessThan(markerIdx);
|
|
// User customizations below the marker preserved.
|
|
expect(content).toContain('projects/*/my-custom.txt');
|
|
// v1.40.0.0 done-marker created.
|
|
expect(existsSync(join(home, '.gstack', '.migrations', 'v1.40.0.0.done'))).toBe(true);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('adds eng-review-test-plan entry to privacy-map.json via jq', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-privacy-map.json'), JSON.stringify([
|
|
{ pattern: 'projects/*/*-design-*.md', class: 'artifact' },
|
|
{ pattern: 'projects/*/*-test-plan-*.md', class: 'artifact' },
|
|
], null, 2));
|
|
|
|
const r = runMigrationV140(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const parsed = JSON.parse(readFileSync(join(home, '.gstack', '.brain-privacy-map.json'), 'utf-8'));
|
|
const patterns = parsed.map((e: any) => e.pattern);
|
|
expect(patterns).toContain('projects/*/*-eng-review-test-plan-*.md');
|
|
expect(parsed.find((e: any) => e.pattern === 'projects/*/*-eng-review-test-plan-*.md').class).toBe('artifact');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('adds union-merge rule to gitattributes', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.gitattributes'), [
|
|
'projects/*/*-design-*.md merge=union',
|
|
'projects/*/*-test-plan-*.md merge=union',
|
|
].join('\n') + '\n');
|
|
|
|
const r = runMigrationV140(home);
|
|
expect(r.code).toBe(0);
|
|
|
|
const content = readFileSync(join(home, '.gstack', '.gitattributes'), 'utf-8');
|
|
expect(content).toContain('projects/*/*-eng-review-test-plan-*.md merge=union');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('is idempotent: re-running is a no-op', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
writeFileSync(join(home, '.gstack', '.brain-allowlist'),
|
|
'projects/*/*-eng-review-test-plan-*.md\n# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)\n');
|
|
|
|
const r1 = runMigrationV140(home);
|
|
expect(r1.code).toBe(0);
|
|
|
|
const r2 = runMigrationV140(home);
|
|
expect(r2.code).toBe(0);
|
|
|
|
const content = readFileSync(join(home, '.gstack', '.brain-allowlist'), 'utf-8');
|
|
const occurrences = content.match(/projects\/\*\/\*-eng-review-test-plan-\*\.md/g) || [];
|
|
expect(occurrences.length).toBe(1);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writes done-marker even when files are missing', () => {
|
|
const home = setupFakeHome();
|
|
try {
|
|
// No allowlist / privacy-map / gitattributes — fresh-init users with
|
|
// no federated artifacts yet. Migration should still mark itself done.
|
|
const r = runMigrationV140(home);
|
|
expect(r.code).toBe(0);
|
|
expect(existsSync(join(home, '.gstack', '.migrations', 'v1.40.0.0.done'))).toBe(true);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|