mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 11:02:08 +08:00
test: trim redundant test coverage
This commit is contained in:
@@ -1,173 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import i18next from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { afterEach, beforeAll, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getSnapshotNotices,
|
||||
hideNotice,
|
||||
showNotice,
|
||||
} from '@/services/notice-service'
|
||||
|
||||
import { NoticeManager } from './notice-manager'
|
||||
|
||||
const writeText = vi.fn((_text: string) => Promise.resolve())
|
||||
const restartCore = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const installService = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const getRuntimeState = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
restartCore,
|
||||
installService,
|
||||
getRuntimeState,
|
||||
}))
|
||||
vi.mock('@/utils/get-system', () => ({ default: () => 'macos' }))
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
settings: {
|
||||
modals: {
|
||||
backup: {
|
||||
messages: { backupFailed: 'Backup failed: {{error}}' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restartCore.mockClear()
|
||||
restartCore.mockImplementation(() => Promise.resolve())
|
||||
installService.mockClear()
|
||||
installService.mockImplementation(() => Promise.resolve())
|
||||
getRuntimeState.mockReset()
|
||||
getRuntimeState.mockResolvedValue({ mode: 'Service', serviceUsable: true })
|
||||
cleanup()
|
||||
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
|
||||
writeText.mockClear()
|
||||
})
|
||||
|
||||
const noticeText = () => screen.getByRole('alert').textContent ?? ''
|
||||
|
||||
it('renders a failure detail that fits, in full', () => {
|
||||
showNotice.error({ code: 'CORE_START_FAILED', detail: 'connection refused' })
|
||||
render(<NoticeManager />)
|
||||
|
||||
expect(noticeText()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('shortens an unbounded detail', () => {
|
||||
showNotice.error({
|
||||
code: 'SERVICE_INSTALL_FAILED',
|
||||
detail: `${'x'.repeat(4000)} tail`,
|
||||
})
|
||||
render(<NoticeManager />)
|
||||
|
||||
expect(noticeText().length).toBeLessThanOrEqual(501)
|
||||
expect(noticeText()).not.toContain('tail')
|
||||
expect(noticeText()).toContain('…')
|
||||
})
|
||||
|
||||
it('shortens a detail a caller interpolated into its own message', () => {
|
||||
showNotice.error('profiles.page.feedback.notices.emergencyRefreshFailed', {
|
||||
message: 'y'.repeat(4000),
|
||||
})
|
||||
render(<NoticeManager />)
|
||||
|
||||
expect(noticeText().length).toBeLessThanOrEqual(501)
|
||||
expect(noticeText()).toContain('…')
|
||||
})
|
||||
|
||||
it('renders a failure a caller interpolated, not [object Object]', () => {
|
||||
showNotice.error('settings.modals.backup.messages.backupFailed', {
|
||||
error: { code: 'BACKUP_FAILED', detail: 'webdav returned 401' },
|
||||
})
|
||||
render(<NoticeManager />)
|
||||
|
||||
expect(noticeText()).toBe('Backup failed: webdav returned 401')
|
||||
})
|
||||
|
||||
it('renders a failure inside a descriptor, not [object Object]', () => {
|
||||
showNotice.error({
|
||||
key: 'settings.modals.backup.messages.backupFailed',
|
||||
params: { error: { code: 'BACKUP_FAILED', detail: 'webdav returned 401' } },
|
||||
})
|
||||
render(<NoticeManager />)
|
||||
|
||||
expect(noticeText()).toBe('Backup failed: webdav returned 401')
|
||||
})
|
||||
|
||||
it('leaves the ordinary lifetime alone where there is nothing to offer', () => {
|
||||
showNotice.error({ code: 'CORE_START_FAILED', detail: 'connection refused' })
|
||||
|
||||
expect(getSnapshotNotices().at(-1)?.duration).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('finds the code a caller interpolated into its own message', () => {
|
||||
showNotice.error('settings.modals.backup.messages.backupFailed', {
|
||||
error: { code: 'SYSPROXY_GUARD_STOPPED', detail: 'the guard gave up' },
|
||||
})
|
||||
|
||||
expect(getSnapshotNotices().at(-1)?.code).toBe('SYSPROXY_GUARD_STOPPED')
|
||||
})
|
||||
it('shows one notice per code, replacing rather than stacking', async () => {
|
||||
showNotice.error({ code: 'SYSPROXY_GUARD_STOPPED', detail: 'first' })
|
||||
showNotice.error({ code: 'SYSPROXY_GUARD_STOPPED', detail: 'second' })
|
||||
render(<NoticeManager />)
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1)
|
||||
expect(screen.getByRole('alert').textContent).toContain('second')
|
||||
})
|
||||
|
||||
it('remembers which way the user was asking, for whoever acts on it next', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'admin privileges required to modify system proxy',
|
||||
operation: 'systemProxyEnable',
|
||||
})
|
||||
|
||||
expect(getSnapshotNotices().at(-1)?.operation).toBe('systemProxyEnable')
|
||||
})
|
||||
|
||||
it('copies the whole detail, not the shortened one', async () => {
|
||||
const detail = `${'x'.repeat(4000)} tail`
|
||||
showNotice.error({ code: 'SERVICE_INSTALL_FAILED', detail })
|
||||
render(<NoticeManager />)
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('alert'))
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
|
||||
expect(writeText).toHaveBeenCalledWith(detail)
|
||||
})
|
||||
|
||||
it('copies the whole message when the caller interpolated the detail', async () => {
|
||||
const detail = 'y'.repeat(4000)
|
||||
showNotice.error('profiles.page.feedback.notices.emergencyRefreshFailed', {
|
||||
message: detail,
|
||||
})
|
||||
render(<NoticeManager />)
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('alert'))
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
|
||||
expect(writeText.mock.calls[0]?.[0]).toContain(detail)
|
||||
})
|
||||
@@ -1,350 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import i18next from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { afterEach, beforeAll, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PendingFailure } from '@/services/cmds'
|
||||
import { getSnapshotNotices, hideNotice } from '@/services/notice-service'
|
||||
import { clearServiceRequest, requestService } from '@/services/service-request'
|
||||
|
||||
const reported = (key: string) =>
|
||||
getSnapshotNotices().some(
|
||||
(notice) =>
|
||||
notice.i18n?.key === key ||
|
||||
Object.values(notice.i18n?.params ?? {}).some(
|
||||
(param) =>
|
||||
typeof param === 'object' &&
|
||||
param !== null &&
|
||||
'key' in param &&
|
||||
(param as { key?: unknown }).key === key,
|
||||
),
|
||||
)
|
||||
|
||||
import { SysproxyPrivilegeDialog } from './sysproxy-privilege-dialog'
|
||||
|
||||
const restartCore = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const installService = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const getRuntimeState = vi.hoisted(() => vi.fn())
|
||||
const getPendingFailures = vi.hoisted(() => vi.fn())
|
||||
const patchVergeConfig = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const onFocusChanged = vi.hoisted(() => vi.fn(() => Promise.resolve(() => {})))
|
||||
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
restartCore,
|
||||
installService,
|
||||
getRuntimeState,
|
||||
getPendingFailures,
|
||||
patchVergeConfig,
|
||||
}))
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ onFocusChanged }),
|
||||
}))
|
||||
|
||||
let onSubscribed: (() => void) | undefined
|
||||
vi.mock('@/services/events', () => ({
|
||||
subscribeVergeEvents: (
|
||||
_handlers: Record<string, () => void>,
|
||||
subscribed?: () => void,
|
||||
) => {
|
||||
onSubscribed = subscribed
|
||||
return () => {}
|
||||
},
|
||||
}))
|
||||
|
||||
const failure = (
|
||||
code: string,
|
||||
operation: PendingFailure['operation'] = 'systemProxyEnable',
|
||||
): PendingFailure => ({
|
||||
code,
|
||||
detail: 'admin privileges required to modify system proxy',
|
||||
operation,
|
||||
sequence: 1,
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
resources: { en: { translation: {} } },
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restartCore.mockClear()
|
||||
restartCore.mockImplementation(() => Promise.resolve())
|
||||
installService.mockClear()
|
||||
installService.mockImplementation(() => Promise.resolve())
|
||||
getRuntimeState.mockReset()
|
||||
getRuntimeState.mockResolvedValue({ mode: 'Service', serviceUsable: true })
|
||||
getPendingFailures.mockReset()
|
||||
patchVergeConfig.mockClear()
|
||||
patchVergeConfig.mockImplementation(() => Promise.resolve())
|
||||
onSubscribed = undefined
|
||||
clearServiceRequest()
|
||||
cleanup()
|
||||
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
|
||||
})
|
||||
|
||||
const showing = async (
|
||||
code: string,
|
||||
operation: PendingFailure['operation'] = 'systemProxyEnable',
|
||||
) => {
|
||||
getPendingFailures.mockResolvedValue([failure(code, operation)])
|
||||
render(<SysproxyPrivilegeDialog />)
|
||||
onSubscribed?.()
|
||||
await screen.findByRole('dialog')
|
||||
}
|
||||
|
||||
const clickPrimary = (name: string) =>
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
|
||||
it('offers to install the service when the write was refused', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'settings.sections.proxyControl.actions.installService',
|
||||
}),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers only a restart when the service is already there', async () => {
|
||||
await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY')
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'settings.sections.proxyControl.actions.switchToServiceMode',
|
||||
}),
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'settings.sections.proxyControl.actions.installService',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('installs the service, restarts into it, and makes the request again', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(installService).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
|
||||
await waitFor(() =>
|
||||
expect(patchVergeConfig).toHaveBeenCalledWith({
|
||||
enable_system_proxy: true,
|
||||
}),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
reported(
|
||||
'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
),
|
||||
).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
it('replays the direction that was asked for, not always “on”', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED', 'systemProxyDisable')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchVergeConfig).toHaveBeenCalledWith({
|
||||
enable_system_proxy: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('changes nothing when nobody asked for a proxy state', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED', 'systemProxyRestore')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
|
||||
expect(patchVergeConfig).not.toHaveBeenCalled()
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
reported('settings.sections.proxyControl.messages.installedCheckProxy'),
|
||||
).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not install again when only the restart is needed', async () => {
|
||||
await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.switchToServiceMode')
|
||||
|
||||
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
|
||||
expect(installService).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks what actually happened rather than trusting the restart', async () => {
|
||||
getRuntimeState.mockResolvedValue({ mode: 'Sidecar', serviceUsable: true })
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
reported(
|
||||
'settings.sections.proxyControl.messages.installedCoreNotOnService',
|
||||
),
|
||||
).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
it('says which part of the wait it is in, rather than looking stuck', async () => {
|
||||
const installing = deferred()
|
||||
const restarting = deferred()
|
||||
const applying = deferred()
|
||||
installService.mockImplementation(() => installing.promise)
|
||||
restartCore.mockImplementation(() => restarting.promise)
|
||||
patchVergeConfig.mockImplementation(() => applying.promise)
|
||||
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.installing')
|
||||
installing.resolve()
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.restarting')
|
||||
restarting.resolve()
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.applying')
|
||||
applying.resolve()
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
it('does not claim to be installing when only a restart is needed', async () => {
|
||||
const restarting = deferred()
|
||||
restartCore.mockImplementation(() => restarting.promise)
|
||||
|
||||
await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY')
|
||||
clickPrimary('settings.sections.proxyControl.actions.switchToServiceMode')
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.restarting')
|
||||
expect(
|
||||
screen.queryByText('layout.components.sysproxyPrivilege.installing'),
|
||||
).toBeNull()
|
||||
restarting.resolve()
|
||||
})
|
||||
|
||||
it('offers no way out while the installer is holding a password prompt', async () => {
|
||||
const installing = deferred()
|
||||
installService.mockImplementation(() => installing.promise)
|
||||
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.installing')
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'layout.components.sysproxyPrivilege.later',
|
||||
}),
|
||||
).toBeNull()
|
||||
installing.resolve()
|
||||
})
|
||||
|
||||
it('closes once the core is on the service, whatever the table still says', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
it('stays open when the core did not reach the service', async () => {
|
||||
getRuntimeState.mockResolvedValue({ mode: 'Sidecar', serviceUsable: false })
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not go on to restart when the install itself failed', async () => {
|
||||
installService.mockRejectedValue({
|
||||
code: 'SERVICE_INSTALL_FAILED',
|
||||
detail: 'user cancelled',
|
||||
})
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(installService).toHaveBeenCalledOnce())
|
||||
expect(restartCore).not.toHaveBeenCalled()
|
||||
expect(getRuntimeState).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('closes on “later” without touching the machine', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
clickPrimary('layout.components.sysproxyPrivilege.later')
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(installService).not.toHaveBeenCalled()
|
||||
expect(restartCore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers a switch that asked outright, with no failure to go on', async () => {
|
||||
getPendingFailures.mockResolvedValue([])
|
||||
render(<SysproxyPrivilegeDialog />)
|
||||
onSubscribed?.()
|
||||
|
||||
act(() =>
|
||||
requestService({
|
||||
reason: 'tunNeedsService',
|
||||
restore: { enable_tun_mode: true },
|
||||
}),
|
||||
)
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.tunMessage')
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(installService).toHaveBeenCalledOnce())
|
||||
await waitFor(() =>
|
||||
expect(patchVergeConfig).toHaveBeenCalledWith({ enable_tun_mode: true }),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
it('shows what the user is doing now over what the table still holds', async () => {
|
||||
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
|
||||
|
||||
act(() => requestService({ reason: 'tunNeedsService' }))
|
||||
|
||||
await screen.findByText('layout.components.sysproxyPrivilege.tunMessage')
|
||||
})
|
||||
|
||||
it('changes nothing when a switch asked without saying what for', async () => {
|
||||
getPendingFailures.mockResolvedValue([])
|
||||
render(<SysproxyPrivilegeDialog />)
|
||||
onSubscribed?.()
|
||||
|
||||
act(() => requestService({ reason: 'tunNeedsService' }))
|
||||
await screen.findByRole('dialog')
|
||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
||||
|
||||
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
|
||||
expect(patchVergeConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveEmptyListReason,
|
||||
resolveProxyListState,
|
||||
} from './proxy-empty-state-model'
|
||||
|
||||
const settled = {
|
||||
isProfilesPending: false,
|
||||
isProxyViewPending: false,
|
||||
isRunningModePending: false,
|
||||
}
|
||||
|
||||
const subscribed = {
|
||||
current: 'a',
|
||||
items: [{ uid: 'a', type: 'remote' }],
|
||||
}
|
||||
|
||||
describe('resolveProxyListState', () => {
|
||||
test('direct mode is its own outcome, not an empty list', () => {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
mode: 'direct',
|
||||
profiles: { current: undefined, items: [] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'direct' })
|
||||
})
|
||||
|
||||
test('anything still loading outranks every verdict about emptiness', () => {
|
||||
for (const pending of [
|
||||
{ isProfilesPending: true },
|
||||
{ isProxyViewPending: true },
|
||||
{ isRunningModePending: true },
|
||||
]) {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
...pending,
|
||||
mode: 'rule',
|
||||
profiles: { current: undefined, items: [] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'loading' })
|
||||
}
|
||||
})
|
||||
|
||||
test('no subscription at all is reported before anything about proxies', () => {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
mode: 'rule',
|
||||
profiles: { current: undefined, items: [{ uid: 'a', type: 'merge' }] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'empty', reason: 'no-subscriptions' })
|
||||
})
|
||||
|
||||
test('a subscription that is not the active one is its own explanation', () => {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
mode: 'rule',
|
||||
profiles: { current: 'other', items: [{ uid: 'a', type: 'remote' }] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'empty', reason: 'inactive-subscription' })
|
||||
})
|
||||
|
||||
test('with a usable subscription it defers to the list rather than guessing', () => {
|
||||
// The point of the split: this function no longer has an opinion on whether the
|
||||
// renderer will find anything, because the two opinions used to disagree.
|
||||
for (const mode of ['rule', 'script', 'global']) {
|
||||
expect(
|
||||
resolveProxyListState({ ...settled, mode, profiles: subscribed }),
|
||||
).toEqual({
|
||||
kind: 'render',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('it asks nothing about the proxy view at all', () => {
|
||||
// Guarding the shape, not just the behaviour: reintroducing a proxyView input here is
|
||||
// how the second, divergent derivation came back.
|
||||
const inputs = Object.keys({
|
||||
...settled,
|
||||
mode: 'rule',
|
||||
profiles: subscribed,
|
||||
})
|
||||
|
||||
expect(inputs).not.toContain('proxyView')
|
||||
expect(inputs).not.toContain('isChainMode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEmptyListReason', () => {
|
||||
test('a stopped core explains an empty list better than the list does', () => {
|
||||
expect(
|
||||
resolveEmptyListReason({
|
||||
runningMode: 'NotRunning',
|
||||
isProxyViewError: false,
|
||||
}),
|
||||
).toBe('core-unavailable')
|
||||
})
|
||||
|
||||
test('a failed proxy fetch is reported as the core being unavailable', () => {
|
||||
expect(
|
||||
resolveEmptyListReason({
|
||||
runningMode: 'Sidecar',
|
||||
isProxyViewError: true,
|
||||
}),
|
||||
).toBe('core-unavailable')
|
||||
})
|
||||
|
||||
test('a running core with a clean fetch means the profile simply has nothing', () => {
|
||||
expect(
|
||||
resolveEmptyListReason({
|
||||
runningMode: 'Sidecar',
|
||||
isProxyViewError: false,
|
||||
}),
|
||||
).toBe('no-proxy-info')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { hasRenderableItems, type IRenderItem } from './use-render-list'
|
||||
|
||||
const group = (hidden = false) =>
|
||||
({ name: 'Proxies', hidden }) as unknown as IRenderItem['group']
|
||||
|
||||
const header = (hidden = false): IRenderItem => ({
|
||||
type: 0,
|
||||
key: 'header',
|
||||
group: group(hidden),
|
||||
})
|
||||
|
||||
const member = (key: string): IRenderItem => ({
|
||||
type: 2,
|
||||
key,
|
||||
group: group(),
|
||||
})
|
||||
|
||||
const memberColumn = (key: string): IRenderItem => ({
|
||||
type: 4,
|
||||
key,
|
||||
group: group(),
|
||||
})
|
||||
|
||||
describe('hasRenderableItems', () => {
|
||||
test('an empty list has nothing to show', () => {
|
||||
expect(hasRenderableItems([])).toBe(false)
|
||||
})
|
||||
|
||||
test('chain mode is answered by the chain list itself', () => {
|
||||
// Rendered rows, not group types, determine whether chain mode is empty.
|
||||
expect(hasRenderableItems([member('chain:a')])).toBe(true)
|
||||
expect(hasRenderableItems([memberColumn('chain-col:a')])).toBe(true)
|
||||
})
|
||||
|
||||
test('a visible group counts even before it is expanded', () => {
|
||||
expect(hasRenderableItems([header()])).toBe(true)
|
||||
})
|
||||
|
||||
test('hidden groups alone are not content', () => {
|
||||
expect(hasRenderableItems([header(true), header(true)])).toBe(false)
|
||||
})
|
||||
|
||||
test('one visible group among hidden ones is enough', () => {
|
||||
expect(hasRenderableItems([header(true), header(), header(true)])).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('rows inside a hidden but expanded group are still rows on screen', () => {
|
||||
// Visible member rows mean the page is not empty even without a visible group header.
|
||||
expect(hasRenderableItems([header(true), member('member:a')])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSnapshotNotices, hideNotice } from '@/services/notice-service'
|
||||
import {
|
||||
clearServiceRequest,
|
||||
getServiceRequest,
|
||||
} from '@/services/service-request'
|
||||
|
||||
import ProxyControlSwitches from './proxy-control-switches'
|
||||
|
||||
const patchVerge = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mutateVerge = vi.hoisted(() => vi.fn())
|
||||
const isTunModeAvailable = vi.hoisted(() => ({ current: false }))
|
||||
const tunEnabled = vi.hoisted(() => ({ current: false }))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
vi.mock('@/hooks/use-verge', () => ({
|
||||
useVerge: () => ({
|
||||
verge: { enable_tun_mode: tunEnabled.current },
|
||||
mutateVerge,
|
||||
patchVerge,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/hooks/use-system-state', () => ({
|
||||
useSystemState: () => ({
|
||||
runState: { serviceUsable: false },
|
||||
isTunModeAvailable: isTunModeAvailable.current,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/hooks/use-system-proxy-state', () => ({
|
||||
useSystemProxyState: () => ({
|
||||
indicator: false,
|
||||
toggleSystemProxy: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/hooks/use-service-uninstaller', () => ({
|
||||
useServiceUninstaller: () => ({ uninstallServiceAndStartSidecar: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/components/setting/mods/sysproxy-viewer', () => ({
|
||||
SysproxyViewer: () => null,
|
||||
}))
|
||||
vi.mock('@/components/setting/mods/tun-viewer', () => ({
|
||||
TunViewer: () => null,
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
patchVerge.mockClear()
|
||||
mutateVerge.mockClear()
|
||||
isTunModeAvailable.current = false
|
||||
tunEnabled.current = false
|
||||
clearServiceRequest()
|
||||
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const renderTun = () =>
|
||||
render(
|
||||
<ProxyControlSwitches label="settings.sections.system.toggles.tunMode" />,
|
||||
)
|
||||
|
||||
const theSwitch = () => screen.getByRole('switch') as HTMLInputElement
|
||||
const flip = () => fireEvent.click(theSwitch())
|
||||
|
||||
it('asks for the service when TUN cannot work, saying what it was for', async () => {
|
||||
renderTun()
|
||||
|
||||
flip()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getServiceRequest()).toEqual({
|
||||
reason: 'tunNeedsService',
|
||||
restore: { enable_tun_mode: true },
|
||||
}),
|
||||
)
|
||||
expect(patchVerge).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the switch where it was until something can carry the request', async () => {
|
||||
renderTun()
|
||||
|
||||
flip()
|
||||
|
||||
await waitFor(() => expect(theSwitch().checked).toBe(false))
|
||||
})
|
||||
|
||||
it('patches directly when the service can already carry it', async () => {
|
||||
isTunModeAvailable.current = true
|
||||
renderTun()
|
||||
|
||||
flip()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchVerge).toHaveBeenCalledWith({ enable_tun_mode: true }),
|
||||
)
|
||||
expect(getServiceRequest()).toBeNull()
|
||||
})
|
||||
|
||||
it('no longer offers a separate install button', () => {
|
||||
renderTun()
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText(
|
||||
'settings.sections.proxyControl.actions.installService',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not ask for a service in order to turn TUN off', async () => {
|
||||
tunEnabled.current = true
|
||||
renderTun()
|
||||
|
||||
flip()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchVerge).toHaveBeenCalledWith({ enable_tun_mode: false }),
|
||||
)
|
||||
expect(getServiceRequest()).toBeNull()
|
||||
})
|
||||
|
||||
it('says nothing beside the dialog it just opened', async () => {
|
||||
const onError = vi.fn()
|
||||
render(
|
||||
<ProxyControlSwitches
|
||||
label="settings.sections.system.toggles.tunMode"
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
|
||||
flip()
|
||||
|
||||
await waitFor(() => expect(getServiceRequest()).not.toBeNull())
|
||||
await waitFor(() => expect(theSwitch().checked).toBe(false))
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(getSnapshotNotices()).toEqual([])
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useForgetSelection, useRecordSelection } from './use-record-selection'
|
||||
|
||||
const recordSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const forgetSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
|
||||
vi.mock('@/services/cmds', () => ({ forgetSelectedNode, recordSelectedNode }))
|
||||
|
||||
const record = (groupName: string, proxyName: string) => {
|
||||
const { result } = renderHook(() => useRecordSelection())
|
||||
result.current(groupName, proxyName)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordSelectedNode.mockClear()
|
||||
forgetSelectedNode.mockClear()
|
||||
})
|
||||
|
||||
describe('useForgetSelection', () => {
|
||||
it('sends only the group name', () => {
|
||||
const { result } = renderHook(() => useForgetSelection())
|
||||
|
||||
result.current('Proxy')
|
||||
|
||||
expect(forgetSelectedNode).toHaveBeenCalledWith('Proxy')
|
||||
})
|
||||
|
||||
it('resolves after the clear request finishes', async () => {
|
||||
let resolveForget!: () => void
|
||||
forgetSelectedNode.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (resolveForget = resolve)),
|
||||
)
|
||||
const { result } = renderHook(() => useForgetSelection())
|
||||
|
||||
const forget = result.current('Proxy')
|
||||
let settled = false
|
||||
forget.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
resolveForget()
|
||||
await forget
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a failed clear without throwing at the caller', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
forgetSelectedNode.mockRejectedValueOnce(new Error('no profile'))
|
||||
const { result } = renderHook(() => useForgetSelection())
|
||||
|
||||
expect(() => result.current('Proxy')).not.toThrow()
|
||||
|
||||
await vi.waitFor(() => expect(consoleError).toHaveBeenCalled())
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRecordSelection', () => {
|
||||
it('sends only the group and the node', () => {
|
||||
record('Proxy', 'Node A')
|
||||
|
||||
expect(recordSelectedNode).toHaveBeenCalledWith('Proxy', 'Node A')
|
||||
})
|
||||
|
||||
it('does not derive anything from a rendered selection list', () => {
|
||||
// Pair-wise writes let the backend merge concurrent choices against fresh state.
|
||||
record('Proxy', 'Node A')
|
||||
record('Fallback', 'Node C')
|
||||
|
||||
expect(recordSelectedNode).toHaveBeenNthCalledWith(1, 'Proxy', 'Node A')
|
||||
expect(recordSelectedNode).toHaveBeenNthCalledWith(2, 'Fallback', 'Node C')
|
||||
})
|
||||
|
||||
it('is stable across renders, so callers may hold it in a dependency array', () => {
|
||||
const { result, rerender } = renderHook(() => useRecordSelection())
|
||||
const first = result.current
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('reports a failed write without throwing at the caller', async () => {
|
||||
// Persistence failure cannot roll back a core selection that already succeeded.
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
recordSelectedNode.mockRejectedValueOnce(new Error('no profile'))
|
||||
|
||||
expect(() => record('Proxy', 'Node A')).not.toThrow()
|
||||
|
||||
await vi.waitFor(() => expect(consoleError).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
@@ -1,147 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSystemProxyState } from './use-system-proxy-state'
|
||||
|
||||
const patchVergeConfig = vi.hoisted(() => vi.fn())
|
||||
const mutateVerge = vi.hoisted(() => vi.fn())
|
||||
const getCacheData = vi.hoisted(() => vi.fn())
|
||||
const revalidateQueries = vi.hoisted(() => vi.fn())
|
||||
|
||||
const CACHED: IVergeConfig = { enable_system_proxy: false } as IVergeConfig
|
||||
|
||||
vi.mock('@/hooks/use-verge', () => ({
|
||||
useVerge: () => ({ verge: CACHED, mutateVerge }),
|
||||
}))
|
||||
vi.mock('@/hooks/use-displayed-mixed-port', () => ({
|
||||
useDisplayedMixedPort: () => 7897,
|
||||
}))
|
||||
vi.mock('@/providers/app-data-context', () => ({
|
||||
useSystemData: () => ({ sysproxy: undefined }),
|
||||
}))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getAutotemProxy: vi.fn(),
|
||||
getEmbeddedServerPort: vi.fn(),
|
||||
patchVergeConfig,
|
||||
}))
|
||||
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
closeAllConnections: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
vi.mock('@/services/query-client', () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
getCacheData,
|
||||
revalidateQueries,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
patchVergeConfig.mockReset()
|
||||
mutateVerge.mockReset()
|
||||
getCacheData.mockReset()
|
||||
getCacheData.mockReturnValue(CACHED)
|
||||
revalidateQueries.mockReset()
|
||||
revalidateQueries.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const lastWrite = () => {
|
||||
const call = mutateVerge.mock.calls[mutateVerge.mock.calls.length - 1]?.[0]
|
||||
return typeof call === 'function' ? call(CACHED) : call
|
||||
}
|
||||
|
||||
it('puts the cache back when the toggle fails', async () => {
|
||||
patchVergeConfig.mockRejectedValue({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
detail: 'refused',
|
||||
})
|
||||
const { result } = renderHook(() => useSystemProxyState())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.toggleSystemProxy(true)).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
expect(lastWrite()).toMatchObject({ enable_system_proxy: false })
|
||||
})
|
||||
|
||||
it('falls back to what the last successful click reached, not to where the run began', async () => {
|
||||
patchVergeConfig.mockResolvedValueOnce(undefined).mockRejectedValueOnce({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
detail: 'refused',
|
||||
})
|
||||
getCacheData.mockReturnValue({ enable_system_proxy: true } as IVergeConfig)
|
||||
const { result } = renderHook(() => useSystemProxyState())
|
||||
|
||||
await act(async () => {
|
||||
const inFlight = result.current.toggleSystemProxy(false)
|
||||
void result.current.toggleSystemProxy(true)
|
||||
await expect(inFlight).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
expect(lastWrite()).toMatchObject({ enable_system_proxy: false })
|
||||
})
|
||||
|
||||
it('lets a revalidation failure surface when the toggle itself worked', async () => {
|
||||
patchVergeConfig.mockResolvedValue(undefined)
|
||||
revalidateQueries.mockRejectedValue(new Error('network gone'))
|
||||
const { result } = renderHook(() => useSystemProxyState())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.toggleSystemProxy(true)).rejects.toThrow(
|
||||
'network gone',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the failure the backend classified, not a revalidation that failed after it', async () => {
|
||||
patchVergeConfig.mockRejectedValue({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
detail: 'refused',
|
||||
})
|
||||
revalidateQueries.mockRejectedValue(new Error('network gone'))
|
||||
const { result } = renderHook(() => useSystemProxyState())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.toggleSystemProxy(true)).rejects.toMatchObject({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('revalidates the config it wrote to, not only the two proxy reads', async () => {
|
||||
patchVergeConfig.mockResolvedValue(undefined)
|
||||
const { result } = renderHook(() => useSystemProxyState())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleSystemProxy(true)
|
||||
})
|
||||
|
||||
expect(revalidateQueries).toHaveBeenCalledWith([
|
||||
['getVergeConfig'],
|
||||
['getSystemProxy'],
|
||||
['getAutotemProxy'],
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a click queued behind one that failed', async () => {
|
||||
let rejectFirst: ((reason: unknown) => void) | undefined
|
||||
patchVergeConfig.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
}),
|
||||
)
|
||||
const { result } = renderHook(() => useSystemProxyState())
|
||||
|
||||
let inFlight: Promise<unknown> = Promise.resolve()
|
||||
await act(async () => {
|
||||
inFlight = result.current.toggleSystemProxy(true)
|
||||
void result.current.toggleSystemProxy(false)
|
||||
rejectFirst?.({ code: 'SYSPROXY_PRIVILEGE_REQUIRED', detail: 'refused' })
|
||||
await expect(inFlight).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
expect(patchVergeConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { setPreloadConfig } from '@/services/preload'
|
||||
|
||||
import { useUpdate } from './use-update'
|
||||
|
||||
const check = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tauri-apps/plugin-updater', () => ({ check }))
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setPreloadConfig({ auto_check_update: false } as IVergeConfig)
|
||||
check.mockReset()
|
||||
})
|
||||
|
||||
test('manual update check works when automatic checks are disabled', async () => {
|
||||
const availableUpdate = { version: '9.9.9' }
|
||||
check.mockResolvedValueOnce(availableUpdate)
|
||||
const { result } = renderHook(() => useUpdate())
|
||||
|
||||
let checked
|
||||
await act(async () => {
|
||||
checked = await result.current.checkUpdate()
|
||||
})
|
||||
|
||||
expect(check).toHaveBeenCalledOnce()
|
||||
expect(checked).toEqual({ data: availableUpdate })
|
||||
await waitFor(() => expect(result.current.lastCheckUpdate).not.toBeNull())
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PendingFailure } from '@/services/cmds'
|
||||
import { getSnapshotNotices, hideNotice } from '@/services/notice-service'
|
||||
|
||||
import { useDialogFailure, usePendingFailures } from './use-pending-failures'
|
||||
|
||||
const getPendingFailures = vi.hoisted(() => vi.fn())
|
||||
const onFocusChanged = vi.hoisted(() => vi.fn())
|
||||
const isVisible = vi.hoisted(() => vi.fn())
|
||||
const isMinimized = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/cmds', () => ({ getPendingFailures }))
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ onFocusChanged, isVisible, isMinimized }),
|
||||
}))
|
||||
|
||||
let table: PendingFailure[] = []
|
||||
let onSubscribed: (() => void) | undefined
|
||||
let onTableChanged: (() => void) | undefined
|
||||
let focusListener: ((event: { payload: boolean }) => void) | undefined
|
||||
|
||||
vi.mock('@/services/events', () => ({
|
||||
subscribeVergeEvents: (
|
||||
handlers: Record<string, () => void>,
|
||||
subscribed?: () => void,
|
||||
) => {
|
||||
onTableChanged = handlers['verge://pending-failures-changed']
|
||||
onSubscribed = subscribed
|
||||
return () => {}
|
||||
},
|
||||
}))
|
||||
|
||||
const failure = (sequence: number, detail: string): PendingFailure => ({
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail,
|
||||
operation: 'systemProxyGuard',
|
||||
sequence,
|
||||
})
|
||||
|
||||
const dialogFailure = (sequence: number): PendingFailure => ({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
detail: 'refused',
|
||||
operation: 'systemProxyEnable',
|
||||
sequence,
|
||||
})
|
||||
|
||||
const shownDetails = () =>
|
||||
getSnapshotNotices().map((notice) => notice.i18n?.params?.message)
|
||||
|
||||
beforeEach(() => {
|
||||
getPendingFailures.mockClear()
|
||||
onFocusChanged.mockClear()
|
||||
isVisible.mockReset()
|
||||
isVisible.mockResolvedValue(true)
|
||||
isMinimized.mockReset()
|
||||
isMinimized.mockResolvedValue(false)
|
||||
table = []
|
||||
onSubscribed = undefined
|
||||
onTableChanged = undefined
|
||||
focusListener = undefined
|
||||
getPendingFailures.mockImplementation(() => Promise.resolve(table))
|
||||
onFocusChanged.mockImplementation(
|
||||
(listener: (event: { payload: boolean }) => void) => {
|
||||
focusListener = listener
|
||||
return Promise.resolve(() => {})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
|
||||
})
|
||||
|
||||
it('shows a failure that was recorded before the listeners were live', async () => {
|
||||
table = [failure(1, 'refused')]
|
||||
renderHook(() => usePendingFailures())
|
||||
|
||||
onSubscribed?.()
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
})
|
||||
|
||||
it('shows a failure recorded after the window was already up', async () => {
|
||||
renderHook(() => usePendingFailures())
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(getPendingFailures).toHaveBeenCalled())
|
||||
|
||||
table = [failure(1, 'refused')]
|
||||
onTableChanged?.()
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
})
|
||||
|
||||
it('does not show the same failure again when asked again', async () => {
|
||||
table = [failure(1, 'refused')]
|
||||
renderHook(() => usePendingFailures())
|
||||
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
onTableChanged?.()
|
||||
focusListener?.({ payload: true })
|
||||
|
||||
await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(3))
|
||||
expect(shownDetails()).toEqual(['refused'])
|
||||
})
|
||||
|
||||
it('shows the same code again when it has failed again', async () => {
|
||||
table = [failure(1, 'refused')]
|
||||
renderHook(() => usePendingFailures())
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
|
||||
table = [failure(2, 'refused again')]
|
||||
onTableChanged?.()
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused again']))
|
||||
})
|
||||
|
||||
it('reads when the window takes focus', async () => {
|
||||
renderHook(() => usePendingFailures())
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(getPendingFailures).toHaveBeenCalled())
|
||||
|
||||
table = [failure(1, 'refused')]
|
||||
focusListener?.({ payload: true })
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
})
|
||||
|
||||
it('keeps a failure pending while the window is hidden', async () => {
|
||||
isVisible.mockResolvedValue(false)
|
||||
table = [failure(1, 'refused')]
|
||||
renderHook(() => usePendingFailures())
|
||||
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(isVisible).toHaveBeenCalled())
|
||||
expect(shownDetails()).toEqual([])
|
||||
|
||||
isVisible.mockResolvedValue(true)
|
||||
focusListener?.({ payload: true })
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
})
|
||||
|
||||
it('keeps a failure pending while the window is minimised', async () => {
|
||||
isMinimized.mockResolvedValue(true)
|
||||
table = [failure(1, 'refused')]
|
||||
renderHook(() => usePendingFailures())
|
||||
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(isMinimized).toHaveBeenCalled())
|
||||
expect(shownDetails()).toEqual([])
|
||||
|
||||
isMinimized.mockResolvedValue(false)
|
||||
focusListener?.({ payload: true })
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
|
||||
})
|
||||
|
||||
it('keeps a failure pending when the window cannot say what it is doing', async () => {
|
||||
isVisible.mockRejectedValue(new Error('no window'))
|
||||
table = [failure(1, 'refused')]
|
||||
renderHook(() => usePendingFailures())
|
||||
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(isVisible).toHaveBeenCalled())
|
||||
expect(shownDetails()).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores losing focus', async () => {
|
||||
renderHook(() => usePendingFailures())
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(1))
|
||||
|
||||
focusListener?.({ payload: false })
|
||||
|
||||
expect(getPendingFailures).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves a failure the dialog owns out of the toasts', async () => {
|
||||
table = [dialogFailure(1), failure(2, 'guard gave up')]
|
||||
renderHook(() => usePendingFailures())
|
||||
onSubscribed?.()
|
||||
|
||||
await waitFor(() => expect(shownDetails()).toEqual(['guard gave up']))
|
||||
})
|
||||
|
||||
it('offers the dialog the failure it owns, and nothing else', async () => {
|
||||
table = [failure(1, 'guard gave up'), dialogFailure(2)]
|
||||
const { result } = renderHook(() => useDialogFailure())
|
||||
onSubscribed?.()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.failure?.code).toBe('SYSPROXY_PRIVILEGE_REQUIRED'),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a dismissed dialog closed while the table still holds the failure', async () => {
|
||||
table = [dialogFailure(1)]
|
||||
const { result } = renderHook(() => useDialogFailure())
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(result.current.failure).not.toBeNull())
|
||||
|
||||
act(() => result.current.dismiss())
|
||||
expect(result.current.failure).toBeNull()
|
||||
|
||||
focusListener?.({ payload: true })
|
||||
await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(2))
|
||||
expect(result.current.failure).toBeNull()
|
||||
})
|
||||
|
||||
it('opens again when the same code fails again', async () => {
|
||||
table = [dialogFailure(1)]
|
||||
const { result } = renderHook(() => useDialogFailure())
|
||||
onSubscribed?.()
|
||||
await waitFor(() => expect(result.current.failure).not.toBeNull())
|
||||
act(() => result.current.dismiss())
|
||||
expect(result.current.failure).toBeNull()
|
||||
|
||||
table = [dialogFailure(2)]
|
||||
onTableChanged?.()
|
||||
|
||||
await waitFor(() => expect(result.current.failure?.sequence).toBe(2))
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
// Stub only the Tauri boundary so the real provider tree still renders.
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(async () => undefined),
|
||||
Channel: class {},
|
||||
}))
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
TauriEvent: {},
|
||||
}))
|
||||
|
||||
import { AppDataProvider } from './app-data-provider'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Rendering catches a provider reading its own context, which static checks cannot detect.
|
||||
test('AppDataProvider mounts and renders its children', () => {
|
||||
render(
|
||||
<AppDataProvider>
|
||||
<span>child rendered</span>
|
||||
</AppDataProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('child rendered')).toBeDefined()
|
||||
})
|
||||
|
||||
test('AppDataProvider does not consume a context it provides', () => {
|
||||
const errors: unknown[] = []
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation((...args) => {
|
||||
errors.push(args)
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<AppDataProvider>
|
||||
<span>child rendered</span>
|
||||
</AppDataProvider>,
|
||||
),
|
||||
).not.toThrow()
|
||||
|
||||
const reported = errors.flat().join(' ')
|
||||
expect(reported).not.toContain('must be used within AppDataProvider')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Stub only the delay API; these tests cover group-settle notification granularity.
|
||||
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
delayProxyByName: vi.fn(async () => ({ delay: 120 })),
|
||||
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
||||
@@ -22,7 +21,7 @@ const node = (name: string) =>
|
||||
},
|
||||
}) as unknown as ResolvedProxyMember
|
||||
|
||||
const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
let settles = 0
|
||||
let unsubscribe: () => void
|
||||
@@ -34,90 +33,26 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
afterEach(() => unsubscribe())
|
||||
|
||||
describe('group settle notifications', () => {
|
||||
test('a single test settles the group once', async () => {
|
||||
// A single-proxy retest must invalidate delay sorting.
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('a batch settles the group once, however many proxies it covered', async () => {
|
||||
// A batch announces once to avoid reordering rows as individual results arrive.
|
||||
const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`))
|
||||
|
||||
await delayManager.checkListDelay(proxies as never, 'g', 5000, 4)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('an unsubscribed listener stops being called', async () => {
|
||||
unsubscribe()
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(0)
|
||||
})
|
||||
|
||||
test('two listeners on one group both hear it', async () => {
|
||||
// Multiple views must not replace one another's group subscriptions.
|
||||
let second = 0
|
||||
const stop = delayManager.addGroupListener('g', () => {
|
||||
second += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
expect(second).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
test('a single test inside a running batch waits for the batch', async () => {
|
||||
// Nested batches must wait for the outermost settle before announcing.
|
||||
describe('group delay completion', () => {
|
||||
test('notifies once after a batch settles', async () => {
|
||||
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
||||
|
||||
const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||
await delayManager.checkDelay(node('single') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
expect(settles).toBe(0)
|
||||
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||
await flush()
|
||||
|
||||
await batch
|
||||
await nextFrame()
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('unsubscribing one listener leaves the others subscribed', async () => {
|
||||
let second = 0
|
||||
const stop = delayManager.addGroupListener('g', () => {
|
||||
second += 1
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(0)
|
||||
expect(second).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
test('another group is not disturbed', async () => {
|
||||
test('notifies only listeners for the completed group', async () => {
|
||||
let other = 0
|
||||
const stop = delayManager.addGroupListener('other', () => {
|
||||
other += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
await flush()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
expect(other).toBe(0)
|
||||
|
||||
+12
-60
@@ -6,22 +6,6 @@ const listen = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen }))
|
||||
|
||||
/** A `listen` whose registrations only resolve when the test says so. */
|
||||
const deferredListen = () => {
|
||||
const resolvers: Array<() => void> = []
|
||||
listen.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(() => resolve(() => {}))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
registrations: resolvers,
|
||||
settleAll: () => resolvers.forEach((resolve) => resolve()),
|
||||
}
|
||||
}
|
||||
|
||||
/** Let every already-resolved promise in the chain run. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,8 +13,14 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('subscribeVergeEvents', () => {
|
||||
it('reports subscribed only once every listener is live', async () => {
|
||||
const { registrations, settleAll } = deferredListen()
|
||||
it('reports readiness only after every listener is registered', async () => {
|
||||
const registrations: Array<() => void> = []
|
||||
listen.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
registrations.push(() => resolve(() => {}))
|
||||
}),
|
||||
)
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
subscribeVergeEvents(
|
||||
@@ -41,48 +31,16 @@ describe('subscribeVergeEvents', () => {
|
||||
onSubscribed,
|
||||
)
|
||||
|
||||
expect(registrations).toHaveLength(2)
|
||||
registrations[0]?.()
|
||||
await flush()
|
||||
expect(onSubscribed).not.toHaveBeenCalled()
|
||||
|
||||
settleAll()
|
||||
registrations[1]?.()
|
||||
await flush()
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
expect(onSubscribed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not report subscribed after teardown', async () => {
|
||||
// Registration outliving the caller is the whole reason this is tracked here; resyncing
|
||||
// for a subscription that no longer exists would fetch into a cache nobody reads.
|
||||
const { settleAll } = deferredListen()
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
const teardown = subscribeVergeEvents(
|
||||
{ 'verge://run-state-changed': () => {} },
|
||||
onSubscribed,
|
||||
)
|
||||
teardown()
|
||||
|
||||
settleAll()
|
||||
await flush()
|
||||
expect(onSubscribed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports subscribed when a listener fails to register', async () => {
|
||||
// A resync is most needed when a subscription is missing, not least.
|
||||
listen.mockRejectedValue(new Error('no such event'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
subscribeVergeEvents(
|
||||
{ 'verge://run-state-changed': () => {} },
|
||||
onSubscribed,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(onSubscribed).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('delivers event payloads to the matching handler', async () => {
|
||||
it('delivers a payload to its matching handler', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>()
|
||||
listen.mockImplementation(
|
||||
(name: string, handler: (event: unknown) => void) => {
|
||||
@@ -91,16 +49,10 @@ describe('subscribeVergeEvents', () => {
|
||||
},
|
||||
)
|
||||
const onRunState = vi.fn()
|
||||
const onNotice = vi.fn()
|
||||
|
||||
subscribeVergeEvents({
|
||||
'verge://run-state-changed': onRunState,
|
||||
'verge://notice-message': onNotice,
|
||||
})
|
||||
|
||||
subscribeVergeEvents({ 'verge://run-state-changed': onRunState })
|
||||
handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' })
|
||||
|
||||
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
|
||||
expect(onNotice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
boundNoticeText,
|
||||
errorDetail,
|
||||
getSnapshotNotices,
|
||||
hideNotice,
|
||||
showNotice,
|
||||
} from '@/services/notice-service'
|
||||
|
||||
/** Return the last notice text that is actually rendered. */
|
||||
function renderedDetail(): string | undefined {
|
||||
const notices = getSnapshotNotices()
|
||||
const notice = notices[notices.length - 1]
|
||||
if (typeof notice?.message === 'string') return notice.message
|
||||
const slot = notice?.i18n?.params?.message
|
||||
return typeof slot === 'string' ? slot : undefined
|
||||
}
|
||||
|
||||
/** Return the rendered failure notice fields. */
|
||||
function renderedExplanation() {
|
||||
const notices = getSnapshotNotices()
|
||||
const i18n = notices[notices.length - 1]?.i18n
|
||||
const readString = (value: unknown) =>
|
||||
typeof value === 'string' ? value : undefined
|
||||
|
||||
return {
|
||||
outerKey: i18n?.key,
|
||||
explanationKey: readString(i18n?.params?.prefixKey),
|
||||
prefix: readString(i18n?.params?.prefix),
|
||||
detail: readString(i18n?.params?.message),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
|
||||
})
|
||||
|
||||
describe('command failures reaching a notice', () => {
|
||||
it('keeps the detail when the failure is the message', () => {
|
||||
showNotice.error({
|
||||
code: 'CORE_START_FAILED',
|
||||
detail: 'connection refused',
|
||||
})
|
||||
|
||||
expect(renderedDetail()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('keeps the detail when a translation key comes first', () => {
|
||||
showNotice.error('settings.feedback.errors.clash.startFailed', {
|
||||
code: 'CORE_START_FAILED',
|
||||
detail: 'connection refused',
|
||||
})
|
||||
|
||||
expect(renderedDetail()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('keeps the detail when real translation params come first', () => {
|
||||
showNotice.error(
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
{ name: 'mihomo' },
|
||||
{ code: 'CORE_START_FAILED', detail: 'connection refused' },
|
||||
)
|
||||
|
||||
expect(renderedDetail()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('keeps the detail for a failure that carries no code', () => {
|
||||
showNotice.error('settings.feedback.errors.clash.startFailed', {
|
||||
detail: 'disk full',
|
||||
})
|
||||
|
||||
expect(renderedDetail()).toContain('disk full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('system proxy failures the user can be told about', () => {
|
||||
it('explains a fallback to a direct connection', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_DIRECT_FALLBACK',
|
||||
detail: 'service could not set the proxy',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.directFallback',
|
||||
prefix: undefined,
|
||||
detail: 'service could not set the proxy',
|
||||
})
|
||||
})
|
||||
|
||||
it('explains a guard that gave up', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
|
||||
prefix: undefined,
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
})
|
||||
|
||||
it('says nothing at all about a failure a dialog is already reporting', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
detail: 'admin privileges required to modify system proxy',
|
||||
})
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
|
||||
detail: 'the core is still a sidecar',
|
||||
})
|
||||
|
||||
expect(getSnapshotNotices()).toEqual([])
|
||||
})
|
||||
|
||||
it('explains the cause even when the caller names the operation', () => {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.restartFailed',
|
||||
{
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
},
|
||||
)
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
|
||||
prefix: undefined,
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the caller key in place for a code nothing explains', () => {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.restartFailed',
|
||||
{
|
||||
code: 'SOME_UNMAPPED_CODE',
|
||||
detail: 'connection refused',
|
||||
},
|
||||
)
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'layout.components.serviceMigration.errors.restartFailed',
|
||||
prefix: undefined,
|
||||
detail: 'connection refused',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves literal caller text in place, mapped code or not', () => {
|
||||
showNotice.error('Could not reconnect the profile', {
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: undefined,
|
||||
prefix: 'Could not reconnect the profile',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
})
|
||||
|
||||
it('explains a guard that gave up', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'service refused three times running',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
|
||||
prefix: undefined,
|
||||
detail: 'service refused three times running',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the generic message for a code nothing maps', () => {
|
||||
showNotice.error({ code: 'SYSPROXY_NOT_A_REAL_CODE', detail: 'boom' })
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'shared.feedback.errors.operationFailed',
|
||||
prefix: undefined,
|
||||
detail: 'boom',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundNoticeText', () => {
|
||||
it('leaves text that fits exactly at the limit alone', () => {
|
||||
const exact = 'x'.repeat(500)
|
||||
|
||||
expect(boundNoticeText(exact)).toBe(exact)
|
||||
})
|
||||
|
||||
it('marks text it had to shorten', () => {
|
||||
expect(boundNoticeText('x'.repeat(501))).toBe(`${'x'.repeat(500)}…`)
|
||||
})
|
||||
|
||||
it('does not cut a surrogate pair in half', () => {
|
||||
expect(boundNoticeText('🙂'.repeat(600))).toBe(`${'🙂'.repeat(500)}…`)
|
||||
})
|
||||
|
||||
it('does not mistake a long message for a translation key', () => {
|
||||
const message = `a.${'b'.repeat(400)}`
|
||||
showNotice.error(message)
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.raw',
|
||||
explanationKey: undefined,
|
||||
prefix: undefined,
|
||||
detail: message,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not leave a space before the ellipsis', () => {
|
||||
expect(boundNoticeText(`${'x'.repeat(498)} yyy`)).toBe(
|
||||
`${'x'.repeat(498)}…`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorDetail', () => {
|
||||
it('reads the detail out of a command failure', () => {
|
||||
expect(errorDetail({ code: 'X', detail: 'legacy TLS not supported' })).toBe(
|
||||
'legacy TLS not supported',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes plain strings through untouched', () => {
|
||||
expect(errorDetail('something broke')).toBe('something broke')
|
||||
})
|
||||
|
||||
it('still reads an Error, which not every failure path replaces', () => {
|
||||
expect(errorDetail(new Error('boom'))).toBe('boom')
|
||||
})
|
||||
})
|
||||
+27
-66
@@ -2,80 +2,41 @@ import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { classifyDelay, compareByDelay, DEFAULT_DELAY_TIMEOUT } from './delay'
|
||||
|
||||
describe('classifyDelay', () => {
|
||||
test('an ordinary latency is a measurement', () => {
|
||||
expect(classifyDelay(120)).toBe('measured')
|
||||
expect(classifyDelay(DEFAULT_DELAY_TIMEOUT - 1)).toBe('measured')
|
||||
})
|
||||
|
||||
test('the core reports an in-flight test as -2', () => {
|
||||
expect(classifyDelay(-2)).toBe('testing')
|
||||
})
|
||||
|
||||
test('any other negative means we have never measured it', () => {
|
||||
expect(classifyDelay(-1)).toBe('untested')
|
||||
expect(classifyDelay(-99)).toBe('untested')
|
||||
})
|
||||
|
||||
test('zero and anything past the timeout are timeouts, not fast results', () => {
|
||||
// Zero is the trap: sorted as a number it would come first.
|
||||
expect(classifyDelay(0)).toBe('timeout')
|
||||
expect(classifyDelay(DEFAULT_DELAY_TIMEOUT)).toBe('timeout')
|
||||
})
|
||||
|
||||
test('an implausible value is an error rather than a very slow node', () => {
|
||||
expect(classifyDelay(1e5 + 1)).toBe('error')
|
||||
})
|
||||
|
||||
test('a non-number is treated as never measured', () => {
|
||||
expect(classifyDelay(Number.NaN)).toBe('untested')
|
||||
expect(classifyDelay(Number.POSITIVE_INFINITY)).toBe('untested')
|
||||
})
|
||||
|
||||
test('the timeout is configurable per test URL', () => {
|
||||
expect(classifyDelay(3000, 2000)).toBe('timeout')
|
||||
expect(classifyDelay(3000, 5000)).toBe('measured')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareByDelay', () => {
|
||||
const sorted = (delays: number[]) =>
|
||||
[...delays].sort((a, b) => compareByDelay(a, b))
|
||||
|
||||
test('faster measurements come first', () => {
|
||||
expect(sorted([300, 100, 200])).toEqual([100, 200, 300])
|
||||
})
|
||||
|
||||
test('a timeout never sorts ahead of a measurement, however it is encoded', () => {
|
||||
expect(sorted([0, 150])).toEqual([150, 0])
|
||||
expect(sorted([DEFAULT_DELAY_TIMEOUT, 150])).toEqual([
|
||||
150,
|
||||
DEFAULT_DELAY_TIMEOUT,
|
||||
describe('delay semantics', () => {
|
||||
test('classifies measurements and core sentinel values', () => {
|
||||
expect([
|
||||
classifyDelay(120),
|
||||
classifyDelay(-2),
|
||||
classifyDelay(-1),
|
||||
classifyDelay(0),
|
||||
classifyDelay(DEFAULT_DELAY_TIMEOUT),
|
||||
classifyDelay(1e5 + 1),
|
||||
classifyDelay(Number.NaN),
|
||||
]).toEqual([
|
||||
'measured',
|
||||
'testing',
|
||||
'untested',
|
||||
'timeout',
|
||||
'timeout',
|
||||
'error',
|
||||
'untested',
|
||||
])
|
||||
expect(classifyDelay(3000, 5000)).toBe('measured')
|
||||
expect(classifyDelay(3000, 2000)).toBe('timeout')
|
||||
})
|
||||
|
||||
test('untested nodes sink below everything measurable', () => {
|
||||
expect(sorted([-1, 0, 1e5 + 1, 150])).toEqual([150, 0, 1e5 + 1, -1])
|
||||
test('sorts measurements first and sentinel states by meaning', () => {
|
||||
const delays = [-1, 0, -2, 1e5 + 1, 300, 100]
|
||||
|
||||
expect(delays.sort(compareByDelay)).toEqual([100, 300, 0, 1e5 + 1, -2, -1])
|
||||
})
|
||||
|
||||
test('a node being tested outranks one never tested', () => {
|
||||
expect(sorted([-1, -2])).toEqual([-2, -1])
|
||||
})
|
||||
|
||||
test('non-numbers sink rather than scrambling the order around them', () => {
|
||||
// This is the case the two previous copies disagreed on: one sorted a non-number
|
||||
// among the timeouts, the other after the sentinels.
|
||||
expect(compareByDelay(Number.NaN, 150)).toBeGreaterThan(0)
|
||||
expect(compareByDelay(Number.NaN, -1)).toBe(0)
|
||||
})
|
||||
|
||||
test('the order is total, so sorting is stable whichever way a pair is compared', () => {
|
||||
const direction = (value: number) => (value > 0 ? 1 : value < 0 ? -1 : 0)
|
||||
const samples = [150, 0, -1, -2, 1e5 + 1, 20]
|
||||
test('keeps the comparator symmetric', () => {
|
||||
const direction = (value: number) => Math.sign(value)
|
||||
const samples = [150, 0, -1, -2, 1e5 + 1]
|
||||
|
||||
for (const a of samples) {
|
||||
for (const b of samples) {
|
||||
// Summing avoids comparing against -0, which Object.is treats as distinct from 0.
|
||||
expect(
|
||||
direction(compareByDelay(a, b)) + direction(compareByDelay(b, a)),
|
||||
).toBe(0)
|
||||
|
||||
Reference in New Issue
Block a user