1478 lines
77 KiB
TypeScript
1478 lines
77 KiB
TypeScript
import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { IpcMainInvokeEvent } from 'electron'
|
|
import { join } from 'node:path'
|
|
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron'
|
|
import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts'
|
|
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
|
|
import { DesktopHostUncleanExitError } from '../src/host-process.ts'
|
|
import { en } from '../src/locale.ts'
|
|
import { DesktopUpdatePreparationError } from '../src/update-error.ts'
|
|
|
|
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
|
|
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
|
|
|
|
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
|
|
|
|
const harness = await vi.hoisted(async () => {
|
|
const { EventEmitter } = await import('node:events')
|
|
function deferred() {
|
|
let resolve!: () => void
|
|
let reject!: (error: Error) => void
|
|
const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
|
|
return { promise, resolve, reject }
|
|
}
|
|
const windows: FakeWindow[] = []
|
|
let windowFailure: Error | undefined
|
|
const powerMonitor = new EventEmitter()
|
|
const hosts: FakeHost[] = []
|
|
const handlers = new Map<string, InvokeHandler>()
|
|
let pluginsEnabled = false
|
|
let prepareUpdate: (() => Promise<boolean>) | undefined
|
|
let publishUpdate: ((state: DesktopUpdateState) => DesktopUpdateState) | undefined
|
|
let preparing = deferred()
|
|
let prepared = deferred()
|
|
let hostStarted = deferred()
|
|
let navigated = deferred()
|
|
let dialogShown = deferred()
|
|
let quitCompleted = deferred()
|
|
let policyBlocked = deferred()
|
|
let embeddedPolicy: unknown
|
|
let closeWindowsOnQuit = false
|
|
let updateState: DesktopUpdateState = { phase: 'idle' }
|
|
const updateCheck = vi.fn(async (_manual?: boolean): Promise<DesktopUpdateState> => updateState)
|
|
const updateDownload = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
|
const updateInstall = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
|
const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>()
|
|
const menuBuilder = vi.fn<(template: MenuItemConstructorOptions[]) => { popup: typeof popup }>(() => ({ popup }))
|
|
const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() })
|
|
class FakeWindow extends EventEmitter {
|
|
destroyed = false
|
|
readonly urls: string[] = []
|
|
readonly webContents = Object.assign(new EventEmitter(), {
|
|
id: 42,
|
|
setWindowOpenHandler: vi.fn(),
|
|
insertCSS: vi.fn(async () => 'blur'),
|
|
removeInsertedCSS: vi.fn(async () => {}),
|
|
openDevTools: vi.fn(),
|
|
getURL: () => this.urls.at(-1) ?? '',
|
|
mainFrame: { url: '' },
|
|
getZoomFactor: () => 1,
|
|
focus: vi.fn(),
|
|
sendInputEvent: vi.fn(),
|
|
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
|
|
if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve()
|
|
}),
|
|
})
|
|
readonly show = vi.fn()
|
|
readonly hide = vi.fn()
|
|
readonly focus = vi.fn()
|
|
readonly restore = vi.fn()
|
|
readonly setSize = vi.fn()
|
|
readonly setTitleBarOverlay = vi.fn()
|
|
constructor(readonly options: { show: boolean; modal?: boolean }) {
|
|
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this)
|
|
}
|
|
isDestroyed() { return this.destroyed }
|
|
fullscreen = false
|
|
isFullScreen() { return this.fullscreen }
|
|
isMinimized() { return false }
|
|
isFocused() { return true }
|
|
async loadURL(url: string) {
|
|
this.urls.push(url)
|
|
this.webContents.mainFrame.url = url
|
|
if (url === 'dsh-app://app/') navigated.resolve()
|
|
}
|
|
static getAllWindows() { return windows.filter(window => !window.destroyed) }
|
|
setMenu() {}
|
|
getContentBounds() { return { x: 0, y: 0, width: 900, height: 650 } }
|
|
setBounds() {}
|
|
setTitle = vi.fn()
|
|
destroy() { this.destroyed = true; this.emit('closed') }
|
|
close() {
|
|
const event = { preventDefault: vi.fn() }
|
|
this.emit('close', event)
|
|
if (event.preventDefault.mock.calls.length === 0) this.destroy()
|
|
}
|
|
}
|
|
class FakeHost {
|
|
readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false)
|
|
url = 'http://127.0.0.1:3080/?token=test'
|
|
readonly ready = deferred()
|
|
readonly exited = deferred()
|
|
readonly stopping = deferred()
|
|
readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise.then(() => ({ url: this.url, injections: [] })) })
|
|
readonly stop = vi.fn(() => {
|
|
this.stopping.resolve()
|
|
this.ready.reject(new Error('child stopped'))
|
|
return this.exited.promise
|
|
})
|
|
constructor(
|
|
readonly node: string, readonly runtime: string, readonly profile: string,
|
|
readonly inspectPort?: number, readonly environment?: NodeJS.ProcessEnv, readonly onFailure?: (error: Error) => void,
|
|
readonly primaryRuntime?: string,
|
|
readonly packageManager?: { pnpm: string; nodeBin: string },
|
|
) { hosts.push(this) }
|
|
}
|
|
const app = Object.assign(new EventEmitter(), {
|
|
isPackaged: true,
|
|
name: 'Desktop test',
|
|
whenReady: () => Promise.resolve(),
|
|
getLocale: (): string => 'en-US',
|
|
getVersion: () => '1.0.0',
|
|
getAppPath: (): string => 'desktop-test-app',
|
|
setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(),
|
|
requestSingleInstanceLock: () => true,
|
|
exit: vi.fn(),
|
|
relaunch: vi.fn(),
|
|
quit: vi.fn(() => {
|
|
const event = { preventDefault: vi.fn() }
|
|
app.emit('before-quit', event)
|
|
if (event.preventDefault.mock.calls.length === 0) {
|
|
if (closeWindowsOnQuit) for (const window of [...windows]) if (!window.isDestroyed()) window.close()
|
|
if (!closeWindowsOnQuit || windows.every(window => window.isDestroyed())) quitCompleted.resolve()
|
|
}
|
|
}),
|
|
})
|
|
return {
|
|
failWindow(error: Error) { windowFailure = error },
|
|
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor,
|
|
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
|
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
|
|
get updateState() { return updateState },
|
|
set updateState(value: DesktopUpdateState) { updateState = value },
|
|
get prepareUpdate() { return prepareUpdate! },
|
|
set prepareUpdate(value: () => Promise<boolean>) { prepareUpdate = value },
|
|
get publishUpdate() { return publishUpdate! },
|
|
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
|
|
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
|
|
openExternal: vi.fn(),
|
|
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
|
|
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
|
disableAllPlugins: vi.fn(async () => {
|
|
pluginsEnabled = false
|
|
return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
|
|
}),
|
|
get preparing() { return preparing }, get prepared() { return prepared },
|
|
get hostStarted() { return hostStarted }, get navigated() { return navigated },
|
|
get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
|
|
get policyBlocked() { return policyBlocked },
|
|
get embeddedPolicy() { return embeddedPolicy },
|
|
set embeddedPolicy(value: unknown) { embeddedPolicy = value },
|
|
nextNavigation() { navigated = deferred(); return navigated.promise },
|
|
nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
|
|
get pluginsEnabled() { return pluginsEnabled },
|
|
set pluginsEnabled(value: boolean) { pluginsEnabled = value },
|
|
set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
|
|
reset() {
|
|
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
|
powerMonitor.removeAllListeners()
|
|
app.isPackaged = true
|
|
windowFailure = undefined
|
|
pluginsEnabled = false
|
|
closeWindowsOnQuit = false
|
|
prepareUpdate = undefined
|
|
publishUpdate = undefined
|
|
updateState = { phase: 'idle' }
|
|
updateCheck.mockReset().mockImplementation(async () => updateState)
|
|
updateDownload.mockReset().mockImplementation(async () => updateState)
|
|
updateInstall.mockReset().mockImplementation(async () => updateState)
|
|
preparing = deferred(); prepared = deferred(); hostStarted = deferred()
|
|
navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred()
|
|
policyBlocked = deferred()
|
|
embeddedPolicy = undefined
|
|
},
|
|
}
|
|
})
|
|
|
|
const testAuth = vi.hoisted(() => ({ login: vi.fn<() => Promise<'returned' | 'cancelled' | 'failed'>>(),
|
|
focus: vi.fn(), dispose: vi.fn(async () => {}) }))
|
|
vi.mock('../src/policy-test-auth.ts', () => ({ DesktopPolicyTestAuth: class {
|
|
readonly login = testAuth.login
|
|
readonly focus = testAuth.focus
|
|
readonly dispose = testAuth.dispose
|
|
readonly request: typeof fetch = (input, init) => fetch(input, init)
|
|
} }))
|
|
|
|
vi.mock('electron', () => ({
|
|
app: harness.app,
|
|
BrowserWindow: harness.FakeWindow,
|
|
dialog: harness.dialog,
|
|
shell: { openExternal: harness.openExternal },
|
|
nativeTheme: { themeSource: 'system' },
|
|
ipcMain: {
|
|
on: harness.ipcOn,
|
|
handle: (channel: string, handler: InvokeHandler) => {
|
|
if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`)
|
|
harness.handlers.set(channel, handler)
|
|
},
|
|
removeHandler: (channel: string) => { harness.handlers.delete(channel) },
|
|
},
|
|
Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu },
|
|
session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
|
|
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
|
|
powerMonitor: harness.powerMonitor,
|
|
}))
|
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('node:fs/promises')>()
|
|
return { ...original, readFile: vi.fn((path: Parameters<typeof original.readFile>[0], encoding?: 'utf8') => {
|
|
if (path === join('desktop-test-app', 'package.json')) {
|
|
return Promise.resolve(JSON.stringify({ dshDesktopAppId: 'com.deepseek.dsh', dshMandatoryUpdatePolicy: harness.embeddedPolicy }))
|
|
}
|
|
return encoding === undefined ? original.readFile(path) : original.readFile(path, encoding)
|
|
}) }
|
|
})
|
|
vi.mock('../src/runtime-tree.ts', () => ({ readDesktopRuntime: () => ({ release: { version: '1.0.0' } }) }))
|
|
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
|
|
vi.mock('../src/project-manager.ts', () => ({
|
|
DesktopProjectManager: class {
|
|
readonly applyRelease = harness.applyRelease
|
|
disableAllPlugins = harness.disableAllPlugins
|
|
|
|
},
|
|
}))
|
|
vi.mock('../src/host-process.ts', async importOriginal => ({
|
|
...await importOriginal<typeof import('../src/host-process.ts')>(), DesktopHostProcess: harness.FakeHost,
|
|
}))
|
|
vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class {
|
|
show(owner: { options: { modal?: boolean } }, options: unknown) {
|
|
return (owner.options.modal ? harness.dialog.showMessageBox(owner, options)
|
|
: harness.dialog.showMessageBox(options)) as Promise<Electron.MessageBoxReturnValue>
|
|
}
|
|
cancel() {}
|
|
focus() {}
|
|
dispose() {}
|
|
} }))
|
|
vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class {
|
|
constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise<boolean>) {
|
|
harness.prepareUpdate = beforeRestart
|
|
harness.publishUpdate = publish
|
|
}
|
|
get state() { return harness.updateState }
|
|
readonly check = harness.updateCheck
|
|
readonly download = harness.updateDownload
|
|
readonly install = harness.updateInstall
|
|
readonly dispose = vi.fn()
|
|
} }))
|
|
|
|
function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown {
|
|
const handler = harness.handlers.get(channel)
|
|
if (handler === undefined) throw new Error(`missing handler ${channel}`)
|
|
if (origin === 'app') {
|
|
const sender = harness.windows[0]!.webContents
|
|
return handler({ sender, senderFrame: sender.mainFrame }, ...args)
|
|
}
|
|
return handler({ senderFrame: { url: `dsh-app://${origin}/index.html` } }, ...args)
|
|
}
|
|
|
|
function applicationMenuItems(): MenuItemConstructorOptions[] {
|
|
const native = harness.menu.mock.calls[0]?.[0][0]?.submenu
|
|
if (Array.isArray(native)) return native
|
|
const sender = harness.windows[0]!.webContents
|
|
void harness.handlers.get(DESKTOP_IPC.windowsMenu)!({ sender, senderFrame: sender.mainFrame }, 'application', 0, 0)
|
|
return harness.menu.mock.lastCall![0]
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
harness.dialog.showMessageBox.mockReset()
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 1 })
|
|
testAuth.login.mockReset()
|
|
testAuth.login.mockResolvedValue('cancelled')
|
|
vi.useFakeTimers()
|
|
harness.reset()
|
|
harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => {
|
|
if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 })
|
|
harness.dialogShown.resolve()
|
|
return new Promise(() => {})
|
|
})
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
|
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
|
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
|
|
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
|
|
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
|
|
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
harness.prepared.resolve()
|
|
for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() }
|
|
harness.app.quit()
|
|
await harness.quitCompleted.promise
|
|
vi.restoreAllMocks()
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
vi.unstubAllEnvs()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('desktop main startup', () => {
|
|
it('routes shell update documents and assets through the registered main protocol handler', async () => {
|
|
const root = join(import.meta.dirname, '..')
|
|
vi.spyOn(harness.app, 'getAppPath').mockReturnValue(root)
|
|
const web = await import('../src/web-document.ts')
|
|
const actual = await vi.importActual<typeof import('../src/web-document.ts')>('../src/web-document.ts')
|
|
vi.mocked(web.serveWebDocument).mockImplementation(actual.serveWebDocument)
|
|
try {
|
|
await readyForUpdate()
|
|
const { protocol } = await import('electron')
|
|
const handler = vi.mocked(protocol).handle.mock.calls.at(-1)?.[1]
|
|
if (handler === undefined) throw new Error('main did not register its protocol handler')
|
|
for (const [name, mime] of [
|
|
['update-dialog.html', 'text/html'], ['update-dialog.css', 'text/css'], ['update-dialog.js', 'text/javascript'],
|
|
['mandatory-update.html', 'text/html'], ['mandatory-update.css', 'text/css'], ['mandatory-update.js', 'text/javascript'],
|
|
['update-close.svg', 'image/svg+xml'],
|
|
] as const) {
|
|
const response = await handler(new Request(`dsh-app://shell/${name}`))
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('content-type')).toContain(mime)
|
|
expect(await response.text()).toBe(readFileSync(join(root, 'renderer', name), 'utf8'))
|
|
}
|
|
const head = await handler(new Request('dsh-app://shell/update-dialog.html', { method: 'HEAD' }))
|
|
expect(head.status).toBe(200)
|
|
expect(await head.text()).toBe('')
|
|
expect((await handler(new Request('dsh-app://shell/update-dialog.html', { method: 'POST' }))).status).toBe(405)
|
|
expect((await handler(new Request('dsh-app://shell/%'))).status).toBe(400)
|
|
expect((await handler(new Request('dsh-app://shell/%2e%2e%2fpackage.json'))).status).toBe(403)
|
|
expect((await handler(new Request('dsh-app://shell/missing.html'))).status).toBe(404)
|
|
expect((await handler(new Request('dsh-app://other/update-dialog.html'))).status).toBe(404)
|
|
} finally {
|
|
vi.mocked(web.serveWebDocument).mockReset()
|
|
}
|
|
})
|
|
|
|
it('serves shell dialogs and their assets without forwarding them to the Host', async () => {
|
|
await readyForUpdate()
|
|
const { serveWebDocument, forwardWebRequest } = await import('../src/web-document.ts')
|
|
const handler = harness.protocolHandle.mock.calls[0]![1]
|
|
for (const file of ['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg', 'mandatory-update.html']) {
|
|
const request = new Request(`dsh-app://shell/${file}`)
|
|
await handler(request)
|
|
expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer'))
|
|
}
|
|
expect(forwardWebRequest).not.toHaveBeenCalled()
|
|
expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404)
|
|
})
|
|
|
|
it.each([
|
|
['darwin', true, 'en-US'],
|
|
['darwin', false, 'zh-CN'],
|
|
['win32', true, 'zh-CN'],
|
|
['win32', false, 'en-US'],
|
|
] as const)('offers the native About panel before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => {
|
|
vi.stubGlobal('process', { ...process, platform })
|
|
harness.app.isPackaged = packaged
|
|
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
|
|
await readyForUpdate()
|
|
const submenu = applicationMenuItems()
|
|
const options = harness.app.setAboutPanelOptions.mock.calls[0]![0]
|
|
const expected = JSON.parse(readFileSync(new URL('./expected/about-panel.json', import.meta.url), 'utf8')) as Record<string, unknown>
|
|
expect({ menu: submenu.slice(0, 2), options: { ...options, iconPath: '<app icon>' } }).toEqual(expected[locale])
|
|
expect(options.iconPath).toBe(packaged ? join('desktop-test-resources', 'icon.png')
|
|
: join('desktop-test-app', 'resources', 'icon-windows.png'))
|
|
})
|
|
|
|
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
|
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
|
|
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
|
|
const explanation = Promise.withResolvers<{ response: number }>()
|
|
const explained = Promise.withResolvers<undefined>()
|
|
const login = Promise.withResolvers<'cancelled'>()
|
|
const entered = Promise.withResolvers<undefined>()
|
|
harness.dialog.showMessageBox.mockImplementationOnce(() => { explained.resolve(undefined); return explanation.promise })
|
|
testAuth.login.mockImplementationOnce(() => { entered.resolve(undefined); return login.promise })
|
|
const checks: Promise<unknown>[] = []
|
|
try {
|
|
await import('../src/main.ts')
|
|
await explained.promise
|
|
expect(harness.hosts).toHaveLength(0)
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({
|
|
message: en.policyLoginRequired, buttons: [en.policyLogin, en.later], cancelId: 1,
|
|
}))
|
|
checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')))
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
expect(testAuth.login).not.toHaveBeenCalled()
|
|
explanation.resolve({ response: 0 })
|
|
await entered.promise
|
|
checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')))
|
|
expect(testAuth.focus).toHaveBeenCalled()
|
|
expect(testAuth.login).toHaveBeenCalledOnce()
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
login.resolve('cancelled')
|
|
await Promise.all(checks)
|
|
await vi.advanceTimersByTimeAsync(10_000)
|
|
expect(testAuth.login).toHaveBeenCalledOnce()
|
|
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
|
|
expect(messages.filter(message => message === en.policyLoginRequired)).toHaveLength(1)
|
|
expect(messages).toContain('No updates available. Current version: V1.0.0')
|
|
} finally {
|
|
explanation.resolve({ response: 1 })
|
|
login.resolve('cancelled')
|
|
await Promise.allSettled(checks)
|
|
}
|
|
})
|
|
|
|
it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
|
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
|
|
const request = vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
|
|
vi.stubGlobal('fetch', request)
|
|
await readyForUpdate()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(testAuth.login).not.toHaveBeenCalled()
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({ message: en.policyLoginRequired }))
|
|
harness.dialog.showMessageBox.mockClear()
|
|
testAuth.login.mockImplementationOnce(async () => {
|
|
request.mockImplementation(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
|
|
return outcome
|
|
})
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(testAuth.login).toHaveBeenCalledOnce()
|
|
expect(harness.dialog.showMessageBox.mock.calls.map(call => call.at(-1) as unknown)).toContainEqual(expect.objectContaining({
|
|
message: en.policyLoginRequired, buttons: [en.policyLogin, en.later],
|
|
}))
|
|
expect(harness.updateDownload).not.toHaveBeenCalled()
|
|
expect(harness.updateInstall).not.toHaveBeenCalled()
|
|
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
|
|
const expected = JSON.parse(readFileSync(new URL('./expected/policy-login-en.json', import.meta.url), 'utf8')) as Record<string, string[]>
|
|
expect(messages).toEqual(expected[outcome])
|
|
})
|
|
|
|
it('does not open Feishu when the user declines test login', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
|
allowedPageOrigins: ['https://downloads.example.com'] }
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
|
|
await readyForUpdate()
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 1 })
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(testAuth.login).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not require gateway login to download an already available ordinary update', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
|
allowedPageOrigins: ['https://downloads.example.com'] }
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
|
|
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
|
|
await readyForUpdate()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' }
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1')
|
|
expect(testAuth.login).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('retains the embedded block and running Host after expired test login is cancelled', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
|
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
|
|
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
|
|
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
|
|
.mockResolvedValueOnce(Response.json({ code: 40005, data: { show_content: { title: 'Update', detail: 'Required' },
|
|
desktop_app_link: 'https://downloads.example.com/' } }))
|
|
vi.stubGlobal('fetch', request)
|
|
const host = await readyForUpdate()
|
|
await harness.policyBlocked.promise
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
const modal = harness.windows[0]!
|
|
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
|
|
testAuth.login.mockResolvedValueOnce('cancelled')
|
|
await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh')
|
|
expect(testAuth.login).toHaveBeenCalledOnce()
|
|
expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({
|
|
policy: { blocking: true, error: 'authentication-required' },
|
|
})
|
|
expect(modal.isDestroyed()).toBe(false)
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
expect(harness.updateDownload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('persists opt-in update evidence from the real main entry without private diagnostics', async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), 'dsh-main-update-journal-'))
|
|
try {
|
|
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', directory)
|
|
await readyForUpdate()
|
|
harness.publishUpdate({ phase: 'error', failedOperation: 'download', version: '1.2.3', message: 'ENOSPC secret-url' })
|
|
const checkUpdates = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void
|
|
checkUpdates()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
for (const host of harness.hosts) host.exited.resolve()
|
|
harness.app.quit()
|
|
await harness.quitCompleted.promise
|
|
const files = readdirSync(directory)
|
|
expect(files).toHaveLength(1)
|
|
const contents = readFileSync(join(directory, files[0]!), 'utf8')
|
|
const records = contents.trim().split('\n').map(line => JSON.parse(line) as { event: string })
|
|
expect(records.map(row => row.event)).toEqual(expect.arrayContaining(['started', 'workspace-ready', 'state', 'check-requested', 'quit-requested']))
|
|
expect(contents).toContain('ENOSPC')
|
|
expect(contents).not.toContain('secret-url')
|
|
} finally {
|
|
// The existing teardown calls quit again; retain its journal until listeners are removed.
|
|
harness.app.removeAllListeners('before-quit')
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('accepts update IPC only from the current application top frame in the owned main window', async () => {
|
|
await readyForUpdate()
|
|
const handler = harness.handlers.get(DESKTOP_IPC.updatesStatus)!
|
|
const sender = harness.windows[0]!.webContents
|
|
expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow()
|
|
for (const event of [
|
|
{ sender: {}, senderFrame: sender.mainFrame },
|
|
{ sender, senderFrame: { url: sender.mainFrame.url } },
|
|
{ sender, senderFrame: { url: 'http://127.0.0.1:40000/' } },
|
|
]) expect(() => handler(event)).toThrow('unowned renderer')
|
|
const original = sender.mainFrame.url
|
|
sender.mainFrame.url = 'http://127.0.0.1:40000/'
|
|
expect(() => handler({ sender, senderFrame: sender.mainFrame })).toThrow('unowned renderer')
|
|
sender.mainFrame.url = original
|
|
})
|
|
|
|
it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
if (platform === 'darwin') {
|
|
expect(window.options).toMatchObject({ titleBarStyle: 'hiddenInset', vibrancy: 'sidebar', backgroundColor: '#00000000' })
|
|
} else if (platform === 'win32') {
|
|
expect(window.options).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT } })
|
|
expect(window.options).not.toHaveProperty('vibrancy')
|
|
expect(harness.menu.setApplicationMenu).toHaveBeenCalledWith(null)
|
|
} else {
|
|
expect(window.options).not.toHaveProperty('titleBarStyle')
|
|
expect(window.options).not.toHaveProperty('vibrancy')
|
|
}
|
|
expect(harness.hosts).toHaveLength(0)
|
|
})
|
|
|
|
it('relays the macOS fullscreen state on transitions and after each load', async () => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
const sent = () => window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)
|
|
expect(sent()).toHaveLength(0)
|
|
window.fullscreen = true
|
|
window.emit('enter-full-screen')
|
|
expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, true])
|
|
// A reload re-registers the preload listener; the finished load resends
|
|
// the current state so fullscreen CSS survives the reload.
|
|
window.webContents.emit('did-finish-load')
|
|
expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, true])
|
|
window.fullscreen = false
|
|
window.emit('leave-full-screen')
|
|
expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, false])
|
|
const relayed = sent().length
|
|
window.destroyed = true
|
|
window.emit('enter-full-screen')
|
|
expect(sent()).toHaveLength(relayed)
|
|
})
|
|
|
|
it.each(['win32', 'linux'] as const)('registers no fullscreen relay on %s', async (platform) => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
window.emit('enter-full-screen')
|
|
window.webContents.emit('did-finish-load')
|
|
expect(window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)).toHaveLength(0)
|
|
})
|
|
|
|
it('follows the Windows primary document language and palette without trusting other frames', async () => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
const listener = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1]
|
|
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
|
listener({ ...event, senderFrame: { url: 'dsh-app://app/' } }, 'zh-CN', '#ffffff', '#000000')
|
|
expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
|
|
listener(event, 'zh-CN', 'rgb(249, 250, 251)', '#0f1115')
|
|
expect(window.setTitleBarOverlay).toHaveBeenCalledWith({ color: 'rgb(249, 250, 251)', symbolColor: '#0f1115' })
|
|
window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } })
|
|
expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: '复制', accelerator: '' }])
|
|
listener(event, 'en', '#1b1b1c', '#f9fafb')
|
|
window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } })
|
|
expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: 'Copy', accelerator: '' }])
|
|
window.setTitleBarOverlay.mockClear()
|
|
listener(event, 'en', 'url(file:///bad)', '#fff')
|
|
expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
|
|
listener(event, {}, '#fff', '#000')
|
|
expect(window.setTitleBarOverlay).toHaveBeenLastCalledWith({ color: '#fff', symbolColor: '#000' })
|
|
window.setTitleBarOverlay.mockClear()
|
|
window.webContents.mainFrame.url = 'dsh-app://unowned/index.html'
|
|
listener(event, 'zh-CN', '#fff', '#000')
|
|
expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
|
|
expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null)
|
|
})
|
|
|
|
it('maps Windows caption menus to localized native commands and rejects foreign popup requests', async () => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
|
const appearance = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1]
|
|
appearance(event, 'zh-CN', '#fff', '#000')
|
|
const handler = harness.handlers.get(DESKTOP_IPC.windowsMenu)!
|
|
const foreignEvent = { ...event, sender: {} }
|
|
expect(() => handler(foreignEvent, 'application', 48, 34)).toThrow('rejected sender')
|
|
expect(() => handler(event, 'arbitrary-command', 48, 34)).toThrow('invalid popup request')
|
|
expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
|
|
const application = handler(event, 'application', 48, 34)
|
|
expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
|
|
'关于 DeepSeek Harness', 'separator', '检查更新…', 'separator', '退出',
|
|
])
|
|
expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
|
|
expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
|
|
harness.popup.mock.lastCall![0].callback!()
|
|
await application
|
|
const edit = handler(event, 'edit', 104, 34)
|
|
expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
|
|
'撤销', '重做', 'separator', '剪切', '复制', '粘贴', '删除', 'separator', '全选',
|
|
])
|
|
const commands = harness.menu.buildFromTemplate.mock.lastCall![0].filter(item => item.type !== 'separator')
|
|
for (const [index, keyCode] of ['Z', 'Y', 'X', 'C', 'V', 'Delete', 'A'].entries()) {
|
|
const click = commands[index]!.click as () => void
|
|
click()
|
|
const modifiers = keyCode === 'Delete' ? [] : ['control']
|
|
expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 1, { type: 'keyDown', keyCode, modifiers })
|
|
expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 2, { type: 'keyUp', keyCode, modifiers })
|
|
}
|
|
harness.popup.mock.lastCall![0].callback!()
|
|
await edit
|
|
})
|
|
|
|
it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const describeItem = (item: MenuItemConstructorOptions): string | undefined =>
|
|
item.role ?? (item.type === 'separator' ? 'separator' : item.label)
|
|
const template = harness.menu.buildFromTemplate.mock.calls
|
|
.map(call => call[0])
|
|
.find(items => items.some(item => item.role === 'editMenu'))
|
|
if (template === undefined) throw new Error('application menu missing')
|
|
expect(template.map(describeItem)).toEqual(platform === 'darwin'
|
|
? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu']
|
|
: ['Application', 'editMenu'])
|
|
const application = template[0]!.submenu as MenuItemConstructorOptions[]
|
|
expect(application.map(describeItem)).toEqual(platform === 'darwin'
|
|
? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
|
: ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit'])
|
|
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
|
|
const originalName = harness.app.name
|
|
harness.app.name = '@deepseek-ai/dsh-desktop'
|
|
try {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const commands = applicationMenuItems().filter(item =>
|
|
item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit')
|
|
await expect(JSON.stringify(commands, null, 2) + '\n')
|
|
.toMatchFileSnapshot(`./expected/application-menu-${locale}.json`)
|
|
expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop')
|
|
} finally { harness.app.name = originalName }
|
|
})
|
|
|
|
it('attaches Host socket credentials only to the owned application origin and window', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
harness.hosts[0]!.ready.resolve()
|
|
await Promise.resolve(invoke(DESKTOP_IPC.boot))
|
|
const handler = harness.socketHeaders.mock.calls[0]![1] as (
|
|
details: { url: string; webContentsId: number; requestHeaders: Record<string, string> },
|
|
callback: (result: unknown) => void,
|
|
) => void
|
|
const callback = vi.fn()
|
|
const details = { url: 'ws://127.0.0.1:3080/api/remote.mux', webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } }
|
|
handler(details, callback)
|
|
expect(callback).toHaveBeenLastCalledWith({ requestHeaders: {
|
|
origin: 'http://127.0.0.1:3080', cookie: 'test-cookie', 'sec-fetch-site': 'same-origin',
|
|
} })
|
|
handler({ ...details, requestHeaders: { Origin: 'https://other.example' } }, callback)
|
|
expect(callback).toHaveBeenLastCalledWith({ cancel: true })
|
|
handler({ ...details, webContentsId: 43 }, callback)
|
|
expect(callback).toHaveBeenLastCalledWith({})
|
|
handler({ ...details, url: 'ws://127.0.0.1:9999/api/remote.mux' }, callback)
|
|
expect(callback).toHaveBeenLastCalledWith({})
|
|
})
|
|
|
|
it('registers the window-owned directory picker during startup and rejects foreign callers', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
const handler = harness.handlers.get(DESKTOP_IPC.directoryPick) as (event: IpcMainInvokeEvent) => Promise<string | null>
|
|
expect(handler).toBeTypeOf('function')
|
|
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } as unknown as IpcMainInvokeEvent
|
|
harness.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: ['/workspace'] })
|
|
await expect(handler(event)).resolves.toBe('/workspace')
|
|
expect(harness.dialog.showOpenDialog).toHaveBeenCalledExactlyOnceWith(window, { properties: ['openDirectory', 'createDirectory'] })
|
|
window.webContents.mainFrame.url = 'https://other.example/'
|
|
await expect(handler(event)).rejects.toThrow('unowned renderer')
|
|
})
|
|
|
|
it('holds boot injections until the Host is ready and rejects foreign boot callers', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const handler = harness.handlers.get(DESKTOP_IPC.boot)!
|
|
await expect(handler({ senderFrame: { url: 'https://other.example/' } })).rejects.toThrow('unowned renderer')
|
|
let settled = false
|
|
const boot = Promise.resolve(handler({ senderFrame: { url: 'dsh-app://app/' } })).then((value) => { settled = true; return value })
|
|
await Promise.resolve()
|
|
expect(settled).toBe(false)
|
|
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
expect(settled).toBe(false)
|
|
harness.hosts[0]!.ready.resolve()
|
|
await expect(boot).resolves.toEqual({ injections: [], streamBaseUrl: 'http://127.0.0.1:3080' })
|
|
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
})
|
|
|
|
it('retains macOS native editing actions on right-click and only copy for selected read-only text', async () => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
const editFlags = { canUndo: true, canRedo: false, canCut: true, canCopy: true, canPaste: true, canSelectAll: true }
|
|
harness.menu.mockClear()
|
|
|
|
window.webContents.emit('context-menu', {}, { isEditable: true, selectionText: 'text', editFlags })
|
|
expect(harness.menu).toHaveBeenLastCalledWith([
|
|
{ role: 'undo', enabled: true, accelerator: '' }, { role: 'redo', enabled: false, accelerator: '' },
|
|
{ type: 'separator', accelerator: '' },
|
|
{ role: 'cut', enabled: true, accelerator: '' }, { role: 'copy', enabled: true, accelerator: '' },
|
|
{ role: 'paste', enabled: true, accelerator: '' }, { type: 'separator', accelerator: '' },
|
|
{ role: 'selectAll', enabled: true, accelerator: '' },
|
|
])
|
|
expect(harness.popup).toHaveBeenCalledWith({ window })
|
|
|
|
window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags })
|
|
expect(harness.menu).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, accelerator: '' }])
|
|
|
|
harness.menu.mockClear()
|
|
window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: '', editFlags })
|
|
expect(harness.menu).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('opens message links externally while retaining same-origin application navigation', async () => {
|
|
await readyForUpdate()
|
|
const window = harness.windows[0]!
|
|
const openWindow = window.webContents.setWindowOpenHandler.mock.calls[0]![0] as
|
|
(details: { url: string }) => { action: string }
|
|
const source = 'https://example.com/source?q=reference'
|
|
expect(openWindow({ url: source })).toEqual({ action: 'deny' })
|
|
expect(harness.openExternal).toHaveBeenCalledWith(source)
|
|
harness.openExternal.mockClear()
|
|
const external = { preventDefault: vi.fn() }
|
|
window.webContents.emit('will-navigate', external, 'https://example.com/document')
|
|
expect(external.preventDefault).toHaveBeenCalledOnce()
|
|
expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/document')
|
|
harness.openExternal.mockClear()
|
|
const internal = { preventDefault: vi.fn() }
|
|
window.webContents.emit('will-navigate', internal, 'dsh-app://app/session/task-1')
|
|
expect(internal.preventDefault).not.toHaveBeenCalled()
|
|
expect(harness.openExternal).not.toHaveBeenCalled()
|
|
})
|
|
|
|
async function readyForUpdate() {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
harness.hosts[0]!.ready.resolve()
|
|
await harness.navigated.promise
|
|
return harness.hosts[0]!
|
|
}
|
|
|
|
it('hides the workspace before intentional Host shutdown can look like reconnection', async () => {
|
|
const host = await readyForUpdate()
|
|
const window = harness.windows[0]!
|
|
window.show.mockClear()
|
|
window.focus.mockClear()
|
|
harness.app.quit()
|
|
expect(window.hide).toHaveBeenCalledOnce()
|
|
await host.stopping.promise
|
|
harness.app.emit('second-instance')
|
|
expect(window.show).not.toHaveBeenCalled()
|
|
expect(window.focus).not.toHaveBeenCalled()
|
|
host.exited.resolve()
|
|
await harness.quitCompleted.promise
|
|
})
|
|
|
|
it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => {
|
|
const host = await readyForUpdate()
|
|
const window = harness.windows[0]!
|
|
window.destroyed = true
|
|
window.hide.mockImplementation(() => { throw new TypeError('Object has been destroyed') })
|
|
expect(() => { harness.app.quit() }).not.toThrow()
|
|
expect(window.hide).not.toHaveBeenCalled()
|
|
await host.stopping.promise
|
|
window.emit('closed')
|
|
host.exited.resolve()
|
|
await harness.quitCompleted.promise
|
|
})
|
|
|
|
it('shares the failed-check deadline across focus and resume, while explicit checks reset polling', async () => {
|
|
vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', '1000')
|
|
vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', '4000')
|
|
vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_JITTER', '0')
|
|
harness.updateCheck.mockResolvedValue({ phase: 'error', failedOperation: 'check', message: 'offline' })
|
|
const host = await readyForUpdate()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(harness.updateCheck).toHaveBeenCalledOnce()
|
|
await vi.advanceTimersByTimeAsync(1999)
|
|
harness.windows[0]!.emit('focus')
|
|
harness.powerMonitor.emit('resume')
|
|
expect(harness.updateCheck).toHaveBeenCalledOnce()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(harness.updateCheck).toHaveBeenCalledTimes(2)
|
|
harness.updateCheck.mockResolvedValue({ phase: 'idle' })
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(harness.updateCheck).toHaveBeenLastCalledWith(true)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
expect(harness.updateCheck).toHaveBeenCalledTimes(4)
|
|
harness.app.quit()
|
|
try {
|
|
await host.stopping.promise
|
|
await vi.advanceTimersByTimeAsync(10_000)
|
|
harness.powerMonitor.emit('resume')
|
|
expect(harness.updateCheck).toHaveBeenCalledTimes(4)
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
} finally { host.exited.resolve(); await harness.quitCompleted.promise }
|
|
})
|
|
|
|
it('blocks subsequent product operations without stopping the Host and clears only on a fresh no-force policy', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com',
|
|
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
|
|
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', '{invalid environment override}')
|
|
const request = vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ code: 40005,
|
|
data: { show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/' } }))
|
|
vi.stubGlobal('fetch', request)
|
|
const host = await readyForUpdate()
|
|
await harness.policyBlocked.promise
|
|
const modal = harness.windows[0]!
|
|
expect(modal).toBeDefined()
|
|
expect(harness.windows).toHaveLength(1)
|
|
const status = harness.handlers.get(MANDATORY_IPC.status)!
|
|
const action = harness.handlers.get(MANDATORY_IPC.action)!
|
|
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
|
|
expect(status(owned)).toMatchObject({ policy: { blocking: true } })
|
|
const unowned = [
|
|
{ ...owned, sender: {} },
|
|
{ ...owned, senderFrame: { url: 'dsh-app://app/index.html' } },
|
|
{ ...owned, senderFrame: { url: 'https://untrusted.example.com/' } },
|
|
]
|
|
for (const event of unowned) {
|
|
expect(() => status(event)).toThrow('unowned renderer')
|
|
expect(() => action(event, 'download', '1.0.1-nightly.1')).toThrow('unowned renderer')
|
|
}
|
|
expect(() => action(owned, 'open-arbitrary-url')).toThrow('invalid action')
|
|
expect(() => action(owned, 'download')).toThrow('missing confirmed version')
|
|
expect(() => action(owned, 'install', 123)).toThrow('missing confirmed version')
|
|
expect(harness.updateDownload).not.toHaveBeenCalled()
|
|
expect(harness.updateInstall).not.toHaveBeenCalled()
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
expect(harness.updateDownload).not.toHaveBeenCalled()
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
request.mockImplementationOnce(async () => Response.json({ code: 500 }, { status: 503 }))
|
|
await vi.advanceTimersByTimeAsync(10_000)
|
|
expect(modal.isDestroyed()).toBe(false)
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
|
|
await vi.advanceTimersByTimeAsync(20_000)
|
|
expect(modal.isDestroyed()).toBe(false)
|
|
await vi.advanceTimersByTimeAsync(150)
|
|
expect(modal.isDestroyed()).toBe(false)
|
|
expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }])
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' })
|
|
})
|
|
|
|
it('keeps one checking dialog open until the manual check settles, then reports the current version', async () => {
|
|
await readyForUpdate()
|
|
const checked = Promise.withResolvers<DesktopUpdateState>()
|
|
const checking = Promise.withResolvers<AbortSignal>()
|
|
const requested = Promise.withResolvers<undefined>()
|
|
harness.updateCheck.mockImplementationOnce(() => { requested.resolve(undefined); return checked.promise })
|
|
harness.dialog.showMessageBox.mockImplementationOnce(({ signal }: { signal: AbortSignal }) => {
|
|
checking.resolve(signal)
|
|
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
|
|
}).mockImplementationOnce(() => {
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as { signal: AbortSignal }).signal.aborted).toBe(false)
|
|
return Promise.resolve({ response: 0 })
|
|
})
|
|
const submenu = applicationMenuItems()
|
|
const action = submenu.find(item => item.label === 'Check for Updates…')
|
|
expect(action?.click).toBeTypeOf('function')
|
|
// Electron supplies menu arguments that this callback does not consume.
|
|
Reflect.apply(action!.click!, undefined, [])
|
|
const prompt = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app'))
|
|
const signal = await checking.promise
|
|
await requested.promise
|
|
expect(signal.aborted).toBe(false)
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(1)
|
|
expect(harness.updateCheck).toHaveBeenLastCalledWith(true)
|
|
checked.resolve({ phase: 'idle' })
|
|
await prompt
|
|
expect(signal.aborted).toBe(true)
|
|
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
message: 'No updates available. Current version: V1.0.0',
|
|
}))
|
|
})
|
|
|
|
it('reports a manual check failure without offering a download', async () => {
|
|
await readyForUpdate()
|
|
harness.updateCheck.mockResolvedValueOnce({ phase: 'error', failedOperation: 'check', message: 'Feed unavailable' })
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(2)
|
|
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'error',
|
|
message: 'Could not check for updates. Please try again later.', technicalDetails: 'Feed unavailable' }))
|
|
expect(harness.updateDownload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports a stale download confirmation as a download failure', async () => {
|
|
await readyForUpdate()
|
|
harness.updateCheck.mockResolvedValueOnce({ phase: 'available', version: '1.0.1-nightly.1' })
|
|
harness.updateDownload.mockRejectedValueOnce(new Error('desktop update: download confirmation is stale'))
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'error',
|
|
message: en.updateDownloadFailed, technicalDetails: 'desktop update: download confirmation is stale' }))
|
|
})
|
|
|
|
it('keeps policy failures silent while an ordinary update proceeds', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com',
|
|
allowedPageOrigins: ['https://downloads.example.com'] }
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
|
|
Response.json({ code: 500 }, { status: 503 })))
|
|
await readyForUpdate()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
harness.dialog.showMessageBox.mockClear()
|
|
harness.updateCheck.mockResolvedValueOnce({ phase: 'available', version: '1.0.1-nightly.1' })
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
|
|
expect(messages).not.toContain(en.mandatoryUnavailable)
|
|
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1')
|
|
})
|
|
|
|
it('does not wait for a pending policy request before proceeding with an ordinary update', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com',
|
|
allowedPageOrigins: ['https://downloads.example.com'] }
|
|
const policy = Promise.withResolvers<Response>()
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(() => policy.promise))
|
|
harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' })
|
|
const operation = Promise.resolve().then(async () => {
|
|
await readyForUpdate()
|
|
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
})
|
|
try {
|
|
await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') })
|
|
} finally {
|
|
policy.resolve(Response.json({ code: 500 }, { status: 503 }))
|
|
await operation
|
|
}
|
|
})
|
|
|
|
it('lets a confirmed mandatory policy preempt an ordinary result dialog', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com',
|
|
allowedPageOrigins: ['https://downloads.example.com'] }
|
|
const policy = Promise.withResolvers<Response>()
|
|
const available = Promise.withResolvers<AbortSignal>()
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(() => policy.promise))
|
|
harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' })
|
|
harness.dialog.showMessageBox.mockImplementation(({ signal, message }: { signal?: AbortSignal; message: string }) => {
|
|
if (message !== en.updateAvailable || signal === undefined) return Promise.resolve({ response: 1 })
|
|
available.resolve(signal)
|
|
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
|
|
})
|
|
await readyForUpdate()
|
|
const submenu = applicationMenuItems()
|
|
const action = submenu.find(item => item.label === 'Check for Updates…')
|
|
Reflect.apply(action!.click!, undefined, [])
|
|
const operation = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app'))
|
|
try {
|
|
const signal = await available.promise
|
|
policy.resolve(Response.json({ code: 40005,
|
|
data: { show_content: { title: 'Update required', detail: 'Please update' },
|
|
desktop_app_link: 'https://downloads.example.com/' } }))
|
|
await harness.policyBlocked.promise
|
|
expect(signal.aborted).toBe(true)
|
|
await operation
|
|
expect(harness.updateDownload).not.toHaveBeenCalled()
|
|
} finally {
|
|
policy.resolve(Response.json({ code: 500 }, { status: 503 }))
|
|
await operation
|
|
}
|
|
})
|
|
|
|
it('queues policy authentication until the ordinary result dialog closes', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
|
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
|
|
const request = vi.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
|
|
.mockResolvedValue(Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
|
|
vi.stubGlobal('fetch', request)
|
|
await readyForUpdate()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
const ordinaryResult = Promise.withResolvers<{ response: number }>()
|
|
const ordinaryShown = Promise.withResolvers<undefined>()
|
|
const policyShown = Promise.withResolvers<undefined>()
|
|
harness.dialog.showMessageBox.mockImplementation(({ message }: { message: string }) => {
|
|
if (message.startsWith('No updates available.')) {
|
|
ordinaryShown.resolve(undefined)
|
|
return ordinaryResult.promise
|
|
}
|
|
if (message === en.policyLoginRequired) policyShown.resolve(undefined)
|
|
return Promise.resolve({ response: 1 })
|
|
})
|
|
const operation = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app'))
|
|
await ordinaryShown.promise
|
|
expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message))
|
|
.not.toContain(en.policyLoginRequired)
|
|
ordinaryResult.resolve({ response: 0 })
|
|
await operation
|
|
await policyShown.promise
|
|
expect(testAuth.login).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('downloads on the first click and opens installation confirmation only after readiness', async () => {
|
|
await readyForUpdate()
|
|
harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' }
|
|
const downloading = Promise.withResolvers<DesktopUpdateState>()
|
|
const started = Promise.withResolvers<undefined>()
|
|
harness.updateDownload.mockImplementationOnce(async (version) => {
|
|
harness.updateState = { phase: 'downloading', version }
|
|
started.resolve(undefined)
|
|
return downloading.promise
|
|
})
|
|
const action = invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
await started.promise
|
|
const repeated = invoke(DESKTOP_IPC.updatesOpen, 'app')
|
|
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1')
|
|
expect(harness.updateInstall).not.toHaveBeenCalled()
|
|
downloading.resolve({ phase: 'ready', version: '1.0.1-nightly.1' })
|
|
await Promise.all([action, repeated])
|
|
expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1')
|
|
await harness.updateCheck()
|
|
expect(harness.updateInstall).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('does not lock or stop tasks when restart confirmation is dismissed', async () => {
|
|
const host = await readyForUpdate()
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 })
|
|
await expect(harness.prepareUpdate()).resolves.toBe(false)
|
|
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rechecks admission after approval and rejects work that started during confirmation', async () => {
|
|
const host = await readyForUpdate()
|
|
host.updateTasks.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
await expect(harness.prepareUpdate()).rejects.toThrow(/New tasks/u)
|
|
expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']])
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('warns about active tasks and waits for a graceful Host exit after approval', async () => {
|
|
const host = await readyForUpdate()
|
|
host.updateTasks.mockResolvedValue(true)
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
const preparing = harness.prepareUpdate()
|
|
await host.stopping.promise
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({
|
|
message: 'Tasks are still in progress', buttons: ['Stop tasks and update', 'Update later'],
|
|
}))
|
|
expect(host.stop).toHaveBeenCalledWith(true)
|
|
host.exited.resolve()
|
|
await expect(preparing).resolves.toBe(true)
|
|
})
|
|
|
|
it('unlocks admission without stopping the Host when request draining fails', async () => {
|
|
const host = await readyForUpdate()
|
|
host.updateTasks.mockResolvedValueOnce(false).mockRejectedValueOnce(new Error('task inspection timed out'))
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
await expect(harness.prepareUpdate()).rejects.toThrow('task inspection timed out')
|
|
expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']])
|
|
expect(host.stop).not.toHaveBeenCalled()
|
|
})
|
|
|
|
async function answerMandatory(action: 'install' | 'later') {
|
|
const modal = harness.windows[0]!
|
|
const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
|
|
await vi.waitFor(() => {
|
|
expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation')
|
|
})
|
|
const view = harness.handlers.get(MANDATORY_IPC.status)!(event) as { confirmation: { version: string; revision: number } }
|
|
await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision)
|
|
}
|
|
|
|
it('closes the main window when the confirmed installer quits Electron', async () => {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
|
|
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
|
|
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
|
|
} })))
|
|
const host = await readyForUpdate()
|
|
await harness.policyBlocked.promise
|
|
const modal = harness.windows[0]!
|
|
const preparing = harness.prepareUpdate()
|
|
await answerMandatory('install')
|
|
await host.stopping.promise
|
|
host.exited.resolve()
|
|
await expect(preparing).resolves.toBe(true)
|
|
harness.closeWindowsOnQuit = true
|
|
harness.app.quit()
|
|
await harness.quitCompleted.promise
|
|
expect(modal.isDestroyed()).toBe(true)
|
|
expect(harness.app.quit).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it.each([false, true])('restores a cleanly stopped Host after installer failure and retains mandatory blocking: %s', async (mandatory) => {
|
|
if (mandatory) {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
|
|
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
|
|
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
|
|
} })))
|
|
}
|
|
const host = await readyForUpdate()
|
|
if (mandatory) await harness.policyBlocked.promise
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
const preparing = harness.prepareUpdate()
|
|
if (mandatory) await answerMandatory('install')
|
|
await host.stopping.promise
|
|
host.exited.resolve()
|
|
await expect(preparing).resolves.toBe(true)
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 1 })
|
|
const restarted = harness.nextHostStart()
|
|
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' })
|
|
await restarted
|
|
const replacement = harness.hosts[1]!
|
|
replacement.url = 'http://127.0.0.1:3099/?token=replacement'
|
|
if (mandatory) replacement.updateTasks.mockResolvedValue(true)
|
|
const retry = harness.prepareUpdate()
|
|
expect(replacement.updateTasks).not.toHaveBeenCalled()
|
|
replacement.ready.resolve()
|
|
await vi.waitFor(() => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/']) })
|
|
await expect(Promise.resolve(invoke(DESKTOP_IPC.boot))).resolves.toEqual({ injections: [], streamBaseUrl: 'http://127.0.0.1:3099' })
|
|
if (mandatory) await answerMandatory('later')
|
|
await expect(retry).resolves.toBe(false)
|
|
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
|
|
expect(replacement.stop).not.toHaveBeenCalled()
|
|
if (mandatory) {
|
|
expect(harness.windows).toHaveLength(1)
|
|
expect(harness.windows[0]!.isDestroyed()).toBe(false)
|
|
}
|
|
})
|
|
|
|
it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => {
|
|
if (mandatory) {
|
|
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
|
|
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
|
|
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
|
|
} })))
|
|
}
|
|
const host = await readyForUpdate()
|
|
if (mandatory) await harness.policyBlocked.promise
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
const preparing = harness.prepareUpdate()
|
|
const rejected = expect(preparing).rejects.toMatchObject(new DesktopUpdatePreparationError('stop-failed', en.updateStopFailed, 'Task teardown failed after child exit'))
|
|
if (mandatory) await answerMandatory('install')
|
|
await host.stopping.promise
|
|
host.exited.reject(new DesktopHostUncleanExitError('Task teardown failed after child exit'))
|
|
await rejected
|
|
expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock']])
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 1 })
|
|
const restarted = harness.nextHostStart()
|
|
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Task teardown failed' })
|
|
await restarted
|
|
const replacement = harness.hosts[1]!
|
|
if (mandatory) replacement.updateTasks.mockResolvedValue(true)
|
|
const retry = harness.prepareUpdate()
|
|
expect(replacement.updateTasks).not.toHaveBeenCalled()
|
|
replacement.ready.resolve()
|
|
await vi.waitFor(() => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/']) })
|
|
if (mandatory) await answerMandatory('later')
|
|
await expect(retry).resolves.toBe(false)
|
|
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
|
|
expect(replacement.stop).not.toHaveBeenCalled()
|
|
if (mandatory) {
|
|
expect(harness.windows).toHaveLength(1)
|
|
expect(harness.windows[0]!.isDestroyed()).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('does not replace a Host whose failed stop has not confirmed process exit', async () => {
|
|
const host = await readyForUpdate()
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
const preparing = harness.prepareUpdate()
|
|
const rejected = expect(preparing).rejects.toThrow('child did not exit')
|
|
await host.stopping.promise
|
|
host.exited.reject(new Error('child did not exit'))
|
|
await rejected
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 1 })
|
|
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'child did not exit' })
|
|
await expect(harness.prepareUpdate()).rejects.toThrow('Task status is unavailable')
|
|
expect(harness.hosts).toHaveLength(1)
|
|
expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']])
|
|
})
|
|
|
|
it('reports replacement startup failure without authorizing installation or retrying automatically', async () => {
|
|
const host = await readyForUpdate()
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
const preparing = harness.prepareUpdate()
|
|
const rejected = expect(preparing).rejects.toMatchObject(new DesktopUpdatePreparationError('stop-failed', en.updateStopFailed, 'Task teardown failed after child exit'))
|
|
await host.stopping.promise
|
|
host.exited.reject(new DesktopHostUncleanExitError('Task teardown failed after child exit'))
|
|
await rejected
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response: 1 })
|
|
const restarted = harness.nextHostStart()
|
|
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Task teardown failed' })
|
|
await restarted
|
|
const replacement = harness.hosts[1]!
|
|
harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) })
|
|
replacement.ready.reject(new Error('replacement startup failed'))
|
|
replacement.exited.resolve()
|
|
await harness.dialogShown.promise
|
|
expect(harness.dialog.showMessageBox.mock.calls.some(call =>
|
|
(call.at(-1) as MessageBoxOptions).detail?.includes('replacement startup failed'))).toBe(true)
|
|
await expect(harness.prepareUpdate()).rejects.toThrow('replacement startup failed')
|
|
expect(harness.hosts).toHaveLength(2)
|
|
})
|
|
|
|
it('opens fatal recovery when the replacement page cannot reload', async () => {
|
|
const host = await readyForUpdate()
|
|
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
|
const preparing = harness.prepareUpdate()
|
|
await host.stopping.promise
|
|
host.exited.resolve()
|
|
await expect(preparing).resolves.toBe(true)
|
|
const window = harness.windows[0]!
|
|
vi.spyOn(window, 'loadURL').mockRejectedValueOnce(new Error('replacement page failed to load'))
|
|
harness.dialog.showMessageBox.mockImplementation((options: MessageBoxOptions) => {
|
|
if (options.detail?.includes('replacement page failed to load')) {
|
|
harness.dialogShown.resolve()
|
|
return new Promise(() => {})
|
|
}
|
|
return Promise.resolve({ response: 1 })
|
|
})
|
|
const restarted = harness.nextHostStart()
|
|
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' })
|
|
await restarted
|
|
harness.hosts[1]!.ready.resolve()
|
|
await harness.dialogShown.promise
|
|
expect(harness.dialog.showMessageBox.mock.calls.some(call =>
|
|
(call.at(-1) as MessageBoxOptions).detail?.includes('replacement page failed to load'))).toBe(true)
|
|
})
|
|
|
|
it('reports a window construction failure without requiring a window', async () => {
|
|
const shown = Promise.withResolvers<undefined>()
|
|
harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
|
|
harness.failWindow(new Error('window creation failed'))
|
|
await import('../src/main.ts')
|
|
await shown.promise
|
|
expect(harness.windows).toHaveLength(0)
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('window creation failed')
|
|
})
|
|
|
|
it('accepts Web fatal reports only from the primary application frame', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
const frame = { url: 'dsh-app://app/' }
|
|
Object.assign(window.webContents, { mainFrame: frame })
|
|
const handler = harness.handlers.get(DESKTOP_IPC.bootFailed)! as (event: unknown, message: unknown) => void
|
|
const event = { sender: window.webContents, senderFrame: frame }
|
|
expect(() => { handler({ ...event, senderFrame: { url: 'https://other.example/' } }, 'untrusted') }).toThrow('unowned renderer')
|
|
expect(() => { handler({ ...event, senderFrame: { ...frame } }, 'subframe') }).toThrow('non-primary frame')
|
|
expect(() => { handler(event, {}) }).toThrow('must be text')
|
|
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
handler(event, 'client mount failed')
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('client mount failed')
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
})
|
|
|
|
it('ignores subresource failures and navigation cancellation but reports a failed main document', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/image.png', false)
|
|
window.webContents.emit('did-fail-load', {}, -3, 'aborted', 'dsh-app://app/', true)
|
|
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true)
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('Desktop page failed to load')
|
|
})
|
|
|
|
it('offers all recovery choices when resources fail before the Host starts', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.reject(new Error('runtime resources missing'))
|
|
await harness.dialogShown.promise
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('runtime resources missing')
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart'])
|
|
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
})
|
|
|
|
it.each(['preload', 'renderer'])('retains the document after a fatal %s failure and reports only the first error', async (kind) => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
if (kind === 'preload') window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
|
|
else window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
|
|
window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('secondary failure'))
|
|
harness.prepared.reject(new Error('backend also failed'))
|
|
await harness.dialogShown.promise
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).not.toContain('secondary failure')
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
})
|
|
|
|
it('ignores clean renderer exits and exits of a closed window', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
const window = harness.windows[0]!
|
|
window.webContents.emit('render-process-gone', {}, { reason: 'clean-exit' })
|
|
window.close()
|
|
window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
|
|
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports a rejected document load without navigating to a recovery page', async () => {
|
|
const shown = Promise.withResolvers<undefined>()
|
|
harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
|
|
vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('document missing'))
|
|
await import('../src/main.ts')
|
|
await shown.promise
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('document missing')
|
|
expect(harness.hosts).toHaveLength(0)
|
|
})
|
|
|
|
it.each([0, 1, 2])('waits for Host exit before recovery action %s', async (response) => {
|
|
harness.dialog.showMessageBox.mockResolvedValue({ response, checkboxChecked: false })
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
const host = harness.hosts[0]!
|
|
host.ready.resolve()
|
|
await Promise.resolve(invoke(DESKTOP_IPC.boot))
|
|
host.onFailure!(new Error('backend exited'))
|
|
await host.stopping.promise
|
|
expect(harness.app.relaunch).not.toHaveBeenCalled()
|
|
expect(harness.disableAllPlugins).not.toHaveBeenCalled()
|
|
host.exited.resolve()
|
|
await harness.quitCompleted.promise
|
|
expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
|
|
expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
|
|
if (response === 2) {
|
|
expect(console.info).toHaveBeenCalledWith('Desktop profile recovery completed:', {
|
|
profilePatchBackup: 'desktop-test-profile/cordis.patch.yml.bak-1789555200000', homePatch: 'unchanged',
|
|
})
|
|
} else {
|
|
expect(console.info).not.toHaveBeenCalled()
|
|
}
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
})
|
|
|
|
it('shows the loading window before profile preparation and starts one actual Host', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
expect(harness.windows).toHaveLength(1)
|
|
const window = harness.windows[0]!
|
|
expect(window.options.show).toBe(true)
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
expect(harness.hosts).toHaveLength(0)
|
|
const retry = invoke(DESKTOP_IPC.boot)
|
|
const secondRetry = invoke(DESKTOP_IPC.boot)
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
expect(harness.hosts).toHaveLength(1)
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
harness.hosts[0]!.ready.resolve()
|
|
await Promise.all([retry, secondRetry, harness.navigated.promise])
|
|
expect(harness.applyRelease).toHaveBeenCalledTimes(1)
|
|
expect(harness.hosts[0]).toMatchObject({
|
|
node: process.execPath,
|
|
runtime: join(harness.app.getAppPath(), 'dsh'),
|
|
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
|
|
profile: 'desktop-test-profile',
|
|
})
|
|
expect(harness.hosts[0]!.environment).toBe(process.env)
|
|
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
|
expect(harness.windows).toHaveLength(1)
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
})
|
|
|
|
it('prepares an independent plugin profile for the unpackaged Host', async () => {
|
|
harness.app.isPackaged = false
|
|
vi.stubEnv('DSH_DESKTOP_DSH_DIR', undefined)
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
|
|
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, profile: 'desktop-test-profile' })
|
|
expect(harness.applyRelease).toHaveBeenCalledOnce()
|
|
harness.hosts[0]!.ready.resolve()
|
|
await harness.navigated.promise
|
|
expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps startup errors in the existing window without a retry handler', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
const first = harness.hosts[0]!
|
|
first.exited.resolve()
|
|
first.ready.reject(new Error('plugin composition failed'))
|
|
await harness.dialogShown.promise
|
|
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false)
|
|
expect(harness.hosts).toHaveLength(1)
|
|
})
|
|
|
|
it('waits for a pending child to exit on quit without late window navigation', async () => {
|
|
await import('../src/main.ts')
|
|
await harness.preparing.promise
|
|
harness.prepared.resolve()
|
|
await harness.hostStarted.promise
|
|
const window = harness.windows[0]!
|
|
const host = harness.hosts[0]!
|
|
host.stop.mockImplementation(() => { host.stopping.resolve(); return host.exited.promise })
|
|
window.close()
|
|
harness.app.quit()
|
|
await host.stopping.promise
|
|
expect(harness.app.quit).toHaveBeenCalledTimes(1)
|
|
host.ready.resolve()
|
|
host.exited.resolve()
|
|
await harness.quitCompleted.promise
|
|
expect(host.stop).toHaveBeenCalledTimes(1)
|
|
expect(window.urls).toEqual(['dsh-app://app/'])
|
|
expect(harness.windows).toHaveLength(1)
|
|
})
|
|
})
|