Skip to content

Commit dab071f

Browse files
committed
Testing: Speed up store tests by ~20s
- Replace `vi.resetModules()` + `loadModule()` pattern with static imports + `resetForTesting()` in `beforeEach` across `mtp-store`, `ai-state`, and `licensing-store` tests - Add `resetForTesting()` to each store module, resetting state without re-parsing the full dependency tree - Module import cost paid once instead of once per test (~8s saved per file) - Document `resetForTesting()` maintenance requirement in colocated `CLAUDE.md` files
1 parent 17808d4 commit dab071f

9 files changed

Lines changed: 84 additions & 85 deletions

File tree

apps/desktop/src/lib/ai/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ provide checksums) — file size check only.
5858
- **Model switch requires app restart**: Changing selected model in Settings requires download + restart. No hot-swap.
5959
- **`opted_out` flag is legacy**: The `opted_out` field in `AiState` is superseded by `ai.provider` setting. It remains
6060
in the struct but is no longer checked. `ai.provider` in the frontend settings store is the source of truth.
61+
- **`resetForTesting()` must stay in sync with module state**: When adding new `$state` fields to `ai-state.svelte.ts`,
62+
update `resetForTesting()` to clear them. Tests use this instead of `vi.resetModules()` to avoid ~8s module re-parse
63+
penalty per test.
6164

6265
## Development
6366

apps/desktop/src/lib/ai/ai-state.svelte.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export function getAiState(): AiStateData {
3434
return aiState
3535
}
3636

