249 lines
11 KiB
TypeScript
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' });
|
|
});
|
|
});
|