From 8b43d9e9f73985ae92d3fed5eeca1edbe11bec77 Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Thu, 26 Mar 2026 20:21:26 +0500 Subject: [PATCH 1/7] Fix: text generation logic and styles --- src/test/setup.ts | 6 +++--- vite.config.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/setup.ts b/src/test/setup.ts index 70475c7..e3b8eb4 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -20,9 +20,9 @@ const _cancelledRafs = new Set(); // Polyfill ReadableStream if missing in jsdom if (typeof ReadableStream === 'undefined') { - import('node:stream/web').then(({ ReadableStream: NodeReadableStream }) => { - ;(globalThis as any).ReadableStream = NodeReadableStream - }) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ReadableStream: NodeReadableStream } = require('node:stream/web') + ;(globalThis as any).ReadableStream = NodeReadableStream } // Polyfill localStorage if it's missing or broken in the test environment diff --git a/vite.config.ts b/vite.config.ts index 12bba2a..3516faf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,7 +56,9 @@ export default defineConfig({ environment: 'jsdom', setupFiles: './src/test/setup.ts', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - pool: 'vmThreads', + pool: 'forks', isolate: false, + testTimeout: 10000, + maxWorkers: 2, } }) From 442c1f93c39ea8062bda6ef39c07332eb45da51b Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Thu, 26 Mar 2026 20:36:32 +0500 Subject: [PATCH 2/7] Fix: text generation logic and styles --- .github/workflows/ci.yml | 16 ++++++++++++++++ vite.config.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35d7c8..effc7ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,22 @@ jobs: cache: "pnpm" cache-dependency-path: pnpm-lock.yaml + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node_modules-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node_modules- + + - name: Cache Vite transform cache + uses: actions/cache@v4 + with: + path: node_modules/.vite + key: ${{ runner.os }}-vite-${{ hashFiles('pnpm-lock.yaml', 'vite.config.ts') }} + restore-keys: | + ${{ runner.os }}-vite- + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/vite.config.ts b/vite.config.ts index 3516faf..f059bda 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,8 +57,12 @@ export default defineConfig({ setupFiles: './src/test/setup.ts', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, isolate: false, testTimeout: 10000, - maxWorkers: 2, } }) From 70869215726f1e27cfdd5c02e90d8cb6c49f603d Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Thu, 26 Mar 2026 20:47:34 +0500 Subject: [PATCH 3/7] Fix: text generation logic and styles --- vite.config.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index f059bda..d620b37 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,12 +57,6 @@ export default defineConfig({ setupFiles: './src/test/setup.ts', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], pool: 'forks', - poolOptions: { - forks: { - singleFork: true, - }, - }, - isolate: false, testTimeout: 10000, } }) From 2d7057e2a475911db7242f52681e0be0ba7b2a55 Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Thu, 26 Mar 2026 21:33:59 +0500 Subject: [PATCH 4/7] Fix: text generation logic and styles --- package.json | 1 + pnpm-lock.yaml | 62 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a2f6c49..e2e3dcc 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.8.1", "autoprefixer": "^10.4.24", + "happy-dom": "^20.8.8", "jsdom": "^28.0.0", "msw": "^2.12.10", "playwright": "^1.58.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 998b2fd..7b2811d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,7 +125,7 @@ importers: version: 6.0.4(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) '@vitest/coverage-v8': specifier: 4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2)) '@vue/compiler-core': specifier: ^3.5.28 version: 3.5.28 @@ -141,6 +141,9 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) + happy-dom: + specifier: ^20.8.8 + version: 20.8.8 jsdom: specifier: ^28.0.0 version: 28.0.0 @@ -170,7 +173,7 @@ importers: version: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(yaml@2.8.2) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.12)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2) vue-tsc: specifier: ^3.1.4 version: 3.2.4(typescript@5.9.3) @@ -957,6 +960,12 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -1799,6 +1808,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.8.8: + resolution: {integrity: sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2980,6 +2993,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -3022,6 +3039,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3745,6 +3774,12 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.12 + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -3766,7 +3801,7 @@ snapshots: vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.10.12)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -3778,7 +3813,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.12)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2) '@vitest/expect@4.0.18': dependencies: @@ -4723,6 +4758,18 @@ snapshots: hachure-fill@0.5.2: {} + happy-dom@20.8.8: + dependencies: + '@types/node': 24.10.12 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -5821,7 +5868,7 @@ snapshots: jiti: 1.21.7 yaml: 2.8.2 - vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2): + vitest@4.0.18(@types/node@24.10.12)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@28.0.0)(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.12)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(yaml@2.8.2)) @@ -5845,6 +5892,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.12 + happy-dom: 20.8.8 jsdom: 28.0.0 transitivePeerDependencies: - jiti @@ -5944,6 +5992,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.0: @@ -6006,6 +6056,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + ws@8.20.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} From 7b6b66954c25d0a466139c3db83dc13ac46b5a6c Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Thu, 26 Mar 2026 21:43:38 +0500 Subject: [PATCH 5/7] Fix: text generation logic and styles --- .github/workflows/ci.yml | 1 + src/__tests__/usePermissions.spec.ts | 61 ++++++++++++---------------- 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index effc7ec..392c5ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: test: name: Typecheck & Unit Tests runs-on: self-hosted + timeout-minutes: 10 steps: - uses: actions/checkout@v4 diff --git a/src/__tests__/usePermissions.spec.ts b/src/__tests__/usePermissions.spec.ts index 74ba6fa..0ae984b 100644 --- a/src/__tests__/usePermissions.spec.ts +++ b/src/__tests__/usePermissions.spec.ts @@ -1,67 +1,56 @@ +// @vitest-environment node import { describe, it, expect, vi, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' +// vi.mock is hoisted — this mock is in place before any import below +vi.mock('../stores/auth', () => ({ + useAuthStore: vi.fn(), +})) + +import { usePermissions } from '../composables/usePermissions' +import { useAuthStore } from '../stores/auth' + +const mockStore = (tier: string) => { + vi.mocked(useAuthStore).mockReturnValue({ user: { subscription_tier: tier } } as any) +} + describe('usePermissions', () => { beforeEach(() => { setActivePinia(createPinia()) }) - it('starter tier gets correct chat limit (300)', async () => { - vi.doMock('../stores/auth', () => ({ - useAuthStore: () => ({ user: { subscription_tier: 'starter' } }) - })) - vi.resetModules() - const { usePermissions } = await import('../composables/usePermissions') + it('starter tier gets correct chat limit (300)', () => { + mockStore('starter') const { chatLimit } = usePermissions() expect(chatLimit.value).toBe(300) }) - it('pro tier has unlimited chat', async () => { - vi.doMock('../stores/auth', () => ({ - useAuthStore: () => ({ user: { subscription_tier: 'pro' } }) - })) - vi.resetModules() - const { usePermissions } = await import('../composables/usePermissions') + it('pro tier has unlimited chat', () => { + mockStore('pro') const { isUnlimitedChat } = usePermissions() expect(isUnlimitedChat.value).toBe(true) }) - it('free tier cannot use auto tasks', async () => { - vi.doMock('../stores/auth', () => ({ - useAuthStore: () => ({ user: { subscription_tier: 'free' } }) - })) - vi.resetModules() - const { usePermissions } = await import('../composables/usePermissions') + it('free tier cannot use auto tasks', () => { + mockStore('free') const { canUseAutoTasks } = usePermissions() expect(canUseAutoTasks.value).toBe(false) }) - it('starter tier can use auto tasks', async () => { - vi.doMock('../stores/auth', () => ({ - useAuthStore: () => ({ user: { subscription_tier: 'starter' } }) - })) - vi.resetModules() - const { usePermissions } = await import('../composables/usePermissions') + it('starter tier can use auto tasks', () => { + mockStore('starter') const { canUseAutoTasks } = usePermissions() expect(canUseAutoTasks.value).toBe(true) }) - it('unknown tier falls back to free limits', async () => { - vi.doMock('../stores/auth', () => ({ - useAuthStore: () => ({ user: { subscription_tier: 'bogus' } }) - })) - vi.resetModules() - const { usePermissions } = await import('../composables/usePermissions') + it('unknown tier falls back to free limits', () => { + mockStore('bogus') const { chatLimit } = usePermissions() expect(chatLimit.value).toBe(30) }) - it('unlimited tier has unlimited projects', async () => { - vi.doMock('../stores/auth', () => ({ - useAuthStore: () => ({ user: { subscription_tier: 'unlimited' } }) - })) - vi.resetModules() - const { usePermissions } = await import('../composables/usePermissions') + it('unlimited tier has unlimited projects', () => { + mockStore('unlimited') const { isUnlimitedProjects } = usePermissions() expect(isUnlimitedProjects.value).toBe(true) }) From f0f9da15efdf158067db99825295d04752ac8281 Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Fri, 27 Mar 2026 00:14:30 +0500 Subject: [PATCH 6/7] Fix: text generation logic and styles --- src/test/setup.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/test/setup.ts b/src/test/setup.ts index e3b8eb4..9503de1 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -2,29 +2,40 @@ import { afterEach } from 'vitest' import { cleanup } from '@testing-library/vue' import '@testing-library/jest-dom' -// jsdom doesn't implement requestAnimationFrame; mock it with setTimeout(0) so -// rAF-based animations in stores resolve within the same test tick. +// jsdom doesn't implement requestAnimationFrame. We mock it with a synthetic +// advancing timestamp so the chat store's RAF-based text reveal animation always +// terminates in a finite number of microtasks. +// +// ROOT CAUSE this fixes: chat.ts `sendMessage` has `await _revealCompletePromise` +// in its finally block. That promise resolves only when `_flushTokenQueue` drains +// its token queue. The queue drains only when: +// charsToReveal = Math.floor((timestamp - _lastRevealTime) / _msPerChar) > 0 +// With the original Promise.resolve().then() polyfill, `performance.now()` returned +// essentially the same value between consecutive microtasks, so charsToReveal was +// always 0 → the RAF loop rescheduled itself forever → _revealCompletePromise never +// resolved → every test that called sendMessage() hung permanently. +// +// Fix: advance a synthetic _rafTimestamp by 50ms per frame. _msPerChar is at most +// ~33ms (30 chars/sec for very short text), so 50ms > 33ms guarantees at least one +// char is revealed per frame after the first. Any text drains in O(length) microtasks. let _rafId = 0; +let _rafTimestamp = 0; const _cancelledRafs = new Set(); -(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => { + +;(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => { const id = ++_rafId; + _rafTimestamp += 50; // advance 50 ms per frame (> max _msPerChar ≈ 33 ms) + const ts = _rafTimestamp; Promise.resolve().then(() => { - if (!_cancelledRafs.has(id)) cb(performance.now()); + if (!_cancelledRafs.has(id)) cb(ts); _cancelledRafs.delete(id); }); return id; }; -(globalThis as any).cancelAnimationFrame = (id: number) => { +;(globalThis as any).cancelAnimationFrame = (id: number) => { _cancelledRafs.add(id); }; -// Polyfill ReadableStream if missing in jsdom -if (typeof ReadableStream === 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ReadableStream: NodeReadableStream } = require('node:stream/web') - ;(globalThis as any).ReadableStream = NodeReadableStream -} - // Polyfill localStorage if it's missing or broken in the test environment if (typeof localStorage === 'undefined' || !localStorage.getItem) { const store: Record = {}; From d199f558e859c80d61328c6ed1d735475993d73d Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Fri, 27 Mar 2026 10:19:57 +0500 Subject: [PATCH 7/7] Fucking tests --- .../__tests__/useVoiceInput.test.ts | 30 +++++++++++++++---- src/test/globalSetup.ts | 10 +++++++ src/test/setup.ts | 6 ++++ vite.config.ts | 3 +- 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 src/test/globalSetup.ts diff --git a/src/composables/__tests__/useVoiceInput.test.ts b/src/composables/__tests__/useVoiceInput.test.ts index 858cdac..041b0a7 100644 --- a/src/composables/__tests__/useVoiceInput.test.ts +++ b/src/composables/__tests__/useVoiceInput.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // Mock SpeechRecognition const mockRecognition = { @@ -48,20 +48,34 @@ vi.stubGlobal('navigator', { }, }) +// Track the stop function for each test so we can clean up the RAF animation +// loop after tests that call start(). Without this, the RAF loop runs forever +// as an infinite microtask chain (each frame enqueues the next), hanging the +// process. +let currentStop: (() => void) | null = null + describe('useVoiceInput', () => { beforeEach(() => { vi.clearAllMocks() + currentStop = null + }) + + afterEach(() => { + currentStop?.() + currentStop = null }) it('starts with isListening = false', async () => { const { useVoiceInput } = await import('@/composables/useVoiceInput') - const { isListening } = useVoiceInput() + const { isListening, stop } = useVoiceInput() + currentStop = stop expect(isListening.value).toBe(false) }) it('start() sets isListening = true and calls recognition.start', async () => { const { useVoiceInput } = await import('@/composables/useVoiceInput') - const { isListening, start } = useVoiceInput() + const { isListening, start, stop } = useVoiceInput() + currentStop = stop await start() expect(isListening.value).toBe(true) expect(mockRecognition.start).toHaveBeenCalled() @@ -70,6 +84,7 @@ describe('useVoiceInput', () => { it('stop() sets isListening = false and calls recognition.stop', async () => { const { useVoiceInput } = await import('@/composables/useVoiceInput') const { isListening, start, stop } = useVoiceInput() + currentStop = stop await start() stop() expect(isListening.value).toBe(false) @@ -78,13 +93,15 @@ describe('useVoiceInput', () => { it('amplitudeBars has BAR_COUNT entries', async () => { const { useVoiceInput, BAR_COUNT } = await import('@/composables/useVoiceInput') - const { amplitudeBars } = useVoiceInput() + const { amplitudeBars, stop } = useVoiceInput() + currentStop = stop expect(amplitudeBars.value).toHaveLength(BAR_COUNT) }) it('transcript updates on recognition result', async () => { const { useVoiceInput } = await import('@/composables/useVoiceInput') - const { start, transcript } = useVoiceInput() + const { start, stop, transcript } = useVoiceInput() + currentStop = stop await start() // Simulate a speech result mockRecognition.onresult({ @@ -97,7 +114,8 @@ describe('useVoiceInput', () => { it('isFinal result fires onFinal callback', async () => { const { useVoiceInput } = await import('@/composables/useVoiceInput') const onFinal = vi.fn() - const { start } = useVoiceInput({ onFinal }) + const { start, stop } = useVoiceInput({ onFinal }) + currentStop = stop await start() mockRecognition.onresult({ results: [[{ transcript: 'confirmed text', isFinal: true }]], diff --git a/src/test/globalSetup.ts b/src/test/globalSetup.ts new file mode 100644 index 0000000..2ab91d4 --- /dev/null +++ b/src/test/globalSetup.ts @@ -0,0 +1,10 @@ +// globalSetup runs in the main Vitest process (not in workers). +// teardown() is called after all test files and reporters complete. +// We force-exit here because some open handle in the main process +// (likely Vitest's internal file watcher or a jsdom resource) prevents +// a natural exit even after all tests pass. +export default function () { + return function teardown() { + process.exit(0); + }; +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 9503de1..12c3205 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,7 +1,13 @@ import { afterEach } from 'vitest' import { cleanup } from '@testing-library/vue' +import { enableAutoUnmount } from '@vue/test-utils' import '@testing-library/jest-dom' +// Automatically call wrapper.unmount() after each test for any component +// mounted with @vue/test-utils mount(). Without this, Vue app instances and +// their onMounted timers/watchers outlive the test and keep the process alive. +enableAutoUnmount(afterEach) + // jsdom doesn't implement requestAnimationFrame. We mock it with a synthetic // advancing timestamp so the chat store's RAF-based text reveal animation always // terminates in a finite number of microtasks. diff --git a/vite.config.ts b/vite.config.ts index d620b37..20b5f00 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,8 +55,9 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', + globalSetup: './src/test/globalSetup.ts', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - pool: 'forks', + pool: 'threads', testTimeout: 10000, } })