37+
/** Resets all state to initial values. For use in tests only. */
38+
export function resetForTesting(): void {
39+
aiState.notificationState = 'hidden'
40+
aiState.downloadProgress = null
41+
aiState.progressText = ''
42+
aiState.modelInfo = null
43+
}
44+
3745
export async function initAiState(): Promise<() => void> {
3846
// Don't show toast when provider is off or openai-compatible
3947
const aiProvider = getSetting('ai.provider')

apps/desktop/src/lib/ai/ai-state.test.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ vi.mock('$lib/tauri-commands', async () => {
1616
})
1717

1818
import { getAiStatus, getAiModelInfo, startAiDownload, cancelAiDownload, dismissAiOffer } from '$lib/tauri-commands'
19+
import {
20+
getAiState,
21+
initAiState,
22+
handleDownload,
23+
handleCancel,
24+
handleDismiss,
25+
handleGotIt,
26+
resetForTesting,
27+
} from './ai-state.svelte'
1928

2029
const mockModelInfo = {
2130
id: 'ministral-3b-instruct-q4km',
@@ -29,28 +38,22 @@ const mockModelInfo = {
2938
describe('ai-state', () => {
3039
beforeEach(() => {
3140
vi.clearAllMocks()
32-
vi.resetModules()
41+
resetForTesting()
3342
})
3443

35-
async function loadModule() {
36-
return await import('./ai-state.svelte')
37-
}
38-
3944
describe('getAiState', () => {
40-
it('returns initial hidden state', async () => {
41-
const { getAiState } = await loadModule()
45+
it('returns initial hidden state', () => {
4246
const state = getAiState()
4347
expect(state.notificationState).toBe('hidden')
4448
expect(state.downloadProgress).toBeNull()
4549
expect(state.progressText).toBe('')
46-
}, 15_000)
50+
})
4751
})
4852

4953
describe('initAiState', () => {
5054
it('sets offer state when status is offer', async () => {
5155
vi.mocked(getAiStatus).mockResolvedValue('offer')
5256
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
53-
const { initAiState, getAiState } = await loadModule()
5457

5558
await initAiState()
5659

@@ -61,7 +64,6 @@ describe('ai-state', () => {
6164
it('stays hidden when status is available', async () => {
6265
vi.mocked(getAiStatus).mockResolvedValue('available')
6366
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
64-
const { initAiState, getAiState } = await loadModule()
6567

6668
await initAiState()
6769

@@ -72,7 +74,6 @@ describe('ai-state', () => {
7274
it('stays hidden when status is unavailable', async () => {
7375
vi.mocked(getAiStatus).mockResolvedValue('unavailable')
7476
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
75-
const { initAiState, getAiState } = await loadModule()
7677

7778
await initAiState()
7879

@@ -91,7 +92,6 @@ describe('ai-state', () => {
9192
.mockResolvedValueOnce(unlistenFns[3])
9293
.mockResolvedValueOnce(unlistenFns[4])
9394

94-
const { initAiState } = await loadModule()
9595
const cleanup = await initAiState()
9696

9797
expect(listen).toHaveBeenCalledTimes(5)
@@ -114,7 +114,6 @@ describe('ai-state', () => {
114114
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
115115
vi.mocked(startAiDownload).mockResolvedValue(undefined)
116116

117-
const { initAiState, handleDownload, getAiState } = await loadModule()
118117
await initAiState()
119118

120119
await handleDownload()
@@ -129,7 +128,6 @@ describe('ai-state', () => {
129128
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
130129
vi.mocked(startAiDownload).mockRejectedValue(new Error('Network error'))
131130

132-
const { initAiState, handleDownload, getAiState } = await loadModule()
133131
await initAiState()
134132

135133
await handleDownload()
@@ -146,7 +144,6 @@ describe('ai-state', () => {
146144
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
147145
vi.mocked(cancelAiDownload).mockResolvedValue(undefined)
148146

149-
const { initAiState, handleCancel, getAiState } = await loadModule()
150147
await initAiState()
151148

152149
await handleCancel()
@@ -164,7 +161,6 @@ describe('ai-state', () => {
164161
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
165162
vi.mocked(dismissAiOffer).mockResolvedValue(undefined)
166163

167-
const { initAiState, handleDismiss, getAiState } = await loadModule()
168164
await initAiState()
169165
expect(getAiState().notificationState).toBe('offer')
170166

@@ -179,7 +175,6 @@ describe('ai-state', () => {
179175
it('hides the notification', async () => {
180176
vi.mocked(getAiStatus).mockResolvedValue('offer')
181177
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
182-
const { initAiState, handleGotIt, getAiState } = await loadModule()
183178
await initAiState()
184179

185180
handleGotIt()
@@ -200,7 +195,6 @@ describe('ai-state', () => {
200195
return Promise.resolve(() => {})
201196
})
202197

203-
const { initAiState, getAiState } = await loadModule()
204198
await initAiState()
205199

206200
progressCallback?.({
@@ -224,7 +218,6 @@ describe('ai-state', () => {
224218
return Promise.resolve(() => {})
225219
})
226220

227-
const { initAiState, getAiState } = await loadModule()
228221
await initAiState()
229222

230223
progressCallback?.({ payload: { bytesDownloaded: 0, totalBytes: 0, speed: 0, etaSeconds: 0 } })
@@ -244,7 +237,6 @@ describe('ai-state', () => {
244237
return Promise.resolve(() => {})
245238
})
246239

247-
const { initAiState, getAiState } = await loadModule()
248240
await initAiState()
249241

250242
installingCallback?.()
@@ -265,7 +257,6 @@ describe('ai-state', () => {
265257
return Promise.resolve(() => {})
266258
})
267259

268-
const { initAiState, getAiState } = await loadModule()
269260
await initAiState()
270261

271262
completeCallback?.()

apps/desktop/src/lib/licensing/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ dismiss). The About window and modals read the cached value on mount.
5252

5353
## Gotchas
5454

55+
- **`resetForTesting()` must stay in sync with module state** — when adding new fields to `licenseState` in
56+
`licensing-store.svelte.ts`, update `resetForTesting()` to clear them. Tests use this instead of `vi.resetModules()`
57+
to avoid module re-parse penalty per test.
5558
- **Mock mode only in debug builds**`CMDR_MOCK_LICENSE` env var bypasses validation. Silently ignored in release.
5659
- **Ed25519 public key embedded** — hardcoded in `verification.rs`. Must match license server's private key.
5760
- **Commercial reminder timing** — tracked in `license.json` via `firstRunTimestamp`. Shows 30 days after first launch,

apps/desktop/src/lib/licensing/licensing-store.svelte.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ const licenseState = {
2020
pendingVerification: false,
2121
}
2222

23+
/** Resets all state to initial values. For use in tests only. */
24+
export function resetForTesting(): void {
25+
licenseState.cachedStatus = null
26+
licenseState.shouldShowModal = false
27+
licenseState.pendingVerification = false
28+
}
29+
2330
/**
2431
* Loads the license status from the backend.
2532
* Should be called once at app startup.

apps/desktop/src/lib/licensing/licensing.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ vi.mock('$lib/tauri-commands', () => ({
99

1010
import type { LicenseStatus } from '$lib/tauri-commands'
1111
import { getLicenseStatus, needsLicenseValidation, validateLicenseWithServer } from '$lib/tauri-commands'
12+
import {
13+
getCachedStatus,
14+
loadLicenseStatus,
15+
triggerValidationIfNeeded,
16+
resetForTesting,
17+
} from './licensing-store.svelte'
1218

1319
const personalStatus: LicenseStatus = { type: 'personal', showCommercialReminder: false }
1420
const commercialStatus: LicenseStatus = {
@@ -27,24 +33,18 @@ const expiredStatus: LicenseStatus = {
2733
describe('licensing-store', () => {
2834
beforeEach(() => {
2935
vi.clearAllMocks()
30-
vi.resetModules()
36+
resetForTesting()
3137
})
3238

33-
async function loadStore() {
34-
return await import('./licensing-store.svelte')
35-
}
36-
3739
describe('getCachedStatus', () => {
38-
it('returns null before status is loaded', async () => {
39-
const { getCachedStatus } = await loadStore()
40+
it('returns null before status is loaded', () => {
4041
expect(getCachedStatus()).toBeNull()
41-
}, 15_000)
42+
})
4243
})
4344

4445
describe('loadLicenseStatus', () => {
4546
it('fetches status from backend and caches it', async () => {
4647
vi.mocked(getLicenseStatus).mockResolvedValue(personalStatus)
47-
const { loadLicenseStatus, getCachedStatus } = await loadStore()
4848

4949
const result = await loadLicenseStatus()
5050

@@ -56,7 +56,6 @@ describe('licensing-store', () => {
5656
describe('triggerValidationIfNeeded', () => {
5757
it('skips validation when not needed', async () => {
5858
vi.mocked(needsLicenseValidation).mockResolvedValue(false)
59-
const { triggerValidationIfNeeded } = await loadStore()
6059

6160
const result = await triggerValidationIfNeeded()
6261

@@ -67,7 +66,6 @@ describe('licensing-store', () => {
6766
it('validates with server and updates cache when needed', async () => {
6867
vi.mocked(needsLicenseValidation).mockResolvedValue(true)
6968
vi.mocked(validateLicenseWithServer).mockResolvedValue(commercialStatus)
70-
const { triggerValidationIfNeeded, getCachedStatus } = await loadStore()
7169

7270
const result = await triggerValidationIfNeeded()
7371

@@ -79,7 +77,6 @@ describe('licensing-store', () => {
7977
vi.mocked(getLicenseStatus).mockResolvedValue(personalStatus)
8078
vi.mocked(needsLicenseValidation).mockResolvedValue(true)
8179
vi.mocked(validateLicenseWithServer).mockRejectedValue(new Error('Network error'))
82-
const { loadLicenseStatus, triggerValidationIfNeeded, getCachedStatus } = await loadStore()
8380

8481
await loadLicenseStatus()
8582
const result = await triggerValidationIfNeeded()
@@ -91,7 +88,6 @@ describe('licensing-store', () => {
9188
it('caches expired status from server validation', async () => {
9289
vi.mocked(needsLicenseValidation).mockResolvedValue(true)
9390
vi.mocked(validateLicenseWithServer).mockResolvedValue(expiredStatus)
94-
const { triggerValidationIfNeeded, getCachedStatus } = await loadStore()
9591

9692
const result = await triggerValidationIfNeeded()
9793

apps/desktop/src/lib/mtp/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,6 @@ clipboard requires local file paths, which MTP virtual paths can't provide — t
6464
device. Not granular (don't know which directory changed without extra MTP calls).
6565
- **30-second timeout is intentional**: Some Android devices are slow (USB 2.0, old hardware). MTP operations have 30s
6666
timeout, not the usual 10s.
67+
- **`resetForTesting()` must stay in sync with module state**: When adding new module-level state to
68+
`mtp-store.svelte.ts`, update `resetForTesting()` to clear it. Tests use this instead of `vi.resetModules()` to avoid
69+
~8s module re-parse penalty per test.

apps/desktop/src/lib/mtp/mtp-store.svelte.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,21 @@ export async function initialize(): Promise<void> {
364364
logger.debug('MTP store initialized')
365365
}
366366

367+
/** Resets all state to initial values. For use in tests only. */
368+
export function resetForTesting(): void {
369+
state = {
370+
devices: new SvelteMap(),
371+
initialized: false,
372+
scanning: false,
373+
}
374+
unlistenConnected = undefined
375+
unlistenDisconnected = undefined
376+
unlistenExclusiveAccess = undefined
377+
unlistenPermissionError = undefined
378+
unlistenDeviceDetected = undefined
379+
unlistenDeviceRemoved = undefined
380+
}
381+
367382
/**
368383
* Cleans up the MTP store.
369384
* Should be called when the app is shutting down.

0 commit comments

Comments
 (0)