kanban
v0.1.11File-backed kanban board — tickets, threaded comments, agent assignment, the kanban:change and kanban:comment trigger types, and an injectable console board with agent profiles for planning and review.
Install
iii trigger compose::add worker=kanban@0.1.11Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
skill doc
frontend-testing
how-toFrontend testing
A test exists to catch a regression you would otherwise ship. If it only restates the implementation, it will fail whenever you refactor and pass whenever you break the app.
Choose the level deliberately
| Level | Tool | Covers |
|---|---|---|
| Types + lint | tsc --noEmit, eslint |
contracts, dead code, a11y lint |
| Unit | Vitest | pure functions, formatters, reducers, query-key factories |
| Component | Vitest + Testing Library | rendering, user interaction, states, ARIA |
| Integration | RTL + MSW + a real router | one screen end to end against a fake network |
| Journey (e2e) | Playwright | login, checkout, the three flows the business depends on |
The common failure is too many e2e tests and too few component tests: e2e is slow, flaky, and tells you "something broke", so it belongs on critical journeys only.
Vitest setup
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
restoreMocks: true,
coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
},
})environment: 'jsdom'(orhappy-dom, faster but less faithful for layout). Neither computes real layout: anything that depends on measured sizes must be tested in a browser (vitest --browseror Playwright).setup.ts: import@testing-library/jest-dom, start and stop the MSW server, and reset any global state.
Testing Library: query the way a user finds things
The accessible name is the query, and that is the point: a missing label breaks the test, which is a real bug.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('filters the list by tag', async () => {
const user = userEvent.setup()
render(<PostList />)
await user.type(screen.getByRole('searchbox', { name: /search/i }), 'vite')
await user.click(screen.getByRole('button', { name: /apply/i }))
expect(await screen.findByRole('heading', { name: 'Vite' })).toBeInTheDocument()
expect(screen.queryByText('Loading')).not.toBeInTheDocument()
})Rules:
- Query priority: role/name → label → text → test id.
getByTestIdis the last resort, for things with no accessible identity. userEventoverfireEvent— it models real input (focus, key sequences, pointer events) and will surface focus-management bugs thatfireEvent.clickhides.awaitevery interaction and everyfindBy*. An unawaiteduser.clickis the number one source of "works locally, flakes in CI".- Assert on what the user sees, not on state, props, or class names.
- Test the states every screen has: loading, empty, error, success, and long content. Those are where the bug is.
- No snapshots for component output. They get blessed on failure and then assert nothing.
- Wrap in the real providers the component needs (QueryClient, router, iii client) via a local
renderWithProviders— not by mocking the provider away.
The network boundary: MSW, not module mocks
// src/test/handlers.ts
export const handlers = [
http.get('/api/posts', () => HttpResponse.json([{ id: '1', title: 'Vite' }])),
]- Mock the protocol, not the module you are testing. A test that mocks
usePostsproves nothing aboutusePostsor about the screen that consumes it. - Override per test with
server.use(...); the shared handlers stay the happy path. - Same handlers can run in the browser during development, so the fake data is exercised twice.
- For an iii app, the boundary is the engine: wrap the
iii-browser-sdkclient in a thin adapter ({ trigger, registerFunction }) and inject a fake for tests. Mocking the SDK's internals makes your tests depend on a third party's module shape.
Async, timers, and flake traps
- Never
await new Promise(r => setTimeout(r, 100)). UsefindBy*,waitFor, andwaitForElementToBeRemoved— they poll until the assertion holds or the timeout expires. - Fake timers and
userEventare a classic conflict:userEvent.setup({ advanceTimers: vi.advanceTimersByTime }), or avoid fake timers where real ones work. - A fresh
QueryClientper test withretry: falseandgcTime: 0; a shared client leaks cache between tests and makes them order-dependent. - React
act(...)warnings are real: state updated outsideactmeans an unawaited update. Fix the await, do not silence the warning. - Clock- and timezone-dependent tests: pin them (
vi.setSystemTime,TZ=UTCin CI). - Stub randomness (
vi.spyOn(Math, 'random')), never assert on a generated uuid.
Playwright, for the flows that must not break
const test = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => localStorage.setItem('seen-onboarding', '1'))
await use(page)
},
})webServerinplaywright.config.tsso the suite starts the app itself;reuseExistingServerlocally.storageStatefor a signed-in session instead of logging in through the UI in every test.- Built-in auto-waiting
expect(locator).toBeVisible()— no sleeps, ever. trace: 'on-first-retry',screenshot: 'only-on-failure': you will need them at 2 a.m.- Run against the production build at least once in CI (
vite build && vite preview) — dev servers hide production-only failures. - One journey per spec, named after the user outcome.
What not to test
- Third-party library behaviour.
- Exact CSS, class names, or DOM structure.
- Private functions you reached through a re-export.
- A component's internal state.
- The same logic at three levels; pick one level per rule and trust it.
Definition of done for a UI change
tsc --noEmitand lint clean.- Unit/component tests cover the new states, including empty and error.
- The primary flow passes in a real browser at a narrow and a wide viewport.
- Keyboard-only pass of the new interaction.
- The suite is green twice in a row (a flaky test is a red test).