Files
makelore/tests/unit/project-packager.test.ts
T

531 lines
22 KiB
TypeScript

// @vitest-environment node
import { createRequire } from 'node:module';
import {
mkdir,
mkdtemp,
realpath,
readFile,
rename,
rm,
symlink,
utimes,
writeFile,
} from 'node:fs/promises';
import { join, relative } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createStaticProjectPackage,
ProjectPackageError,
} from '@electron/services/project-packager';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
import type { ProjectType } from '../../shared/project-config';
const openRaceHook = vi.hoisted(() => ({
beforeOpen: null as null | ((path: string) => Promise<void>),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
await openRaceHook.beforeOpen?.(String(args[0]));
return await actual.open(...args);
},
};
});
const require = createRequire(import.meta.url);
const AdmZip = require('adm-zip') as typeof import('adm-zip');
const tempDirectories: string[] = [];
const projectId = '11111111-1111-4111-8111-111111111111';
async function createProject(projectType: ProjectType = 'interactive_ai_app'): Promise<string> {
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-'));
tempDirectories.push(projectPath);
await mkdir(join(projectPath, 'src'), { recursive: true });
await mkdir(join(projectPath, 'public'), { recursive: true });
await mkdir(join(projectPath, '.makelore'), { recursive: true });
await writeFile(
join(projectPath, '.makelore', 'project.json'),
JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', projectType)),
'utf8',
);
await writeFile(
join(projectPath, 'package.json'),
JSON.stringify({
name: 'space-cleaner',
private: true,
packageManager: 'npm@10.9.2',
devDependencies: { vite: '^7.0.0' },
}),
'utf8',
);
await writeFile(
join(projectPath, 'package-lock.json'),
JSON.stringify({
name: 'space-cleaner',
lockfileVersion: 3,
requires: true,
packages: {},
}),
'utf8',
);
await writeFile(join(projectPath, 'index.html'), '<div id="app"></div>', 'utf8');
await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8');
await writeFile(join(projectPath, 'public', 'icon.txt'), 'icon\n', 'utf8');
return await realpath(projectPath);
}
async function createNestedSource(projectPath: string, directory = 'games/Alice 的游戏'): Promise<string> {
const source = join(projectPath, directory);
await mkdir(join(source, 'src'), { recursive: true });
await writeFile(join(source, 'package.json'), JSON.stringify({
name: 'selected-inner-game', packageManager: 'npm@10.9.2',
scripts: { dev: 'vite --host 0.0.0.0 --port 5179' }, devDependencies: { vite: '^7.0.0' },
}));
await writeFile(join(source, 'package-lock.json'), JSON.stringify({ name: 'selected-inner-game', lockfileVersion: 3, packages: {} }));
await writeFile(join(source, 'index.html'), '<main>selected inner game</main>');
await writeFile(join(source, 'src', 'main.ts'), 'console.log("inner source")\n');
return source;
}
async function saveSourceEntry(projectPath: string, directory: string, patch: Record<string, unknown> = {}): Promise<void> {
const configPath = join(projectPath, '.makelore', 'project.json');
const config = JSON.parse(await readFile(configPath, 'utf8'));
await writeFile(configPath, JSON.stringify({ ...config, projectId }));
await writeFile(join(projectPath, '.makelore', 'work-entry.json'), JSON.stringify({
version: 1, projectId, projectPath, url: 'http://127.0.0.1:5179/',
launch: { kind: 'vite', directory, args: [] }, ...patch,
}));
}
async function archiveTarget(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(directory);
return join(directory, 'project.zip');
}
async function removeOuterSource(projectPath: string): Promise<void> {
await Promise.all(['package.json', 'package-lock.json', 'index.html', 'src', 'public']
.map(name => rm(join(projectPath, name), { recursive: true, force: true })));
}
afterEach(async () => {
openRaceHook.beforeOpen = null;
await Promise.all(
tempDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
);
});
describe('project packager', () => {
it.each([false, true])('packages the saved inner source at zip root using only outer project configuration (inner custom config=%s)', async (innerConfig) => {
const projectPath = await createProject();
const source = await createNestedSource(projectPath);
if (innerConfig) {
await mkdir(join(source, '.makelore'));
await writeFile(join(source, '.makelore', 'project.json'), JSON.stringify(
createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom', '22222222-2222-4222-8222-222222222222'),
));
}
await mkdir(join(projectPath, 'knowledge'));
await mkdir(join(projectPath, 'sibling-assets'));
await writeFile(join(projectPath, 'knowledge', 'notes.txt'), 'private knowledge');
await writeFile(join(projectPath, 'sibling-assets', 'cover.png'), 'unrelated image');
await writeFile(join(projectPath, 'private.txt'), 'outer private file');
await saveSourceEntry(projectPath, 'games/Alice 的游戏');
const outerConfig = await readFile(join(projectPath, '.makelore', 'project.json'));
const savedEntry = await readFile(join(projectPath, '.makelore', 'work-entry.json'));
const archivePath = await archiveTarget();
const summary = await createStaticProjectPackage({ projectPath, archivePath });
const archive = new AdmZip(archivePath);
expect(archive.getEntries().map(entry => entry.entryName)).toEqual([
'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'src/main.ts',
]);
expect(archive.readAsText('index.html')).toBe('<main>selected inner game</main>');
expect(JSON.parse(archive.readAsText('package.json')).name).toBe('selected-inner-game');
expect(summary.manifest.project_type).toBe('interactive_ai_app');
expect(summary.fileCount).toBe(5);
expect(await readFile(join(projectPath, '.makelore', 'project.json'))).toEqual(outerConfig);
expect(await readFile(join(projectPath, '.makelore', 'work-entry.json'))).toEqual(savedEntry);
expect(await readFile(join(projectPath, 'private.txt'), 'utf8')).toBe('outer private file');
});
it('discovers a unique nested standard Vite source without saving or rewriting project metadata', async () => {
const projectPath = await createProject();
await removeOuterSource(projectPath);
await createNestedSource(projectPath);
const outerConfig = await readFile(join(projectPath, '.makelore', 'project.json'));
const archivePath = await archiveTarget();
await createStaticProjectPackage({ projectPath, archivePath });
expect(new AdmZip(archivePath).readAsText('index.html')).toBe('<main>selected inner game</main>');
expect(new AdmZip(archivePath).getEntry('games/Alice 的游戏/index.html')).toBeNull();
expect(await readFile(join(projectPath, '.makelore', 'project.json'))).toEqual(outerConfig);
await expect(readFile(join(projectPath, '.makelore', 'work-entry.json'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects multiple nested candidates without choosing a game or producing an archive', async () => {
const projectPath = await createProject();
await removeOuterSource(projectPath);
await createNestedSource(projectPath, 'first');
await createNestedSource(projectPath, 'second');
const archivePath = await archiveTarget();
await expect(createStaticProjectPackage({ projectPath, archivePath }))
.rejects.toMatchObject({ code: 'PROJECT_SOURCE_UNCONFIRMED' });
await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each([undefined, 'vite && node custom-dev.js'])('retains root Vite package compatibility for dev script %s', async (dev) => {
const projectPath = await createProject();
const packagePath = join(projectPath, 'package.json');
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
await writeFile(packagePath, JSON.stringify({ ...packageJson, ...(dev ? { scripts: { dev } } : {}) }));
const archivePath = await archiveTarget();
await createStaticProjectPackage({ projectPath, archivePath });
expect(new AdmZip(archivePath).readAsText('index.html')).toBe('<div id="app"></div>');
expect(JSON.parse(new AdmZip(archivePath).readAsText('package.json'))).toEqual({
...packageJson, ...(dev ? { scripts: { dev } } : {}),
});
});
it('reports a missing lock in the saved source without falling back to the valid root or another game', async () => {
const projectPath = await createProject();
const source = await createNestedSource(projectPath, 'selected');
await createNestedSource(projectPath, 'other');
await saveSourceEntry(projectPath, 'selected');
await rm(join(source, 'package-lock.json'));
const archivePath = await archiveTarget();
await expect(createStaticProjectPackage({ projectPath, archivePath }))
.rejects.toMatchObject({ code: 'PROJECT_FILE_MISSING', message: expect.stringContaining('package-lock.json') });
await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each(['traversal', 'copied-project-id', 'copied-root', 'symlink'] as const)('ignores an unsafe %s saved directory and never packages its files', async (kind) => {
const projectPath = await createProject();
const outside = await createProject();
await writeFile(join(outside, 'external-secret.txt'), 'external private data');
let directory = '.makelore/copied-source';
let patch: Record<string, unknown> = {};
if (kind === 'traversal') directory = relative(projectPath, outside).split('\\').join('/');
else if (kind === 'symlink') {
directory = '.makelore/linked-source';
await symlink(outside, join(projectPath, directory), 'junction');
} else {
await createNestedSource(projectPath, directory);
patch = kind === 'copied-project-id'
? { projectId: '22222222-2222-4222-8222-222222222222' }
: { projectPath: outside };
}
await saveSourceEntry(projectPath, directory, patch);
const archivePath = await archiveTarget();
await createStaticProjectPackage({ projectPath, archivePath });
const archive = new AdmZip(archivePath);
expect(archive.readAsText('index.html')).toBe('<div id="app"></div>');
expect(archive.getEntries().map(entry => entry.entryName)).toEqual([
'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'public/icon.txt', 'src/main.ts',
]);
expect(archive.readAsText('src/main.ts')).toBe('console.log("ready")\n');
});
it('rejects a saved source ancestor replaced by a symlink between scan and streamed file reads', async () => {
const projectPath = await createProject();
const source = await createNestedSource(projectPath, 'games/selected');
await saveSourceEntry(projectPath, 'games/selected');
const outside = await createProject();
await createNestedSource(outside, 'selected');
await writeFile(join(outside, 'selected', 'src', 'main.ts'), 'external private data');
const archivePath = await archiveTarget();
let replaced = false;
openRaceHook.beforeOpen = async path => {
if (replaced || path !== join(source, 'src', 'main.ts')) return;
replaced = true;
await rename(join(projectPath, 'games'), join(projectPath, 'games-original'));
await symlink(outside, join(projectPath, 'games'), 'junction');
};
await expect(createStaticProjectPackage({ projectPath, archivePath }))
.rejects.toMatchObject({ code: 'PROJECT_SYMLINK_UNSUPPORTED' });
expect(replaced).toBe(true);
await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('generates a deterministic static manifest zip and excludes local or sensitive files', async () => {
const projectPath = await createProject();
await mkdir(join(projectPath, 'node_modules', 'vite'), { recursive: true });
await mkdir(join(projectPath, '.git'), { recursive: true });
await mkdir(join(projectPath, 'dist'), { recursive: true });
for (const directory of ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.niancode', '.opencode', 'secrets']) {
await mkdir(join(projectPath, directory), { recursive: true });
await writeFile(join(projectPath, directory, 'credential'), 'private', 'utf8');
}
await writeFile(join(projectPath, 'node_modules', 'vite', 'index.js'), 'generated', 'utf8');
await writeFile(join(projectPath, '.git', 'config'), 'private', 'utf8');
await writeFile(join(projectPath, 'dist', 'index.html'), 'built', 'utf8');
await writeFile(join(projectPath, '.makelore', 'local-state.json'), '{}', 'utf8');
await writeFile(join(projectPath, '.env'), 'TOKEN=secret', 'utf8');
await writeFile(join(projectPath, '.env.example'), 'TOKEN=replace-me', 'utf8');
await writeFile(join(projectPath, '.npmrc'), '//registry/:_authToken=secret', 'utf8');
await writeFile(join(projectPath, 'debug.log'), 'local log', 'utf8');
await writeFile(join(projectPath, 'niancode.yml'), 'runtime: compose\n', 'utf8');
for (const filename of [
'.netrc',
'_netrc',
'.git-credentials',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'service-account.json',
'service_account.prod.json',
'firebase-adminsdk-project.json',
'application_default_credentials.json',
'terraform.tfstate',
'terraform.tfstate.backup',
'production.tfvars',
'secrets.auto.tfvars.json',
'signing.ppk',
'auth-key.p8',
]) {
await writeFile(join(projectPath, filename), 'private', 'utf8');
}
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
const firstArchive = join(outputDirectory, 'first.zip');
const secondArchive = join(outputDirectory, 'second.zip');
const first = await createStaticProjectPackage({ projectPath, archivePath: firstArchive });
await utimes(join(projectPath, 'src', 'main.ts'), new Date(), new Date());
const second = await createStaticProjectPackage({ projectPath, archivePath: secondArchive });
expect(await readFile(firstArchive)).toEqual(await readFile(secondArchive));
expect(first.sha256).toBe(second.sha256);
expect(first.archiveBytes).toBe(second.archiveBytes);
expect(first.fileCount).toBe(6);
expect(first.excludedPaths).toEqual(expect.arrayContaining([
'.env',
'.env.example',
'.git/',
'.git-credentials',
'.gnupg/',
'.makelore/',
'.niancode/',
'.npmrc',
'.opencode/',
'.ssh/',
'.aws/',
'.docker/',
'.kube/',
'.netrc',
'_netrc',
'debug.log',
'dist/',
'firebase-adminsdk-project.json',
'id_rsa',
'node_modules/',
'production.tfvars',
'secrets.auto.tfvars.json',
'secrets/',
'service-account.json',
'terraform.tfstate.backup',
]));
const archive = new AdmZip(firstArchive);
const entries = archive.getEntries();
expect(entries.map((entry) => entry.entryName)).toEqual([
'index.html',
'niancode.yml',
'package-lock.json',
'package.json',
'public/icon.txt',
'src/main.ts',
]);
expect(archive.readAsText('niancode.yml')).toBe(
'schema_version: 1\n'
+ 'project_type: interactive_ai_app\n'
+ 'kind: web\n'
+ 'runtime: static\n'
+ 'build:\n'
+ ' preset: vite\n'
+ ' package_manager: npm\n'
+ ' entry: index.html\n',
);
expect(first.manifest.project_type).toBe('interactive_ai_app');
expect(entries.every((entry) => entry.header.time.getFullYear() === 1980)).toBe(true);
await expect(readFile(join(projectPath, 'niancode.yml'), 'utf8')).resolves.toBe('runtime: compose\n');
});
it.each(['mini_game', 'mini_program'] as const)(
'normalizes a legacy %s project to the canonical manifest type',
async (legacyProjectType) => {
const projectPath = await createProject();
await writeFile(
join(projectPath, '.makelore', 'project.json'),
JSON.stringify({
...createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'interactive_ai_app'),
projectType: legacyProjectType,
}),
'utf8',
);
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
const archivePath = join(outputDirectory, 'project.zip');
const summary = await createStaticProjectPackage({ projectPath, archivePath });
expect(summary.manifest.project_type).toBe('interactive_ai_app');
expect(new AdmZip(archivePath).readAsText('niancode.yml'))
.toContain('project_type: interactive_ai_app\n');
},
);
it('rejects custom projects before validating Vite source files', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-custom-packager-'));
tempDirectories.push(projectPath);
await mkdir(join(projectPath, '.makelore'), { recursive: true });
await writeFile(
join(projectPath, '.makelore', 'project.json'),
JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom')),
'utf8',
);
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
})).rejects.toMatchObject<ProjectPackageError>({
code: 'PROJECT_TYPE_UNPUBLISHABLE',
message: '自定义项目暂未配置发布方式,请新建交互式 AI 应用项目',
});
});
it('rejects a project that is not a locked npm Vite project', async () => {
const projectPath = await createProject();
await writeFile(
join(projectPath, 'package.json'),
JSON.stringify({
name: 'space-cleaner',
packageManager: 'pnpm@10.0.0',
devDependencies: { vite: '^7.0.0' },
}),
'utf8',
);
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
})).rejects.toMatchObject<ProjectPackageError>({
code: 'PACKAGE_MANAGER_UNSUPPORTED',
message: '当前仅支持带 package-lock.json 的 npm 项目',
});
});
it('returns a precise validation error when a required root file is missing', async () => {
const projectPath = await createProject();
await rm(join(projectPath, 'index.html'));
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
})).rejects.toMatchObject<ProjectPackageError>({
code: 'PROJECT_FILE_MISSING',
message: '项目根目录缺少 index.html',
});
});
it('does not follow a linked directory outside the project root', async () => {
const projectPath = await createProject();
const externalPath = await mkdtemp(join(tmpdir(), 'makelore-project-external-'));
tempDirectories.push(externalPath);
await writeFile(join(externalPath, 'secret.txt'), 'must not be archived', 'utf8');
await symlink(externalPath, join(projectPath, 'linked-external'), 'junction');
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
})).rejects.toMatchObject<ProjectPackageError>({
code: 'PROJECT_SYMLINK_UNSUPPORTED',
});
});
it('rejects a parent directory replaced by a junction after the pre-open identity check', async () => {
const projectPath = await createProject();
const externalPath = await mkdtemp(join(tmpdir(), 'makelore-project-external-'));
tempDirectories.push(externalPath);
await writeFile(join(externalPath, 'main.ts'), 'console.log("external secret")\n', 'utf8');
const sourcePath = join(projectPath, 'src');
const sourceFilePath = join(sourcePath, 'main.ts');
let replaced = false;
openRaceHook.beforeOpen = async (path) => {
if (replaced || path !== sourceFilePath) return;
replaced = true;
await rename(sourcePath, join(projectPath, 'src-original'));
await symlink(externalPath, sourcePath, 'junction');
};
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
})).rejects.toMatchObject<ProjectPackageError>({
code: 'PROJECT_SYMLINK_UNSUPPORTED',
});
expect(replaced).toBe(true);
});
it('fails before allocating an oversized source package', async () => {
const projectPath = await createProject();
await writeFile(join(projectPath, 'large.bin'), Buffer.alloc(32));
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
limits: {
maxFiles: 20,
maxSourceBytes: 16,
maxArchiveBytes: 1024,
},
})).rejects.toMatchObject<ProjectPackageError>({ code: 'PROJECT_TOO_LARGE' });
});
it('rejects an oversized required root file before reading it into memory', async () => {
const projectPath = await createProject();
await writeFile(join(projectPath, 'package-lock.json'), Buffer.alloc(512, 0x20));
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
limits: {
maxFiles: 20,
maxSourceBytes: 256,
maxArchiveBytes: 1024,
},
})).rejects.toMatchObject<ProjectPackageError>({ code: 'PROJECT_TOO_LARGE' });
});
});