import { strict as assert } from 'node:assert' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { _electron as electron } from 'playwright' const scriptDir = dirname(fileURLToPath(import.meta.url)) const packageRoot = resolve(scriptDir, '..') const repoRoot = resolve(packageRoot, '../..') const mockVersion = process.env.OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION || '99.0.0' const relaunchLabels = ['Relaunch to Update', '重启以更新'] async function waitFor(check, label, timeoutMs = 45_000) { const startedAt = Date.now() let lastError while (Date.now() - startedAt < timeoutMs) { try { const value = await check() if (value) return value } catch (error) { lastError = error } await delay(250) } const suffix = lastError ? ` Last error: ${lastError.message || lastError}` : '' throw new Error(`Timed out waiting for ${label}.${suffix}`) } async function menuLabels(app) { return await app.evaluate(({ Menu }) => { const menu = Menu.getApplicationMenu() const labels = [] function walk(items) { for (const item of items || []) { if (item.label) labels.push(item.label) if (item.submenu) walk(item.submenu.items) } } if (menu) walk(menu.items) return labels }) } async function clickRelaunchToUpdate(app) { return await app.evaluate(({ BrowserWindow, Menu }, labels) => { const menu = Menu.getApplicationMenu() function find(items) { for (const item of items || []) { if (item.label && labels.includes(item.label)) return item const child = item.submenu ? find(item.submenu.items) : null if (child) return child } return null } const item = menu ? find(menu.items) : null if (!item || typeof item.click !== 'function') return false item.click(undefined, BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], undefined) return true }, relaunchLabels) } const isolationRoot = await mkdtemp(join(tmpdir(), 'opensquilla-electron-mock-update-test-')) const userDataDir = join(isolationRoot, 'chromium-user-data') const isolatedHome = join(isolationRoot, 'home') let app try { await mkdir(userDataDir, { recursive: true }) await mkdir(isolatedHome, { recursive: true }) // Seed a keyless, entirely synthetic profile so this update test reaches the // Control UI without depending on a developer's real desktop credential. const now = new Date().toISOString() await writeFile(join(userDataDir, 'desktop-credential.json'), JSON.stringify({ provider: 'ollama', model: 'opensquilla-update-test-model', baseUrl: 'http://127.0.0.1:11434', apiKeyEnv: '', encryptedApiKey: '', modelRoutingMode: 'direct', routerMode: 'disabled', routerDefaultTier: 'c1', routerTiers: {}, searchProvider: 'duckduckgo', searchApiKeyEnv: '', encryptedSearchApiKey: '', encryption: 'plain', disableNetworkObservability: false, createdAt: now, updatedAt: now, }, null, 2), { mode: 0o600 }) app = await electron.launch({ args: [ '--use-mock-keychain', `--user-data-dir=${userDataDir}`, packageRoot, ], env: { ...process.env, HOME: isolatedHome, USERPROFILE: isolatedHome, OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot, OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain', OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION: mockVersion, // mock install: OK. Availability/download now stay in renderer state. OPENSQUILLA_DESKTOP_MOCK_UPDATE_DIALOG_RESPONSES: '0', }, }) const runtimeIsolation = await app.evaluate(({ app: electronApp }) => ({ userData: electronApp.getPath('userData'), home: process.env.HOME, userProfile: process.env.USERPROFILE, })) assert.equal(await realpath(runtimeIsolation.userData), await realpath(userDataDir)) assert.equal(resolve(runtimeIsolation.home), resolve(isolatedHome)) assert.equal(resolve(runtimeIsolation.userProfile), resolve(isolatedHome)) const page = await app.firstWindow({ timeout: 60_000 }) await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {}) await waitFor( async () => page.url().includes('/control/chat'), 'Control UI to load on Chat', 60_000, ) const nativeAutoUpdateEnabled = await page.evaluate( () => window.opensquillaDesktop.isAutoUpdateEnabled(), ) assert.equal(nativeAutoUpdateEnabled, true, 'mock update should enable native update bridge') const updateBannerCount = await page.locator('[data-testid="update-banner"]').count() assert.equal(updateBannerCount, 0, 'desktop native update should suppress the web release banner') const availableState = await waitFor(async () => { return await page.evaluate(async () => { const api = window.opensquillaDesktop if (!api.getUpdateState) return null const state = await api.getUpdateState() return state?.status === 'available' ? state : null }) }, 'mock update available renderer state') assert.equal(availableState.latestVersion, mockVersion) await page.locator('[data-testid="desktop-update-indicator"]').waitFor({ state: 'visible', timeout: 30_000 }) await page.locator('[data-testid="desktop-update-indicator"]').click() await page.locator('[data-testid="desktop-update-download"]').click() const downloadedState = await waitFor(async () => { return await page.evaluate(async () => { const state = await window.opensquillaDesktop.getUpdateState() return state?.status === 'downloaded' ? state : null }) }, 'mock update downloaded renderer state') assert.equal(downloadedState.latestVersion, mockVersion) const relaunchLabel = await waitFor(async () => { const labels = await menuLabels(app) return labels.find((label) => relaunchLabels.includes(label)) }, 'Relaunch to Update menu item') assert.ok(relaunchLabel, 'pending mock update should expose relaunch menu item') const clicked = await clickRelaunchToUpdate(app) assert.equal(clicked, true, 'Relaunch to Update menu item should be clickable') await delay(500) assert.equal(page.isClosed(), false, 'mock install should not quit the app') assert.match(await page.title(), /OpenSquilla/, 'Control UI should remain available after mock install') const labelsAfterClick = await menuLabels(app) assert.ok( labelsAfterClick.some((label) => relaunchLabels.includes(label)), 'mock install keeps the pending relaunch menu available for repeated inspection', ) console.log(JSON.stringify({ ok: true, version: mockVersion, updateState: downloadedState.status, relaunchLabel, url: page.url(), title: await page.title(), }, null, 2)) } finally { await app?.close().catch(() => {}) await rm(isolationRoot, { recursive: true, force: true }).catch(() => {}) }