fix: cross-verify update API claims against npm registry (#1082)
* fix: cross-verify update API claims against npm registry * Update packages/web/server/lib/package-manager.js Signed-off-by: Islam Nofl <islamnofl.official@gmail.com> * fix: show live server version in AboutDialog instead of stale build-time constant * fix: add comment to empty catch block to satisfy lint no-empty rule * fix: preserve live about dialog version in electron * fix: scope update checks by runtime --------- Signed-off-by: Islam Nofl <islamnofl.official@gmail.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
c924e85a29
commit
a23f5e7545
@@ -24,6 +24,7 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
|||||||
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||||
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { UsageWindow } from '@/types';
|
import type { UsageWindow } from '@/types';
|
||||||
@@ -58,6 +59,41 @@ type VSCodeView = 'sessions' | 'chat' | 'settings';
|
|||||||
export const VSCodeLayout: React.FC = () => {
|
export const VSCodeLayout: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const runtimeApis = useRuntimeAPIs();
|
const runtimeApis = useRuntimeAPIs();
|
||||||
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const initialDelayMs = 3000;
|
||||||
|
const defaultIntervalMs = 60 * 60 * 1000;
|
||||||
|
const minIntervalMs = 5 * 60 * 1000;
|
||||||
|
const maxIntervalMs = 24 * 60 * 60 * 1000;
|
||||||
|
let disposed = false;
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const clampIntervalMs = (seconds: number): number => {
|
||||||
|
const ms = Math.round(seconds * 1000);
|
||||||
|
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNext = (delayMs: number) => {
|
||||||
|
if (disposed) return;
|
||||||
|
timer = window.setTimeout(async () => {
|
||||||
|
const suggestedSec = await checkForUpdates();
|
||||||
|
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
|
||||||
|
? clampIntervalMs(suggestedSec)
|
||||||
|
: defaultIntervalMs;
|
||||||
|
scheduleNext(nextDelay);
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext(initialDelayMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (timer !== null) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [checkForUpdates]);
|
||||||
|
|
||||||
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
|
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
|
||||||
const configured =
|
const configured =
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { debugUtils } from '@/lib/debug';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { getDesktopAppVersion } from '@/lib/desktopNative';
|
||||||
declare const __APP_VERSION__: string | undefined;
|
|
||||||
|
|
||||||
interface AboutDialogProps {
|
interface AboutDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -60,22 +59,24 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
if (isDesktop) {
|
const response = await fetch('/api/system/info');
|
||||||
const fetchVersion = async () => {
|
if (response.ok) {
|
||||||
try {
|
const data = await response.json();
|
||||||
const { getVersion } = await import('@tauri-apps/api/app');
|
if (typeof data.openchamberVersion === 'string' && data.openchamberVersion.trim()) {
|
||||||
const v = await getVersion();
|
setVersion(data.openchamberVersion);
|
||||||
setVersion(v);
|
return;
|
||||||
} catch {
|
}
|
||||||
setVersion(typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null);
|
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
fetchVersion();
|
// Fall back to the native shell version when the web server is unavailable.
|
||||||
} else {
|
}
|
||||||
setVersion(typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null);
|
|
||||||
}
|
setVersion(await getDesktopAppVersion());
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchVersion();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
downloadDesktopUpdate,
|
downloadDesktopUpdate,
|
||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
isDesktopLocalOriginActive,
|
isDesktopLocalOriginActive,
|
||||||
|
isElectronShell,
|
||||||
isTauriShell,
|
isTauriShell,
|
||||||
isVSCodeRuntime,
|
isVSCodeRuntime,
|
||||||
isWebRuntime,
|
isWebRuntime,
|
||||||
@@ -46,6 +47,12 @@ function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | 'unknown' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectArch(): 'arm64' | 'x64' | 'unknown' {
|
function detectArch(): 'arm64' | 'x64' | 'unknown' {
|
||||||
|
const vscodeArch = typeof window !== 'undefined'
|
||||||
|
? (window as { __VSCODE_CONFIG__?: { arch?: string } }).__VSCODE_CONFIG__?.arch?.toLowerCase?.()
|
||||||
|
: undefined;
|
||||||
|
if (vscodeArch === 'arm64' || vscodeArch === 'aarch64') return 'arm64';
|
||||||
|
if (vscodeArch === 'x64' || vscodeArch === 'amd64' || vscodeArch === 'x86_64') return 'x64';
|
||||||
|
|
||||||
const nav = typeof navigator !== 'undefined' ? (navigator as Navigator & { userAgentData?: { architecture?: string } }).userAgentData : undefined;
|
const nav = typeof navigator !== 'undefined' ? (navigator as Navigator & { userAgentData?: { architecture?: string } }).userAgentData : undefined;
|
||||||
const fromUAData = nav?.architecture?.toLowerCase?.();
|
const fromUAData = nav?.architecture?.toLowerCase?.();
|
||||||
if (fromUAData === 'arm' || fromUAData === 'arm64' || fromUAData === 'aarch64') return 'arm64';
|
if (fromUAData === 'arm' || fromUAData === 'arm64' || fromUAData === 'aarch64') return 'arm64';
|
||||||
@@ -75,7 +82,7 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
|||||||
params.set('arch', detectArch());
|
params.set('arch', detectArch());
|
||||||
params.set('platform', detectPlatform());
|
params.set('platform', detectPlatform());
|
||||||
if (runtime === 'desktop') {
|
if (runtime === 'desktop') {
|
||||||
params.set('appType', 'desktop-tauri');
|
params.set('appType', isElectronShell() ? 'desktop-electron' : 'desktop-tauri');
|
||||||
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
|
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -94,7 +101,11 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
|||||||
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
|
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
|
||||||
try {
|
try {
|
||||||
const params = mapRuntimeParams(runtime);
|
const params = mapRuntimeParams(runtime);
|
||||||
|
const vscodeVersion = typeof window !== 'undefined'
|
||||||
|
? (window as { __VSCODE_CONFIG__?: { extensionVersion?: string } }).__VSCODE_CONFIG__?.extensionVersion
|
||||||
|
: undefined;
|
||||||
if (currentVersion) params.set('currentVersion', currentVersion);
|
if (currentVersion) params.set('currentVersion', currentVersion);
|
||||||
|
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
|
||||||
const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, {
|
const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export class AgentManagerPanelProvider {
|
|||||||
initialStatus: this._cachedStatus,
|
initialStatus: this._cachedStatus,
|
||||||
cliAvailable,
|
cliAvailable,
|
||||||
panelType: 'agentManager',
|
panelType: 'agentManager',
|
||||||
|
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||||
devServerUrl: this._webviewDevServerUrl,
|
devServerUrl: this._webviewDevServerUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
workspaceFolder,
|
workspaceFolder,
|
||||||
initialStatus,
|
initialStatus,
|
||||||
cliAvailable,
|
cliAvailable,
|
||||||
|
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||||
devServerUrl: this._webviewDevServerUrl,
|
devServerUrl: this._webviewDevServerUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export class SessionEditorPanelProvider {
|
|||||||
panelType: 'chat',
|
panelType: 'chat',
|
||||||
initialSessionId: sessionId ?? undefined,
|
initialSessionId: sessionId ?? undefined,
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
|
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||||
devServerUrl: this._webviewDevServerUrl,
|
devServerUrl: this._webviewDevServerUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export async function handleSystemBridgeMessage(
|
|||||||
const body = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
|
const body = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
|
||||||
const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0
|
const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0
|
||||||
? body.currentVersion.trim()
|
? body.currentVersion.trim()
|
||||||
: 'unknown';
|
: String(ctx?.context?.extension?.packageJSON?.version || 'unknown');
|
||||||
const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0
|
const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0
|
||||||
? body.instanceMode.trim()
|
? body.instanceMode.trim()
|
||||||
: 'local';
|
: 'local';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import * as os from 'os';
|
||||||
import { getThemeKindName } from './theme';
|
import { getThemeKindName } from './theme';
|
||||||
import type { ConnectionStatus } from './opencode';
|
import type { ConnectionStatus } from './opencode';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export interface WebviewHtmlOptions {
|
|||||||
initialSessionId?: string;
|
initialSessionId?: string;
|
||||||
viewMode?: 'sidebar' | 'editor';
|
viewMode?: 'sidebar' | 'editor';
|
||||||
devServerUrl?: string | null;
|
devServerUrl?: string | null;
|
||||||
|
extensionVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asCspToken = (value: string | null | undefined): string | null => {
|
const asCspToken = (value: string | null | undefined): string | null => {
|
||||||
@@ -50,6 +52,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
|||||||
initialSessionId,
|
initialSessionId,
|
||||||
viewMode = 'sidebar',
|
viewMode = 'sidebar',
|
||||||
devServerUrl,
|
devServerUrl,
|
||||||
|
extensionVersion = '',
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||||
@@ -166,6 +169,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
|||||||
theme: "${themeKind}",
|
theme: "${themeKind}",
|
||||||
connectionStatus: "${initialStatus}",
|
connectionStatus: "${initialStatus}",
|
||||||
cliAvailable: ${cliAvailable},
|
cliAvailable: ${cliAvailable},
|
||||||
|
extensionVersion: "${extensionVersion.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}",
|
||||||
|
platform: "${os.platform()}",
|
||||||
|
arch: "${os.arch()}",
|
||||||
panelType: "${panelType}",
|
panelType: "${panelType}",
|
||||||
viewMode: "${viewMode}",
|
viewMode: "${viewMode}",
|
||||||
initialSessionId: ${initialSessionId ? `"${initialSessionId.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : 'null'},
|
initialSessionId: ${initialSessionId ? `"${initialSessionId.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : 'null'},
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ declare global {
|
|||||||
theme: string;
|
theme: string;
|
||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
cliAvailable?: boolean;
|
cliAvailable?: boolean;
|
||||||
|
extensionVersion?: string;
|
||||||
|
platform?: string;
|
||||||
|
arch?: string;
|
||||||
panelType?: PanelType;
|
panelType?: PanelType;
|
||||||
viewMode?: 'sidebar' | 'editor';
|
viewMode?: 'sidebar' | 'editor';
|
||||||
initialSessionId?: string | null;
|
initialSessionId?: string | null;
|
||||||
@@ -919,8 +922,8 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
||||||
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
||||||
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
|
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
|
||||||
const platform = url.searchParams.get('platform') || undefined;
|
const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined;
|
||||||
const arch = url.searchParams.get('arch') || undefined;
|
const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined;
|
||||||
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
|
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
|
||||||
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
||||||
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function getOpenChamberConfigDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeInstallScope(scope) {
|
function sanitizeInstallScope(scope) {
|
||||||
if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
if (scope === 'desktop-electron' || scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
||||||
return 'web';
|
return 'web';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ function mapArch(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAppType(value) {
|
function normalizeAppType(value) {
|
||||||
if (value === 'web' || value === 'desktop-tauri' || value === 'vscode') return value;
|
if (value === 'web' || value === 'desktop-electron' || value === 'desktop-tauri' || value === 'vscode') return value;
|
||||||
return 'web';
|
return 'web';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,10 +701,17 @@ export async function fetchChangelogNotes(fromVersion, toVersion) {
|
|||||||
export async function checkForUpdates(options = {}) {
|
export async function checkForUpdates(options = {}) {
|
||||||
const currentVersion = options.currentVersion || getCurrentVersion();
|
const currentVersion = options.currentVersion || getCurrentVersion();
|
||||||
const pm = detectPackageManager();
|
const pm = detectPackageManager();
|
||||||
|
const appType = normalizeAppType(options.appType);
|
||||||
|
|
||||||
if (currentVersion !== 'unknown') {
|
if (currentVersion !== 'unknown') {
|
||||||
const remote = await checkForUpdatesFromApi(currentVersion, options);
|
const remote = await checkForUpdatesFromApi(currentVersion, options);
|
||||||
if (remote) {
|
if (remote) {
|
||||||
|
if (remote.available && appType === 'web') {
|
||||||
|
const npmLatest = await getLatestVersion();
|
||||||
|
if (!npmLatest || compareVersions(npmLatest, remote.version) < 0) {
|
||||||
|
remote.available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...remote,
|
...remote,
|
||||||
packageManager: pm,
|
packageManager: pm,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock child_process to prevent real spawnSync calls that would hang in tests
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
spawn: vi.fn(),
|
||||||
|
spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { checkForUpdates } = await import('./package-manager.js');
|
||||||
|
|
||||||
|
/** Helper: create a fetch mock that routes by URL pattern */
|
||||||
|
function createFetchMock() {
|
||||||
|
const handlers = new Map();
|
||||||
|
|
||||||
|
const mock = vi.fn((url, options) => {
|
||||||
|
const urlStr = typeof url === 'string' ? url : url.toString();
|
||||||
|
|
||||||
|
for (const [pattern, response] of handlers) {
|
||||||
|
if (urlStr.includes(pattern)) {
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected fetch call: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.when = (pattern, response) => {
|
||||||
|
handlers.set(pattern, response);
|
||||||
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('checkForUpdates', () => {
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = createFetchMock();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Scenario: API says update available, npm confirms ---
|
||||||
|
|
||||||
|
it('returns available=true when both API and npm confirm a newer version', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
latestVersion: '1.10.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.when('registry.npmjs.org', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
'dist-tags': { latest: '1.10.0' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.when('raw.githubusercontent.com', {
|
||||||
|
ok: true,
|
||||||
|
text: async () => '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.version).toBe('1.10.0');
|
||||||
|
expect(result.currentVersion).toBe('1.9.10');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Scenario (THE FIX): API says update available, npm does NOT have it ---
|
||||||
|
|
||||||
|
it('returns available=false when API claims update but npm has same version', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
latestVersion: '1.10.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.when('registry.npmjs.org', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
'dist-tags': { latest: '1.9.10' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cross-check desktop update claims against npm', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
latestVersion: '1.10.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({
|
||||||
|
appType: 'desktop-tauri',
|
||||||
|
currentVersion: '1.9.10',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.version).toBe('1.10.0');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts electron desktop update claims without npm cross-checking', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
latestVersion: '1.10.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({
|
||||||
|
appType: 'desktop-electron',
|
||||||
|
currentVersion: '1.9.10',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.version).toBe('1.10.0');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns available=false when API claims update but npm is behind', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
latestVersion: '1.10.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.when('registry.npmjs.org', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
'dist-tags': { latest: '1.9.9' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Scenario: API says no update, npm agrees ---
|
||||||
|
|
||||||
|
it('returns available=false when API says no update and versions match', async () => {
|
||||||
|
fetchMock.when('api.openchamber.dev', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
latestVersion: '1.9.10',
|
||||||
|
updateAvailable: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Scenario: API unreachable, npm fallback ---
|
||||||
|
|
||||||
|
it('returns available=true from npm fallback when API is unreachable and npm has newer version', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
|
||||||
|
.when('registry.npmjs.org', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
'dist-tags': { latest: '1.10.0' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.when('raw.githubusercontent.com', {
|
||||||
|
ok: true,
|
||||||
|
text: async () => '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.version).toBe('1.10.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns available=false from npm fallback when API is unreachable and versions match', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
|
||||||
|
.when('registry.npmjs.org', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
'dist-tags': { latest: '1.9.10' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Scenario: API returns null (bad response), npm fallback ---
|
||||||
|
|
||||||
|
it('returns available=false when API returns non-ok status and versions match on npm', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
.when('registry.npmjs.org', {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
'dist-tags': { latest: '1.9.10' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Scenario: Both API and npm are unreachable ---
|
||||||
|
|
||||||
|
it('returns available=false when both sources are unreachable', async () => {
|
||||||
|
fetchMock
|
||||||
|
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
|
||||||
|
.when('registry.npmjs.org', Promise.reject(new Error('Registry unreachable')));
|
||||||
|
|
||||||
|
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user