Files
makelore/tests/unit/device-package-manager.test.ts

249 lines
11 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DevicePackageError,
DevicePackageManager,
type DevicePackageInstallInput,
} from '../../electron/coding-packages/device-package-manager';
const roots: string[] = [];
const NOW = Date.parse('2026-09-02T12:00:00.000Z');
async function temporaryRoot(name: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), `makelore-device-package-${name}-`));
roots.push(root);
return root;
}
async function writeJson(filePath: string, value: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
async function looseSkill(root: string, id = 'fixture-skill'): Promise<string> {
const source = path.join(root, id);
await mkdir(source, { recursive: true });
await writeFile(path.join(source, 'SKILL.md'), `---\nname: ${id}\ndescription: Fixture\n---\n`, 'utf8');
return source;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('DevicePackageManager', () => {
it('previews and commits a loose Skill only after a distinct confirmation turn', async () => {
const root = await temporaryRoot('loose-skill');
const source = await looseSkill(root);
const changed = vi.fn();
const manager = new DevicePackageManager({
rootDir: path.join(root, 'store'),
now: () => NOW,
createId: () => 'plan-loose',
onGenerationChanged: changed,
});
const preview = await manager.prepare(source, 'turn-prepare');
expect(preview).toMatchObject({
schemaVersion: 1,
planId: 'plan-loose',
packageId: 'fixture-skill',
displayName: 'fixture-skill',
kind: 'skill-only',
includesExecutableCode: false,
scope: 'device-parent-workers',
skillEntries: [{ id: 'fixture-skill', entryPath: 'SKILL.md' }],
extensionEntries: [],
});
await expect(manager.commit(preview.planId, true, 'turn-prepare'))
.rejects.toMatchObject({ code: 'local_package_confirmation_required' });
await expect(manager.commit(preview.planId, false as never, 'turn-confirm'))
.rejects.toMatchObject({ code: 'local_package_confirmation_required' });
const installed = await manager.commit(preview.planId, true, 'turn-confirm');
expect(installed.generation).toBe(1);
expect(installed.packages).toEqual([expect.objectContaining({
packageId: 'fixture-skill', enabled: true, confirmedExecutableCode: false,
})]);
const resources = await manager.resolveEnabledResources();
expect(resources).toMatchObject({
generation: 1,
packageIds: ['fixture-skill'],
extensionPaths: [],
});
expect(resources.skillEntries).toEqual([expect.objectContaining({
id: 'fixture-skill',
entryPath: 'SKILL.md',
})]);
const installedSkill = resources.skillEntries[0]!;
expect(await readFile(path.join(installedSkill.packageRoot, installedSkill.entryPath), 'utf8'))
.toContain('name: fixture-skill');
expect(changed).toHaveBeenCalledTimes(1);
});
it('discloses executable Skill scripts and preserves their bundled resources', async () => {
const root = await temporaryRoot('script-skill');
const source = await looseSkill(root, 'script-skill');
await mkdir(path.join(source, 'scripts'), { recursive: true });
await mkdir(path.join(source, 'assets'), { recursive: true });
await mkdir(path.join(source, 'references'), { recursive: true });
await writeFile(path.join(source, 'scripts/scaffold.mjs'), 'console.log("scaffold");\n');
await writeFile(path.join(source, 'assets/template.txt'), 'template\n');
await writeFile(path.join(source, 'references/submission.md'), 'requirements\n');
const manager = new DevicePackageManager({
rootDir: path.join(root, 'store'),
now: () => NOW,
createId: () => 'plan-script-skill',
});
const preview = await manager.prepare(source, 'turn-prepare');
expect(preview).toMatchObject({
kind: 'skill-only',
includesExecutableCode: true,
extensionEntries: [],
});
expect(preview.warnings.join(' ')).toContain('Skill 脚本');
expect(preview.warnings.join(' ')).toContain('文件、网络和进程权限');
const installed = await manager.commit(preview.planId, true, 'turn-confirm');
expect(installed.packages[0]).toMatchObject({ confirmedExecutableCode: true });
const [resource] = (await manager.resolveEnabledResources()).skillEntries;
expect(await readFile(path.join(resource!.packageRoot, 'scripts/scaffold.mjs'), 'utf8'))
.toContain('scaffold');
expect(await readFile(path.join(resource!.packageRoot, 'assets/template.txt'), 'utf8'))
.toBe('template\n');
expect(await readFile(path.join(resource!.packageRoot, 'references/submission.md'), 'utf8'))
.toBe('requirements\n');
});
it('recognizes Pi extension and mixed package manifests and shows the desktop-permission warning', async () => {
const root = await temporaryRoot('pi-manifests');
const source = path.join(root, 'mixed-package');
await writeJson(path.join(source, 'package.json'), {
name: '@example/mixed-package',
version: '1.2.3',
pi: { extensions: ['./extensions/index.ts'], skills: ['./skills/research/SKILL.md'] },
});
await mkdir(path.join(source, 'extensions'), { recursive: true });
await writeFile(path.join(source, 'extensions/index.ts'), 'export default function fixture() {}\n');
await mkdir(path.join(source, 'skills/research'), { recursive: true });
await writeFile(path.join(source, 'skills/research/SKILL.md'), '---\nname: research\n---\n');
const manager = new DevicePackageManager({
rootDir: path.join(root, 'store'),
now: () => NOW,
createId: () => 'plan-mixed',
});
const preview = await manager.prepare(source, 'turn-a');
expect(preview).toMatchObject({
packageId: 'example.mixed-package',
displayName: '@example/mixed-package',
resolvedVersion: '1.2.3',
kind: 'mixed',
includesExecutableCode: true,
extensionEntries: ['extensions/index.ts'],
skillEntries: [{ id: 'research', entryPath: 'skills/research/SKILL.md' }],
});
expect(preview.warnings.join(' ')).toContain('完整桌面权限');
expect(preview.warnings.join(' ')).toContain('网络');
});
it('uses the bundled Pi runtime for remote installation when the app Pi graph is unavailable', async () => {
const root = await temporaryRoot('remote');
const runtimeRoot = path.join(root, 'pi-runtime');
const cliPath = path.join(runtimeRoot, 'dist', 'cli.js');
await writeJson(path.join(runtimeRoot, 'package.json'), { type: 'module' });
await mkdir(path.dirname(cliPath), { recursive: true });
await writeFile(path.join(path.dirname(cliPath), 'index.js'), [
"import path from 'node:path';",
'export class SettingsManager { static inMemory() { return {}; } }',
'export class DefaultPackageManager {',
' constructor(options) { this.agentDir = options.agentDir; }',
" getInstalledPath() { return path.join(this.agentDir, 'npm', 'node_modules', 'fixture-installed'); }",
'}',
].join('\n'), 'utf8');
const calls: DevicePackageInstallInput[] = [];
const runInstall = vi.fn(async (input: DevicePackageInstallInput) => {
calls.push(input);
const packageRoot = path.join(input.agentDir, 'npm', 'node_modules', 'fixture-installed');
await writeJson(path.join(packageRoot, 'package.json'), {
name: 'pi-web-search',
version: '2.4.0',
scripts: { postinstall: 'node forbidden.js', test: 'ignored test' },
pi: { extensions: ['./index.js'] },
});
await writeFile(path.join(packageRoot, 'index.js'), 'export default function fixture() {}\n');
});
const manager = new DevicePackageManager({
rootDir: path.join(root, 'store'),
executablePath: 'Makelore.exe',
cliPath,
npmCliPath: 'npm-cli.js',
runInstall,
now: () => NOW,
createId: () => 'plan-npm',
});
const preview = await manager.prepare('npm:pi-web-search@2.4.0', 'turn-a');
expect(runInstall).toHaveBeenCalledOnce();
expect(calls[0]?.source).toBe('npm:pi-web-search@2.4.0');
expect(calls[0]?.env).toMatchObject({
ELECTRON_RUN_AS_NODE: '1',
PI_TELEMETRY: '0',
GIT_TERMINAL_PROMPT: '0',
CI: '1',
npm_config_ignore_scripts: 'true',
npm_config_update_notifier: 'false',
});
expect(preview).toMatchObject({
packageId: 'pi-web-search',
resolvedSource: 'npm:pi-web-search@2.4.0',
resolvedVersion: '2.4.0',
kind: 'pi-extension',
ignoredLifecycleScripts: ['postinstall'],
});
});
it('increments generation only for durable changes and removes disabled packages', async () => {
const root = await temporaryRoot('generation');
const source = await looseSkill(root, 'generation-skill');
let id = 0;
const manager = new DevicePackageManager({
rootDir: path.join(root, 'store'),
now: () => NOW + id * 1000,
createId: () => `plan-${++id}`,
});
const preview = await manager.prepare(source, 'turn-a');
await manager.commit(preview.planId, true, 'turn-b');
expect((await manager.setEnabled('generation-skill', true)).generation).toBe(1);
expect((await manager.setEnabled('generation-skill', false)).generation).toBe(2);
expect((await manager.resolveEnabledResources()).packageIds).toEqual([]);
expect((await manager.setEnabled('generation-skill', false)).generation).toBe(2);
expect((await manager.uninstall('generation-skill')).generation).toBe(3);
expect((await manager.uninstall('generation-skill')).generation).toBe(3);
expect((await manager.list()).packages).toEqual([]);
});
it('rejects escaping and ambiguous manifest entries', async () => {
const root = await temporaryRoot('invalid');
const source = path.join(root, 'bad-package');
await writeJson(path.join(source, 'package.json'), {
name: 'bad-package', version: '1.0.0', pi: { extensions: ['../outside.js'] },
});
await writeFile(path.join(root, 'outside.js'), 'export default function bad() {}\n');
const manager = new DevicePackageManager({ rootDir: path.join(root, 'store') });
await expect(manager.prepare(source, 'turn-a')).rejects.toBeInstanceOf(DevicePackageError);
await expect(manager.prepare(source, 'turn-a'))
.rejects.toMatchObject({ code: 'local_package_manifest_invalid' });
});
});