openpencil/tests/engine/tauri/processes.test.ts
Danila Poyarkov a029e267c2
feat(updater): show download progress while installing an update
Report desktop update downloads through persistent determinate or indeterminate toasts while retaining native confirmation. Use a spinner during progress and cancel pending expiry when progress resumes.

Standardize substantial Storybook fixtures as colocated example SFCs, preserve shared SDK documentation examples, and enforce semantic anatomy instead of shared-layer test IDs.
2026-09-25 23:15:14 +04:00

232 lines
7.5 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'bun:test'
import { ref } from 'vue'
import { spawnACPProcess } from '@/app/ai/acp/process'
import { toast } from '@/app/shell/ui'
import { checkForAppUpdate } from '@/app/shell/updater'
import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks'
afterEach(async () => {
await clearTauriMocks()
vi.restoreAllMocks()
for (const entry of toast.toasts.value) toast.remove(entry.id)
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'navigator')
})
describe('Tauri process helpers', () => {
test('spawns ACP processes and streams stdout/stdin through plugin-shell', async () => {
let onEvent: ((event: unknown) => void) | null = null
const calls: Array<{ cmd: string; args: unknown }> = []
await mockTauriIPC((cmd, args) => {
calls.push({ cmd, args })
if (cmd === 'plugin:shell|spawn') {
expect(args).toMatchObject({
program: 'agent-cli',
args: ['--stdio'],
options: { encoding: 'raw', env: {} }
})
onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent.onmessage
return 42
}
return null
})
const process = await spawnACPProcess({
command: 'agent-cli',
args: ['--stdio'],
logId: 'test',
destroying: () => false,
onUnexpectedClose: vi.fn()
})
const reader = process.output.getReader()
onEvent?.({ event: 'Stdout', payload: [1, 2, 3] })
await expect(reader.read()).resolves.toEqual({ done: false, value: new Uint8Array([1, 2, 3]) })
const writer = process.input.getWriter()
await writer.write(new Uint8Array([4, 5]))
await process.child.kill()
expect(calls.map((call) => call.cmd)).toEqual([
'plugin:shell|spawn',
'plugin:shell|stdin_write',
'plugin:shell|kill'
])
expect(calls[1]?.args).toEqual({ pid: 42, buffer: [4, 5] })
expect(calls[2]?.args).toEqual({ cmd: 'killChild', pid: 42 })
})
test('starts Windows ACP command shims through cmd', async () => {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
})
await mockTauriIPC((cmd, args) => {
if (cmd === 'plugin:shell|spawn') {
expect(args).toMatchObject({
program: 'cmd',
args: ['/c', 'agent-cli', '--stdio'],
options: { encoding: 'raw', env: {} }
})
return 44
}
return null
})
const process = await spawnACPProcess({
command: 'agent-cli',
args: ['--stdio'],
logId: 'test',
destroying: () => false,
onUnexpectedClose: vi.fn()
})
await process.child.kill()
})
test('signals unexpected ACP process close to the output stream', async () => {
let onEvent: ((event: unknown) => void) | null = null
const onUnexpectedClose = vi.fn()
await mockTauriIPC((cmd, args) => {
if (cmd === 'plugin:shell|spawn') {
onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent.onmessage
return 43
}
return null
})
const process = await spawnACPProcess({
command: 'agent-cli',
args: [],
logId: 'test',
destroying: () => false,
onUnexpectedClose
})
const reader = process.output.getReader()
onEvent?.({ event: 'Terminated', payload: { code: 1, signal: null } })
await expect(reader.read()).rejects.toThrow('Agent process exited unexpectedly.')
expect(onUnexpectedClose).toHaveBeenCalled()
})
})
describe('Tauri updater helper', () => {
const messages = ref({
upToDate: 'Up to date',
availableTitle: 'Update available',
available: ({ version }: { version: string }) => `Version ${version}`,
installPrompt: 'Install now?',
downloading: ({ version }: { version: string }) => `Downloading ${version}`,
downloadProgress: ({
percent,
downloaded,
total
}: {
percent: number
downloaded: string
total: string
}) => `${percent}% of ${total} (${downloaded})`,
downloadProgressUnknown: ({ downloaded }: { downloaded: string }) => `${downloaded} downloaded`,
installedTitle: 'Installed',
installed: ({ version, size }: { version: string; size: string }) =>
`Installed ${version}${size}`,
unavailable: 'Unavailable',
checkFailed: ({ error }: { error: string }) => `Failed: ${error}`
})
test('returns quietly when no Tauri update is available', async () => {
const calls: string[] = []
await mockTauriIPC((cmd) => {
calls.push(cmd)
if (cmd === 'plugin:updater|check') return null
return null
})
await checkForAppUpdate({ silent: true, messages })
expect(calls).toEqual(['plugin:updater|check'])
})
test('confirms, installs, and relaunches available Tauri updates', async () => {
const calls: Array<{ cmd: string; args: unknown }> = []
await mockTauriIPC((cmd, args) => {
calls.push({ cmd, args })
if (cmd === 'plugin:updater|check') {
return {
rid: 9,
currentVersion: '0.11.8',
version: '0.11.9',
date: null,
body: 'Release notes',
rawJson: '{}'
}
}
if (cmd === 'plugin:dialog|message') {
const options = args as { buttons?: string }
if (options.buttons === 'OkCancel') return 'Ok'
}
if (cmd === 'plugin:updater|download_and_install') {
const onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent
onEvent.onmessage({ event: 'Started', data: { contentLength: 10 } })
onEvent.onmessage({ event: 'Progress', data: { chunkLength: 10 } })
}
return null
})
await checkForAppUpdate({ messages })
expect(calls.map((call) => call.cmd)).toEqual([
'plugin:updater|check',
'plugin:dialog|message',
'plugin:updater|download_and_install',
'plugin:dialog|message',
'plugin:process|restart'
])
expect(calls[1]?.args).toMatchObject({
title: 'Update available',
kind: 'info',
buttons: 'OkCancel'
})
expect(calls[2]?.args).toMatchObject({ rid: 9 })
})
test('reports download progress once and clears the toast on failure', async () => {
await mockTauriIPC((cmd, args) => {
if (cmd === 'plugin:updater|check') {
return {
rid: 9,
currentVersion: '0.11.8',
version: '0.11.9',
date: null,
body: 'Release notes',
rawJson: '{}'
}
}
if (cmd === 'plugin:dialog|message') {
const options = args as { buttons?: string }
if (options.buttons === 'OkCancel') return 'Ok'
}
if (cmd === 'plugin:updater|download_and_install') {
const onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent
onEvent.onmessage({ event: 'Started', data: { contentLength: 4 } })
onEvent.onmessage({ event: 'Progress', data: { chunkLength: 2 } })
expect(toast.toasts.value).toHaveLength(1)
expect(toast.toasts.value[0]?.progress).toEqual({ value: 2, max: 4 })
expect(toast.toasts.value[0]?.progressLabel).toBe('50% of 4 B (2 B)')
throw new Error('download interrupted')
}
return null
})
await checkForAppUpdate({ messages })
// The failed progress toast must not linger: it never expires on its own.
expect(toast.toasts.value.map((entry) => entry.message)).toEqual([
'Failed: download interrupted'
])
})
})