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

340 lines
13 KiB
TypeScript

// @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<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[] = [];
async function createProject(projectType: ProjectType = 'mini_game'): 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, '.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'), '<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 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<ProjectPackageError>({
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<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' });
});
});