// @vitest-environment node import { execFile } from 'node:child_process'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; import { CodingProjectFileService } from '../../electron/coding-projects/project-files'; const exec = promisify(execFile); const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); async function temporaryRoot(prefix: string): Promise { const root = await mkdtemp(path.join(tmpdir(), prefix)); roots.push(root); return root; } async function git(root: string, ...args: string[]): Promise { await exec('git', ['-C', root, ...args], { windowsHide: true }); } async function gitOutput(root: string, ...args: string[]): Promise { return (await exec('git', ['-C', root, ...args], { windowsHide: true })).stdout.trim(); } async function initializeRepository(root: string): Promise { await git(root, 'init'); await git(root, 'config', 'user.email', 'pi-files@example.invalid'); await git(root, 'config', 'user.name', 'PI Files'); await mkdir(path.join(root, 'src'), { recursive: true }); await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "baseline";\n', 'utf8'); await writeFile(path.join(root, '.gitignore'), 'ignored.txt\n', 'utf8'); await git(root, 'add', '.'); await git(root, 'commit', '-m', 'baseline'); } describe('PI-105 project file service', () => { it('reports Git status and finds tracked or untracked files using project-relative DTOs', async () => { const root = await temporaryRoot('makelore-pi-files-git-'); await initializeRepository(root); await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "changed";\n', 'utf8'); await writeFile(path.join(root, 'src', 'app.test.ts'), 'test("changed", () => true);\n', 'utf8'); await writeFile(path.join(root, 'ignored.txt'), 'ignored\n', 'utf8'); const service = new CodingProjectFileService(); expect(await service.status(root)).toEqual([ expect.objectContaining({ path: 'src/app.ts', name: 'app.ts', status: 'modified' }), expect.objectContaining({ path: 'src/app.test.ts', name: 'app.test.ts', status: 'untracked' }), ]); const found = await service.find(root, 'app', 1); expect(found).toHaveLength(1); expect(found[0]).toMatchObject({ path: 'src/app.ts', name: 'app.ts', type: 'file' }); expect(JSON.stringify({ status: await service.status(root), found })).not.toContain(root); expect((await service.find(root, 'ignored')).map(({ path: filePath }) => filePath)).toEqual([]); }); it('returns bounded UTF-8 content and rejects absolute, traversal, and binary reads', async () => { const root = await temporaryRoot('makelore-pi-files-content-'); await writeFile(path.join(root, 'notes.txt'), 'hello project\n', 'utf8'); await writeFile(path.join(root, 'large.txt'), 'x'.repeat((256 * 1024) + 100), 'utf8'); await writeFile(path.join(root, 'large-unicode.txt'), '你'.repeat(90_000), 'utf8'); await writeFile(path.join(root, 'binary.bin'), Buffer.from([1, 0, 2])); const service = new CodingProjectFileService(); expect(await service.content(root, 'notes.txt')).toEqual({ path: 'notes.txt', content: 'hello project\n', truncated: false, }); const large = await service.content(root, 'large.txt'); expect(Buffer.byteLength(large.content, 'utf8')).toBe(256 * 1024); expect(large.truncated).toBe(true); const largeUnicode = await service.content(root, 'large-unicode.txt'); expect(Buffer.byteLength(largeUnicode.content, 'utf8')).toBeLessThanOrEqual(256 * 1024); expect(largeUnicode.content).toMatch(/^你+$/u); expect(largeUnicode.truncated).toBe(true); await expect(service.content(root, '../secret.txt')).rejects.toThrow('escapes the active project'); await expect(service.content(root, path.resolve(root, 'notes.txt'))).rejects.toThrow('must be relative'); await expect(service.content(root, 'binary.bin')).rejects.toThrow('Binary project files'); }); it('keeps reachable Git merge conflicts visible in file status', async () => { const root = await temporaryRoot('makelore-pi-files-conflict-'); await initializeRepository(root); const baseBranch = await gitOutput(root, 'branch', '--show-current'); await writeFile(path.join(root, 'conflict.txt'), 'baseline\n', 'utf8'); await git(root, 'add', 'conflict.txt'); await git(root, 'commit', '-m', 'conflict baseline'); await git(root, 'switch', '-c', 'conflicting-change'); await writeFile(path.join(root, 'conflict.txt'), 'branch change\n', 'utf8'); await git(root, 'commit', '-am', 'branch change'); await git(root, 'switch', baseBranch); await writeFile(path.join(root, 'conflict.txt'), 'base change\n', 'utf8'); await git(root, 'commit', '-am', 'base change'); await expect(exec('git', ['-C', root, 'merge', 'conflicting-change'], { windowsHide: true })) .rejects.toThrow(); expect(await new CodingProjectFileService().status(root)).toContainEqual({ path: 'conflict.txt', name: 'conflict.txt', type: 'file', status: 'conflicted', }); }); it('searches literal text with bounded relative matches and falls back outside Git', async () => { const root = await temporaryRoot('makelore-pi-files-search-'); await mkdir(path.join(root, 'docs'), { recursive: true }); await mkdir(path.join(root, 'node_modules', 'private-package'), { recursive: true }); await writeFile(path.join(root, 'docs', 'guide.md'), 'First line\nNeedle and needle again\n', 'utf8'); await writeFile(path.join(root, 'docs', 'unicode.md'), 'İx needle and NEEDLE\n', 'utf8'); await writeFile(path.join(root, 'node_modules', 'private-package', 'secret.txt'), 'needle\n', 'utf8'); const service = new CodingProjectFileService({ async run() { throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }); }, }); expect((await service.find(root, 'guide')).map(({ path: filePath }) => filePath)).toEqual([ 'docs/guide.md', ]); const matches = await service.search(root, 'needle'); expect(matches).toContainEqual({ path: 'docs/guide.md', name: 'guide.md', lineNumber: 2, lineText: 'Needle and needle again', submatches: [ { text: 'Needle', start: 0, end: 6 }, { text: 'needle', start: 11, end: 17 }, ], }); expect(JSON.stringify(matches)).not.toContain(root); expect(JSON.stringify(matches)).not.toContain('private-package'); expect(await service.search(root, 'i')).toContainEqual(expect.objectContaining({ path: 'docs/unicode.md', submatches: [{ text: 'İ', start: 0, end: 1 }], })); expect(await service.search(root, 'needle')).toContainEqual(expect.objectContaining({ path: 'docs/unicode.md', submatches: [ { text: 'needle', start: 3, end: 9 }, { text: 'NEEDLE', start: 14, end: 20 }, ], })); }); });