/** * lib/fs — assertWebProjectRoot guard tests. * * Reproduces the ba-develop incident: a frontend scaffold received the REPO * ROOT as projectPath, wrote into the backend's src/, and a subagent then * `rm -rf`'d the colliding src/. The guard must REFUSE a .NET/backend/repo root * BEFORE any write, so that collision can never happen. Every frontend scaffold * (scaffold-component/api-client/routes/layout/theme/ui-primitives) calls this * before its write loop. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { assertWebProjectRoot, FileSystemError } from '../fs.js'; let root: string; beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'assert-web-root-')); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); function dir(...segs: string[]): string { const p = path.join(root, ...segs); mkdirSync(p, { recursive: true }); return p; } function file(rel: string, content = ''): void { const p = path.join(root, rel); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, content, 'utf-8'); } describe('assertWebProjectRoot', () => { it('accepts a real React web app root (package.json, no .sln, no backend src)', () => { const web = dir('web', 'test-web'); writeFileSync(path.join(web, 'package.json'), '{"name":"test-web"}', 'utf-8'); mkdirSync(path.join(web, 'src', 'pages'), { recursive: true }); expect(() => assertWebProjectRoot(web)).not.toThrow(); }); it('REJECTS a .NET solution / repo root (has a .sln) even with a root package.json', () => { file('Test.sln', ''); file('package.json', '{}'); expect(() => assertWebProjectRoot(root)).toThrow(FileSystemError); expect(() => assertWebProjectRoot(root)).toThrow(/\.sln|repo root/i); }); it('REJECTS a backend root whose src/ holds .NET projects (the exact incident shape)', () => { dir('src', 'Test.Domain'); dir('src', 'Test.Application'); dir('src', 'Test.Infrastructure'); dir('src', 'Test.Api'); file('package.json', '{}'); // present at root, but src/ is the backend → still refused const web = dir('web', 'test-web'); writeFileSync(path.join(web, 'package.json'), '{"name":"test-web"}', 'utf-8'); expect(() => assertWebProjectRoot(root)).toThrow(FileSystemError); expect(() => assertWebProjectRoot(root)).toThrow(/backend|Test\.(Domain|Api)/i); }); it('REJECTS a backend root whose src/ holds a flat .csproj', () => { file('src/Test.Api.csproj', ''); file('package.json', '{}'); expect(() => assertWebProjectRoot(root)).toThrow(/backend|\.csproj/i); }); it('accepts the CORRECT web app subdir even when the repo root is a backend', () => { // The fix: ba-develop must pass web/test-web (this), not the repo root. dir('src', 'Test.Domain'); file('Test.sln', ''); const web = dir('web', 'test-web'); writeFileSync(path.join(web, 'package.json'), '{"name":"test-web"}', 'utf-8'); mkdirSync(path.join(web, 'src'), { recursive: true }); expect(() => assertWebProjectRoot(web)).not.toThrow(); }); it('REJECTS a directory with no package.json (not a web root)', () => { const empty = dir('some-empty-dir'); expect(() => assertWebProjectRoot(empty)).toThrow(/package\.json/i); }); it('REJECTS a non-existent path', () => { expect(() => assertWebProjectRoot(path.join(root, 'does-not-exist'))).toThrow(/does not exist|unreadable/i); }); });