@opice/harness 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,9 +41,33 @@ browserTest('DataGrid', () => {
41
41
  `ElementHandle` properties:
42
42
 
43
43
  - `.exists`, `.text`, `.value`, `.isDisabled`, `.attr(name)`, `.count()`
44
- - `.click()`, `.fill(value)`, `.select(optionText)`
44
+ - `.click()`, `.fill(value)`, `.select(optionText)`, `.focus()`, `.hover()`, `.press(key)`
45
45
 
46
46
  Each action call auto-scrolls into view and sleeps 500ms to let the UI settle.
47
+ `.press(key)` focuses first, then sends the key (`Enter`, `Tab`, `Control+a`).
48
+
49
+ ### Accessible-name selectors
50
+
51
+ For apps you can't annotate with `data-testid` (third-party UIs, generated form
52
+ ids). These wrap agent-browser's `find` locators, so a test reads the same way
53
+ the authoring dry-run drives the page (`byRole('button','Save').click()` ⇄
54
+ `agent-browser find role button click --name 'Save'`). Each returns an
55
+ `ElementHandle`.
56
+
57
+ - `byRole(role, name?)` — by ARIA role, optionally filtered by accessible name.
58
+ - `byLabel(text)` — a form control by its `<label>` (resolved via `for`/nesting).
59
+ - `byText(text)` — a leaf element by its visible text.
60
+
61
+ Actions go through `find`; queries (`.exists`, `.text`, …) and the focus/press
62
+ path fall back to a small `eval`. Prefer `data-testid` + `el()` when you own the
63
+ markup.
64
+
65
+ ### Navigation
66
+
67
+ - `open(url)`, `reload()`, `back()`, `forward()` — page navigation. Use
68
+ `reload()` after writing auth to localStorage/cookies (an `eval`-triggered
69
+ reload is dropped by agent-browser).
70
+ - `currentUrl()`, `currentPath()` — read `location.href` / `location.pathname`.
47
71
 
48
72
  ### Waiting
49
73
 
@@ -63,4 +87,8 @@ Each action call auto-scrolls into view and sleeps 500ms to let the UI settle.
63
87
  ## Configuration
64
88
 
65
89
  - `PLAYGROUND_URL` — base URL for `browserTest` (default `http://localhost:15180`).
66
- - `OPICE_ENDPOINT`, `OPICE_PROJECT`, `OPICE_API_KEY` — reporter config (currently no-op).
90
+ - `OPICE_ENDPOINT`, `OPICE_PROJECT`, `OPICE_API_KEY` — reporter config (or a single `OPICE_DSN`).
91
+ - `OPICE_REPORT` — `auto` (default: report only in CI), `always` (report locally too), or
92
+ `never`. Outside CI, reporting is opt-in so iterating with bare `bun test` doesn't stream
93
+ half-finished runs onto the shared dashboard. CI-detected runs are tagged `ci`, opted-in
94
+ local runs `local`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opice/harness",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Runtime primitives for opice — AI-driven E2E browser tests on top of agent-browser",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,204 @@
1
+ import { exec, q } from './agent-browser.js'
2
+ import { el, type ElementHandle, evalJs } from './element.js'
3
+
4
+ /**
5
+ * Accessible-name selectors — `byRole` / `byLabel` / `byText`.
6
+ *
7
+ * opice prefers `data-testid` (see `el`), but real apps often can't be
8
+ * annotated — third-party UIs, generated form-field ids, components you don't
9
+ * own. These wrap agent-browser's own `find` locators, so a test reads the same
10
+ * way the authoring dry-run drives the page:
11
+ *
12
+ * byRole('button', 'Save').click() ⇄ agent-browser find role button click --name 'Save'
13
+ * byLabel('Email').fill('a@b.c') ⇄ agent-browser find label 'Email' fill 'a@b.c'
14
+ *
15
+ * `find` covers actions only (click/fill/hover/check/…). Queries (`exists`,
16
+ * `text`, …) and the focus/press path (Radix popovers open on focus+Enter, and
17
+ * `find focus` is unreliable) fall back to a small `eval` against the same
18
+ * accessible-name predicate. Accessible name is a pragmatic approximation
19
+ * (`aria-label` || text || value), not the full ARIA computation.
20
+ */
21
+
22
+ let counter = 0
23
+
24
+ /** Page-side helpers injected before each finder expression. */
25
+ const HELPERS = `const __norm = s => (s||'').replace(/\\s+/g,' ').replace(/\\*/g,'').trim().toLowerCase();`
26
+ + `const __match = (text, want) => { const a = __norm(text), b = __norm(want); return a === b || (b.length > 0 && a.includes(b)); };`
27
+
28
+ interface Locator {
29
+ /** agent-browser `find` locator + value, e.g. `role button` or `label 'Email'`. */
30
+ readonly findPart: string
31
+ /** Options appended after the `find` action, e.g. ` --name 'Save'`. */
32
+ readonly findOpts: string
33
+ /** JS expression evaluating to the target `Element | null` in the page. */
34
+ readonly nodeExpr: string
35
+ readonly describe: string
36
+ }
37
+
38
+ function parseEval(raw: string): string {
39
+ try {
40
+ const value: unknown = JSON.parse(raw)
41
+ return typeof value === 'string' ? value : String(value)
42
+ } catch {
43
+ return raw
44
+ }
45
+ }
46
+
47
+ /** Evaluate `nodeExpr` and return whether it found an element. */
48
+ function probe(loc: Locator, expr: string): string {
49
+ return parseEval(evalJs(`(() => { ${HELPERS} const node = (${loc.nodeExpr}); return (${expr}); })()`))
50
+ }
51
+
52
+ /** Stamp the matched element with a fresh `data-opice-ref` and return its selector. */
53
+ function stamp(loc: Locator): string {
54
+ const ref = `opice-${++counter}`
55
+ const ok = parseEval(evalJs(
56
+ `(() => {`
57
+ + `document.querySelectorAll('[data-opice-ref="${ref}"]').forEach(e => e.removeAttribute('data-opice-ref'));`
58
+ + HELPERS
59
+ + `const node = (${loc.nodeExpr});`
60
+ + `if (!node) return 'NONE';`
61
+ + `node.setAttribute('data-opice-ref', '${ref}');`
62
+ + `return 'OK';`
63
+ + `})()`,
64
+ ))
65
+ if (ok !== 'OK') throw new Error(`${loc.describe} not found`)
66
+ return `[data-opice-ref="${ref}"]`
67
+ }
68
+
69
+ function handleFor(loc: Locator): ElementHandle {
70
+ const find = (action: string, text?: string): void => {
71
+ const textArg = text === undefined ? '' : ` ${q(text)}`
72
+ exec(`agent-browser find ${loc.findPart} ${action}${textArg}${loc.findOpts}`)
73
+ }
74
+ return {
75
+ // Queries — small eval against the accessible-name predicate.
76
+ get exists(): boolean {
77
+ return probe(loc, '!!node') === 'true'
78
+ },
79
+ get text(): string {
80
+ return probe(loc, "node ? (node.textContent||'') : ''")
81
+ },
82
+ get value(): string {
83
+ return probe(loc, "node ? (node.value||'') : ''")
84
+ },
85
+ get isDisabled(): boolean {
86
+ return probe(loc, '!!(node && (node.disabled || node.getAttribute(\'aria-disabled\') === \'true\'))') === 'true'
87
+ },
88
+ attr(name: string): string {
89
+ return probe(loc, `node ? (node.getAttribute(${JSON.stringify(name)})||'') : ''`)
90
+ },
91
+ // Accessible handles target a single element (the first match). For real
92
+ // counts use `el('css').count()`.
93
+ count(): number {
94
+ return probe(loc, '!!node') === 'true' ? 1 : 0
95
+ },
96
+ // Actions — agent-browser `find` passthrough (mirrors the authoring dry-run).
97
+ click(): void {
98
+ find('click')
99
+ },
100
+ fill(value: string): void {
101
+ find('fill', value)
102
+ },
103
+ select(optionText: string): void {
104
+ el(stamp(loc)).select(optionText)
105
+ },
106
+ focus(): void {
107
+ el(stamp(loc)).focus()
108
+ },
109
+ hover(): void {
110
+ find('hover')
111
+ },
112
+ press(key: string): void {
113
+ el(stamp(loc)).press(key)
114
+ },
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Find an element by ARIA role and (optionally) its accessible name.
120
+ * `byRole('button', 'Save').click()` → `agent-browser find role button click --name 'Save'`.
121
+ */
122
+ export function byRole(role: string, name?: string): ElementHandle {
123
+ const sel = roleSelector(role)
124
+ const nodeExpr = `(() => {`
125
+ + `const __sel = ${JSON.stringify(sel)};`
126
+ + `const __want = ${JSON.stringify(name ?? null)};`
127
+ + `const __accName = e => e.getAttribute('aria-label') || e.textContent || e.value || '';`
128
+ + `return Array.from(document.querySelectorAll(__sel)).find(e => __want == null ? true : __match(__accName(e), __want)) || null;`
129
+ + `})()`
130
+ return handleFor({
131
+ findPart: `role ${role}`,
132
+ findOpts: name === undefined ? '' : ` --name ${q(name)}`,
133
+ nodeExpr,
134
+ describe: `byRole(${role}${name ? `, ${JSON.stringify(name)}` : ''})`,
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Find a form control by its visible `<label>` text (resolved via `for`→id, a
140
+ * nested control, or the next control after the label).
141
+ * `byLabel('Email').fill('x')` → `agent-browser find label 'Email' fill 'x'`.
142
+ */
143
+ export function byLabel(text: string): ElementHandle {
144
+ const controls = 'input,textarea,select,button,[role=textbox],[role=combobox]'
145
+ const nodeExpr = `(() => {`
146
+ + `const __want = ${JSON.stringify(text)};`
147
+ + `const __label = Array.from(document.querySelectorAll('label')).find(l => __match(l.textContent, __want));`
148
+ + `if (!__label) return null;`
149
+ + `const __id = __label.getAttribute('for');`
150
+ + `if (__id) { const c = document.getElementById(__id); if (c) return c; }`
151
+ + `const __nested = __label.querySelector(${JSON.stringify(controls)}); if (__nested) return __nested;`
152
+ + `let __n = __label.nextElementSibling;`
153
+ + `while (__n) {`
154
+ + `if (__n.matches && __n.matches(${JSON.stringify(controls)})) return __n;`
155
+ + `const __inner = __n.querySelector && __n.querySelector(${JSON.stringify(controls)}); if (__inner) return __inner;`
156
+ + `__n = __n.nextElementSibling;`
157
+ + `}`
158
+ + `return null;`
159
+ + `})()`
160
+ return handleFor({
161
+ findPart: `label ${q(text)}`,
162
+ findOpts: '',
163
+ nodeExpr,
164
+ describe: `byLabel(${JSON.stringify(text)})`,
165
+ })
166
+ }
167
+
168
+ /**
169
+ * Find a leaf element by its visible text.
170
+ * `byText('Saved').exists` / `byText('Continue').click()`.
171
+ */
172
+ export function byText(text: string): ElementHandle {
173
+ const nodeExpr = `(Array.from(document.querySelectorAll('body *')).find(e => e.children.length === 0 && __match(e.textContent, ${JSON.stringify(text)})) || null)`
174
+ return handleFor({
175
+ findPart: `text ${q(text)}`,
176
+ findOpts: '',
177
+ nodeExpr,
178
+ describe: `byText(${JSON.stringify(text)})`,
179
+ })
180
+ }
181
+
182
+ /** CSS candidates per ARIA role (for the query/focus fallback predicate). */
183
+ function roleSelector(role: string): string {
184
+ switch (role) {
185
+ case 'button':
186
+ return 'button,[role=button]'
187
+ case 'link':
188
+ return 'a[href],[role=link]'
189
+ case 'textbox':
190
+ return 'input:not([type=button]):not([type=submit]):not([type=reset]):not([type=checkbox]):not([type=radio]),textarea,[role=textbox],[contenteditable=true]'
191
+ case 'checkbox':
192
+ return 'input[type=checkbox],[role=checkbox]'
193
+ case 'combobox':
194
+ return 'select,[role=combobox]'
195
+ case 'heading':
196
+ return 'h1,h2,h3,h4,h5,h6,[role=heading]'
197
+ case 'option':
198
+ return 'option,[role=option]'
199
+ case 'tab':
200
+ return '[role=tab]'
201
+ default:
202
+ return `[role=${role}]`
203
+ }
204
+ }
package/src/element.ts CHANGED
@@ -51,6 +51,10 @@ export interface ElementHandle {
51
51
  click(): void
52
52
  fill(value: string): void
53
53
  select(optionText: string): void
54
+ focus(): void
55
+ hover(): void
56
+ /** Focus the element, then send a key (e.g. `Enter`, `Escape`, `ArrowDown`). */
57
+ press(key: string): void
54
58
  }
55
59
 
56
60
  export function el(selector: string): ElementHandle {
@@ -90,6 +94,20 @@ export function el(selector: string): ElementHandle {
90
94
  exec(`agent-browser select ${quoted} ${q(optionText)}`)
91
95
  Bun.sleepSync(ACTION_SETTLE_MS)
92
96
  },
97
+ focus(): void {
98
+ exec(`agent-browser scrollintoview ${quoted}`)
99
+ exec(`agent-browser focus ${quoted}`)
100
+ },
101
+ hover(): void {
102
+ exec(`agent-browser scrollintoview ${quoted}`)
103
+ exec(`agent-browser hover ${quoted}`)
104
+ Bun.sleepSync(ACTION_SETTLE_MS)
105
+ },
106
+ press(key: string): void {
107
+ exec(`agent-browser focus ${quoted}`)
108
+ exec(`agent-browser press ${key}`)
109
+ Bun.sleepSync(ACTION_SETTLE_MS)
110
+ },
93
111
  }
94
112
  }
95
113
 
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export { el, tid, waitFor, wait, evalJs, screenshot } from './element.js'
2
2
  export type { ElementHandle } from './element.js'
3
3
 
4
+ export { byLabel, byRole, byText } from './accessible.js'
5
+
6
+ export { back, currentPath, currentUrl, forward, open, reload } from './navigation.js'
7
+
4
8
  export { browserTest, step } from './scenario.js'
5
9
  export type { BrowserTestOptions } from './scenario.js'
6
10
 
@@ -0,0 +1,53 @@
1
+ import { exec } from './agent-browser.js'
2
+ import { evalJs } from './element.js'
3
+
4
+ /**
5
+ * Page navigation primitives. `browserTest` opens the scenario URL for you in
6
+ * `beforeAll`; these are for mid-scenario navigation — following a hard link,
7
+ * reloading after mutating storage/cookies, or going back/forward.
8
+ *
9
+ * Note on reload: a reload triggered from inside `evalJs('location.reload()')`
10
+ * is dropped by agent-browser (the eval's execution context is torn down before
11
+ * the navigation commits), so `reload()` shells out to the CLI instead. Use it
12
+ * after writing auth tokens to localStorage/cookies so the app re-reads them.
13
+ */
14
+
15
+ /** Navigate to a URL in the current session. */
16
+ export function open(url: string): void {
17
+ exec(`agent-browser open ${url}`)
18
+ }
19
+
20
+ /** Reload the current page (and wait for the CLI to settle). */
21
+ export function reload(): void {
22
+ exec('agent-browser reload')
23
+ }
24
+
25
+ /** Go back in history. */
26
+ export function back(): void {
27
+ exec('agent-browser back')
28
+ }
29
+
30
+ /** Go forward in history. */
31
+ export function forward(): void {
32
+ exec('agent-browser forward')
33
+ }
34
+
35
+ /** The current full URL (`location.href`). */
36
+ export function currentUrl(): string {
37
+ return readLocation('href')
38
+ }
39
+
40
+ /** The current path (`location.pathname`). */
41
+ export function currentPath(): string {
42
+ return readLocation('pathname')
43
+ }
44
+
45
+ function readLocation(prop: 'href' | 'pathname'): string {
46
+ const raw = evalJs(`location.${prop}`)
47
+ try {
48
+ const value: unknown = JSON.parse(raw)
49
+ return typeof value === 'string' ? value : raw
50
+ } catch {
51
+ return raw
52
+ }
53
+ }
package/src/reporter.ts CHANGED
@@ -26,10 +26,14 @@ export interface ReporterConfig {
26
26
  apiKey: string
27
27
  branch?: string
28
28
  commit?: string
29
+ /** 'ci' for runs from automation, 'local' for opted-in dev runs. */
30
+ source?: 'ci' | 'local'
29
31
  }
30
32
 
31
33
  export interface StepEvent {
32
34
  scenarioId: string
35
+ /** Authoring order within the scenario, assigned at step() call time. */
36
+ sequence: number
33
37
  name: string
34
38
  status: 'passed' | 'failed'
35
39
  durationMs: number
@@ -96,6 +100,7 @@ class HttpReporter implements Reporter {
96
100
  const response = await this.fetch('POST', '/api/v1/runs', {
97
101
  branch: this.config.branch,
98
102
  commit: this.config.commit,
103
+ source: this.config.source,
99
104
  })
100
105
  const runId = response['runId'] as string
101
106
  // Synchronous write so the CLI can pick this up even if the test
@@ -136,6 +141,7 @@ class HttpReporter implements Reporter {
136
141
  ? await this.encodeScreenshot(event.screenshotPath)
137
142
  : undefined
138
143
  await this.fetch('POST', `/api/v1/runs/${runId}/scenarios/${event.scenarioId}/steps`, {
144
+ sequence: event.sequence,
139
145
  name: event.name,
140
146
  status: event.status,
141
147
  durationMs: event.durationMs,
@@ -240,12 +246,24 @@ export function configureFromEnv(env: NodeJS.ProcessEnv = process.env): Reporter
240
246
  if (!endpoint || !projectId || !apiKey) {
241
247
  return new NoopReporter()
242
248
  }
249
+ // Reporting is opt-in outside CI. A local `bun test` while authoring would
250
+ // otherwise stream half-finished runs onto the shared dashboard (they never
251
+ // get the CLI's POST /finish, so they'd sit there as "running" forever).
252
+ // CI reports automatically; OPICE_REPORT=always forces it locally, =never
253
+ // silences it everywhere.
254
+ const isCI = !!(env['CI'] || env['GITHUB_ACTIONS'])
255
+ const mode = (env['OPICE_REPORT'] ?? 'auto').toLowerCase()
256
+ const shouldReport = mode === 'never' ? false : mode === 'always' ? true : isCI
257
+ if (!shouldReport) {
258
+ return new NoopReporter()
259
+ }
243
260
  const reporter = new HttpReporter({
244
261
  endpoint,
245
262
  projectId,
246
263
  apiKey,
247
264
  branch: env['OPICE_BRANCH'] ?? env['GITHUB_REF_NAME'],
248
265
  commit: env['OPICE_COMMIT'] ?? env['GITHUB_SHA'],
266
+ source: isCI ? 'ci' : 'local',
249
267
  })
250
268
  setReporter(reporter)
251
269
  return reporter
package/src/scenario.ts CHANGED
@@ -51,6 +51,11 @@ function defaultScenarioFile(testFile: string | undefined): string | undefined {
51
51
  let currentScenarioId: string | null = null
52
52
  let currentScenarioStart: number = 0
53
53
  let currentScenarioFailures = 0
54
+ // Monotonic per-scenario step counter. Assigned synchronously at each step()
55
+ // call so order reflects authoring order — step records are POSTed
56
+ // fire-and-forget and would otherwise be sequenced by arrival order at the
57
+ // worker, which screenshot-encoding latency can reshuffle.
58
+ let currentScenarioStepSeq = 0
54
59
 
55
60
  /**
56
61
  * Register a top-level browser test scenario.
@@ -71,6 +76,7 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
71
76
  setSession(session)
72
77
  currentScenarioStart = Date.now()
73
78
  currentScenarioFailures = 0
79
+ currentScenarioStepSeq = 0
74
80
  try {
75
81
  currentScenarioId = await reporter.startScenario({ name, hash: opts.hash, testFile, scenarioFile })
76
82
  } catch {
@@ -126,6 +132,8 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
126
132
  */
127
133
  export function step(name: string, fn: () => void): void {
128
134
  const reporter = getReporter()
135
+ // Capture order at call time, before the fire-and-forget record below.
136
+ const sequence = currentScenarioStepSeq++
129
137
  const start = Date.now()
130
138
  let status: 'passed' | 'failed' = 'passed'
131
139
  let error: string | undefined
@@ -147,6 +155,7 @@ export function step(name: string, fn: () => void): void {
147
155
  if (currentScenarioId) {
148
156
  void reporter.recordStep({
149
157
  scenarioId: currentScenarioId,
158
+ sequence,
150
159
  name,
151
160
  status,
152
161
  durationMs,