test: cover electron boundary modules

This commit is contained in:
Misaka
2026-03-21 18:26:13 +08:00
parent 13db99d51d
commit ed2a42ad6b
7 changed files with 657 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '../../src/shared/ipc-channels'
const handleMock = vi.fn()
const withErrorHandlingMock = vi.fn(async (handler: () => Promise<unknown>) => {
const data = await handler()
return { success: true, data }
})
const serviceMethods = {
getComputerName: vi.fn(async () => 'TEST-PC'),
silentLogin: vi.fn(async () => ({ success: true })),
login: vi.fn(async (username: string) => ({ success: true, username })),
logout: vi.fn(async () => undefined),
getCurrentUser: vi.fn(async () => ({ success: true })),
getAllUsers: vi.fn(async () => []),
switchUser: vi.fn(async (user: unknown) => ({ success: true, user })),
isAdmin: vi.fn(async () => true)
}
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../../src/main/ipc/index', () => ({
withErrorHandling: withErrorHandlingMock
}))
vi.mock('../../src/main/services/auth/auth-application-service', () => ({
AuthApplicationService: class {
getComputerName = serviceMethods.getComputerName
silentLogin = serviceMethods.silentLogin
login = serviceMethods.login
logout = serviceMethods.logout
getCurrentUser = serviceMethods.getCurrentUser
getAllUsers = serviceMethods.getAllUsers
switchUser = serviceMethods.switchUser
isAdmin = serviceMethods.isAdmin
}
}))
describe('registerAuthHandlers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('registers auth IPC handlers and delegates login requests', async () => {
const { registerAuthHandlers } = await import('../../src/main/ipc/auth-handler')
registerAuthHandlers()
expect(handleMock).toHaveBeenCalledWith(IPC_CHANNELS.AUTH_LOGIN, expect.any(Function))
expect(handleMock).toHaveBeenCalledWith(IPC_CHANNELS.AUTH_SILENT_LOGIN, expect.any(Function))
expect(handleMock).toHaveBeenCalledTimes(8)
const loginHandler = handleMock.mock.calls.find(
([channel]) => channel === IPC_CHANNELS.AUTH_LOGIN
)?.[1]
const result = await loginHandler?.({}, { username: 'alice', password: 'secret' })
expect(serviceMethods.login).toHaveBeenCalledWith('alice', 'secret')
expect(withErrorHandlingMock).toHaveBeenCalled()
expect(result).toEqual({
success: true,
data: { success: true, username: 'alice' }
})
})
it('delegates switch user requests through the service layer', async () => {
const { registerAuthHandlers } = await import('../../src/main/ipc/auth-handler')
const userInfo = { id: 1, username: 'admin' }
registerAuthHandlers()
const switchUserHandler = handleMock.mock.calls.find(
([channel]) => channel === IPC_CHANNELS.AUTH_SWITCH_USER
)?.[1]
await switchUserHandler?.({}, userInfo)
expect(serviceMethods.switchUser).toHaveBeenCalledWith(userInfo)
})
})

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'path'
const mkdirSyncMock = vi.fn()
const existsSyncMock = vi.fn()
const readdirSyncMock = vi.fn()
const showErrorBoxMock = vi.fn()
const setAppUserModelIdMock = vi.fn()
const appOnMock = vi.fn()
const configInitializeMock = vi.fn(async () => undefined)
const updateInitializeMock = vi.fn()
const registerIpcHandlersMock = vi.fn()
vi.mock('fs', () => ({
default: {
mkdirSync: mkdirSyncMock,
existsSync: existsSyncMock,
readdirSync: readdirSyncMock
},
mkdirSync: mkdirSyncMock,
existsSync: existsSyncMock,
readdirSync: readdirSyncMock
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => 'D:/userData'),
setAppUserModelId: setAppUserModelIdMock,
on: appOnMock,
isPackaged: false
},
dialog: {
showErrorBox: showErrorBoxMock
}
}))
vi.mock('../../src/main/services/config/config-manager', () => ({
ConfigManager: {
getInstance: vi.fn(() => ({
initialize: configInitializeMock
}))
}
}))
vi.mock('../../src/main/services/update/update-service', () => ({
UpdateService: {
getInstance: vi.fn(() => ({
initialize: updateInitializeMock
}))
}
}))
vi.mock('../../src/main/ipc', () => ({
registerIpcHandlers: registerIpcHandlersMock
}))
describe('bootstrap runtime', () => {
beforeEach(() => {
vi.clearAllMocks()
existsSyncMock.mockReset()
readdirSyncMock.mockReset()
})
it('configures Playwright browser path under app userData', async () => {
const { configurePlaywrightBrowsersPath } = await import('../../src/main/bootstrap/runtime')
const result = configurePlaywrightBrowsersPath()
const expectedPath = join('D:/userData', 'ms-playwright')
expect(result).toBe(expectedPath)
expect(process.env.PLAYWRIGHT_BROWSERS_PATH).toBe(expectedPath)
})
it('initializes config, update service and IPC registration', async () => {
const { initializeMainProcessServices } = await import('../../src/main/bootstrap/runtime')
await initializeMainProcessServices()
expect(configInitializeMock).toHaveBeenCalledTimes(1)
expect(updateInitializeMock).toHaveBeenCalledTimes(1)
expect(registerIpcHandlersMock).toHaveBeenCalledTimes(1)
})
it('shows an error dialog when no Playwright browser is found', async () => {
const { ensurePlaywrightRuntime } = await import('../../src/main/bootstrap/runtime')
existsSyncMock.mockReturnValue(false)
readdirSyncMock.mockReturnValue([])
ensurePlaywrightRuntime('D:/userData/ms-playwright')
expect(mkdirSyncMock).toHaveBeenCalledWith('D:/userData/ms-playwright', { recursive: true })
expect(showErrorBoxMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '../../src/shared/ipc-channels'
const handleMock = vi.fn()
const withErrorHandlingMock = vi.fn(async (handler: () => Promise<unknown>) => {
const data = await handler()
return { success: true, data }
})
const serviceMethods = {
runCleaner: vi.fn(async (_sender: unknown, input: unknown) => ({ success: true, input })),
exportResults: vi.fn(async (items: unknown[]) => ({ success: true, total: items.length }))
}
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../../src/main/ipc/index', () => ({
withErrorHandling: withErrorHandlingMock
}))
vi.mock('../../src/main/services/cleaner/cleaner-application-service', () => ({
CleanerApplicationService: class {
runCleaner = serviceMethods.runCleaner
exportResults = serviceMethods.exportResults
}
}))
describe('registerCleanerHandlers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('registers cleaner IPC handlers and forwards the sender to the application service', async () => {
const { registerCleanerHandlers } = await import('../../src/main/ipc/cleaner-handler')
const sender = { id: 99 }
const event = { sender }
const input = { orderNumbers: ['MO-001'] }
registerCleanerHandlers()
expect(handleMock).toHaveBeenCalledTimes(2)
expect(handleMock).toHaveBeenCalledWith(IPC_CHANNELS.CLEANER_RUN, expect.any(Function))
const runHandler = handleMock.mock.calls.find(
([channel]) => channel === IPC_CHANNELS.CLEANER_RUN
)?.[1]
const result = await runHandler?.(event, input)
expect(serviceMethods.runCleaner).toHaveBeenCalledWith(sender, input)
expect(result).toEqual({
success: true,
data: { success: true, input }
})
})
it('forwards export requests to the cleaner application service', async () => {
const { registerCleanerHandlers } = await import('../../src/main/ipc/cleaner-handler')
const items = [{ id: '1' }, { id: '2' }]
registerCleanerHandlers()
const exportHandler = handleMock.mock.calls.find(
([channel]) => channel === IPC_CHANNELS.CLEANER_EXPORT_RESULTS
)?.[1]
await exportHandler?.({}, items)
expect(serviceMethods.exportResults).toHaveBeenCalledWith(items)
})
})

