@opice/harness 0.0.4 → 0.1.1

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 (46) hide show
  1. package/README.md +80 -44
  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 +21 -187
  40. package/src/command.ts +134 -0
  41. package/src/context.ts +55 -0
  42. package/src/element.ts +56 -99
  43. package/src/index.ts +12 -1
  44. package/src/navigation.ts +16 -28
  45. package/src/scenario.ts +29 -22
  46. package/src/agent-browser.ts +0 -30
package/src/element.ts CHANGED
@@ -1,134 +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
- focus(): void
55
- hover(): void
56
- /** Focus the element, then send a key (e.g. `Enter`, `Escape`, `ArrowDown`). */
57
- press(key: string): void
58
- }
59
-
60
- export function el(selector: string): ElementHandle {
61
- const sel = resolveSelector(selector)
62
- const quoted = q(sel)
63
- return {
64
- get exists(): boolean {
65
- return parseInt(exec(`agent-browser get count ${quoted}`), 10) > 0
66
- },
67
- get text(): string {
68
- return exec(`agent-browser get text ${quoted}`)
69
- },
70
- get value(): string {
71
- return exec(`agent-browser get value ${quoted}`)
72
- },
73
- get isDisabled(): boolean {
74
- return exec(`agent-browser is enabled ${quoted}`) !== 'true'
75
- },
76
- attr(name: string): string {
77
- return exec(`agent-browser get attr ${name} ${quoted}`)
78
- },
79
- count(): number {
80
- return parseInt(exec(`agent-browser get count ${quoted}`), 10) || 0
81
- },
82
- click(): void {
83
- exec(`agent-browser scrollintoview ${quoted}`)
84
- exec(`agent-browser click ${quoted}`)
85
- Bun.sleepSync(ACTION_SETTLE_MS)
86
- },
87
- fill(value: string): void {
88
- exec(`agent-browser scrollintoview ${quoted}`)
89
- exec(`agent-browser fill ${quoted} ${q(value)}`)
90
- Bun.sleepSync(ACTION_SETTLE_MS)
91
- },
92
- select(optionText: string): void {
93
- exec(`agent-browser scrollintoview ${quoted}`)
94
- exec(`agent-browser select ${quoted} ${q(optionText)}`)
95
- Bun.sleepSync(ACTION_SETTLE_MS)
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
- },
111
- }
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))
112
76
  }
113
77
 
114
78
  /**
115
- * Build a `[data-testid="..."]` selector for compound selectors.
116
- * 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).
117
81
  */
118
- export function tid(testId: string): string {
119
- return `[data-testid="${testId}"]`
120
- }
121
-
122
- export function wait(ms: number): void {
123
- Bun.sleepSync(ms)
124
- }
125
-
126
- export function evalJs(js: string): string {
127
- 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>
128
84
  }
129
85
 
130
- 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> {
131
88
  const target = path ?? `/tmp/opice-screenshot-${Date.now()}.png`
132
- exec(`agent-browser screenshot ${target}`)
89
+ await getPage().screenshot({ path: target })
133
90
  return target
134
91
  }
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { el, tid, waitFor, wait, evalJs, screenshot } from './element.js'
2
- export type { ElementHandle } from './element.js'
3
2
 
4
3
  export { byLabel, byRole, byText } from './accessible.js'
5
4
 
6
5
  export { back, currentPath, currentUrl, forward, open, reload } from './navigation.js'
7
6
 
7
+ export { getPage, getContext } from './context.js'
8
+
8
9
  export { browserTest, step } from './scenario.js'
9
10
  export type { BrowserTestOptions } from './scenario.js'
10
11
 
