Files
makelore/tests/unit/project-release-builder.test.ts
T

151 lines
9.9 KiB
TypeScript

// @vitest-environment node
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
const runElectronNodeMock = vi.hoisted(() => vi.fn());
vi.mock('@electron/services/publish-runtime', () => ({
resolvePublishRuntime: async () => ({ nodeExecutable: 'electron', npmCli: 'npm-cli.js', nodeVersion: '22.0.0', npmVersion: '11.6.2' }),
runElectronNode: runElectronNodeMock,
PublishRuntimeError: class extends Error {},
}));
import { prepareProjectRelease, prepareProjectReleaseSummary } from '@electron/services/project-release-builder';
import { startStaticReleaseServer, staticArtifactSnapshotFiles } from '@electron/services/static-release-server';
const require = createRequire(import.meta.url);
const AdmZip = require('adm-zip') as typeof import('adm-zip');
describe('project release builder', () => {
let root: string | null = null;
afterEach(async () => { if (root) await rm(root, { recursive: true, force: true }); root = null; vi.clearAllMocks(); });
it.each(['full', 'summary'] as const)('builds a %s release from the saved inner source snapshot while preserving outer identity and all user files', async (mode) => {
root = await realpath(await mkdtemp(join(tmpdir(), 'release-builder-nested-')));
const source = join(root, 'games', 'Alice 的游戏');
const projectId = '11111111-1111-4111-8111-111111111111';
await mkdir(join(root, '.makelore'), { recursive: true });
await mkdir(join(root, 'knowledge'));
await mkdir(join(root, 'sibling-assets'));
await mkdir(join(source, 'src'), { recursive: true });
await mkdir(join(source, '.makelore'));
const userFiles = new Map<string, string>([
[join(root, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'interactive_ai_app', projectId))],
[join(root, '.makelore', 'work-entry.json'), JSON.stringify({
version: 1, projectId, projectPath: root, url: 'http://127.0.0.1:5179/',
launch: { kind: 'vite', directory: 'games/Alice 的游戏', args: [] },
})],
[join(root, 'knowledge', 'notes.txt'), 'outer private knowledge'],
[join(root, 'sibling-assets', 'unused.png'), 'unrelated image'],
[join(root, 'private.txt'), 'outer private file'],
[join(source, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom', '22222222-2222-4222-8222-222222222222'))],
[join(source, 'package.json'), JSON.stringify({ name: 'inner-game', packageManager: 'npm@11.6.2', scripts: { dev: 'vite' }, devDependencies: { vite: '7.3.1' } })],
[join(source, 'package-lock.json'), JSON.stringify({ name: 'inner-game', lockfileVersion: 3, requires: true, packages: {} })],
[join(source, 'index.html'), '<main>inner source</main>'],
[join(source, 'src', 'main.ts'), 'console.log("user source")'],
]);
for (const [path, content] of userFiles) await writeFile(path, content);
runElectronNodeMock.mockImplementation(async (input: { args: string[]; cwd: string }) => {
expect(input.cwd).not.toBe(root);
expect(input.cwd).not.toBe(source);
expect(await readFile(join(input.cwd, 'index.html'), 'utf8')).toBe('<main>inner source</main>');
expect(JSON.parse(await readFile(join(input.cwd, 'package.json'), 'utf8')).name).toBe('inner-game');
await expect(readFile(join(input.cwd, '.makelore', 'project.json'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(join(input.cwd, 'private.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
if (input.args.includes('ci')) {
await mkdir(join(input.cwd, 'node_modules', 'vite', 'bin'), { recursive: true });
await writeFile(join(input.cwd, 'node_modules', 'vite', 'package.json'), JSON.stringify({ version: '7.3.1' }));
await writeFile(join(input.cwd, 'src', 'main.ts'), 'changed only inside build snapshot');
} else if (input.args.includes('build')) {
const output = input.args[input.args.indexOf('--outDir') + 1];
await mkdir(output, { recursive: true });
await writeFile(join(output, 'index.html'), `<main>built ${await readFile(join(input.cwd, 'src', 'main.ts'), 'utf8')}</main>`);
}
return { stdout: '', stderr: '' };
});
const prepare = mode === 'full' ? prepareProjectRelease : prepareProjectReleaseSummary;
const release = await prepare({ projectPath: root, clientVersion: '2.0.0' });
try {
expect(runElectronNodeMock).toHaveBeenCalledTimes(2);
expect(runElectronNodeMock.mock.calls[0][0].cwd).toBe(runElectronNodeMock.mock.calls[1][0].cwd);
const archive = new AdmZip(release.sourceArchive.path);
expect(archive.getEntries().map(entry => entry.entryName)).toEqual([
'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'src/main.ts',
]);
expect(archive.readAsText('src/main.ts')).toBe('console.log("user source")');
expect(release.sourceArchive.summary.manifest.project_type).toBe('interactive_ai_app');
expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex'));
expect(new AdmZip(release.builtArchive.path).readAsText('index.html')).toBe('<main>built changed only inside build snapshot</main>');
if ('staticArtifact' in release) {
expect(staticArtifactSnapshotFiles(release.staticArtifact)[0].bytes.toString()).toBe('<main>built changed only inside build snapshot</main>');
} else {
expect(release.sourceArchive).not.toHaveProperty('bytes');
expect(release.builtArchive).not.toHaveProperty('bytes');
}
for (const [path, content] of userFiles) expect(await readFile(path, 'utf8')).toBe(content);
await expect(readFile(join(source, 'node_modules', 'vite', 'package.json'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(join(source, 'dist', 'index.html'))).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await release.dispose();
}
await expect(readFile(release.sourceArchive.path)).rejects.toMatchObject({ code: 'ENOENT' });
for (const [path, content] of userFiles) expect(await readFile(path, 'utf8')).toBe(content);
});
it('builds from the exact source archive and returns the protocol-v1 canonical contract', async () => {
root = await mkdtemp(join(tmpdir(), 'release-builder-test-'));
await mkdir(join(root, '.makelore'), { recursive: true });
await writeFile(join(root, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2(new Date().toISOString(), 'interactive_ai_app')));
await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'demo', packageManager: 'npm@11.6.2', devDependencies: { vite: '7.3.1' } }));
await writeFile(join(root, 'package-lock.json'), JSON.stringify({ name: 'demo', lockfileVersion: 3, requires: true, packages: {} }));
await writeFile(join(root, 'index.html'), '<main>source</main>');
runElectronNodeMock.mockImplementation(async (input: { args: string[]; cwd: string }) => {
if (input.args.includes('ci')) {
await mkdir(join(input.cwd, 'node_modules', 'vite', 'bin'), { recursive: true });
await writeFile(join(input.cwd, 'node_modules', 'vite', 'package.json'), JSON.stringify({ version: '7.3.1' }));
} else if (input.args.includes('build')) {
const output = input.args[input.args.indexOf('--outDir') + 1];
await mkdir(join(output, 'assets'), { recursive: true });
await writeFile(join(output, 'index.html'), '<main>built</main>');
await writeFile(join(output, 'assets', '中.js'), 'ok');
}
return { stdout: '', stderr: '' };
});
const release = await prepareProjectRelease({ projectPath: root, clientVersion: '2.0.0' });
try {
expect(release.contract).toMatchObject({
schema_version: 1, entry_path: 'index.html', security_profile: 'works-square-static-sandbox-v1',
toolchain: { client: 'makelore', client_version: '2.0.0', node: '22.0.0', npm: '11.6.2', vite: '7.3.1' },
});
expect(release.contract.files.map((file) => file.path)).toEqual(['assets/中.js', 'index.html']);
const canonical = JSON.stringify(release.contract.files.map(({ path, sha256, size }) => ({ path, sha256, size })));
expect(release.contract.artifact_digest).toBe(createHash('sha256').update(canonical).digest('hex'));
expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex'));
expect(release.contract.built_archive_digest).toBe(createHash('sha256').update(release.builtArchive.bytes).digest('hex'));
expect(runElectronNodeMock.mock.calls[0][0].args).toEqual([
'npm-cli.js', 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', '--progress=false',
'--userconfig', expect.stringMatching(/empty-npmrc$/), '--cache', expect.stringMatching(/npm-cache$/),
]);
await rm(release.distRoot, { recursive: true, force: true });
expect(staticArtifactSnapshotFiles(release.staticArtifact).map(({ path, bytes }) => ({
path,
size: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
}))).toEqual(release.contract.files);
const server = await startStaticReleaseServer(release.staticArtifact);
try {
expect(await (await fetch(server.entryUrl)).text()).toBe('<main>built</main>');
expect(await (await fetch(new URL('assets/%E4%B8%AD.js', server.entryUrl))).text()).toBe('ok');
} finally {
await server.close();
}
} finally { await release.dispose(); }
await expect(readFile(release.sourceArchive.path)).rejects.toThrow();
});
});