View File

@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
import { UpdateCatalogService } from '../../src/main/services/update/update-catalog-service'
import type { UpdateConfig } from '../../src/main/types/config.schema'
import type { UpdateCatalog, UpdateRelease, UpdateStatus } from '../../src/main/types/update.types'
const config: UpdateConfig = {
enabled: true,
allowDevMode: false,
endpoint: 'http://localhost:9000',
accessKey: 'key',
secretKey: 'secret',
bucket: 'bucket',
region: 'us-east-1',
basePrefix: 'updates/win-portable',
checkIntervalMinutes: 30,
maxAdminHistoryPerChannel: 2
}
function createRelease(
version: string,
channel: 'stable' | 'preview',
overrides: Partial<UpdateRelease> = {}
): UpdateRelease {
return {
version,
channel,
artifactKey: `${channel}/${version}.exe`,
sha256: `${channel}-${version}-sha`,
size: 1,
publishedAt: '2026-03-21T10:00:00Z',
changelogKey: `${channel}/${version}.md`,
...overrides
}
}
function createStatus(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return {
enabled: true,
supported: true,
phase: 'idle',
currentVersion: '1.0.0',
currentChannel: 'stable',
currentUserType: 'User',
...overrides
}
}
describe('UpdateCatalogService', () => {
it('returns disabled dialog catalog when updates are unavailable', () => {
const storageClient = { readText: vi.fn() }
const installer = { getValidDownloadedRelease: vi.fn() }
const service = new UpdateCatalogService(config, storageClient as never, installer as never)
const result = service.getDialogCatalog(
createStatus({ enabled: false, currentUserType: null }),
{ stable: [], preview: [] }
)
expect(result).toEqual({ mode: 'disabled' })
})
it('loads stable and preview catalogs for admin users', async () => {
const storageClient = {
readText: vi
.fn()
.mockResolvedValueOnce(JSON.stringify({ releases: [createRelease('1.1.0', 'stable')] }))
.mockResolvedValueOnce(JSON.stringify({ releases: [createRelease('1.2.0', 'preview')] }))
}
const installer = { getValidDownloadedRelease: vi.fn() }
const service = new UpdateCatalogService(config, storageClient as never, installer as never)
const result = await service.loadCatalog('Admin')
expect(storageClient.readText).toHaveBeenCalledTimes(2)
expect(result.stable[0]?.version).toBe('1.1.0')
expect(result.preview[0]?.version).toBe('1.2.0')
})
it('marks user update as downloaded when a verified package already exists', async () => {
const recommended = createRelease('1.1.0', 'stable')
const storageClient = { readText: vi.fn() }
const installer = {
getValidDownloadedRelease: vi.fn().mockResolvedValue({
...recommended,
localPath: 'D:/downloads/stable-1.1.0.exe'
})
}
const service = new UpdateCatalogService(config, storageClient as never, installer as never)
const result = await service.resolveUserStatus(createStatus(), {
stable: [recommended],
preview: []
})
expect(result.phase).toBe('downloaded')
expect(result.downloadedRelease?.localPath).toContain('stable-1.1.0.exe')
expect(result.recommendedRelease?.version).toBe('1.1.0')
})
it('returns available admin status and trims catalog history', () => {
const storageClient = { readText: vi.fn() }
const installer = { getValidDownloadedRelease: vi.fn() }
const service = new UpdateCatalogService(config, storageClient as never, installer as never)
const catalog: UpdateCatalog = {
stable: [
createRelease('1.0.0', 'stable'),
createRelease('1.1.0', 'stable'),
createRelease('1.2.0', 'stable')
],
preview: [
createRelease('1.3.0', 'preview'),
createRelease('1.4.0', 'preview'),
createRelease('1.5.0', 'preview')
]
}
const adminStatus = service.resolveAdminStatus(
createStatus({ currentUserType: 'Admin', currentVersion: '1.0.0' }),
catalog
)
const dialogCatalog = service.getDialogCatalog(
createStatus({
currentUserType: 'Admin',
currentVersion: '1.0.0',
recommendedRelease: createRelease('1.5.0', 'preview')
}),
catalog
)
expect(adminStatus.phase).toBe('available')
expect(adminStatus.recommendedRelease?.version).toBe('1.5.0')
expect(dialogCatalog.mode).toBe('admin')
expect(dialogCatalog.channels?.stable).toHaveLength(2)
expect(dialogCatalog.channels?.preview).toHaveLength(2)
})
})