@@ -13,3 +14,13 @@ export type { Reporter, ReporterConfig, StepEvent, ScenarioStart, ScenarioFinish
13
14
 
14
15
  export { parseOpiceDsn } from './dsn.js'
15
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'
package/src/navigation.ts CHANGED
@@ -1,53 +1,41 @@
1
- import { exec } from './agent-browser.js'
2
- import { evalJs } from './element.js'
1
+ import { getPage } from './context.js'
3
2
 
4
3
  /**
5
4
  * Page navigation primitives. `browserTest` opens the scenario URL for you in
6
5
  * `beforeAll`; these are for mid-scenario navigation — following a hard link,
7
6
  * reloading after mutating storage/cookies, or going back/forward.
8
7
  *
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.
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.
13
11
  */
14
12
 
15
- /** Navigate to a URL in the current session. */
16
- export function open(url: string): void {
17
- exec(`agent-browser open ${url}`)
13
+ /** Navigate to a URL in the current page. */
14
+ export async function open(url: string): Promise<void> {
15
+ await getPage().goto(url)
18
16
  }
19
17
 
20
- /** Reload the current page (and wait for the CLI to settle). */
21
- export function reload(): void {
22
- exec('agent-browser reload')
18
+ /** Reload the current page. */
19
+ export async function reload(): Promise<void> {
20
+ await getPage().reload()
23
21
  }
24
22
 
25
23
  /** Go back in history. */
26
- export function back(): void {
27
- exec('agent-browser back')
24
+ export async function back(): Promise<void> {
25
+ await getPage().goBack()
28
26
  }
29
27
 
30
28
  /** Go forward in history. */
31
- export function forward(): void {
32
- exec('agent-browser forward')
29
+ export async function forward(): Promise<void> {
30
+ await getPage().goForward()
33
31
  }
34
32
 
35
33
  /** The current full URL (`location.href`). */
36
34
  export function currentUrl(): string {
37
- return readLocation('href')
35
+ return getPage().url()
38
36
  }
39
37
 
40
38
  /** The current path (`location.pathname`). */
41
39
  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
- }
40
+ return new URL(getPage().url()).pathname
53
41
  }
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 {
@@ -60,20 +71,20 @@ let currentScenarioStepSeq = 0
60
71
  /**
61
72
  * Register a top-level browser test scenario.
62
73
  *
63
- * Each `browserTest(name, fn)` opens its own agent-browser session, navigates
64
- * to the playground URL, runs the given `fn` (which typically contains nested
65
- * `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`.
66
78
  */
67
79
  export function browserTest(name: string, fn: () => void, options: BrowserTestOptions | string = {}): void {
68
80
  const opts: BrowserTestOptions = typeof options === 'string' ? { hash: options } : options
69
81
  const reporter = getReporter()
70
82
  const testFile = captureTestFile()
71
83
  const scenarioFile = opts.scenarioFile ?? defaultScenarioFile(testFile)
84
+ const { describe, beforeAll, afterAll } = bunTest()
72
85
 
73
86
  describe(name, () => {
74
87
  beforeAll(async () => {
75
- const session = `opice-${crypto.randomUUID().slice(0, 8)}`
76
- setSession(session)
77
88
  currentScenarioStart = Date.now()
78
89
  currentScenarioFailures = 0
79
90
  currentScenarioStepSeq = 0
@@ -82,25 +93,18 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
82
93
  } catch {
83
94
  currentScenarioId = null
84
95
  }
96
+ const page = await launchPage()
85
97
  const base = opts.url ?? PLAYGROUND_URL
86
98
  const url = opts.hash ? `${base}#${opts.hash}` : base
87
- exec(`agent-browser open ${url}`)
88
- waitFor(() => {
89
- try {
90
- return exec('agent-browser get title').length > 0
91
- } catch {
92
- return false
93
- }
94
- }, { timeout: 15_000 })
99
+ await page.goto(url)
95
100
  }, 30_000)
96
101
 
97
102
  afterAll(async () => {
98
103
  try {
99
- exec('agent-browser close')
104
+ await closePage()
100
105
  } catch {
101
106
  // ignore close errors
102
107
  }
103
- setSession(null)
104
108
  if (currentScenarioId) {
105
109
  // Drain pending step records (incl. their screenshot uploads)
106
110
  // before marking the scenario done. step() fires recordStep
@@ -129,8 +133,11 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
129
133
  /**
130
134
  * A reportable step inside a scenario. Captures duration + screenshot on
131
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 () => { … })`.
132
139
  */
133
- export function step(name: string, fn: () => void): void {
140
+ export async function step(name: string, fn: () => void | Promise<void>): Promise<void> {
134
141
  const reporter = getReporter()
135
142
  // Capture order at call time, before the fire-and-forget record below.
136
143
  const sequence = currentScenarioStepSeq++
@@ -138,7 +145,7 @@ export function step(name: string, fn: () => void): void {
138
145
  let status: 'passed' | 'failed' = 'passed'
139
146
  let error: string | undefined
140
147
  try {
141
- fn()
148
+ await fn()
142
149
  } catch (e) {
143
150
  status = 'failed'
144
151
  error = e instanceof Error ? e.message : String(e)
@@ -148,7 +155,7 @@ export function step(name: string, fn: () => void): void {
148
155
  const durationMs = Date.now() - start
149
156
  let screenshotPath: string | undefined
150
157
  try {
151
- screenshotPath = screenshot()
158
+ screenshotPath = await screenshot()
152
159
  } catch {
153
160
  // screenshot failure shouldn't fail the test
154
161
  }
@@ -1,30 +0,0 @@
1
- import { execSync } from 'node:child_process'
2
-
3
- const EXEC_TIMEOUT = 30_000
4
-
5
- let currentSession: string | null = null
6
-
7
- export function setSession(session: string | null): void {
8
- currentSession = session
9
- }
10
-
11
- export function getSession(): string | null {
12
- return currentSession
13
- }
14
-
15
- export function exec(cmd: string): string {
16
- const sessionFlag = currentSession ? `--session ${currentSession} ` : ''
17
- const fullCmd = cmd.replace(/^agent-browser /, `agent-browser ${sessionFlag}`)
18
- try {
19
- const raw = execSync(fullCmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUT, stdio: ['pipe', 'pipe', 'pipe'] }).trim()
20
- return raw.replace(/\x1B\[[0-9;]*m/g, '')
21
- } catch (e: unknown) {
22
- const err = e as { stdout?: string; stderr?: string; message?: string }
23
- const output = err.stdout?.trim() ?? err.stderr?.trim() ?? err.message ?? 'unknown error'
24
- throw new Error(`agent-browser command failed: ${fullCmd}\n${output}`)
25
- }
26
- }
27
-
28
- export function q(s: string): string {
29
- return `'${s.replace(/'/g, "'\\''")}'`
30
- }