// @vitest-environment node import { createRequire } from 'node:module'; import { mkdir, mkdtemp, readFile, rename, rm, symlink, utimes, writeFile, } from 'node:fs/promises'; import { join } 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 { createProjectConfig, type ProjectType } from '../../shared/project-config'; const openRaceHook = vi.hoisted(() => ({ beforeOpen: null as null | ((path: string) => Promise), })); vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, open: async (...args: Parameters) => { 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[] = []; async function createProject(projectType: ProjectType = 'mini_game'): Promise { 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, '.niancode'), { recursive: true }); await writeFile( join(projectPath, '.niancode', 'project.json'), JSON.stringify(createProjectConfig('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'), '
', 'utf8'); await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8'); await writeFile(join(projectPath, 'public', 'icon.txt'), 'icon\n', 'utf8'); return projectPath; } afterEach(async () => { openRaceHook.beforeOpen = null; await Promise.all( tempDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), ); }); describe('project packager', () => { 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', '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, '.niancode', '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/', '.niancode/', '.npmrc', '.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: mini_game\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('mini_game'); 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('writes the selected publishable project type into the generated manifest', async () => { const projectPath = await createProject('mini_program'); 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('mini_program'); expect(new AdmZip(archivePath).readAsText('niancode.yml')).toContain('project_type: mini_program\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, '.niancode'), { recursive: true }); await writeFile( join(projectPath, '.niancode', 'project.json'), JSON.stringify(createProjectConfig('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({ code: 'PROJECT_TYPE_UNPUBLISHABLE', message: '自定义项目暂未配置发布方式,请新建小游戏或小程序项目', }); }); 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({ 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({ 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({ 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({ 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({ 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({ code: 'PROJECT_TOO_LARGE' }); }); });