* refactor(app): share Markdown rendering outside chat Move the vue-stream-markdown wrapper, inline code, token mapping, and styles out of chat into components/markdown and theme/markdown, so other surfaces can render Markdown without importing chat components. A density attribute selects the compact chat styles or a comfortable reading size, and Shiki highlighting is opt-in. ChatMarkdown keeps its streaming render key and wraps the shared component. * refactor(ui): extract AppProgress from the toast The toast drew its own progress track, fill, and label. Move them into AppProgress in ui/feedback, built on Reka's Progress so the bar reports its value and indeterminate state, with an accent tone for panels and a current-colour tone for coloured surfaces. ToastProgress becomes the shared ProgressAmount. AppPlaceholder also accepts an h1 label for placeholders that stand for a whole window. * feat(desktop): show updates in a Software Update window The update prompt passed the release's CHANGELOG section to the native confirm dialog, which cannot format Markdown or scroll, so the 0.15.1 notes showed raw headings and pushed the buttons off screen. When the main window finds an update it now opens a small `updater` webview loading its own `updater.html` entry, which never boots the editor. The window renders the notes with the shared Markdown component in a scrolling box and links to the full release page. Installing is split into stages. The download shows progress and can be cancelled; Tauri cannot abort it, so Cancel detaches and a later Install reuses the running download. On macOS and Linux the update then installs and the window offers Restart Now or Later. A restart, and on Windows the installer that quits the app, first asks the editor window to run the same unsaved-documents approval as Quit. Failed checks, downloads, installs, and restarts keep the release visible and can be retried. The window's capability grants only update, restart, close, and link opening. Closes #743 * fix(desktop): stop waiting for a restart reply from a closed editor The Software Update window waited for the editor's answer with no bound, so an editor closed mid-request left Restart Now, and the Windows install, stuck. Treat the editor window's destruction as approval: it ran its own unsaved-changes prompt and holds no documents. A timeout would instead fail people still answering that prompt.
114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'bun:test'
|
|
|
|
import { spawnACPProcess } from '@/app/ai/acp/process'
|
|
|
|
import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks'
|
|
|
|
afterEach(async () => {
|
|
await clearTauriMocks()
|
|
vi.restoreAllMocks()
|
|
Reflect.deleteProperty(globalThis, 'window')
|
|
Reflect.deleteProperty(globalThis, 'navigator')
|
|
})
|
|
|
|
describe('Tauri process helpers', () => {
|
|
test('spawns ACP processes and streams stdout/stdin through plugin-shell', async () => {
|
|
const spawned: { onEvent: ((event: unknown) => void) | null } = { onEvent: 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: {} }
|
|
})
|
|
spawned.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()
|
|
spawned.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 their cmd scope entry', 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-agent-cli',
|
|
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 () => {
|
|
const spawned: { onEvent: ((event: unknown) => void) | null } = { onEvent: null }
|
|
const onUnexpectedClose = vi.fn()
|
|
await mockTauriIPC((cmd, args) => {
|
|
if (cmd === 'plugin:shell|spawn') {
|
|
spawned.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()
|
|
|
|
spawned.onEvent?.({ event: 'Terminated', payload: { code: 1, signal: null } })
|
|
|
|
await expect(reader.read()).rejects.toThrow('Agent process exited unexpectedly.')
|
|
expect(onUnexpectedClose).toHaveBeenCalled()
|
|
})
|
|
})
|