151 lines
9.9 KiB
TypeScript
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();
|
|
});
|
|
});
|