Skip to content

Testing

Kitsune uses three layers of tests:

  1. Unit tests — pure runtime / module behavior, no DOM.
  2. Browser component tests — Vitest in browser mode for elements and DOM-dependent behavior.
  3. Golden acceptance tests — Playwright through @atheory-ai/kitsune-acceptance for cross-package, end-to-end behavior.

Run them all:

Terminal window
pnpm test

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.

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')
})
})

The workspace has two acceptance packages:

  • @atheory-ai/kitsune-acceptance — fixtures using only kit-app and kit-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:

Terminal window
pnpm --filter @atheory-ai/kitsune-acceptance test
pnpm --filter @atheory-ai/kitsune-react-acceptance test

To open the Playwright UI:

Terminal window
pnpm test:acceptance:open
pnpm test:react-acceptance:open

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.

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 assert
  • One test file per source file. kit-button.tstest/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.