Skip to content

test(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardown RPC leak#1884

Merged
jerome-benoit merged 4 commits into
mainfrom
fix/ui-web-simulatorbar-test-rpc-leak
Jun 4, 2026
Merged

test(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardown RPC leak#1884
jerome-benoit merged 4 commits into
mainfrom
fix/ui-web-simulatorbar-test-rpc-leak

Conversation

@jerome-benoit
Copy link
Copy Markdown
Contributor

Summary

Fix a vitest@4.x teardown-time RPC leak that intermittently fails the Build dashboard matrix on ubuntu-latest/Node 22 (and cascades cancellations to every other matrix cell). All tests pass; the job exits 1 because of:

EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending
This error originated in "tests/unit/skins/modern/SimulatorBar.test.ts"

Root cause

The two final tests in SimulatorBar.test.ts mounted the component 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 its 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 — hence the surfaced symptom. The bug pre-existed the bump; reproducibly fails on Linux/Node 22 in CI but passes on macOS/Node 24 because rAF + dynamic-import timing differs.

Fix

Mirror 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, not 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.

Verification

  • pnpm typecheck
  • pnpm lint
  • pnpm test ✅ — Test Files 31 passed (31) | Tests 531 passed (531), exit 0, 3/3 consecutive runs locally with zero EnvironmentTeardownError / Unhandled Rejection.

Notes

  • No production code changed. Test-only fix.
  • availableSkins in the mock uses SKIN_IDS.map(id => ({ id, label: id })) which is a structural subset of SkinDefinition (no loader); sufficient for the <select> rendering exercised by these tests, and analogous to the precedent in router.test.ts.

…test 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.
@hyperspace-insights
Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Fix Vitest 4.x Teardown RPC Leak in SimulatorBar.test.ts

Bug Fix

🐛 Resolves an intermittent EnvironmentTeardownError in SimulatorBar.test.ts caused by a Vitest 4.x teardown-time RPC leak. The error surfaced as:

EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending

This caused the Build dashboard CI matrix on ubuntu-latest/Node 22 to exit with code 1 and cascade cancellations to all other matrix cells.

Changes

  • tests/unit/skins/modern/SimulatorBar.test.ts: Mocked useTheme and useSkin composables at the top of the test file using vi.mock(). The mocks return shapes matching the real contract (using THEME_IDS / SKIN_IDS from ui-common), with switchSkin resolving immediately to prevent the unawaited Promise from leaking into teardown. Additionally tightened the two affected tests (switchTheme / switchSkin) to assert the mock was called with the correct selected value via setValue + trigger('change'), rather than merely checking element existence.

Root cause: The real useTheme/useSkin composables scheduled console.warn output after test resolution — validateTokenContract() ran via requestAnimationFrame logging ~24 warnings for missing CSS variables in jsdom, and switchSkin() returned an unawaited Promise. Vitest 4.x tightened teardown to fail on pending RPC calls rather than swallowing them.

No production code was changed — this is a test-only fix.


  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.21.4

  • Event Trigger: pull_request.opened
  • LLM: anthropic--claude-4.6-sonnet
  • Correlation ID: d84e2177-b5b6-4e5e-ae1a-83e31fb3372a
  • File Content Strategy: Full file content
  • Summary Prompt: Default Prompt
  • Output Template: Default Template

Copy link
Copy Markdown
Contributor

@hyperspace-insights hyperspace-insights Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is a well-reasoned fix for the Vitest 4.x teardown RPC leak, correctly mirroring the established pattern from router.test.ts. Three issues were flagged:

  1. Mock state accumulation (switchThemeMock/switchSkinMock): The mocks are never reset between tests, so call history bleeds across the suite — a beforeEach with mockClear() is needed.
  2. Hard-coded 'dracula' theme string: If THEME_IDS in ui-common changes and no longer includes 'dracula', setValue silently fails and the assertion becomes meaningless — should use THEME_IDS[1] instead.
  3. Hard-coded 'classic' skin string: Same issue — should use SKIN_IDS[1] to guarantee a valid, distinct option is always selected.
PR Bot Information

Version: 1.21.4

Comment on lines +162 to +164
await themeSelect.setValue('dracula')
await themeSelect.trigger('change')
expect(switchThemeMock).toHaveBeenCalledWith('dracula')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: setValue('dracula') sets the DOM element's value but does not guarantee 'dracula' is actually a valid option in the rendered <select>. The mock's availableThemes is THEME_IDS, so this only works if 'dracula' is present in THEME_IDS. If THEME_IDS changes in ui-common and no longer includes 'dracula', the DOM value will be set to an unselectable string, trigger('change') will fire with whatever option is actually selected, and switchTheme will be called with that option's value — not 'dracula' — silently invalidating the assertion.

Consider using THEME_IDS[1] (or any index other than the active THEME_IDS[0]) to select a theme that is guaranteed to exist, and assert against the same constant:

    await themeSelect.setValue(THEME_IDS[1])
    await themeSelect.trigger('change')
    expect(switchThemeMock).toHaveBeenCalledWith(THEME_IDS[1])
Suggested change
await themeSelect.setValue('dracula')
await themeSelect.trigger('change')
expect(switchThemeMock).toHaveBeenCalledWith('dracula')
await themeSelect.setValue(THEME_IDS[1])
await themeSelect.trigger('change')
expect(switchThemeMock).toHaveBeenCalledWith(THEME_IDS[1])

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0941e53 — switched to THEME_IDS[1] (guaranteed ≠ activeThemeId = THEME_IDS[0]).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On reflection, reverted in ed42e4c: the literal-value pattern ('dracula', 'tokyo-night-storm', 'classic', 'modern') is used consistently across the entire ui/web test suite (useTheme.test.ts, useSkin.test.ts, ClassicLayout.test.ts, App.test.ts, router.test.ts). These IDs are part of the composable's public contract — a test breaking when an ID is removed is a feature, not a bug, because removing the ID is itself a breaking change to the composable surface. Aligning this single file with THEME_IDS[i] indices would have introduced an inconsistency with the rest of the suite, not improved it.

Comment on lines +171 to +173
await skinSelect.setValue('classic')
await skinSelect.trigger('change')
expect(switchSkinMock).toHaveBeenCalledWith('classic')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Same fragility as the theme test — 'classic' is a hard-coded string literal that must exist in SKIN_IDS for the assertion to be meaningful. If SKIN_IDS is ever renamed or reordered, the DOM value becomes invalid, the <select> keeps its previously-selected option, and switchSkinMock is called with that stale value while the assertion for 'classic' still passes vacuously (since mockResolvedValue(true) doesn't enforce the argument).

The active skin in the mock is already 'modern' (index 0), so use the second entry from SKIN_IDS to guarantee a different, valid value:

    await skinSelect.setValue(SKIN_IDS[1])
    await skinSelect.trigger('change')
    expect(switchSkinMock).toHaveBeenCalledWith(SKIN_IDS[1])
Suggested change
await skinSelect.setValue('classic')
await skinSelect.trigger('change')
expect(switchSkinMock).toHaveBeenCalledWith('classic')
await skinSelect.setValue(SKIN_IDS[1])
await skinSelect.trigger('change')
expect(switchSkinMock).toHaveBeenCalledWith(SKIN_IDS[1])

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0941e53 — used SKIN_IDS.find(id => id !== 'modern') ?? SKIN_IDS[0]. Direct SKIN_IDS[1] would have selected 'modern', which equals the mocked activeSkinId and would not actually exercise the change handler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — reverted in ed42e4c. Sticking with the literal-value convention shared by all sibling test files.

// jsdom does not resolve --color-* CSS variables so the contract check
// would log ~24 warnings per <select> change.
const switchThemeMock = vi.fn()
const switchSkinMock = vi.fn().mockResolvedValue(true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Mock-level state is not reset between tests, so switchThemeMock and switchSkinMock call counts and recorded arguments accumulate across the entire test suite run. If any test triggers these mocks, later assertions using toHaveBeenCalledWith become unreliable.

Should add a beforeEach to clear both mocks:

beforeEach(() => {
  switchThemeMock.mockClear()
  switchSkinMock.mockClear()
})

Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed: vitest.config.ts already sets clearMocks: true (line 13) and restoreMocks: true (line 36), which run before each test and reset .mock.calls / .mock.results. An explicit beforeEach(mockClear) would be redundant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated assessment: while vitest.config.ts already sets clearMocks: true and restoreMocks: true globally (so the suggested beforeEach(mockClear) is technically redundant), the dominant convention in this test suite (Dialogs, StationCard, ConnectorRow, ClassicLayout, Actions, ...) is to also add an explicit afterEach(() => { vi.clearAllMocks() }) for visibility. Aligned with that convention in 2293a7d.

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.
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.
@jerome-benoit jerome-benoit merged commit 5790cab into main Jun 4, 2026
34 of 35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant