@opice/harness
v0.3.0
Published
Runtime primitives for opice — AI-driven E2E browser tests on top of Playwright
Readme
@opice/harness
Runtime primitives for opice — AI-driven E2E browser tests on
top of Playwright. The browser runs in-process
under bun test; there is no CLI or daemon in the test path.
Install
bun add -D @opice/harness
bunx playwright install chromiumRuns under the Bun test runner.
Usage
import { test, describe } from 'bun:test'
import { browserTest, el, byRole, byLabel, step, expect } from '@opice/harness'
browserTest('DataGrid', () => {
test('renders and is interactive', async () => {
await step('table is visible', async () => {
await expect(el('datagrid-table')).toBeVisible()
})
await step('clicking a row highlights it', async () => {
await el('datagrid-row-0').click()
await expect(el('datagrid-row-0')).toHaveAttribute('data-highlighted', '')
})
}, 60_000)
}, { hash: 'datagrid' })The DSL is async and returns Playwright Locators, so the full Locator API
(.click(), .fill(), .textContent(), .first(), …) and the web-first
expect(locator) assertions are available. expect is re-exported from the
harness (Playwright's expect, which works under bun:test).
API
Locators
el(selector)— aLocator. A bare word is a test-id (el('foo')≡getByTestId('foo'), matchingdata-testid); anything with CSS-flavoured characters is a raw CSS selector (el('main h1')).tid(id)— build a[data-testid="..."]selector string for composing into a larger CSS selector:el(${tid('row')} button).
Accessible-name selectors
Native Playwright accessibility locators — reliable, real user gestures. Prefer
these (or data-testid) over CSS.
byRole(role, name?)— by ARIA role, optionally filtered by accessible name.byLabel(text)— a form control by its<label>/aria-label.byText(text)— by visible text.
Assertions
expect(locator)— Playwright's web-first, auto-retrying assertions:.toBeVisible(),.toHaveText(),.toContainText(),.toBeEnabled(),.toHaveAttribute(), … Prefer these over manual polling. Generic matchers (.toBe,.toEqual) work too.
Navigation
open(url),reload(),back(),forward()— page navigation (each awaits the load event).currentUrl(),currentPath()— readlocation.href/location.pathname(synchronous).
Waiting
waitFor(condition, opts?)— polls a (possibly async) predicate until true; throws on timeout (default 10s / 200ms). For predicates that don't map to a retryingexpectassertion.wait(ms)— fixed sleep. Avoid whenwaitFororexpectworks.
Scenarios
browserTest(name, fn, options?)— top-level scenario. Launches a fresh isolated Playwright browser + context + page inbeforeAll, navigates to the scenario URL, tears down inafterAll. Pass{ hash: 'foo' }forPLAYGROUND_URL#foo, or a string shorthand:browserTest(name, fn, 'foo').step(name, fn)— reportable async step.await step('…', async () => {…}); captures duration + screenshot. Reporter is a no-op until the platform is wired.
Custom verbs (user-land)
Define a domain verb once in <repo>/browser-tools.ts and use it in both the
authoring agent (opice-browser) and your tests:
// browser-tools.ts
import { command, z } from '@opice/harness'
export const fullEnum = command('fullEnum',
z.object({ label: z.string(), option: z.string() }),
async ({ page }, { label, option }) => {
await page.getByLabel(label).press('Enter')
await page.getByRole('button', { name: option }).click()
})// in a test
import { call } from '@opice/harness'
import { fullEnum } from '../browser-tools'
await call(fullEnum, { label: 'Typ', option: 'Faktura' })Context setup (user-land)
Export setup(context) from <repo>/browser-setup.ts to configure the browser
context once, before the first navigation — on both faces (the test
harness runs it in beforeAll before page.goto; the opice-browser server
runs it after connecting, before navigating to the launch URL). Because it runs
pre-navigation, an addInitScript here fires before the app's own scripts on
first paint — the place to seed storage/cookies, grant permissions, or set a
boot-time flag (e.g. "automated run — skip dev-only chrome"). Keep it
idempotent.
// browser-setup.ts
import type { BrowserSetup } from '@opice/harness'
export const setup: BrowserSetup = async (context) => {
await context.addInitScript(() => {
try { localStorage.setItem('app:e2e', '1') } catch {}
})
}Misc
screenshot(path?)— saves a PNG, returns the path (default under/tmp/).evalJs(js)—page.evaluatepassthrough (returns the real JS value).getPage()/getContext()— the live PlaywrightPage/BrowserContextfor an escape hatch into the raw API.
Configuration
PLAYGROUND_URL— base URL forbrowserTest(defaulthttp://localhost:15180).OPICE_HEADED(orPWDEBUG) — run headed for local debugging (default headless).OPICE_ENDPOINT,OPICE_PROJECT,OPICE_API_KEY— reporter config (or a singleOPICE_DSN).OPICE_REPORT—auto(default: report only in CI),always(report locally too), ornever. Outside CI, reporting is opt-in so iterating with barebun testdoesn't stream half-finished runs onto the shared dashboard. CI-detected runs are taggedci, opted-in local runslocal.