View File

@@ -0,0 +1,33 @@
import path from 'path'
import { describe, expect, it } from 'vitest'
import { UpdateInstaller } from '../../src/main/services/update/update-installer'
describe('UpdateInstaller', () => {
it('builds downloaded package path under userData pending-update', () => {
const installer = new UpdateInstaller()
const result = installer.getDownloadPath({
version: '1.2.3',
channel: 'stable'
})
expect(result).toContain(path.join('logs', 'pending-update'))
expect(result).toContain('stable-1.2.3.exe')
})
it('returns null when downloaded package does not exist', async () => {
const installer = new UpdateInstaller()
const result = await installer.getValidDownloadedRelease({
version: '9.9.9',
channel: 'preview',
artifactKey: 'preview/9.9.9.exe',
sha256: 'missing',
size: 1,
publishedAt: '2026-03-21T10:00:00Z',
changelogKey: 'preview/9.9.9.md'
})
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UpdateConfig } from '../../src/main/types/config.schema'
import type { UpdateCatalog, UpdateRelease, UpdateStatus } from '../../src/main/types/update.types'
const mockPublishUpdateStatus = vi.fn()
const mockReadText = vi.fn()
const mockDownloadToFile = vi.fn()
const mockLoadCatalog = vi.fn()
const mockGetDialogCatalog = vi.fn()
const mockResolveUserStatus = vi.fn()
const mockResolveAdminStatus = vi.fn()
const mockGetDownloadPath = vi.fn()
const mockCalculateSha256 = vi.fn()
const mockInstallDownloadedRelease = vi.fn()
const mockGetValidDownloadedRelease = vi.fn()
let currentUpdateConfig: UpdateConfig = {
enabled: true,
allowDevMode: true,
endpoint: 'http://localhost:9000',
accessKey: 'key',
secretKey: 'secret',
bucket: 'bucket',
region: 'us-east-1',
basePrefix: 'updates/win-portable',
checkIntervalMinutes: 30,
maxAdminHistoryPerChannel: 10
}
function createRelease(version: string, channel: 'stable' | 'preview' = 'stable'): UpdateRelease {
return {
version,
channel,
artifactKey: `${channel}/${version}.exe`,
sha256: `${channel}-${version}-sha`,
size: 1,
publishedAt: '2026-03-21T10:00:00Z',
changelogKey: `${channel}/${version}.md`
}
}
vi.mock('../../src/main/services/config/config-manager', () => ({
ConfigManager: {
getInstance: vi.fn(() => ({
getConfig: () => ({ update: currentUpdateConfig })
}))
}
}))
vi.mock('../../src/main/services/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}))
}))
vi.mock('../../src/main/services/update/update-status-publisher', () => ({
publishUpdateStatus: mockPublishUpdateStatus
}))
vi.mock('../../src/main/services/update/update-storage-client', () => ({
UpdateStorageClient: class {
readText = mockReadText
downloadToFile = mockDownloadToFile
}
}))
vi.mock('../../src/main/services/update/update-catalog-service', () => ({
UpdateCatalogService: class {
loadCatalog = mockLoadCatalog
getDialogCatalog = mockGetDialogCatalog
resolveUserStatus = mockResolveUserStatus
resolveAdminStatus = mockResolveAdminStatus
}
}))
vi.mock('../../src/main/services/update/update-installer', () => ({
UpdateInstaller: class {
getDownloadPath = mockGetDownloadPath
calculateSha256 = mockCalculateSha256
installDownloadedRelease = mockInstallDownloadedRelease
getValidDownloadedRelease = mockGetValidDownloadedRelease
}
}))
describe('UpdateService', () => {
beforeEach(() => {
vi.clearAllMocks()
currentUpdateConfig = {
enabled: true,
allowDevMode: true,
endpoint: 'http://localhost:9000',
accessKey: 'key',
secretKey: 'secret',
bucket: 'bucket',
region: 'us-east-1',
basePrefix: 'updates/win-portable',
checkIntervalMinutes: 30,
maxAdminHistoryPerChannel: 10
}
mockReadText.mockReset()
mockDownloadToFile.mockReset()
mockLoadCatalog.mockReset()
mockGetDialogCatalog.mockReset()
mockResolveUserStatus.mockReset()
mockResolveAdminStatus.mockReset()
mockGetDownloadPath.mockReset()
mockCalculateSha256.mockReset()
mockInstallDownloadedRelease.mockReset()
mockGetValidDownloadedRelease.mockReset()
})
async function loadService() {
vi.resetModules()
const module = await import('../../src/main/services/update/update-service')
;(module.UpdateService as unknown as { instance: unknown }).instance = null
return module.UpdateService.getInstance()
}
it('initializes enabled update status from config', async () => {
const service = await loadService()
service.initialize()
expect(service.getStatus()).toMatchObject({
enabled: true,
supported: true
})
})
it('clears update state when user context becomes guest-like', async () => {
const service = await loadService()
await service.setUserContext(null)
expect(service.getStatus()).toMatchObject({
currentUserType: null,
phase: 'idle',
recommendedRelease: undefined,
downloadedRelease: undefined
})
expect(mockPublishUpdateStatus).toHaveBeenCalled()
})
it('checks updates for user and auto-downloads available recommendation', async () => {
const recommended = createRelease('1.1.0')
const catalog: UpdateCatalog = {
stable: [recommended],
preview: []
}
const userStatus: Partial<UpdateStatus> = {
phase: 'available',
recommendedRelease: recommended,
latestVersion: recommended.version,
latestChannel: recommended.channel,
message: `发现稳定版 ${recommended.version}`
}
mockLoadCatalog.mockResolvedValue(catalog)
mockResolveUserStatus.mockResolvedValue(userStatus)
mockGetDownloadPath.mockReturnValue('D:/downloads/stable-1.1.0.exe')
mockCalculateSha256.mockResolvedValue(recommended.sha256)
const service = await loadService()
await service.setUserContext('User')
expect(mockLoadCatalog).toHaveBeenCalledWith('User')
expect(mockResolveUserStatus).toHaveBeenCalled()
expect(mockDownloadToFile).toHaveBeenCalledWith(
recommended.artifactKey,
'D:/downloads/stable-1.1.0.exe'
)
expect(service.getStatus()).toMatchObject({
phase: 'downloaded',
latestVersion: '1.1.0',
latestChannel: 'stable'
})
})
it('returns disabled catalog when update services are unavailable', async () => {
currentUpdateConfig = {
...currentUpdateConfig,
enabled: false,
allowDevMode: false
}
const service = await loadService()
service.initialize()
const result = service.getCatalog()
expect(result).toEqual({ mode: 'disabled' })
expect(mockGetDialogCatalog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
const mockSend = vi.fn()
const mockGetAllWindows = vi.fn(() => [
{ webContents: { send: mockSend } },
{ webContents: { send: mockSend } }
])
vi.mock('electron', async () => {
const actual = await vi.importActual<typeof import('electron')>('electron')
return {
...actual,
BrowserWindow: {
getAllWindows: mockGetAllWindows
}
}
})
describe('update-status-publisher', () => {
it('broadcasts status to all renderer windows', async () => {
const { publishUpdateStatus } =
await import('../../src/main/services/update/update-status-publisher')
publishUpdateStatus({
enabled: true,
supported: true,
phase: 'checking',
currentVersion: '1.0.0',
currentChannel: 'stable',
currentUserType: 'Admin'
})
expect(mockGetAllWindows).toHaveBeenCalledTimes(1)
expect(mockSend).toHaveBeenCalledTimes(2)
})
})