150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
// @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 gitOutput(root: string, ...args: string[]): Promise<string> {
|
|
return (await exec('git', ['-C', root, ...args], { windowsHide: true })).stdout.trim();
|
|
}
|
|
|
|
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('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 },
|
|
],
|
|
}));
|
|
});
|
|
});
|