Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
test:
name: Typecheck & Unit Tests
runs-on: self-hosted
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

Expand All @@ -29,6 +30,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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 57 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 25 additions & 36 deletions src/__tests__/usePermissions.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Expand Down
30 changes: 24 additions & 6 deletions src/composables/__tests__/useVoiceInput.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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({
Expand All @@ -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 }]],
Expand Down
10 changes: 10 additions & 0 deletions src/test/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
Loading
Loading