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 { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { UsageWindow } from '@/types';
|
||||
@@ -58,6 +59,41 @@ type VSCodeView = 'sessions' | 'chat' | 'settings';
|
||||
export const VSCodeLayout: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
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 configured =
|
||||
|
||||
@@ -9,8 +9,7 @@ import { debugUtils } from '@/lib/debug';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
declare const __APP_VERSION__: string | undefined;
|
||||
import { getDesktopAppVersion } from '@/lib/desktopNative';
|
||||
|
||||
interface AboutDialogProps {
|
||||
open: boolean;
|
||||
@@ -60,22 +59,24 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
||||
|
||||
if (isDesktop) {
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const { getVersion } = await import('@tauri-apps/api/app');
|
||||
const v = await getVersion();
|
||||
setVersion(v);
|
||||
} catch {
|
||||
setVersion(typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null);
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/info');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (typeof data.openchamberVersion === 'string' && data.openchamberVersion.trim()) {
|
||||
setVersion(data.openchamberVersion);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchVersion();
|
||||
} else {
|
||||
setVersion(typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the native shell version when the web server is unavailable.
|
||||
}
|
||||
|
||||
setVersion(await getDesktopAppVersion());
|
||||
};
|
||||
|
||||
void fetchVersion();
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
downloadDesktopUpdate,
|
||||
restartToApplyUpdate,
|
||||
isDesktopLocalOriginActive,
|
||||
isElectronShell,
|
||||
isTauriShell,
|
||||
isVSCodeRuntime,
|
||||
isWebRuntime,
|
||||
@@ -46,6 +47,12 @@ function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | '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 fromUAData = nav?.architecture?.toLowerCase?.();
|
||||
if (fromUAData === 'arm' || fromUAData === 'arm64' || fromUAData === 'aarch64') return 'arm64';
|
||||
@@ -75,7 +82,7 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
||||
params.set('arch', detectArch());
|
||||
params.set('platform', detectPlatform());
|
||||
if (runtime === 'desktop') {
|
||||
params.set('appType', 'desktop-tauri');
|
||||
params.set('appType', isElectronShell() ? 'desktop-electron' : 'desktop-tauri');
|
||||
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
|
||||
return params;
|
||||
}
|
||||
@@ -94,7 +101,11 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
||||
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
|
||||
try {
|
||||
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);
|
||||
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
|
||||
const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
|
||||
@@ -234,6 +234,7 @@ export class AgentManagerPanelProvider {
|
||||
initialStatus: this._cachedStatus,
|
||||
cliAvailable,
|
||||
panelType: 'agentManager',
|
||||
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||
devServerUrl: this._webviewDevServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,6 +406,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
workspaceFolder,
|
||||
initialStatus,
|
||||
cliAvailable,
|
||||
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||
devServerUrl: this._webviewDevServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ export class SessionEditorPanelProvider {
|
||||
panelType: 'chat',
|
||||
initialSessionId: sessionId ?? undefined,
|
||||
viewMode: 'editor',
|
||||
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
|
||||
devServerUrl: this._webviewDevServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ export async function handleSystemBridgeMessage(
|
||||
const body = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
|
||||
const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0
|
||||
? body.currentVersion.trim()
|
||||
: 'unknown';
|
||||
: String(ctx?.context?.extension?.packageJSON?.version || 'unknown');
|
||||
const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0
|
||||
? body.instanceMode.trim()
|
||||
: 'local';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { ConnectionStatus } from './opencode';
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface WebviewHtmlOptions {
|
||||
initialSessionId?: string;
|
||||
viewMode?: 'sidebar' | 'editor';
|
||||
devServerUrl?: string | null;
|
||||
extensionVersion?: string;
|
||||
}
|
||||
|
||||
const asCspToken = (value: string | null | undefined): string | null => {
|
||||
@@ -50,6 +52,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
initialSessionId,
|
||||
viewMode = 'sidebar',
|
||||
devServerUrl,
|
||||
extensionVersion = '',
|
||||
} = options;
|
||||
|
||||
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||
@@ -166,6 +169,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
theme: "${themeKind}",
|
||||
connectionStatus: "${initialStatus}",
|
||||
cliAvailable: ${cliAvailable},
|
||||
extensionVersion: "${extensionVersion.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}",
|
||||
platform: "${os.platform()}",
|
||||
arch: "${os.arch()}",
|
||||
panelType: "${panelType}",
|
||||
viewMode: "${viewMode}",
|
||||
initialSessionId: ${initialSessionId ? `"${initialSessionId.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : 'null'},
|
||||
|
||||
@@ -23,6 +23,9 @@ declare global {
|
||||
theme: string;
|
||||
connectionStatus: string;
|
||||
cliAvailable?: boolean;
|
||||
extensionVersion?: string;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
panelType?: PanelType;
|
||||
viewMode?: 'sidebar' | 'editor';
|
||||
initialSessionId?: string | null;
|
||||
@@ -919,8 +922,8 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
||||
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
||||
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
|
||||
const platform = url.searchParams.get('platform') || undefined;
|
||||
const arch = url.searchParams.get('arch') || undefined;
|
||||
const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined;
|
||||
const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined;
|
||||
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
|
||||
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
||||
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
||||
|
||||
@@ -29,7 +29,7 @@ function getOpenChamberConfigDir() {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function mapArch(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';
|
||||
}
|
||||
|
||||
@@ -701,10 +701,17 @@ export async function fetchChangelogNotes(fromVersion, toVersion) {
|
||||
export async function checkForUpdates(options = {}) {
|
||||
const currentVersion = options.currentVersion || getCurrentVersion();
|
||||
const pm = detectPackageManager();
|
||||
const appType = normalizeAppType(options.appType);
|
||||
|
||||
if (currentVersion !== 'unknown') {
|
||||
const remote = await checkForUpdatesFromApi(currentVersion, options);
|
||||
if (remote) {
|
||||
if (remote.available && appType === 'web') {
|
||||
const npmLatest = await getLatestVersion();
|
||||
if (!npmLatest || compareVersions(npmLatest, remote.version) < 0) {
|
||||
remote.available = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...remote,
|
||||
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