Complete #3227 by keeping browser completion polls on the native updater, checking the requested version, and preserving retry access after a failed restart.
269 lines
9.3 KiB
JavaScript
269 lines
9.3 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import express from 'express';
|
|
import path from 'node:path';
|
|
import request from 'supertest';
|
|
|
|
vi.mock('child_process', () => ({
|
|
spawn: vi.fn(),
|
|
spawnSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../package-manager.js', () => ({
|
|
checkForUpdates: vi.fn(),
|
|
getUpdateCommand: vi.fn(),
|
|
detectPackageManagerDetails: vi.fn(),
|
|
}));
|
|
|
|
const childProcess = await import('child_process');
|
|
const packageManager = await import('../package-manager.js');
|
|
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
|
|
|
|
const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
|
|
const app = express();
|
|
const dependencies = {
|
|
fs: {
|
|
existsSync: vi.fn(() => false),
|
|
promises: {
|
|
readFile: vi.fn(async () => JSON.stringify({
|
|
launchMode: 'foreground',
|
|
port: 7897,
|
|
...storedOptions,
|
|
})),
|
|
},
|
|
},
|
|
path,
|
|
process: {
|
|
env: environment,
|
|
platform: 'linux',
|
|
execPath: '/usr/bin/node',
|
|
},
|
|
server: {
|
|
address: () => ({ port: 7897 }),
|
|
},
|
|
__dirname: '/opt/openchamber/server',
|
|
openchamberDataDir: '/tmp/openchamber',
|
|
modelsDevApiUrl: 'https://models.example.test',
|
|
modelsMetadataCacheTtl: 0,
|
|
readSettingsFromDiskMigrated: vi.fn(),
|
|
fetchFreeZenModels: vi.fn(),
|
|
getCachedZenModels: vi.fn(),
|
|
desktopUpdater,
|
|
};
|
|
|
|
registerOpenChamberRoutes(app, dependencies);
|
|
return { app, dependencies };
|
|
};
|
|
|
|
beforeEach(() => {
|
|
packageManager.checkForUpdates.mockResolvedValue({
|
|
available: true,
|
|
version: '1.17.1',
|
|
});
|
|
packageManager.detectPackageManagerDetails.mockReturnValue({
|
|
packageManager: 'npm',
|
|
});
|
|
packageManager.getUpdateCommand.mockReturnValue('npm install -g @openchamber/web@latest');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('OpenChamber desktop host update route', () => {
|
|
it('reports a restart rejection until the user retries installation', async () => {
|
|
const desktopUpdater = {
|
|
check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
|
|
install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
|
|
restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
|
|
};
|
|
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' }, desktopUpdater });
|
|
const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await request(app).post('/api/openchamber/update-install').expect(200);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
|
|
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
|
|
});
|
|
expect(desktopUpdater.check).not.toHaveBeenCalled();
|
|
expect(logError).toHaveBeenCalledOnce();
|
|
// Availability remains reachable after a browser reload, so users can retry.
|
|
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
|
|
|
|
await request(app).post('/api/openchamber/update-install').expect(200);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
|
|
expect(response.body.currentVersion).toBe('1.17.0');
|
|
expect(response.body.updateOwner).toBe('electron-updater');
|
|
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
|
|
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' } });
|
|
await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
|
|
available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
|
|
});
|
|
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
|
await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
|
|
expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('uses electron-updater to check for Web client updates', async () => {
|
|
const desktopUpdater = {
|
|
check: vi.fn(async () => ({
|
|
available: true,
|
|
currentVersion: '1.17.0',
|
|
version: '1.17.1',
|
|
})),
|
|
install: vi.fn(),
|
|
restart: vi.fn(),
|
|
};
|
|
const { app } = createApp({
|
|
environment: {
|
|
OPENCHAMBER_RUNTIME: 'desktop',
|
|
},
|
|
desktopUpdater,
|
|
});
|
|
|
|
await request(app)
|
|
.get('/api/openchamber/update-check?appType=web&reportUsage=false')
|
|
.expect(200, {
|
|
available: true,
|
|
currentVersion: '1.17.0',
|
|
version: '1.17.1',
|
|
packageManager: 'electron',
|
|
updateOwner: 'electron-updater',
|
|
});
|
|
|
|
expect(desktopUpdater.check).toHaveBeenCalledOnce();
|
|
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('installs through electron-updater and restarts after responding', async () => {
|
|
const desktopUpdater = {
|
|
check: vi.fn(),
|
|
install: vi.fn(async () => ({
|
|
available: true,
|
|
version: '1.17.1',
|
|
})),
|
|
restart: vi.fn(),
|
|
};
|
|
const { app } = createApp({
|
|
environment: {
|
|
OPENCHAMBER_RUNTIME: 'desktop',
|
|
},
|
|
desktopUpdater,
|
|
});
|
|
|
|
await request(app)
|
|
.post('/api/openchamber/update-install')
|
|
.expect(200, {
|
|
success: true,
|
|
message: 'Desktop update downloaded, host will restart shortly',
|
|
version: '1.17.1',
|
|
packageManager: 'electron',
|
|
updateOwner: 'electron-updater',
|
|
autoRestart: true,
|
|
restartManager: 'electron-updater',
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(desktopUpdater.install).toHaveBeenCalledOnce();
|
|
expect(desktopUpdater.restart).toHaveBeenCalledOnce();
|
|
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
|
expect(packageManager.detectPackageManagerDetails).not.toHaveBeenCalled();
|
|
expect(packageManager.getUpdateCommand).not.toHaveBeenCalled();
|
|
expect(childProcess.spawn).not.toHaveBeenCalled();
|
|
expect(childProcess.spawnSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails safely when the Electron updater bridge is unavailable', async () => {
|
|
const { app } = createApp({
|
|
environment: {
|
|
OPENCHAMBER_RUNTIME: 'desktop',
|
|
},
|
|
});
|
|
|
|
await request(app)
|
|
.post('/api/openchamber/update-install')
|
|
.expect(503, {
|
|
code: 'DESKTOP_UPDATER_UNAVAILABLE',
|
|
error: 'The desktop updater is not available.',
|
|
});
|
|
|
|
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
|
|
expect(childProcess.spawn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('OpenChamber foreground update route', () => {
|
|
it('rejects a foreground update when the server is not owned by systemd', async () => {
|
|
const { app } = createApp();
|
|
|
|
await request(app)
|
|
.post('/api/openchamber/update-install')
|
|
.expect(409, {
|
|
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
|
|
});
|
|
|
|
expect(childProcess.spawnSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an unsafe systemd unit override before starting an update job', async () => {
|
|
const { app } = createApp({
|
|
environment: {
|
|
INVOCATION_ID: 'systemd-invocation',
|
|
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber.service; rm -rf /',
|
|
},
|
|
});
|
|
|
|
await request(app)
|
|
.post('/api/openchamber/update-install')
|
|
.expect(409, {
|
|
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
|
|
});
|
|
|
|
expect(childProcess.spawnSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('queues the install in a transient systemd unit and returns its job identifier', async () => {
|
|
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
|
childProcess.spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
|
|
const { app } = createApp({
|
|
environment: {
|
|
INVOCATION_ID: 'systemd-invocation',
|
|
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber@wsl.service',
|
|
PATH: '/home/syu/.npm-global/bin:/usr/bin:/bin',
|
|
},
|
|
});
|
|
|
|
await request(app)
|
|
.post('/api/openchamber/update-install')
|
|
.expect(200, {
|
|
success: true,
|
|
message: 'Update queued; OpenChamber will restart after installation completes',
|
|
version: '1.17.1',
|
|
packageManager: 'npm',
|
|
autoRestart: true,
|
|
restartManager: 'systemd',
|
|
jobId: 'openchamber-update-1700000000000',
|
|
logPath: 'journalctl --user-unit openchamber-update-1700000000000.service',
|
|
});
|
|
|
|
expect(childProcess.spawnSync).toHaveBeenCalledWith('systemd-run', [
|
|
'--user',
|
|
'--unit=openchamber-update-1700000000000',
|
|
'--collect',
|
|
'--service-type=exec',
|
|
'--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
|
|
'/bin/sh',
|
|
'-c',
|
|
"set -eu\nnpm install -g @openchamber/web@latest\nsystemctl --user restart 'openchamber@wsl.service'",
|
|
], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 5000,
|
|
});
|
|
});
|
|
});
|