@opice/harness 0.0.3 → 0.1.0

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.
Files changed (47) hide show
  1. package/README.md +91 -27
  2. package/dist/accessible.d.ts +28 -0
  3. package/dist/accessible.d.ts.map +1 -0
  4. package/dist/accessible.js +31 -0
  5. package/dist/accessible.js.map +1 -0
  6. package/dist/command.d.ts +65 -0
  7. package/dist/command.d.ts.map +1 -0
  8. package/dist/command.js +88 -0
  9. package/dist/command.js.map +1 -0
  10. package/dist/context.d.ts +10 -0
  11. package/dist/context.d.ts.map +1 -0
  12. package/dist/context.js +50 -0
  13. package/dist/context.js.map +1 -0
  14. package/dist/dsn.d.ts +17 -0
  15. package/dist/dsn.d.ts.map +1 -0
  16. package/dist/dsn.js +17 -0
  17. package/dist/dsn.js.map +1 -0
  18. package/dist/element.d.ts +50 -0
  19. package/dist/element.d.ts.map +1 -0
  20. package/dist/element.js +82 -0
  21. package/dist/element.js.map +1 -0
  22. package/dist/index.d.ts +15 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +12 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/navigation.d.ts +22 -0
  27. package/dist/navigation.d.ts.map +1 -0
  28. package/dist/navigation.js +35 -0
  29. package/dist/navigation.js.map +1 -0
  30. package/dist/reporter.d.ts +61 -0
  31. package/dist/reporter.d.ts.map +1 -0
  32. package/dist/reporter.js +210 -0
  33. package/dist/reporter.js.map +1 -0
  34. package/dist/scenario.d.ts +30 -0
  35. package/dist/scenario.d.ts.map +1 -0
  36. package/dist/scenario.js +162 -0
  37. package/dist/scenario.js.map +1 -0
  38. package/package.json +11 -4
  39. package/src/accessible.ts +38 -0
  40. package/src/command.ts +134 -0
  41. package/src/context.ts +55 -0
  42. package/src/element.ts +56 -81
  43. package/src/index.ts +16 -1
  44. package/src/navigation.ts +41 -0
  45. package/src/reporter.ts +18 -0
  46. package/src/scenario.ts +38 -22
  47. package/src/agent-browser.ts +0 -30
