Files
makelore/tests/unit/pi-product-tools.test.ts

258 lines
12 KiB
TypeScript

// @vitest-environment node
import { execFile } from 'node:child_process';
import { mkdtemp, mkdir, rm, utimes, 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 type { AgentBrowserModule } from '../../electron/agent-browser';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import {
ConversationChangeTracker,
type ConversationGitAdapter,
} from '../../electron/coding-projects/conversation-change-tracker';
import {
buildProductCodingCommandCatalog,
listProductCodingSkills,
} from '../../electron/coding-projects/skill-registry';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol';
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-tools@example.invalid');
await git(root, 'config', 'user.name', 'PI Tools');
await writeFile(path.join(root, 'existing.txt'), 'baseline\n', 'utf8');
await mkdir(path.join(root, 'src'), { recursive: true });
await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 1;\n', 'utf8');
await git(root, 'add', '.');
await git(root, 'commit', '-m', 'baseline');
}
describe('PI-090 product tools', () => {
it('tracks touched paths precisely and performs a project refresh after bash', async () => {
const root = await temporaryRoot('makelore-pi-changes-');
await initializeRepository(root);
await writeFile(path.join(root, 'existing.txt'), 'pre-existing dirty\n', 'utf8');
await writeFile(path.join(root, 'pre-existing-untracked.txt'), 'remove during run\n', 'utf8');
const tracker = new ConversationChangeTracker();
const started = await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
});
expect(started.git).toBe(true);
expect(started.baselineHead).toMatch(/^[a-f0-9]{40}$/);
await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 2;\n', 'utf8');
const precise = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['src/app.ts']);
expect(precise.files.map((file) => file.path)).toEqual(['src/app.ts']);
expect(precise.files[0]).toMatchObject({ status: 'modified' });
expect(precise.files[0]?.diff).toContain('value = 2');
await writeFile(path.join(root, 'bash-created.txt'), 'created by command\n', 'utf8');
await rm(path.join(root, 'pre-existing-untracked.txt'));
await tracker.markProjectRefresh('conversation-a', 'run-a');
const settled = await tracker.settleRun('conversation-a', 'run-a');
expect(settled?.files.map((file) => file.path)).toEqual([
'bash-created.txt', 'pre-existing-untracked.txt', 'src/app.ts',
]);
expect(settled?.files.find((file) => file.path === 'bash-created.txt')).toMatchObject({
status: 'untracked', preview: 'created by command\n',
});
expect(settled?.files.find((file) => file.path === 'pre-existing-untracked.txt')).toMatchObject({
status: 'deleted',
});
expect(JSON.stringify(settled)).not.toContain(root);
expect(settled?.files.some((file) => file.path === 'existing.txt')).toBe(false);
});
it('bounds untracked reads and detects equal-length changes beyond the preview', async () => {
const root = await temporaryRoot('makelore-pi-bounded-preview-');
await initializeRepository(root);
const target = path.join(root, 'large.txt');
await writeFile(target, 'a'.repeat(9 * 1024), 'utf8');
const tracker = new ConversationChangeTracker();
await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
});
await writeFile(target, `${'a'.repeat((9 * 1024) - 1)}b`, 'utf8');
const changedAt = new Date(Date.now() + 5_000);
await utimes(target, changedAt, changedAt);
const snapshot = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['large.txt']);
const file = snapshot?.files.find(({ path: filePath }) => filePath === 'large.txt');
expect(file).toMatchObject({ status: 'untracked', truncated: true });
expect(Buffer.byteLength(file?.preview ?? '', 'utf8')).toBeLessThanOrEqual(8 * 1024);
expect(JSON.stringify(snapshot)).not.toContain(root);
});
it('captures a dirty baseline without per-file Git diffs', async () => {
const root = await temporaryRoot('makelore-pi-dirty-baseline-');
const calls: string[][] = [];
const status = Array.from({ length: 100 }, (_, index) => (
`1 .M N... 100644 100644 100644 abc abc dirty-${index}.txt\0`
)).join('');
const adapter: ConversationGitAdapter = {
async run(_projectPath, args) {
calls.push([...args]);
if (args[0] === 'rev-parse' && args[1] === '--is-inside-work-tree') {
return { code: 0, stdout: 'true\n' };
}
if (args[0] === 'rev-parse') return { code: 0, stdout: `${'a'.repeat(40)}\n` };
if (args[0] === 'status') return { code: 0, stdout: status };
throw new Error(`Unexpected Git call: ${args.join(' ')}`);
},
};
const tracker = new ConversationChangeTracker(adapter);
expect((await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
})).git).toBe(true);
expect(calls).toHaveLength(3);
expect(calls.some(([command]) => command === 'diff')).toBe(false);
});
it('degrades to no-Git tracking when the Git executable is unavailable', async () => {
const root = await temporaryRoot('makelore-pi-git-unavailable-');
await writeFile(path.join(root, 'notes.txt'), 'local notes\n', 'utf8');
const tracker = new ConversationChangeTracker({
async run() {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
},
});
expect((await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
})).git).toBe(false);
expect((await tracker.recordTouchedPaths(
'conversation-a', 'run-a', ['notes.txt'],
)).files).toEqual([expect.objectContaining({ path: 'notes.txt', preview: 'local notes\n' })]);
});
it('supports no-git projects and rejects paths outside the project', async () => {
const root = await temporaryRoot('makelore-pi-no-git-');
await writeFile(path.join(root, 'notes.txt'), 'local notes\n', 'utf8');
const tracker = new ConversationChangeTracker();
expect((await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
})).git).toBe(false);
const snapshot = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['notes.txt']);
expect(snapshot.files).toEqual([expect.objectContaining({
path: 'notes.txt', status: 'modified', preview: 'local notes\n',
})]);
await expect(tracker.recordTouchedPaths(
'conversation-a', 'run-a', ['../secret.txt'],
)).rejects.toThrow('escapes the project');
await expect(tracker.recordTouchedPaths(
'conversation-a', 'run-a', [path.resolve(root, 'notes.txt')],
)).rejects.toThrow('project-relative');
});
it('projects only bundled selected skills and safe command metadata', async () => {
const skills = await listProductCodingSkills(
path.resolve('resources/coding-skills'),
['agent-browser', 'grilling'],
);
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
'agent-browser', 'grilling',
]);
expect(JSON.stringify(skills)).not.toContain(path.resolve('resources/coding-skills'));
const commands = buildProductCodingCommandCatalog(skills, [
{ name: 'custom', description: 'Custom Pi command' },
{ name: 'compact', description: 'Must not shadow Makelore' },
]);
expect(commands).toContainEqual(expect.objectContaining({ name: 'compact', source: 'makelore' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
expect(commands.some(({ name }) => name === 'planning-with-files')).toBe(false);
await expect(listProductCodingSkills(
path.resolve('resources/coding-skills'), ['not-installed'],
)).rejects.toThrow('Unknown bundled coding skill');
});
it('accepts only safe versioned product detail projections', () => {
expect(productToolDetails({
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/project.json'],
})).toEqual({
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/project.json'],
});
expect(productToolDetails({
schema: 'changed-file.v1', paths: ['C:\\private\\secret.txt'],
})).toBeNull();
expect(productToolDetails({
schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'attachment-a',
})).toBeNull();
expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull();
});
it('stores browser screenshots as attachment ids and never returns base64', async () => {
const root = await temporaryRoot('makelore-pi-browser-tool-');
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'), {
createId: () => 'attachment-a',
});
const calls: unknown[] = [];
const browser = {
async sendCdp(input: unknown) {
calls.push(input);
return { kind: 'inline', value: { data: Buffer.from('png-data').toString('base64') } };
},
} as unknown as AgentBrowserModule;
const tools = new PiProductTools({
browser,
attachments,
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
const result = await tools.execute('agent_browser', {
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
projectId: 'project-a', projectPath: root, skillIds: ['agent-browser'],
}, { action: 'send_cdp', method: 'Page.captureScreenshot', params: { format: 'png' } });
expect(calls).toHaveLength(1);
expect(result).toMatchObject({
details: {
schema: 'agent-browser.v1', action: 'send_cdp',
attachmentId: 'attachment-a', mime: 'image/png',
},
});
expect(JSON.stringify(result)).not.toContain(Buffer.from('png-data').toString('base64'));
expect((await attachments.read('attachment-a')).data.toString()).toBe('png-data');
});
it('loads game asset review state through the vendor-neutral product module', async () => {
const root = await temporaryRoot('makelore-pi-game-tool-');
await writeFile(path.join(root, 'ASSET_PLAN.md'), [
'```json',
JSON.stringify({ assets: [{ id: 'hero', name: 'Hero', category: 'visual', status: 'candidate' }] }),
'```',
].join('\n'), 'utf8');
const tools = new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
const result = await tools.execute('game_asset_browser', {
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'review-a',
projectId: 'project-a', projectPath: root, skillIds: [],
}, {});
expect(result.details).toEqual({
schema: 'game-assets.v1', invocationId: 'review-a', candidateIds: ['hero'],
status: 'pending', pendingAssetIds: ['hero'], approvedAssetIds: [], discardedAssetIds: [],
});
expect(JSON.stringify(result)).not.toContain(root);
expect(JSON.stringify(result)).not.toContain('data:');
});
});