Files
SnowLuma/packages/core/tests/update-service.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

139 lines
6.2 KiB
TypeScript

import { updateDirectory } from '../src/update/files';
import { EventEmitter } from 'node:events';
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createUpdateService } from '../src/update/service';
import { updateRoutes } from '../src/webui/update-routes';
import { releaseFixture } from './helpers/update-fixture';
let root: string;
let staging: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-update-service-'));
await releaseFixture(root, '1.0.0');
staging = await updateDirectory(root);
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
await rm(staging, { recursive: true, force: true });
});
describe('update service', () => {
it('stages a single download, survives a service reload and leaves running files alone', async () => {
let finish!: () => void;
const wait = new Promise<void>((resolve) => {
finish = resolve;
});
const download = vi.fn(async (directory: string, version: string, target: string) => {
await wait;
await releaseFixture(path.join(directory, 'payload'), version, target);
await writeFile(path.join(directory, 'archive'), 'archive');
});
const service = await createUpdateService(root, '1.0.0', { download, isContainer: false });
service.start('2.0.0');
expect(service.status()).toEqual({
current: '1.0.0',
phase: 'downloading',
version: '2.0.0',
});
expect(() => service.start('2.0.0')).toThrow('正在进行');
finish();
await vi.waitFor(() => expect(service.status().phase).toBe('ready'));
expect(download).toHaveBeenCalledOnce();
expect(await readdir(path.join(staging, 'pending'))).toEqual(['payload']);
const reloaded = await createUpdateService(root, '1.0.0', { download, isContainer: false });
expect(reloaded.status()).toEqual({ current: '1.0.0', phase: 'ready', version: '2.0.0' });
reloaded.restart();
expect(reloaded.status().phase).toBe('restarting');
expect(() => reloaded.restart()).toThrow('尚未就绪');
});
it('cleans a failed download and allows retry', async () => {
const download = vi.fn(async (directory: string) => {
await writeFile(path.join(directory, 'archive'), 'partial');
throw new Error('connection reset');
});
const service = await createUpdateService(root, '1.0.0', { download, isContainer: false });
service.start('2.0.0');
await vi.waitFor(() =>
expect(service.status()).toEqual({
current: '1.0.0',
phase: 'error',
message: 'connection reset',
}),
);
expect(await readdir(staging)).toEqual([]);
expect(() => service.start('2.0.0')).not.toThrow();
await vi.waitFor(() => expect(download).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(service.status().phase).toBe('error'));
});
it.each([
'1.0.0',
'0.9.0',
'2.0.0-rc.1',
'02.0.0',
'2.0.0\n',
'1.0.0+build.2',
'../../outside',
null,
])('rejects invalid or older version %s', async (version) => {
const download = vi.fn();
const service = await createUpdateService(root, '1.0.0', {
download,
isContainer: false,
});
expect(() => service.start(version)).toThrow('正式版本');
expect(download).not.toHaveBeenCalled();
});
it('downloads stable versions with build metadata without dropping the tag suffix', async () => {
const download = vi.fn(async (directory: string, version: string, target: string) => {
await releaseFixture(path.join(directory, 'payload'), version, target);
await writeFile(path.join(directory, 'archive'), 'archive');
});
const service = await createUpdateService(root, '1.0.0', { download, isContainer: false });
service.start('2.0.0+build.01');
await vi.waitFor(() => expect(service.status().phase).toBe('ready'));
expect(download).toHaveBeenCalledWith(expect.any(String), '2.0.0+build.01', 'win32-x64');
expect(service.status()).toMatchObject({ version: '2.0.0+build.01' });
});
it('does not enable updates for a source checkout', async () => {
const service = await createUpdateService(undefined, '1.0.0');
expect(service.status().phase).toBe('unavailable');
expect(() => service.start('2.0.0')).toThrow('发行包');
expect(() => service.restart()).toThrow('尚未就绪');
});
it('does not update container image layers', async () => {
const service = await createUpdateService(root, '1.0.0', { isContainer: true });
expect(service.status()).toMatchObject({ phase: 'unavailable', message: '请更新容器镜像' });
});
it('restarts only after the HTTP response has finished', async () => {
await releaseFixture(path.join(staging, 'pending/payload'), '2.0.0');
const service = await createUpdateService(root, '1.0.0', { isContainer: false });
const restart = vi.fn();
const outgoing = new EventEmitter();
const app = updateRoutes(service, restart);
const response = await app.request('/restart', { method: 'POST' }, { outgoing });
expect(response.status).toBe(202);
expect(await response.json()).toMatchObject({ phase: 'restarting' });
expect(restart).not.toHaveBeenCalled();
outgoing.emit('finish');
expect(restart).toHaveBeenCalledOnce();
});
it.each(['{}', 'not json'])('rejects an invalid download request: %s', async (body) => {
const service = await createUpdateService(root, '1.0.0', { isContainer: false });
const app = updateRoutes(service, vi.fn());
const response = await app.request('/download', { method: 'POST', body });
expect(response.status).toBe(400);
expect(service.status().phase).toBe('idle');
});
});