Files
DrSmoothl 28f64113e7 test(core): resolve startup fixture paths through symlinks
Canonicalize temporary fixture directories before matching build inputs and locating staged updates. Compare the child working directory with its real path so startup tests also pass when the default temporary directory is a symlink.
2026-10-20 14:24:23 +08:00

196 lines
8.0 KiB
TypeScript

import { spawn } from 'node:child_process';
import { cp, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { builtinModules } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, loadConfigFromFile } from 'vite';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { exists, updateDirectory } from '../src/update/files';
import { releaseFixture } from './helpers/update-fixture';
let compiled: string;
let root: string;
let staging: string;
beforeAll(async () => {
// Module resolution uses canonical paths, including when the temp directory is a symlink.
compiled = await realpath(await mkdtemp(path.join(os.tmpdir(), 'snowluma-entry-build-')));
const loaded = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
);
if (!loaded) {
throw new Error('missing core build config');
}
const config = loaded.config;
const startup = fileURLToPath(new URL('../src/update/startup.ts', import.meta.url)).replaceAll(
'\\',
'/',
);
const files = fileURLToPath(new URL('../src/update/files.ts', import.meta.url)).replaceAll(
'\\',
'/',
);
const fixture = path.join(compiled, 'fixture.ts').replaceAll('\\', '/');
await writeFile(fixture, '');
for (const version of ['1.0.0', '2.0.0']) {
let loadedFixture = false;
await build({
...config,
configFile: false,
logLevel: 'silent',
plugins: [
{
name: 'fixture-application',
enforce: 'pre',
load(id) {
if (id.replaceAll('\\', '/') !== fixture) {
return null;
}
loadedFixture = true;
return `
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { RESTART_EXIT_CODE, runApplication } from '${startup}';
import { updateDirectory } from '${files}';
const root = path.dirname(fileURLToPath(import.meta.url));
if (!process.env.SNOWLUMA_UPDATE_ROOT) {
await runApplication(root);
} else {
const version = '${version}';
if (process.env.UPDATE_TEST_MODE === 'restart' && version === '1.0.0') {
const staging = await updateDirectory(root);
fs.cpSync(path.join(root, 'candidate'), path.join(staging, 'pending'), {recursive:true});
fs.writeFileSync(path.join(root, 'old-pid'), String(process.pid));
process.exit(RESTART_EXIT_CODE);
}
if (process.env.UPDATE_TEST_MODE === 'fail') {
console.error('startup failed');
process.exit(1);
}
let oldAlive = false;
if (process.platform === 'win32' && fs.existsSync(path.join(root, 'old-pid'))) {
try { process.kill(Number(fs.readFileSync(path.join(root, 'old-pid'), 'utf8')), 0); oldAlive = true; }
catch (error) { if (error.code !== 'ESRCH') throw error; }
}
console.log(JSON.stringify({version, node:process.execPath, cwd:process.cwd(), args:process.argv.slice(2), oldAlive}));
process.exit(0);
}
`;
},
generateBundle(_options, bundle) {
expect(loadedFixture).toBe(true);
const entry = bundle['index.mjs'];
expect(entry.type).toBe('chunk');
if (entry.type !== 'chunk') {
throw new Error('missing entry');
}
expect(
entry.imports.filter(
(id) => !builtinModules.includes(id) && !id.startsWith('node:'),
),
).toEqual([]);
expect(entry.dynamicImports).toEqual([]);
},
},
],
build: {
...config.build,
outDir: path.join(compiled, version),
lib: { entry: { index: fixture }, formats: ['es'], fileName: () => 'index.mjs' },
},
});
}
});
afterAll(async () => {
await rm(compiled, { recursive: true, force: true });
});
beforeEach(async () => {
root = await realpath(await mkdtemp(path.join(os.tmpdir(), 'snowluma-entry-test-')));
staging = await updateDirectory(root);
await releaseFixture(root, '1.0.0');
await cp(path.join(compiled, '1.0.0/index.mjs'), path.join(root, 'index.mjs'));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(staging, { recursive: true, force: true });
});
function run(mode = '', args: string[] = []) {
return new Promise<{ code: number | null; output: string }>((resolve, reject) => {
const child = spawn(process.execPath, [path.join(root, 'index.mjs'), ...args], {
cwd: os.tmpdir(),
stdio: 'pipe',
windowsHide: true,
env: { ...process.env, UPDATE_TEST_MODE: mode },
});
let output = '';
child.stdout.on('data', (data: Buffer) => {
output += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
output += data.toString();
});
child.once('error', reject);
child.once('close', (code) => {
resolve({ code, output });
});
});
}
async function candidate(directory: string) {
await releaseFixture(path.join(directory, 'payload'), '2.0.0');
await cp(path.join(compiled, '2.0.0/index.mjs'), path.join(directory, 'payload/index.mjs'));
}
describe('packaged startup', () => {
it('preserves the exact Node executable, arguments and working directory', async () => {
const result = await run('', ['value with spaces']);
expect(result.code, result.output).toBe(0);
expect(JSON.parse(result.output)).toEqual({
version: '1.0.0',
node: process.execPath,
cwd: await realpath(os.tmpdir()),
args: ['value with spaces'],
oldAlive: false,
});
});
it('waits for the old process to exit, applies the release, then executes the new code', async () => {
await candidate(path.join(root, 'candidate'));
const result = await run('restart', ['kept']);
expect(result.code, result.output).toBe(0);
expect(result.output).toContain('已更新至 v2.0.0');
expect(JSON.parse(result.output.trim().split('\n').at(-1)!)).toMatchObject({
version: '2.0.0',
args: ['kept'],
oldAlive: false,
});
expect(JSON.parse(await readFile(path.join(root, 'release.json'), 'utf8')).version).toBe(
'2.0.0',
);
expect(await exists(staging)).toBe(false);
});
it('applies an already staged update on ordinary startup', async () => {
await candidate(path.join(staging, 'pending'));
const result = await run();
expect(result.code, result.output).toBe(0);
expect(result.output).toContain('"version":"2.0.0"');
expect(result.output).not.toContain('"version":"1.0.0"');
});
it('propagates an application failure without restarting it', async () => {
expect(await run('fail')).toEqual({ code: 1, output: 'startup failed\n' });
});
});