Merge pull request #707 from kaitranntt/kai/fix/706-proxy-version-install-restart

fix: restart CLIProxy after dashboard version install
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-07 00:25:54 -05:00
commit c70cd4b08b
11 files changed
+674 -70

No files matched your search

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
describe('installCliproxyVersion', () => {
afterEach(() => {
mock.restore();
});
it('attempts to stop the proxy even when there is no tracked running session', async () => {
const calls = {
stopProxy: 0,
waitForPortFree: 0,
deleteBinary: 0,
ensureBinary: 0,
};
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
mock.module('../../../src/cliproxy/config-generator', () => ({
getBinDir: () => '/tmp/ccs-bin',
CLIPROXY_DEFAULT_PORT: 8317,
}));
mock.module('../../../src/cliproxy/platform-detector', () => ({
DEFAULT_BACKEND: 'plus',
CLIPROXY_MAX_STABLE_VERSION: '9.9.999-0',
BACKEND_CONFIG: {
plus: {
fallbackVersion: '6.6.80',
repo: 'router-for-me/CLIProxyAPIPlus',
},
original: {
fallbackVersion: '0.0.0',
repo: 'router-for-me/CLIProxyAPI',
},
},
}));
mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({
stopProxy: async () => {
calls.stopProxy += 1;
return { stopped: false, error: 'No active CLIProxy session found' };
},
}));
mock.module('../../../src/utils/port-utils', () => ({
waitForPortFree: async () => {
calls.waitForPortFree += 1;
return true;
},
}));
mock.module('../../../src/config/unified-config-loader', () => ({
loadOrCreateUnifiedConfig: () => ({
cliproxy: { backend: 'plus' },
}),
}));
mock.module('../../../src/cliproxy/binary', () => ({
checkForUpdates: async () => ({
hasUpdate: false,
currentVersion: '6.6.80',
latestVersion: '6.6.80',
fromCache: false,
checkedAt: Date.now(),
}),
deleteBinary: () => {
calls.deleteBinary += 1;
},
getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy',
isBinaryInstalled: () => false,
getBinaryInfo: async () => null,
getPinnedVersion: () => null,
savePinnedVersion: () => {},
clearPinnedVersion: () => {},
isVersionPinned: () => false,
getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin',
readInstalledVersion: () => '6.6.80',
ensureBinary: async () => {
calls.ensureBinary += 1;
return '/tmp/ccs-bin/plus/cliproxy';
},
migrateVersionPin: () => {},
}));
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}`
);
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus');
expect(calls.stopProxy).toBe(1);
expect(calls.waitForPortFree).toBe(0);
expect(calls.deleteBinary).toBe(0);
expect(calls.ensureBinary).toBe(1);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'bun:test';
import {
compareCliproxyVersions,
isCliproxyVersionExperimental,
isCliproxyVersionInRange,
} from '../../../ui/src/lib/cliproxy-version-risk';
describe('cliproxy-version-risk helpers', () => {
it('compares versions while ignoring release suffixes', () => {
expect(compareCliproxyVersions('6.6.88', '6.6.81-0')).toBe(1);
expect(compareCliproxyVersions('6.6.81-0', '6.6.81')).toBe(0);
expect(compareCliproxyVersions('6.6.80', '6.6.81')).toBe(-1);
});
it('detects experimental versions against max stable', () => {
expect(isCliproxyVersionExperimental('10.0.0', '9.9.999-0')).toBe(true);
expect(isCliproxyVersionExperimental('6.6.88', '9.9.999-0')).toBe(false);
});
it('detects versions inside the faulty range', () => {
expect(isCliproxyVersionInRange('6.6.81', '6.6.81-0', '6.6.88-0')).toBe(true);
expect(isCliproxyVersionInRange('6.6.88', '6.6.81-0', '6.6.88-0')).toBe(true);
expect(isCliproxyVersionInRange('6.6.89', '6.6.81-0', '6.6.88-0')).toBe(false);
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'bun:test';
import type { CLIProxyBackend } from '../../../src/cliproxy/types';
import {
installDashboardCliproxyVersion,
type DashboardCliproxyInstallResult,
} from '../../../src/web-server/services/cliproxy-dashboard-install-service';
function createDeps(
overrides: {
sessionRunning?: boolean;
remoteRunning?: boolean;
startResult?: { started: boolean; alreadyRunning: boolean; port: number; error?: string };
} = {}
) {
const calls = {
isCliproxyRunning: 0,
installCliproxyVersion: 0,
ensureCliproxyService: 0,
};
const deps = {
getProxyStatus: () => ({ running: overrides.sessionRunning ?? false }),
isCliproxyRunning: async () => {
calls.isCliproxyRunning += 1;
return overrides.remoteRunning ?? false;
},
installCliproxyVersion: async (
_version: string,
_verbose?: boolean,
_backend?: CLIProxyBackend
) => {
calls.installCliproxyVersion += 1;
},
ensureCliproxyService: async () => {
calls.ensureCliproxyService += 1;
return (
overrides.startResult ?? {
started: true,
alreadyRunning: false,
port: 8317,
}
);
},
};
return { deps, calls };
}
describe('installDashboardCliproxyVersion', () => {
it('restarts the proxy after install when it was already running', async () => {
const { deps, calls } = createDeps({ sessionRunning: true });
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: true,
restarted: true,
port: 8317,
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
});
expect(calls.isCliproxyRunning).toBe(0);
expect(calls.installCliproxyVersion).toBe(1);
expect(calls.ensureCliproxyService).toBe(1);
});
it('keeps the proxy stopped after install when it was not running beforehand', async () => {
const { deps, calls } = createDeps({ sessionRunning: false, remoteRunning: false });
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: true,
restarted: false,
message: 'Successfully installed CLIProxy Plus v6.7.1',
});
expect(calls.isCliproxyRunning).toBe(1);
expect(calls.installCliproxyVersion).toBe(1);
expect(calls.ensureCliproxyService).toBe(0);
});
it('reports a restart failure after a successful install when the proxy had been running', async () => {
const { deps, calls } = createDeps({
sessionRunning: false,
remoteRunning: true,
startResult: {
started: false,
alreadyRunning: false,
port: 8317,
error: 'Port 8317 is blocked by another process',
},
});
const result = await installDashboardCliproxyVersion('6.7.1', 'original', deps);
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: false,
restarted: false,
error: 'Port 8317 is blocked by another process',
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
});
expect(calls.isCliproxyRunning).toBe(1);
expect(calls.installCliproxyVersion).toBe(1);
expect(calls.ensureCliproxyService).toBe(1);
});
it('uses a fallback restart error when the start result omits one', async () => {
const { deps } = createDeps({
remoteRunning: true,
startResult: {
started: false,
alreadyRunning: false,
port: 8317,
},
});
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: false,
restarted: false,
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
});
});
});
@@ -0,0 +1,168 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import express from 'express';
import type { Server } from 'http';
const installSpy = {
calls: 0,
};
mock.module('../../../src/config/unified-config-loader', () => ({
loadOrCreateUnifiedConfig: () => ({
cliproxy: { backend: 'plus' },
}),
}));
mock.module('../../../src/cliproxy/binary-manager', () => ({
checkCliproxyUpdate: async () => ({
hasUpdate: false,
currentVersion: '6.6.80',
latestVersion: '6.6.89',
fromCache: false,
checkedAt: Date.now(),
backend: 'plus',
backendLabel: 'CLIProxy Plus',
isStable: true,
maxStableVersion: '9.9.999-0',
}),
getInstalledCliproxyVersion: () => '6.6.80',
installCliproxyVersion: async () => {},
}));
mock.module('../../../src/cliproxy/binary/version-checker', () => ({
fetchAllVersions: async () => ({
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
latestStable: '6.6.89',
latest: '6.6.89',
fromCache: false,
checkedAt: Date.now(),
}),
isNewerVersion: (version: string, maxStable: string) => {
const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number);
const versionParts = normalize(version);
const maxStableParts = normalize(maxStable);
for (let index = 0; index < 3; index += 1) {
const versionPart = versionParts[index] || 0;
const maxStablePart = maxStableParts[index] || 0;
if (versionPart > maxStablePart) return true;
if (versionPart < maxStablePart) return false;
}
return false;
},
isVersionFaulty: (version: string) =>
['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes(
version
),
}));
mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({
installDashboardCliproxyVersion: async () => {
installSpy.calls += 1;
return {
success: true,
restarted: true,
port: 8317,
message: 'installed',
};
},
}));
let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default;
let server: Server;
let baseUrl = '';
beforeAll(async () => {
cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes'))
.default;
const app = express();
app.use(express.json());
app.use('/api/cliproxy', cliproxyStatsRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(() => {
installSpy.calls = 0;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
mock.restore();
});
describe('cliproxy-stats-routes install contract', () => {
it('returns faultyRange in the versions response', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
faultyRange: { min: string; max: string };
currentVersion: string;
};
expect(body.currentVersion).toBe('6.6.80');
expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' });
});
it('returns faulty confirmation metadata without calling the installer', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: '6.6.81' }),
});
expect(response.status).toBe(200);
const body = (await response.json()) as {
success: boolean;
requiresConfirmation: boolean;
isFaulty: boolean;
isExperimental: boolean;
message: string;
};
expect(body.success).toBe(false);
expect(body.requiresConfirmation).toBe(true);
expect(body.isFaulty).toBe(true);
expect(body.isExperimental).toBe(false);
expect(body.message).toContain('known bugs');
expect(installSpy.calls).toBe(0);
});
it('returns experimental confirmation metadata without calling the installer', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: '10.0.0' }),
});
expect(response.status).toBe(200);
const body = (await response.json()) as {
success: boolean;
requiresConfirmation: boolean;
isFaulty: boolean;
isExperimental: boolean;
message: string;
};
expect(body.success).toBe(false);
expect(body.requiresConfirmation).toBe(true);
expect(body.isFaulty).toBe(false);
expect(body.isExperimental).toBe(true);
expect(body.message).toContain('experimental');
expect(installSpy.calls).toBe(0);
});
});