/** * pathResolver 单测 */ const fs = require('fs'); const path = require('path'); const os = require('os'); const pathResolver = require('../../services/devloop/path-resolver'); // 用 tmpdir 隔离测试 const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-')); process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp describe('pathResolver', () => { beforeEach(() => { pathResolver._resetForTesting(); }); describe('isSensitivePath', () => { it('rejects /etc', () => { expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); }); it('rejects /usr/local/bin', () => { expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true); }); it('rejects /etc/passwd absolute', () => { expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); }); it('rejects /var/log/something', () => { expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true); }); it('accepts ~/code/project', () => { expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false); }); it('accepts /tmp/anything', () => { expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false); }); }); describe('allocateProjectRoot · sandbox', () => { it('creates root under data/companies//projects/', () => { const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/); expect(r.mode).toBe('sandbox'); expect(r.root_path).toContain('data/companies/c-test/projects/'); expect(fs.existsSync(r.root_path)).toBe(true); // cleanup fs.rmSync(r.root_path, { recursive: true, force: true }); }); }); describe('allocateProjectRoot · linked-new', () => { it('rejects when target_path missing', () => { expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' }) ).toThrow(/target_path required/); }); it('rejects relative target_path', () => { expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path', }) ).toThrow(/must be absolute/); }); it('rejects sensitive path', () => { expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo', }) ).toThrow(/sensitive_path/); }); it('rejects when parent does not exist', () => { expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new', target_path: '/tmp/devloop-test-nonexistent-parent/proj', }) ).toThrow(/parent dir does not exist/); }); it('rejects when target_path is non-empty dir', () => { const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); fs.writeFileSync(path.join(target, 'existing.txt'), 'x'); expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new', target_path: target, }) ).toThrow(/not empty/); fs.rmSync(target, { recursive: true, force: true }); }); it('accepts empty existing dir', () => { const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new', target_path: target, }); expect(r.mode).toBe('linked-new'); expect(r.root_path).toBe(target); // 检查 linked-registry const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8')); expect(reg[r.pid]).toBeDefined(); expect(reg[r.pid].mode).toBe('linked-new'); fs.rmSync(target, { recursive: true, force: true }); pathResolver.unregisterLinkedProject(r.pid); }); }); describe('allocateProjectRoot · linked-existing', () => { it('rejects nonexistent target_path', () => { const fake = path.join(os.tmpdir(), 'devloop-le-nope-' + Math.random().toString(36).slice(2,8)); expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-existing', target_path: fake, }) ).toThrow(/does not exist/); }); it('rejects target_path that already contains a DevLoop project.json', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dup-')); fs.writeFileSync(path.join(dir, 'project.json'), '{}'); expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-existing', target_path: dir, }) ).toThrow(/already contains/); }); it('accepts existing non-empty plain dir (no .git)', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-plain-')); fs.writeFileSync(path.join(dir, 'README.md'), '# user'); const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-existing', target_path: dir, }); expect(r.pid).toMatch(/^p-/); expect(r.root_path).toBe(dir); expect(r.mode).toBe('linked-existing'); pathResolver.unregisterLinkedProject(r.pid); }); it('rejects existing git repo with dirty worktree', () => { const { execFileSync } = require('child_process'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dirty-')); execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); execFileSync('git', ['add', '-A'], { cwd: dir }); execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); // dirty: untracked fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); expect(() => pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-existing', target_path: dir, }) ).toThrow(/uncommitted changes/); }); }); describe('resolveProjectRoot + 派生路径', () => { it('throws on unknown pid', () => { expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); }); it('after allocate, all resolvers work', () => { const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path); expect(pathResolver.resolveUnitDir(r.pid, 'auth-be')) .toBe(path.join(r.root_path, 'units', 'auth-be')); expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd')); expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd')); expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson')); expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson')); expect(pathResolver.resolveAgentState(r.pid, 'pm')) .toBe(path.join(r.root_path, 'agent-state', 'pm.json')); fs.rmSync(r.root_path, { recursive: true, force: true }); }); }); describe('memory dir 在 company 级', () => { it('does not depend on pid', () => { const m1 = pathResolver.resolveMemoryDir('c-test', 'pm'); expect(m1).toContain('companies/c-test/memory/devloop/pm'); expect(m1).not.toContain('projects/'); }); }); });