Files
makelore/tests/unit/bundled-python-packaging.test.ts
2026-07-29 17:22:35 +08:00

179 lines
7.9 KiB
TypeScript

import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { parse as parseYaml } from 'yaml';
const temporaryDirectories: string[] = [];
async function loadManifest() {
return await import(pathToFileURL(join(process.cwd(), 'scripts', 'bundled-python-manifest.mjs')).href);
}
async function loadDownloader() {
return await import(pathToFileURL(join(process.cwd(), 'scripts', 'download-bundled-python.mjs')).href);
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe('bundled Python packaging', () => {
it('pins Python 3.12.13 archives and checksums for every desktop target', async () => {
const { PYTHON_RELEASE, PYTHON_TARGETS, PYTHON_VERSION } = await loadManifest();
expect(PYTHON_VERSION).toBe('3.12.13');
expect(PYTHON_RELEASE).toBe('20260510');
expect(Object.keys(PYTHON_TARGETS).sort()).toEqual([
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'win32-x64',
]);
expect(PYTHON_TARGETS['win32-x64']).toMatchObject({
archiveName: 'cpython-3.12.13+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
sha256: '24168aff2e7d93784c6a436124c4ebb79b076a4e289bde4902c08333507b71d0',
executable: 'python/python.exe',
});
expect(PYTHON_TARGETS['darwin-arm64'].sha256).toBe('55bc1a5edbc8ac4da0081f4f5731ed2d1ed10c57cb37a820b2a0dbc7cad742e9');
expect(PYTHON_TARGETS['darwin-x64'].sha256).toBe('6bab7fa97d4f2ddba86da0e05acff66c53b5edaca1df8edcf00ddca785a9c59b');
expect(PYTHON_TARGETS['linux-arm64'].sha256).toBe('8e2907baca8b08ae43f366e65f09fda6337d3543f322c399d8800cf689c79973');
expect(PYTHON_TARGETS['linux-x64'].sha256).toBe('d480f5d5878910ecbae212bf23bd7c25d7b209eb8cf5e98823c977384d272e88');
for (const target of Object.values(PYTHON_TARGETS) as Array<{ url: string }>) {
expect(target.url).toContain('/releases/download/20260510/');
expect(target.url).toContain('%2B20260510-');
}
expect(PYTHON_TARGETS['win32-x64'].mirrorUrl).toContain('/python-build-standalone/20260510/');
});
it('rejects unsupported targets before downloading', async () => {
const { stagePythonTarget } = await loadDownloader();
await expect(stagePythonTarget('freebsd-x64')).rejects.toThrow('Unsupported bundled Python target: freebsd-x64');
});
it('rejects a digest mismatch and removes temporary files', async () => {
const resourcesRoot = await mkdtemp(join(tmpdir(), 'niancode-python-bad-digest-'));
temporaryDirectories.push(resourcesRoot);
const { stagePythonTarget } = await loadDownloader();
await expect(stagePythonTarget('win32-x64', {
resourcesRoot,
fetchImpl: async () => new Response(Buffer.from('not-the-pinned-archive')),
extractArchive: async () => undefined,
})).rejects.toThrow('SHA-256 mismatch for win32-x64');
await expect(readFile(join(resourcesRoot, 'python', 'win32-x64', '.niancode-python.json'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('atomically stages a verified runtime and writes its marker', async () => {
const resourcesRoot = await mkdtemp(join(tmpdir(), 'niancode-python-valid-'));
temporaryDirectories.push(resourcesRoot);
const archive = Buffer.from('verified-test-archive');
const digest = createHash('sha256').update(archive).digest('hex');
const { stagePythonTarget } = await loadDownloader();
const staged = await stagePythonTarget('win32-x64', {
resourcesRoot,
targetOverride: {
archiveName: 'fixture.tar.gz',
url: 'https://example.invalid/fixture.tar.gz',
sha256: digest,
executable: 'python/python.exe',
},
fetchImpl: async () => new Response(archive),
extractArchive: async (_archivePath: string, destination: string) => {
const executable = join(destination, 'python', 'python.exe');
await mkdir(dirname(executable), { recursive: true });
await writeFile(executable, 'fixture');
},
});
expect(staged).toBe(join(resourcesRoot, 'python', 'win32-x64'));
expect(JSON.parse(await readFile(join(staged, '.niancode-python.json'), 'utf8'))).toEqual({
release: '20260510',
sha256: digest,
target: 'win32-x64',
version: '3.12.13+20260510',
});
expect(await readFile(join(staged, 'python', 'python.exe'), 'utf8')).toBe('fixture');
});
it('falls back to the configured mirror when GitHub is unreachable', async () => {
const resourcesRoot = await mkdtemp(join(tmpdir(), 'niancode-python-mirror-'));
temporaryDirectories.push(resourcesRoot);
const archive = Buffer.from('mirror-archive');
const digest = createHash('sha256').update(archive).digest('hex');
const requestedUrls: string[] = [];
const { stagePythonTarget } = await loadDownloader();
await stagePythonTarget('win32-x64', {
resourcesRoot,
targetOverride: {
archiveName: 'fixture.tar.gz',
url: 'https://github.example/fixture.tar.gz',
mirrorUrl: 'https://mirror.example/fixture.tar.gz',
sha256: digest,
executable: 'python/python.exe',
},
fetchImpl: async (url: string) => {
requestedUrls.push(url);
if (url.includes('github.example')) throw Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' });
return new Response(archive);
},
extractArchive: async (_archivePath: string, destination: string) => {
const executable = join(destination, 'python', 'python.exe');
await mkdir(dirname(executable), { recursive: true });
await writeFile(executable, 'fixture');
},
});
expect(requestedUrls).toEqual([
'https://github.example/fixture.tar.gz',
'https://mirror.example/fixture.tar.gz',
]);
});
it('prepares Python before every package target', async () => {
const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'));
expect(packageJson.scripts['python:download:win']).toContain('--platform=win');
expect(packageJson.scripts['python:download:mac']).toContain('--platform=mac');
expect(packageJson.scripts['python:download:linux']).toContain('--platform=linux');
expect(packageJson.scripts['package:win']).toMatch(/^pnpm run python:download:win && /);
expect(packageJson.scripts['package:mac']).toMatch(/^pnpm run python:download:mac && /);
expect(packageJson.scripts['package:mac:local']).toMatch(/^pnpm run python:download:mac && /);
expect(packageJson.scripts['package:linux']).toMatch(/^pnpm run python:download:linux && /);
expect(packageJson.scripts.release).toMatch(/^pnpm run python:download && /);
});
it('copies only the target architecture into packaged resources', async () => {
const builder = parseYaml(await readFile(join(process.cwd(), 'electron-builder.yml'), 'utf8'));
expect(builder.win.extraResources).toContainEqual({
from: 'resources/python/win32-${arch}/python',
to: 'python',
});
expect(builder.mac.extraResources).toContainEqual({
from: 'resources/python/darwin-${arch}/python',
to: 'python',
});
expect(builder.linux.extraResources).toContainEqual({
from: 'resources/python/linux-${arch}/python',
to: 'python',
});
});
it('keeps downloaded Python runtimes out of source control', async () => {
const rootIgnore = await readFile(join(process.cwd(), '.gitignore'), 'utf8');
expect(rootIgnore.split(/\r?\n/)).toContain('resources/python/');
});
it('keeps executable module scripts on LF for Windows test runners', async () => {
const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8');
expect(attributes.split(/\r?\n/)).toContain('scripts/*.mjs text eol=lf');
});
});