2026-05-05 19:32:24 +00:00
|
|
|
import { afterEach, describe, expect, test, vi } from 'bun:test'
|
2026-05-05 21:42:45 +00:00
|
|
|
|
2026-05-05 19:32:24 +00:00
|
|
|
import { ref } from 'vue'
|
|
|
|
|
|
2026-08-10 12:48:16 +00:00
|
|
|
import { spawnACPProcess } from '@/app/ai/acp/process'
|
2026-09-25 19:15:14 +00:00
|
|
|
import { toast } from '@/app/shell/ui'
|
2026-05-05 19:32:24 +00:00
|
|
|
import { checkForAppUpdate } from '@/app/shell/updater'
|
2026-05-05 21:42:45 +00:00
|
|
|
|
2026-05-06 13:16:08 +00:00
|
|
|
import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks'
|
2026-05-05 23:22:08 +00:00
|
|
|
|
2026-05-05 19:32:24 +00:00
|
|
|
afterEach(async () => {
|
|
|
|
|
await clearTauriMocks()
|
|
|
|
|
vi.restoreAllMocks()
|
2026-09-25 19:15:14 +00:00
|
|
|
for (const entry of toast.toasts.value) toast.remove(entry.id)
|
2026-05-05 21:42:45 +00:00
|
|
|
Reflect.deleteProperty(globalThis, 'window')
|
2026-07-26 05:47:20 +00:00
|
|
|
Reflect.deleteProperty(globalThis, 'navigator')
|
2026-05-05 19:32:24 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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'],
|
2026-07-26 05:47:20 +00:00
|
|
|
options: { encoding: 'raw', env: {} }
|
2026-05-05 19:32:24 +00:00
|
|
|
})
|
|
|
|
|
onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent.onmessage
|
|
|
|
|
return 42
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 12:48:16 +00:00
|
|
|
const process = await spawnACPProcess({
|
2026-05-05 19:32:24 +00:00
|
|
|
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 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-26 05:47:20 +00:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 12:48:16 +00:00
|
|
|
const process = await spawnACPProcess({
|
2026-07-26 05:47:20 +00:00
|
|
|
command: 'agent-cli',
|
|
|
|
|
args: ['--stdio'],
|
|
|
|
|
logId: 'test',
|
|
|
|
|
destroying: () => false,
|
|
|
|
|
onUnexpectedClose: vi.fn()
|
|
|
|
|
})
|
|
|
|
|
await process.child.kill()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-05 19:32:24 +00:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 12:48:16 +00:00
|
|
|
const process = await spawnACPProcess({
|
2026-05-05 19:32:24 +00:00
|
|
|
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({
|
refactor(i18n): migrate app copy to domain namespaces
* refactor(i18n): migrate app copy to domain namespaces
- Replace the monolithic dialogs catalog with 16 flat product-domain catalogs
- Preserve every translated locale value while moving all 356 messages
- Expose narrow domain composables and retain useI18n as a compatibility aggregate
- Document namespace ownership and admission rules
* fix(i18n): complete migrated locale coverage
- Translate exposed MCP automation, code editor, credential, and media placeholders
- Restore missing German, Spanish, French, Italian, Polish, and Russian diacritics
- Preserve technical product terms and interpolation placeholders
* fix(i18n): polish remaining locale wording
- Correct the mixed-language German code source label
- Preserve stock_photo and Pexels semantics in German and Polish
- Improve Italian MCP and code-editor grammar
- Use the standard Russian term for MCP endpoint
* fix(i18n): migrate diagnostics copy after rebase
- Add a diagnostics domain for newly merged usage and telemetry settings
- Keep settings navigation labels in the settings domain
- Preserve translated diagnostics catalogs from current master
* style(app): format rebased settings components
* refactor(i18n): simplify domain message keys
- Remove redundant domain prefixes from settings, diagnostics, automation, and integration catalogs
- Move feature-specific actions out of the common namespace
- Preserve current-master language picker and diagnostics copy across every locale
- Update consumers to concise semantic keys
* fix(app): preserve font and updater contracts
- Restore browser and abort-aware font loading from current master
- Remove the unused parameter helper from common messages
- Update updater tests for semantic domain keys
* fix(i18n): finish semantic diagnostics access
* refactor(i18n): derive active MCP status message
* fix(i18n): preserve rendering settings after rebase
- Add a rendering domain for tiled canvas preferences
- Preserve the current-master language and rendering settings UI
- Move every translated renderer preference out of the retired dialogs catalog
* fix(i18n): polish reviewed locale semantics
* test(i18n): enforce translation completeness baseline
- Fail on interpolation placeholder drift and suspicious mixed-script values
- Reject new source-identical translations while tracking existing debt explicitly
- Report translated-message coverage for every supported locale
- Require baseline cleanup when existing placeholders are translated
* refactor(i18n): baseline reviewed mixed-script messages
- Replace brittle product-vocabulary exceptions with Unicode script detection
- Track reviewed mixed-script messages by stable locale and message identity
- Reject new entries and stale baseline debt without hardcoded terminology
* fix(fig): resolve relocated stroke test helper
2026-08-31 20:54:14 +00:00
|
|
|
upToDate: 'Up to date',
|
|
|
|
|
availableTitle: 'Update available',
|
|
|
|
|
available: ({ version }: { version: string }) => `Version ${version}`,
|
|
|
|
|
installPrompt: 'Install now?',
|
|
|
|
|
downloading: ({ version }: { version: string }) => `Downloading ${version}`,
|
2026-09-25 19:15:14 +00:00
|
|
|
downloadProgress: ({
|
|
|
|
|
percent,
|
|
|
|
|
downloaded,
|
|
|
|
|
total
|
|
|
|
|
}: {
|
|
|
|
|
percent: number
|
|
|
|
|
downloaded: string
|
|
|
|
|
total: string
|
|
|
|
|
}) => `${percent}% of ${total} (${downloaded})`,
|
|
|
|
|
downloadProgressUnknown: ({ downloaded }: { downloaded: string }) => `${downloaded} downloaded`,
|
refactor(i18n): migrate app copy to domain namespaces
* refactor(i18n): migrate app copy to domain namespaces
- Replace the monolithic dialogs catalog with 16 flat product-domain catalogs
- Preserve every translated locale value while moving all 356 messages
- Expose narrow domain composables and retain useI18n as a compatibility aggregate
- Document namespace ownership and admission rules
* fix(i18n): complete migrated locale coverage
- Translate exposed MCP automation, code editor, credential, and media placeholders
- Restore missing German, Spanish, French, Italian, Polish, and Russian diacritics
- Preserve technical product terms and interpolation placeholders
* fix(i18n): polish remaining locale wording
- Correct the mixed-language German code source label
- Preserve stock_photo and Pexels semantics in German and Polish
- Improve Italian MCP and code-editor grammar
- Use the standard Russian term for MCP endpoint
* fix(i18n): migrate diagnostics copy after rebase
- Add a diagnostics domain for newly merged usage and telemetry settings
- Keep settings navigation labels in the settings domain
- Preserve translated diagnostics catalogs from current master
* style(app): format rebased settings components
* refactor(i18n): simplify domain message keys
- Remove redundant domain prefixes from settings, diagnostics, automation, and integration catalogs
- Move feature-specific actions out of the common namespace
- Preserve current-master language picker and diagnostics copy across every locale
- Update consumers to concise semantic keys
* fix(app): preserve font and updater contracts
- Restore browser and abort-aware font loading from current master
- Remove the unused parameter helper from common messages
- Update updater tests for semantic domain keys
* fix(i18n): finish semantic diagnostics access
* refactor(i18n): derive active MCP status message
* fix(i18n): preserve rendering settings after rebase
- Add a rendering domain for tiled canvas preferences
- Preserve the current-master language and rendering settings UI
- Move every translated renderer preference out of the retired dialogs catalog
* fix(i18n): polish reviewed locale semantics
* test(i18n): enforce translation completeness baseline
- Fail on interpolation placeholder drift and suspicious mixed-script values
- Reject new source-identical translations while tracking existing debt explicitly
- Report translated-message coverage for every supported locale
- Require baseline cleanup when existing placeholders are translated
* refactor(i18n): baseline reviewed mixed-script messages
- Replace brittle product-vocabulary exceptions with Unicode script detection
- Track reviewed mixed-script messages by stable locale and message identity
- Reject new entries and stale baseline debt without hardcoded terminology
* fix(fig): resolve relocated stroke test helper
2026-08-31 20:54:14 +00:00
|
|
|
installedTitle: 'Installed',
|
|
|
|
|
installed: ({ version, size }: { version: string; size: string }) =>
|
2026-05-05 19:32:24 +00:00
|
|
|
`Installed ${version}${size}`,
|
refactor(i18n): migrate app copy to domain namespaces
* refactor(i18n): migrate app copy to domain namespaces
- Replace the monolithic dialogs catalog with 16 flat product-domain catalogs
- Preserve every translated locale value while moving all 356 messages
- Expose narrow domain composables and retain useI18n as a compatibility aggregate
- Document namespace ownership and admission rules
* fix(i18n): complete migrated locale coverage
- Translate exposed MCP automation, code editor, credential, and media placeholders
- Restore missing German, Spanish, French, Italian, Polish, and Russian diacritics
- Preserve technical product terms and interpolation placeholders
* fix(i18n): polish remaining locale wording
- Correct the mixed-language German code source label
- Preserve stock_photo and Pexels semantics in German and Polish
- Improve Italian MCP and code-editor grammar
- Use the standard Russian term for MCP endpoint
* fix(i18n): migrate diagnostics copy after rebase
- Add a diagnostics domain for newly merged usage and telemetry settings
- Keep settings navigation labels in the settings domain
- Preserve translated diagnostics catalogs from current master
* style(app): format rebased settings components
* refactor(i18n): simplify domain message keys
- Remove redundant domain prefixes from settings, diagnostics, automation, and integration catalogs
- Move feature-specific actions out of the common namespace
- Preserve current-master language picker and diagnostics copy across every locale
- Update consumers to concise semantic keys
* fix(app): preserve font and updater contracts
- Restore browser and abort-aware font loading from current master
- Remove the unused parameter helper from common messages
- Update updater tests for semantic domain keys
* fix(i18n): finish semantic diagnostics access
* refactor(i18n): derive active MCP status message
* fix(i18n): preserve rendering settings after rebase
- Add a rendering domain for tiled canvas preferences
- Preserve the current-master language and rendering settings UI
- Move every translated renderer preference out of the retired dialogs catalog
* fix(i18n): polish reviewed locale semantics
* test(i18n): enforce translation completeness baseline
- Fail on interpolation placeholder drift and suspicious mixed-script values
- Reject new source-identical translations while tracking existing debt explicitly
- Report translated-message coverage for every supported locale
- Require baseline cleanup when existing placeholders are translated
* refactor(i18n): baseline reviewed mixed-script messages
- Replace brittle product-vocabulary exceptions with Unicode script detection
- Track reviewed mixed-script messages by stable locale and message identity
- Reject new entries and stale baseline debt without hardcoded terminology
* fix(fig): resolve relocated stroke test helper
2026-08-31 20:54:14 +00:00
|
|
|
unavailable: 'Unavailable',
|
|
|
|
|
checkFailed: ({ error }: { error: string }) => `Failed: ${error}`
|
2026-05-05 19:32:24 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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: '{}'
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 14:44:40 +00:00
|
|
|
if (cmd === 'plugin:dialog|message') {
|
|
|
|
|
const options = args as { buttons?: string }
|
|
|
|
|
if (options.buttons === 'OkCancel') return 'Ok'
|
|
|
|
|
}
|
2026-05-05 19:32:24 +00:00
|
|
|
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',
|
2026-08-18 14:44:40 +00:00
|
|
|
'plugin:dialog|message',
|
2026-05-05 19:32:24 +00:00
|
|
|
'plugin:updater|download_and_install',
|
|
|
|
|
'plugin:dialog|message',
|
|
|
|
|
'plugin:process|restart'
|
|
|
|
|
])
|
|
|
|
|
expect(calls[1]?.args).toMatchObject({
|
|
|
|
|
title: 'Update available',
|
2026-08-18 14:44:40 +00:00
|
|
|
kind: 'info',
|
|
|
|
|
buttons: 'OkCancel'
|
2026-05-05 19:32:24 +00:00
|
|
|
})
|
|
|
|
|
expect(calls[2]?.args).toMatchObject({ rid: 9 })
|
|
|
|
|
})
|
2026-09-25 19:15:14 +00:00
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-05-05 19:32:24 +00:00
|
|
|
})
|