@@ -0,0 +1,38 @@
1
+ import type { Locator, Page } from 'playwright'
2
+ import { getPage } from './context.js'
3
+
4
+ /** The ARIA role union accepted by Playwright's `getByRole`. */
5
+ type Role = Parameters<Page['getByRole']>[0]
6
+
7
+ /**
8
+ * Accessible-name selectors — `byRole` / `byLabel` / `byText`.
9
+ *
10
+ * opice prefers `data-testid` (see `el`), but real apps often can't be
11
+ * annotated — third-party UIs, generated form-field ids, components you don't
12
+ * own. These map straight onto Playwright's accessibility-aware locators, which
13
+ * compute the real ARIA accessible name and fire real user gestures. No
14
+ * in-page resolver, no stamping — the previous engine (agent-browser) was
15
+ * CSS-only and couldn't do this, which is a large part of why opice moved to
16
+ * Playwright.
17
+ *
18
+ * All three return a `Locator`, so the full Locator API and `expect(locator)`
19
+ * assertions apply.
20
+ */
21
+
22
+ /**
23
+ * Find an element by ARIA role and (optionally) its accessible name.
24
+ * `name` does a substring, case-insensitive match by default.
25
+ */
26
+ export function byRole(role: Role, name?: string): Locator {
27
+ return getPage().getByRole(role, name == null ? undefined : { name })
28
+ }
29
+
30
+ /** Find a form control by its associated `<label>` (or `aria-label`) text. */
31
+ export function byLabel(text: string): Locator {
32
+ return getPage().getByLabel(text)
33
+ }
34
+
35
+ /** Find an element by its visible text (substring, case-insensitive). */
36
+ export function byText(text: string): Locator {
37
+ return getPage().getByText(text)
38
+ }
package/src/command.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import type { Locator, Page } from 'playwright'
5
+ import { z } from 'zod'
6
+ import { getPage } from './context.js'
7
+ import { locatorOn } from './element.js'
8
+
9
+ /**
10
+ * The shared command registry.
11
+ *
12
+ * A command is a named, schema-validated browser verb implemented once over a
13
+ * Playwright page. The same command object is used on both faces:
14
+ *
15
+ * - **authoring** — the `opice-browser` daemon loads it and exposes it to the
16
+ * agent (`opice-browser <name> …`),
17
+ * - **tests** — the harness loads the same module so a test can call the verb
18
+ * directly.
19
+ *
20
+ * Built-in verbs (open/click/fill/byRole/…) ship with the `opice-browser`
21
+ * daemon; user-land verbs live in a repo's `browser-tools.ts` and are picked up
22
+ * by `loadUserCommands`. Both are the *same* `Command` objects — that is the
23
+ * unification that closes the authoring↔test vocabulary gap.
24
+ */
25
+
26
+ /** Page + accessibility-aware helpers handed to every command implementation. */
27
+ export interface CommandCtx {
28
+ page: Page
29
+ /** Resolve a test-id (bare word) or raw CSS selector to a locator. */
30
+ el(selectorOrTestId: string): Locator
31
+ byRole(role: Parameters<Page['getByRole']>[0], name?: string): Locator
32
+ byLabel(text: string): Locator
33
+ byText(text: string): Locator
34
+ }
35
+
36
+ export interface Command<S extends z.ZodType = z.ZodType> {
37
+ name: string
38
+ /** One-line description, surfaced in `opice-browser commands`. */
39
+ description?: string
40
+ params: S
41
+ run: (ctx: CommandCtx, args: z.infer<S>) => Promise<unknown>
42
+ }
43
+
44
+ /** Define a browser command. See `CommandCtx` for what `ctx` provides. */
45
+ export function command<S extends z.ZodType>(
46
+ name: string,
47
+ params: S,
48
+ run: (ctx: CommandCtx, args: z.infer<S>) => Promise<unknown>,
49
+ description?: string,
50
+ ): Command<S> {
51
+ return { name, params, run, description }
52
+ }
53
+
54
+ /** Build the command context bound to a specific page. */
55
+ export function makeCtx(page: Page): CommandCtx {
56
+ return {
57
+ page,
58
+ el: (sel) => locatorOn(page, sel),
59
+ byRole: (role, name) => page.getByRole(role, name == null ? undefined : { name }),
60
+ byLabel: (text) => page.getByLabel(text),
61
+ byText: (text) => page.getByText(text),
62
+ }
63
+ }
64
+
65
+ /** Validate args against a command's schema and run it on `page`. */
66
+ export async function runCommand(page: Page, cmd: Command, rawArgs: unknown): Promise<unknown> {
67
+ const args = cmd.params.parse(rawArgs)
68
+ return cmd.run(makeCtx(page), args)
69
+ }
70
+
71
+ /**
72
+ * Invoke a command against the active scenario page from inside a test. Pair
73
+ * with a direct import of the verb from `browser-tools.ts` so the args are
74
+ * type-checked against its schema:
75
+ *
76
+ * ```ts
77
+ * import { call } from '@opice/harness'
78
+ * import { fullEnum } from '../browser-tools'
79
+ * await call(fullEnum, { label: 'Typ', option: 'Faktura' })
80
+ * ```
81
+ */
82
+ export async function call<S extends z.ZodType>(cmd: Command<S>, args: z.infer<S>): Promise<unknown> {
83
+ return runCommand(getPage(), cmd, args)
84
+ }
85
+
86
+ /** Duck-type check: is a module export a `Command`? */
87
+ function isCommand(value: unknown): value is Command {
88
+ return (
89
+ typeof value === 'object'
90
+ && value !== null
91
+ && typeof (value as Command).name === 'string'
92
+ && typeof (value as Command).run === 'function'
93
+ && 'params' in value
94
+ )
95
+ }
96
+
97
+ /**
98
+ * Locate a repo's `browser-tools.ts` (or `.js`/`.mjs`), walking up from `from`.
99
+ * Returns the absolute path, or null if none is found before the filesystem
100
+ * root or a `package.json` boundary without one above it.
101
+ */
102
+ export function findUserCommandsFile(from: string = process.cwd()): string | null {
103
+ let dir = path.resolve(from)
104
+ for (;;) {
105
+ for (const name of ['browser-tools.ts', 'browser-tools.js', 'browser-tools.mjs']) {
106
+ const candidate = path.join(dir, name)
107
+ if (existsSync(candidate)) return candidate
108
+ }
109
+ const parent = path.dirname(dir)
110
+ if (parent === dir) return null
111
+ dir = parent
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Load user-land commands from a repo's `browser-tools.ts`. Returns a map keyed
117
+ * by command name (empty if the file is absent). Throws on a duplicate name.
118
+ */
119
+ export async function loadUserCommands(from?: string): Promise<Map<string, Command>> {
120
+ const registry = new Map<string, Command>()
121
+ const file = findUserCommandsFile(from)
122
+ if (!file) return registry
123
+ const mod = (await import(pathToFileURL(file).href)) as Record<string, unknown>
124
+ for (const value of Object.values(mod)) {
125
+ if (!isCommand(value)) continue
126
+ if (registry.has(value.name)) {
127
+ throw new Error(`browser-tools.ts: duplicate command name "${value.name}" (${file})`)
128
+ }
129
+ registry.set(value.name, value)
130
+ }
131
+ return registry
132
+ }
133
+
134
+ export { z }
package/src/context.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
2
+
3
+ /**
4
+ * The live Playwright page for the running scenario. `browserTest` launches a
5
+ * fresh browser + context + page per scenario (`beforeAll`) and tears it down
6
+ * (`afterAll`); the DSL — `el`, `byRole`, navigation — reads the current page
7
+ * from here. This module replaces the old agent-browser CLI session handling:
8
+ * there is no shell-out and no daemon, the browser runs in-process under
9
+ * `bun test`.
10
+ */
11
+
12
+ let browser: Browser | null = null
13
+ let context: BrowserContext | null = null
14
+ let page: Page | null = null
15
+
16
+ /** Headed mode for local debugging (`OPICE_HEADED=1` or Playwright's `PWDEBUG`). */
17
+ function headed(): boolean {
18
+ return !!(process.env['OPICE_HEADED'] || process.env['PWDEBUG'])
19
+ }
20
+
21
+ /** The active page, or throw if called outside a `browserTest` scenario. */
22
+ export function getPage(): Page {
23
+ if (!page) {
24
+ throw new Error('opice: no active page — call DSL helpers inside a browserTest scenario.')
25
+ }
26
+ return page
27
+ }
28
+
29
+ /** The active browser context (for cookies/storage, new tabs, etc.). */
30
+ export function getContext(): BrowserContext {
31
+ if (!context) {
32
+ throw new Error('opice: no active browser context — call inside a browserTest scenario.')
33
+ }
34
+ return context
35
+ }
36
+
37
+ /** Launch a fresh isolated browser + context + page. Called from `beforeAll`. */
38
+ export async function launchPage(): Promise<Page> {
39
+ browser = await chromium.launch({ headless: !headed() })
40
+ context = await browser.newContext()
41
+ page = await context.newPage()
42
+ return page
43
+ }
44
+
45
+ /** Close the page, context, and browser. Called from `afterAll`. */
46
+ export async function closePage(): Promise<void> {
47
+ try {
48
+ await context?.close()
49
+ } finally {
50
+ await browser?.close()
51
+ page = null
52
+ context = null
53
+ browser = null
54
+ }
55
+ }
package/src/element.ts CHANGED
@@ -1,116 +1,91 @@
1
- import { exec, q } from './agent-browser.js'
1
+ import type { Locator, Page } from 'playwright'
2
+ import { getPage } from './context.js'
2
3
 
3
4
  const POLL_INTERVAL = 200
4
5
  const POLL_TIMEOUT = 10_000
5
- const ACTION_SETTLE_MS = 500
6
6
 
7
7
  /**
8
- * Auto-wrap bare identifiers as `[data-testid="…"]` selectors; treat anything
9
- * with CSS-flavoured characters as a raw selector. Heuristic if you need a
10
- * plain-tag selector (e.g. `h1`), give it some structure (e.g. `main h1`) or
11
- * use a descendant/attribute form.
8
+ * Resolve a selector into a `Locator` on an explicit page — the shared core
9
+ * behind `el()` and the command-registry context. Bare identifiers become
10
+ * test-ids (`getByTestId`, matching `data-testid`); anything with CSS-flavoured
11
+ * characters (`[ ] . # : > ` or a space) is a raw CSS selector.
12
12
  */
13
- function resolveSelector(selectorOrTestId: string): string {
13
+ export function locatorOn(page: Page, selectorOrTestId: string): Locator {
14
14
  if (/[\[\].#:> ]/.test(selectorOrTestId)) {
15
- return selectorOrTestId
15
+ return page.locator(selectorOrTestId)
16
16
  }
17
- return `[data-testid="${selectorOrTestId}"]`
17
+ return page.getByTestId(selectorOrTestId)
18
18
  }
19
19
 
20
20
  /**
21
- * Poll a condition until it returns true or timeout.
22
- * Use instead of fixed sleep — stable on both fast local and slow CI.
21
+ * Resolve a selector into a Playwright `Locator`.
22
+ *
23
+ * Bare identifiers are auto-wrapped as test-ids (`page.getByTestId`, which
24
+ * matches `data-testid` by default); anything with CSS-flavoured characters
25
+ * (`[ ] . # : > ` or a space) is treated as a raw CSS selector. Heuristic — if
26
+ * you need a plain-tag selector (e.g. `h1`), give it structure (`main h1`).
27
+ *
28
+ * The returned value is a real Playwright `Locator`, so the full Locator API
29
+ * (`.click()`, `.fill()`, `.textContent()`, `.first()`, `.nth()`, …) and the
30
+ * web-first `expect(locator)` assertions are available. All actions auto-wait
31
+ * for actionability and fire real user gestures.
23
32
  */
24
- export function waitFor(
25
- condition: () => boolean,
33
+ export function el(selectorOrTestId: string): Locator {
34
+ return locatorOn(getPage(), selectorOrTestId)
35
+ }
36
+
37
+ /**
38
+ * Build a `[data-testid="..."]` selector string, for composing into a larger
39
+ * CSS selector: `el(`${tid('row')} button`)`. For a plain test-id, prefer
40
+ * `el('row')` directly.
41
+ */
42
+ export function tid(testId: string): string {
43
+ return `[data-testid="${testId}"]`
44
+ }
45
+
46
+ /**
47
+ * Poll a (possibly async) condition until it returns true or times out.
48
+ *
49
+ * Prefer Playwright's retrying assertions — `await expect(el('x')).toBeVisible()`,
50
+ * `.toHaveText(...)` — which auto-wait and give better failure messages. Keep
51
+ * `waitFor` for arbitrary predicates that don't map to a locator assertion.
52
+ */
53
+ export async function waitFor(
54
+ condition: () => boolean | Promise<boolean>,
26
55
  { timeout = POLL_TIMEOUT, interval = POLL_INTERVAL, message }: { timeout?: number; interval?: number; message?: string } = {},
27
- ): void {
56
+ ): Promise<void> {
28
57
  const start = Date.now()
29
58
  while (Date.now() - start < timeout) {
30
59
  try {
31
- if (condition()) return
60
+ if (await condition()) return
32
61
  } catch {
33
62
  // condition threw — treat as not yet ready
34
63
  }
35
- Bun.sleepSync(interval)
64
+ await new Promise((resolve) => setTimeout(resolve, interval))
36
65
  }
37
- if (!condition()) {
66
+ if (!(await condition())) {
38
67
  const elapsed = Date.now() - start
39
68
  const hint = message ?? condition.toString().slice(0, 120)
40
69
  throw new Error(`waitFor timed out after ${elapsed}ms: ${hint}`)
41
70
  }
42
71
  }
43
72
 
44
- export interface ElementHandle {
45
- readonly exists: boolean
46
- readonly text: string
47
- readonly value: string
48
- readonly isDisabled: boolean
49
- attr(name: string): string
50
- count(): number
51
- click(): void
52
- fill(value: string): void
53
- select(optionText: string): void
54
- }
55
-
56
- export function el(selector: string): ElementHandle {
57
- const sel = resolveSelector(selector)
58
- const quoted = q(sel)
59
- return {
60
- get exists(): boolean {
61
- return parseInt(exec(`agent-browser get count ${quoted}`), 10) > 0
62
- },
63
- get text(): string {
64
- return exec(`agent-browser get text ${quoted}`)
65
- },
66
- get value(): string {
67
- return exec(`agent-browser get value ${quoted}`)
68
- },
69
- get isDisabled(): boolean {
70
- return exec(`agent-browser is enabled ${quoted}`) !== 'true'
71
- },
72
- attr(name: string): string {
73
- return exec(`agent-browser get attr ${name} ${quoted}`)
74
- },
75
- count(): number {
76
- return parseInt(exec(`agent-browser get count ${quoted}`), 10) || 0
77
- },
78
- click(): void {
79
- exec(`agent-browser scrollintoview ${quoted}`)
80
- exec(`agent-browser click ${quoted}`)
81
- Bun.sleepSync(ACTION_SETTLE_MS)
82
- },
83
- fill(value: string): void {
84
- exec(`agent-browser scrollintoview ${quoted}`)
85
- exec(`agent-browser fill ${quoted} ${q(value)}`)
86
- Bun.sleepSync(ACTION_SETTLE_MS)
87
- },
88
- select(optionText: string): void {
89
- exec(`agent-browser scrollintoview ${quoted}`)
90
- exec(`agent-browser select ${quoted} ${q(optionText)}`)
91
- Bun.sleepSync(ACTION_SETTLE_MS)
92
- },
93
- }
73
+ /** Fixed sleep. Avoid when possible — prefer `waitFor` or retrying assertions. */
74
+ export async function wait(ms: number): Promise<void> {
75
+ await new Promise((resolve) => setTimeout(resolve, ms))
94
76
  }
95
77
 
96
78
  /**
97
- * Build a `[data-testid="..."]` selector for compound selectors.
98
- * Usage: el(`${tid('parent')} button`)
79
+ * Evaluate JavaScript in the page and return its result. Thin wrapper over
80
+ * `page.evaluate`; the value is the real JS value (not a JSON string).
99
81
  */
100
- export function tid(testId: string): string {
101
- return `[data-testid="${testId}"]`
102
- }
103
-
104
- export function wait(ms: number): void {
105
- Bun.sleepSync(ms)
106
- }
107
-
108
- export function evalJs(js: string): string {
109
- return exec(`agent-browser eval ${q(js)}`)
82
+ export function evalJs<T = unknown>(js: string): Promise<T> {
83
+ return getPage().evaluate(js) as Promise<T>
110
84
  }
111
85
 
112
- export function screenshot(path?: string): string {
86
+ /** Capture a screenshot to `path` (or a temp file) and return the path. */
87
+ export async function screenshot(path?: string): Promise<string> {
113
88
  const target = path ?? `/tmp/opice-screenshot-${Date.now()}.png`
114
- exec(`agent-browser screenshot ${target}`)
89
+ await getPage().screenshot({ path: target })
115
90
  return target
116
91
  }
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export { el, tid, waitFor, wait, evalJs, screenshot } from './element.js'
2
- export type { ElementHandle } from './element.js'
2
+
3
+ export { byLabel, byRole, byText } from './accessible.js'
4
+
5
+ export { back, currentPath, currentUrl, forward, open, reload } from './navigation.js'
6
+
7
+ export { getPage, getContext } from './context.js'
3
8
 
4
9
  export { browserTest, step } from './scenario.js'
5
10
  export type { BrowserTestOptions } from './scenario.js'
@@ -9,3 +14,13 @@ export type { Reporter, ReporterConfig, StepEvent, ScenarioStart, ScenarioFinish
9
14
 
10
15
  export { parseOpiceDsn } from './dsn.js'
11
16
  export type { OpiceDsn } from './dsn.js'
17
+
18
+ export { command, call, runCommand, makeCtx, loadUserCommands, findUserCommandsFile, z } from './command.js'
19
+ export type { Command, CommandCtx } from './command.js'
20
+
21
+ // Playwright's web-first `expect` (retrying locator matchers + generic matchers)
22
+ // works under `bun:test`; re-export it so tests use a single `expect`.
23
+ export { expect } from '@playwright/test'
24
+
25
+ // The DSL returns Playwright Locators directly — re-export the type.
26
+ export type { Locator } from 'playwright'
@@ -0,0 +1,41 @@
1
+ import { getPage } from './context.js'
2
+
3
+ /**
4
+ * Page navigation primitives. `browserTest` opens the scenario URL for you in
5
+ * `beforeAll`; these are for mid-scenario navigation — following a hard link,
6
+ * reloading after mutating storage/cookies, or going back/forward.
7
+ *
8
+ * Each navigating call waits for the `load` event (Playwright's default), so
9
+ * the old agent-browser reload caveat (a reload from inside `eval` getting
10
+ * dropped) no longer applies — `reload()` drives the page directly.
11
+ */
12
+
13
+ /** Navigate to a URL in the current page. */
14
+ export async function open(url: string): Promise<void> {
15
+ await getPage().goto(url)
16
+ }
17
+
18
+ /** Reload the current page. */
19
+ export async function reload(): Promise<void> {
20
+ await getPage().reload()
21
+ }
22
+
23
+ /** Go back in history. */
24
+ export async function back(): Promise<void> {
25
+ await getPage().goBack()
26
+ }
27
+
28
+ /** Go forward in history. */
29
+ export async function forward(): Promise<void> {
30
+ await getPage().goForward()
31
+ }
32
+
33
+ /** The current full URL (`location.href`). */
34
+ export function currentUrl(): string {
35
+ return getPage().url()
36
+ }
37
+
38
+ /** The current path (`location.pathname`). */
39
+ export function currentPath(): string {
40
+ return new URL(getPage().url()).pathname
41
+ }
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
@@ -1,10 +1,21 @@
1
- import { describe, beforeAll, afterAll } from 'bun:test'
2
- import crypto from 'node:crypto'
1
+ import { createRequire } from 'node:module'
3
2
  import path from 'node:path'
4
- import { exec, setSession } from './agent-browser.js'
5
- import { waitFor, screenshot } from './element.js'
3
+ import { closePage, launchPage } from './context.js'
4
+ import { screenshot } from './element.js'
6
5
  import { getReporter } from './reporter.js'
7
6
 
7
+ /**
8
+ * `bun:test` is resolved lazily, at the moment `browserTest` registers a
9
+ * scenario — never at module load. That keeps `@opice/harness` importable
10
+ * under plain Node (the `opice-browser` authoring daemon imports the command
11
+ * registry from this package and runs on Node, where `bun:test` doesn't
12
+ * exist). Tests still register synchronously: `require` is sync under Bun.
13
+ */
14
+ const require = createRequire(import.meta.url)
15
+ function bunTest(): typeof import('bun:test') {
16
+ return require('bun:test') as typeof import('bun:test')
17
+ }
18
+
8
19
  const PLAYGROUND_URL = process.env['PLAYGROUND_URL'] ?? 'http://localhost:15180'
9
20
 
10
21
  export interface BrowserTestOptions {
@@ -51,50 +62,49 @@ function defaultScenarioFile(testFile: string | undefined): string | undefined {
51
62
  let currentScenarioId: string | null = null
52
63
  let currentScenarioStart: number = 0
53
64
  let currentScenarioFailures = 0
65
+ // Monotonic per-scenario step counter. Assigned synchronously at each step()
66
+ // call so order reflects authoring order — step records are POSTed
67
+ // fire-and-forget and would otherwise be sequenced by arrival order at the
68
+ // worker, which screenshot-encoding latency can reshuffle.
69
+ let currentScenarioStepSeq = 0
54
70
 
55
71
  /**
56
72
  * Register a top-level browser test scenario.
57
73
  *
58
- * Each `browserTest(name, fn)` opens its own agent-browser session, navigates
59
- * to the playground URL, runs the given `fn` (which typically contains nested
60
- * `describe`/`test` blocks), and closes the session in `afterAll`.
74
+ * Each `browserTest(name, fn)` launches its own isolated Playwright browser +
75
+ * context + page, navigates to the playground URL, runs the given `fn` (which
76
+ * typically contains nested `describe`/`test` blocks), and tears the browser
77
+ * down in `afterAll`.
61
78
  */
62
79
  export function browserTest(name: string, fn: () => void, options: BrowserTestOptions | string = {}): void {
63
80
  const opts: BrowserTestOptions = typeof options === 'string' ? { hash: options } : options
64
81
  const reporter = getReporter()
65
82
  const testFile = captureTestFile()
66
83
  const scenarioFile = opts.scenarioFile ?? defaultScenarioFile(testFile)
84
+ const { describe, beforeAll, afterAll } = bunTest()
67
85
 
68
86
  describe(name, () => {
69
87
  beforeAll(async () => {
70
- const session = `opice-${crypto.randomUUID().slice(0, 8)}`
71
- setSession(session)
72
88
  currentScenarioStart = Date.now()
73
89
  currentScenarioFailures = 0
90
+ currentScenarioStepSeq = 0
74
91
  try {
75
92
  currentScenarioId = await reporter.startScenario({ name, hash: opts.hash, testFile, scenarioFile })
76
93
  } catch {
77
94
  currentScenarioId = null
78
95
  }
96
+ const page = await launchPage()
79
97
  const base = opts.url ?? PLAYGROUND_URL
80
98
  const url = opts.hash ? `${base}#${opts.hash}` : base
81
- exec(`agent-browser open ${url}`)
82
- waitFor(() => {
83
- try {
84
- return exec('agent-browser get title').length > 0
85
- } catch {
86
- return false
87
- }
88
- }, { timeout: 15_000 })
99
+ await page.goto(url)
89
100
  }, 30_000)
90
101
 
91
102
  afterAll(async () => {
92
103
  try {
93
- exec('agent-browser close')
104
+ await closePage()
94
105
  } catch {
95
106
  // ignore close errors
96
107
  }
97
- setSession(null)
98
108
  if (currentScenarioId) {
99
109
  // Drain pending step records (incl. their screenshot uploads)
100
110
  // before marking the scenario done. step() fires recordStep
@@ -123,14 +133,19 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
123
133
  /**
124
134
  * A reportable step inside a scenario. Captures duration + screenshot on
125
135
  * finish, forwards to the active reporter (no-op unless configured via env).
136
+ *
137
+ * The body may be sync or async; `step` always returns a promise, so call it
138
+ * with `await step('…', async () => { … })`.
126
139
  */
127
- export function step(name: string, fn: () => void): void {
140
+ export async function step(name: string, fn: () => void | Promise<void>): Promise<void> {
128
141
  const reporter = getReporter()
142
+ // Capture order at call time, before the fire-and-forget record below.
143
+ const sequence = currentScenarioStepSeq++
129
144
  const start = Date.now()
130
145
  let status: 'passed' | 'failed' = 'passed'
131
146
  let error: string | undefined
132
147
  try {
133
- fn()
148
+ await fn()
134
149
  } catch (e) {
135
150
  status = 'failed'
136
151
  error = e instanceof Error ? e.message : String(e)
@@ -140,13 +155,14 @@ export function step(name: string, fn: () => void): void {
140
155
  const durationMs = Date.now() - start
141
156
  let screenshotPath: string | undefined
142
157
  try {
143
- screenshotPath = screenshot()
158
+ screenshotPath = await screenshot()
144
159
  } catch {
145
160
  // screenshot failure shouldn't fail the test
146
161
  }
147
162
  if (currentScenarioId) {
148
163
  void reporter.recordStep({
149
164
  scenarioId: currentScenarioId,
165
+ sequence,
150
166
  name,
151
167
  status,
152
168
  durationMs,