Testing
Kitsune uses three layers of tests:
- Unit tests — pure runtime / module behavior, no DOM.
- Browser component tests — Vitest in browser mode for elements and DOM-dependent behavior.
- Golden acceptance tests — Playwright through
@atheory-ai/kitsune-acceptancefor cross-package, end-to-end behavior.
Run them all:
pnpm testUnit testing modules
Section titled “Unit testing modules”Modules are values. Test them by installing them on a fresh runtime.
import { describe, it, expect } from 'vitest'import { createKitRuntime } from '@atheory-ai/kitsune-core'import { auditModule, AuditToken } from './audit-module'
describe('auditModule', () => { it('records note.created events', async () => { const runtime = createKitRuntime() await runtime.install(auditModule()) await runtime.start()
runtime.emit({ type: 'note.created', payload: { id: 'n_1' } })
const audit = runtime.inject(AuditToken)! expect(audit.list()).toHaveLength(1) expect(audit.list()[0]?.type).toBe('note.created') })
it('respects max entries', async () => { const runtime = createKitRuntime() await runtime.install(auditModule(2)) for (let i = 0; i < 5; i++) { runtime.emit({ type: 'note.created', payload: { id: `n_${i}` } }) } const audit = runtime.inject(AuditToken)! expect(audit.list()).toHaveLength(2) })})No DOM, no browser. Plain Vitest.
Browser tests for components
Section titled “Browser tests for components”Vitest’s browser mode runs tests in real Chromium. The setup is in vite.config.ts:
import { defineConfig } from 'vitest/config'import { playwright } from '@vitest/browser-playwright'
export default defineConfig({ test: { browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }], }, },})Then write tests that touch the DOM:
import { describe, it, expect } from 'vitest'import '@atheory-ai/kitsune-ui'
describe('kit-dialog', () => { it('opens and closes via showModal', async () => { const dialog = document.createElement('kit-dialog') dialog.id = 'demo' document.body.append(dialog)
await customElements.whenDefined('kit-dialog') await dialog.updateComplete
dialog.show() await dialog.updateComplete
expect(dialog.open).toBe(true) expect(dialog.shadowRoot?.querySelector('dialog')?.open).toBe(true)
dialog.close() await dialog.updateComplete expect(dialog.open).toBe(false) })})For React components use the same pattern with react-dom/client:
import { describe, it, expect } from 'vitest'import { createRoot } from 'react-dom/client'import { KitShellProvider, KitBoundary } from '@atheory-ai/kitsune-react'
describe('KitBoundary', () => { it('emits events from descendant data-meta-event', async () => { const seen: string[] = [] const root = document.createElement('div') document.body.append(root)
createRoot(root).render( <KitShellProvider modules={[{ name: 'spy', events: { '*': (e) => seen.push(e.type) }, }]}> <KitBoundary surface="test"> <button data-meta-event="thing.happened">Click</button> </KitBoundary> </KitShellProvider> )
// Wait for mount await new Promise(r => requestAnimationFrame(r))
root.querySelector('button')!.click() await new Promise(r => setTimeout(r, 0))
expect(seen).toContain('thing.happened') })})Acceptance fixtures
Section titled “Acceptance fixtures”The workspace has two acceptance packages:
@atheory-ai/kitsune-acceptance— fixtures using onlykit-appandkit-ui(no React)@atheory-ai/kitsune-react-acceptance— fixtures using the React adapter
These run a full Playwright suite against built fixtures, exercising the published packages end-to-end. Run them:
pnpm --filter @atheory-ai/kitsune-acceptance testpnpm --filter @atheory-ai/kitsune-react-acceptance testTo open the Playwright UI:
pnpm test:acceptance:openpnpm test:react-acceptance:openDiagnostic-driven testing
Section titled “Diagnostic-driven testing”For tests that care about what the runtime did, subscribe to diagnostics:
const seen: string[] = []runtime.onDiagnostic((d) => seen.push(d.type))
await runtime.command({ type: 'note.create', payload: { title: 'Hello' } })
expect(seen).toContain('command.dispatched')expect(seen).toContain('command.handled')expect(seen).not.toContain('command.failed')This asserts behavior without depending on implementation details.
Testing the metadata protocol
Section titled “Testing the metadata protocol”When testing event delegation, give the boundary time to upgrade and attach its listener:
const shell = document.createElement('kit-shell')shell.modules = [/* spy module */]const boundary = document.createElement('kit-boundary')boundary.setAttribute('surface', 'test')shell.append(boundary)document.body.append(shell)
await customElements.whenDefined('kit-shell')await customElements.whenDefined('kit-boundary')await new Promise(r => setTimeout(r, 0))
// now click and assertConventions
Section titled “Conventions”- One test file per source file.
kit-button.ts↔test/kit-button.test.ts. - Describe blocks match the export name.
- Use real elements over mocks. Mocks for custom elements lie about lifecycle.
- Assert against module-exported state (e.g.,
audit.list()), not against handler call shapes.