Skip to content

Commit 5790cab

Browse files
test(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardown RPC leak (#1884)
* test(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardown leak The two final tests ('should call switchTheme/switchSkin when … select changes') mounted SimulatorBar with the real useTheme/useSkin composables. Both code paths schedule console.warn output AFTER the synchronous test body resolves: 1. validateTokenContract() (src/shared/tokens/contract.ts) wraps work in requestAnimationFrame and warns when CSS variables are missing. jsdom does not resolve --color-* CSS variables, so the contract check logs ~24 warnings via rAF on every theme/skin switch. 2. switchSkin() returns Promise<boolean>, but the @change handler discards the promise and trigger('change') only awaits Vue's nextTick. The unawaited loadStyles() dynamic import resolves later and may also call console.warn. Vitest 4.x tightened teardown to fail rather than swallow pending RPC calls, surfacing this latent bug as: EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending Reproducibly fails on Linux/Node 22 in CI; passes on macOS/Node 24 because rAF + dynamic-import timing differs. Fix mirrors the existing precedent in tests/unit/router.test.ts: vi.mock both composables at the top of the test file, returning shapes that match the real contract (THEME_IDS / SKIN_IDS imported from ui-common rather than hand-rolled subsets). The mocked switchSkin resolves immediately so no teardown leak occurs. Also tightens the two affected tests to actually assert what their names claim (switchTheme/switchSkin called with the selected value), instead of merely checking the <select> exists. * test(ui-web): use THEME_IDS/SKIN_IDS indices instead of hardcoded values Address PR review: hardcoding 'dracula' and 'classic' as the target select values would silently break the assertions if THEME_IDS or SKIN_IDS are reordered or renamed in ui-common (the DOM $lt;select$gt; would keep its current option, switchTheme/switchSkin would be called with that stale value, and the toHaveBeenCalledWith assertion would compare against a constant that no longer matches the dispatched event). Pick targets that are guaranteed to differ from the mocked active values: THEME_IDS[1] (≠ activeThemeId = THEME_IDS[0]) and the first SKIN_IDS entry that is not 'modern' (≠ activeSkinId). The assertions then reference the same constants, keeping the test coupled to the actual contract surface. Note: the third reviewer comment about adding beforeEach(mockClear) is not needed — vitest.config.ts already sets clearMocks: true and restoreMocks: true, which run before each test. * Revert "test(ui-web): use THEME_IDS/SKIN_IDS indices instead of hardcoded values" This reverts commit 0941e53. * test(ui-web): add explicit afterEach mock cleanup in SimulatorBar test Aligns SimulatorBar.test.ts with the dominant convention in ui/web's test suite (Dialogs, StationCard, ConnectorRow, ClassicLayout, Actions, …), which all add an explicit `afterEach(() => { vi.clearAllMocks() })` alongside vitest.config.ts's global `clearMocks: true` / `restoreMocks: true`. Belt-and-braces: the explicit reset keeps mock lifecycle visible next to the mock declarations, even though it is technically redundant with the global flags.
1 parent c55e9e4 commit 5790cab

1 file changed

Lines changed: 46 additions & 1 deletion

File tree

ui/web/tests/unit/skins/modern/SimulatorBar.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,49 @@
33
* @description Server switcher, simulator state display, action buttons.
44
*/
55
import { mount } from '@vue/test-utils'
6-
import { describe, expect, it } from 'vitest'
6+
import { SKIN_IDS, THEME_IDS } from 'ui-common'
7+
import { afterEach, describe, expect, it, vi } from 'vitest'
78

89
import SimulatorBar from '@/skins/modern/components/SimulatorBar.vue'
910

1011
import { createUIServerConfig } from '../../constants.js'
1112

13+
// Mock useTheme/useSkin to avoid post-teardown console.warn from
14+
// validateTokenContract() (scheduled via requestAnimationFrame) and from
15+
// the floating switchSkin() Promise. Vitest 4.x rejects pending RPC at
16+
// teardown ("Closing rpc while \"onUserConsoleLog\" was pending"), and
17+
// jsdom does not resolve --color-* CSS variables so the contract check
18+
// would log ~24 warnings per <select> change.
19+
const switchThemeMock = vi.fn()
20+
const switchSkinMock = vi.fn().mockResolvedValue(true)
21+
22+
vi.mock('@/shared/composables/useTheme.js', async importOriginal => {
23+
const { readonly, ref } = await import('vue')
24+
return {
25+
...(await importOriginal<Record<string, unknown>>()),
26+
useTheme: () => ({
27+
activeThemeId: readonly(ref(THEME_IDS[0])),
28+
availableThemes: THEME_IDS,
29+
lastError: readonly(ref(null)),
30+
switchTheme: switchThemeMock,
31+
}),
32+
}
33+
})
34+
35+
vi.mock('@/shared/composables/useSkin.js', async importOriginal => {
36+
const { readonly, ref } = await import('vue')
37+
return {
38+
...(await importOriginal<Record<string, unknown>>()),
39+
useSkin: () => ({
40+
activeSkinId: readonly(ref<(typeof SKIN_IDS)[number]>('modern')),
41+
availableSkins: SKIN_IDS.map(id => ({ id, label: id })),
42+
isSwitching: readonly(ref(false)),
43+
lastError: readonly(ref(null)),
44+
switchSkin: switchSkinMock,
45+
}),
46+
}
47+
})
48+
1249
const baseServer = createUIServerConfig({ name: 'Alpha' })
1350
const altServer = createUIServerConfig({ host: 'beta', name: 'Beta' })
1451

@@ -28,6 +65,10 @@ function mountBar (props: Record<string, unknown> = {}) {
2865
}
2966

3067
describe('SimulatorBar', () => {
68+
afterEach(() => {
69+
vi.clearAllMocks()
70+
})
71+
3172
it('should show Disconnected pill when simulatorState is undefined', () => {
3273
const wrapper = mountBar()
3374
expect(wrapper.text()).toContain('Disconnected')
@@ -122,14 +163,18 @@ describe('SimulatorBar', () => {
122163
const wrapper = mountBar()
123164
const themeSelect = wrapper.find('.modern-bar__select[aria-label="Theme"]')
124165
expect(themeSelect.exists()).toBe(true)
166+
await themeSelect.setValue('dracula')
125167
await themeSelect.trigger('change')
168+
expect(switchThemeMock).toHaveBeenCalledWith('dracula')
126169
})
127170

128171
it('should call switchSkin when skin select changes', async () => {
129172
const wrapper = mountBar()
130173
const skinSelect = wrapper.find('.modern-bar__select[aria-label="Skin"]')
131174
expect(skinSelect.exists()).toBe(true)
175+
await skinSelect.setValue('classic')
132176
await skinSelect.trigger('change')
177+
expect(switchSkinMock).toHaveBeenCalledWith('classic')
133178
})
134179

135180
it('should emit switch-server with selectedIndex when server select changes via trigger', async () => {

0 commit comments

Comments
 (0)