Files
ComfyUI_frontend/apps/desktop-ui/src/stores/maintenanceTaskStore.test.ts
Kelly Yang 89c11c9aa9 test: add unit test suite for apps/desktop-ui (#11275)
## Summary

This is a follow-up PR of #11102

| Requirement | Status | Implementation |
| :--- | :--- | :--- |
| Add vitest configuration for desktop-ui workspace |  Done | Added
`apps/desktop-ui/vitest.config.mts` with `happy-dom` environment, `@`
alias, and `setupFiles` pointing to `src/test/setup.ts` (registers
`@testing-library/jest-dom` matchers) |
| Add test:unit script to package.json |  Done | Added `"test:unit":
"vitest run --config vitest.config.mts"` to
`apps/desktop-ui/package.json` |
| stores/maintenanceTaskStore.ts |  Done | 34 tests covering task state
machine, IPC integration, executeTask flow, and error handling via
`@pinia/testing` |
| utils/electronMirrorCheck.ts |  Done | 5 tests covering URL
validation, canAccessUrl delegation, and true/false return logic |
| utils/refUtil.ts (useMinLoadingDurationRef) |  Done | 7 tests
covering initial state, timing behavior using `vi.useFakeTimers`, and
computed ref input |
| utils/envUtil.ts |  Done | 7 tests covering electronAPI detection and
fallback behavior |
| constants/desktopDialogs.ts |  Done | 8 tests covering dialog
structure and field contracts |
| constants/desktopMaintenanceTasks.ts |  Done | 5 tests covering
`pythonPackages.execute` success/failure return values, and URL-opening
tasks calling `window.open` |
| composables/bottomPanelTabs/useTerminal.ts |  Done | 7 tests covering
key event handler: Ctrl/Meta+C with/without selection, Ctrl/Meta+V,
non-keydown events, and unrelated keys — mocked xterm with Vitest
v4-compatible function constructors |
| composables/bottomPanelTabs/useTerminalBuffer.ts |  Done | 2 tests
for `copyTo`: verifies serialized buffer content is written to
destination terminal |
| utils/validationUtil.ts |  Skipped | The current file contains only a
`ValidationState` enum with no logic. There is no behavior to test
without writing a change-detector test (asserting enum values), which
violates project testing guidelines |

**Additional config changes (not in issue but required to make tests
work):**

| Change | Reason |
| :--- | :--- |
| Added `"vitest.config.mts"` to `apps/desktop-ui/tsconfig.json` include
| Required for ESLint's TypeScript parser to process the config file
without a parsing error |
| Removed 6 redundant test devDependencies from
`apps/desktop-ui/package.json` | `vitest`, `@testing-library/*`,
`@pinia/testing`, `happy-dom` are already declared at the root and
hoisted by pnpm — re-declaring them in the sub-package is unnecessary |

## Changes
- Add vitest.config.mts with happy-dom environment and path aliases
- Add src/test/setup.ts to register @testing-library/jest-dom matchers
- Add test:unit script to package.json
- Add vitest.config.mts to tsconfig.json include for ESLint
compatibility
- Remove redundant test devDependencies already declared at root
- Add 132 tests across 16 files covering stores, composables, utils, and
constants

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test- and config-only changes; main risk is CI/build instability from
new Vitest configuration or brittle mocks, with no runtime behavior
changes shipped to users.
> 
> **Overview**
> Adds a dedicated Vitest setup for `apps/desktop-ui` (new
`vitest.config.mts` using `happy-dom`, aliases, and a `jest-dom` setup
file) and wires it into the workspace via a new `test:unit` script plus
`tsconfig.json` inclusion.
> 
> Introduces a broad set of new unit tests for desktop UI components,
composables, constants, utilities, and the `maintenanceTaskStore`
(mocking Electron/PrimeVue/Xterm as needed) to validate state
transitions, validation flows, and key UI behaviors without changing
production logic.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0a96ffb37c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11275-test-add-unit-test-suite-for-apps-desktop-ui-3436d73d36508145ae1fe99ec7a3a4fa)
by [Unito](https://www.unito.io)
2026-04-16 22:20:00 +00:00

289 lines
9.5 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron, testTasks } = vi.hoisted(() => {
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
const basicTaskExecute = vi.fn().mockResolvedValue(true)
return {
mockElectron: {
Validation: {
validateInstallation: vi.fn()
}
},
testTasks: [
{
id: 'basicTask',
name: 'Basic Task',
execute: basicTaskExecute
},
{
id: 'terminalTask',
name: 'Terminal Task',
execute: terminalTaskExecute,
usesTerminal: true,
isInstallationFix: true
}
]
}
})
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: testTasks
}))
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
type PartialInstallValidation = Partial<InstallValidation> &
Record<string, unknown>
function makeUpdate(
overrides: PartialInstallValidation = {}
): InstallValidation {
return {
inProgress: false,
installState: 'installed',
...overrides
} as InstallValidation
}
function createStore() {
setActivePinia(createTestingPinia({ stubActions: false }))
return useMaintenanceTaskStore()
}
describe('useMaintenanceTaskStore', () => {
let store: ReturnType<typeof useMaintenanceTaskStore>
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
beforeEach(() => {
vi.resetAllMocks()
store = createStore()
})
describe('processUpdate', () => {
it('sets isRefreshing to true during in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.isRefreshing).toBe(true)
})
it('sets isRefreshing to false when update is complete', () => {
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
expect(store.isRefreshing).toBe(false)
})
it('updates runner state for tasks present in the final update', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).state).toBe('error')
})
it('sets task state to warning from update', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
expect(store.getRunner(basicTask).state).toBe('warning')
})
it('marks runners as refreshing when task id is absent from in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.getRunner(basicTask).refreshing).toBe(true)
})
it('marks task as skipped when absent from final update', () => {
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).state).toBe('skipped')
})
it('clears refreshing flag after final update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).refreshing).toBe(false)
})
it('stores lastUpdate and exposes unsafeBasePath', () => {
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
expect(store.unsafeBasePath).toBe(true)
})
it('exposes unsafeBasePathReason from the update', () => {
store.processUpdate(
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
)
expect(store.unsafeBasePathReason).toBe('oneDrive')
})
})
describe('anyErrors', () => {
it('returns true when any task has error state', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.anyErrors).toBe(true)
})
it('returns false when all tasks are OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
expect(store.anyErrors).toBe(false)
})
it('returns false when all tasks are warning', () => {
store.processUpdate(
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
)
expect(store.anyErrors).toBe(false)
})
})
describe('runner state transitions', () => {
it('marks runner as resolved when transitioning from error to OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
})
it('does not mark resolved for warning to OK transition', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
it('clears resolved flag when task returns to error', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('clearResolved', () => {
it('clears resolved flags on all runners', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
store.clearResolved()
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('execute', () => {
it('returns true when task execution succeeds', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
const result = await store.execute(basicTask)
expect(result).toBe(true)
})
it('returns false when task execution fails', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(false)
const result = await store.execute(basicTask)
expect(result).toBe(false)
})
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(true)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).toHaveBeenCalledOnce()
})
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(false)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('sets runner executing to true during task execution', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(basicTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(basicTask)
expect(store.getRunner(basicTask).executing).toBe(true)
resolveTask(true)
await executePromise
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('clears executing flag when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow('fail')
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('sets runner error message when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(
new Error('something broke')
)
await expect(store.execute(basicTask)).rejects.toThrow()
expect(store.getRunner(basicTask).error).toBe('something broke')
})
it('clears runner error on successful execution', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow()
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(store.getRunner(basicTask).error).toBeUndefined()
})
})
describe('isRunningTerminalCommand', () => {
it('returns true while a terminal task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningTerminalCommand).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningTerminalCommand).toBe(false)
})
it('returns false when no terminal tasks are executing', () => {
expect(store.isRunningTerminalCommand).toBe(false)
})
})
describe('isRunningInstallationFix', () => {
it('returns true while an installation-fix task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningInstallationFix).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningInstallationFix).toBe(false)
})
})
})