feat(coding): add PI-105 product file host API
This commit is contained in:
111
tests/unit/coding-project-files.test.ts
Normal file
111
tests/unit/coding-project-files.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// @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<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function git(root: string, ...args: string[]): Promise<void> {
|
||||
await exec('git', ['-C', root, ...args], { windowsHide: true });
|
||||
}
|
||||
|
||||
async function initializeRepository(root: string): Promise<void> {
|
||||
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('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, '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).toEqual([{
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user