Component browser tests
Production Solid components are tested through their existing *.stories.tsx stories without booting the app, configuring a server, seeding browser storage, or navigating unrelated routes.
Keep each spec in the package that owns its production component:
packages/session-ui/component-tests/owns timeline, tool, notice, reasoning, lifecycle, and review coverage.packages/app/component-tests/owns Composer and other app-only component coverage.packages/stories/owns the story server (a plain Vite page that renders one story with fixed data in place of the server, router and project), the shared Playwright configuration and thestorymount fixture.
Run a package's isolated browser suite from that package:
# Session UI components.
cd packages/session-ui
bun run test:components
bun run test:components -- component-tests/session-timeline.spec.ts
bun run test:components:ui
# App-owned components.
cd packages/app
bun run test:components
bun run test:components -- component-tests/composer.spec.ts
Both suites are separately filterable Turbo tasks:
bun turbo test:components --filter=@opencode/session-ui
bun turbo test:components --filter=@opencode/app
Component browser coverage deliberately remains separate from each package's default test script and from packages/app's test:e2e, so expensive browser checks can be scheduled independently from required unit and full-app journey CI. CI runs each suite in its own leg of the e2e job in .github/workflows/check-test.yml (session-ui-components and app-components). Set PLAYWRIGHT_STORIES_URL to reuse a running story server (bun --cwd packages/stories dev) or PLAYWRIGHT_STORIES_PORT to choose its port.
Adding a test
Keep inspectable scenarios next to the production component in a *.stories.tsx file. A story owns its fixtures, providers, state, and callbacks; its package-local spec owns user-visible interactions and assertions.
import { expect, story } from "../../stories/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves collapsed state while a tool completes", async ({ mount }) => {
const component = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
})
const trigger = component.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
The story ID is the story file's meta id (or its title, kebab-cased) followed by -- and the kebab-cased story export, as Storybook named them. Open http://127.0.0.1:6006/?id=<story id> on a running story server, or the story in the app's components lab, to inspect exactly the scenario covered by the browser test. Preserve an original-source-path comment for every migrated E2E case.
Keep cross-route navigation, remote-server ownership, persistent session state, full-app virtualization, and workflows spanning independent surfaces in packages/app/e2e/.
Component rendering and integration coverage can be complementary. A local story control that installs a completed message does not test event delivery, production reducer cleanup, or a live stream. Keep those original checks in E2E, including stream/chunk identity, compaction and retry events, independent lifecycle transitions, and the real app scroll owner. A provenance comment records the source of a component assertion; it is not evidence that its integration counterpart can be deleted.
When moving an assertion, preserve its discriminating fixture: file status kinds, empty/single-variant inputs, singleton groups, live message state, and the order of intermediate updates. Verify the actual scroll container overflows before asserting that keyboard activation does not scroll it.