@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
package/README.md CHANGED
@@ -1,66 +1,130 @@
1
1
  # @opice/harness
2
2
 
3
- Runtime primitives for [opice](../../README.md) — AI-driven E2E browser tests on top of [`agent-browser`](https://github.com/.../agent-browser).
3
+ Runtime primitives for [opice](../../README.md) — AI-driven E2E browser tests on
4
+ top of [Playwright](https://playwright.dev). The browser runs **in-process**
5
+ under `bun test`; there is no CLI or daemon in the test path.
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
10
  bun add -D @opice/harness
11
+ bunx playwright install chromium
9
12
  ```
10
13
 
11
- Requires `agent-browser` on `PATH` and a Bun test runner.
14
+ Runs under the Bun test runner.
12
15
 
13
16
  ## Usage
14
17
 
15
18
  ```ts
16
- import { test, expect, describe } from 'bun:test'
17
- import { browserTest, el, tid, waitFor, step } from '@opice/harness'
19
+ import { test, describe } from 'bun:test'
20
+ import { browserTest, el, byRole, byLabel, step, expect } from '@opice/harness'
18
21
 
19
22
  browserTest('DataGrid', () => {
20
- test('renders table structure', () => {
21
- waitFor(() => el(tid('datagrid-table')).exists)
22
- expect(el(tid('datagrid-header')).exists).toBe(true)
23
- })
23
+ test('renders and is interactive', async () => {
24
+ await step('table is visible', async () => {
25
+ await expect(el('datagrid-table')).toBeVisible()
26
+ })
24
27
 
25
- test('clicking a row highlights it', () => {
26
- step('user clicks first row', () => {
27
- el(tid('datagrid-row-0')).click()
28
+ await step('clicking a row highlights it', async () => {
29
+ await el('datagrid-row-0').click()
30
+ await expect(el('datagrid-row-0')).toHaveAttribute('data-highlighted', '')
28
31
  })
29
- waitFor(() => el(`${tid('datagrid-row-0')}[data-highlighted]`).exists)
30
- })
32
+ }, 60_000)
31
33
  }, { hash: 'datagrid' })
32
34
  ```
33
35
 
36
+ The DSL is **async** and returns Playwright `Locator`s, so the full Locator API
37
+ (`.click()`, `.fill()`, `.textContent()`, `.first()`, …) and the web-first
38
+ `expect(locator)` assertions are available. `expect` is re-exported from the
39
+ harness (Playwright's `expect`, which works under `bun:test`).
40
+
34
41
  ## API
35
42
 
36
- ### Element handles
43
+ ### Locators
44
+
45
+ - `el(selector)` — a `Locator`. A bare word is a test-id (`el('foo')` ≡
46
+ `getByTestId('foo')`, matching `data-testid`); anything with CSS-flavoured
47
+ characters is a raw CSS selector (`el('main h1')`).
48
+ - `tid(id)` — build a `[data-testid="..."]` selector string for composing into a
49
+ larger CSS selector: `el(`${tid('row')} button`)`.
37
50
 
38
- - `el(selector)` — returns an `ElementHandle`. Plain test-ids are auto-wrapped: `el('foo')` ≡ `el('[data-testid="foo"]')`.
39
- - `tid(id)` — build a `[data-testid="..."]` selector string for compound selectors.
51
+ ### Accessible-name selectors
40
52
 
41
- `ElementHandle` properties:
53
+ Native Playwright accessibility locators — reliable, real user gestures. Prefer
54
+ these (or `data-testid`) over CSS.
42
55
 
43
- - `.exists`, `.text`, `.value`, `.isDisabled`, `.attr(name)`, `.count()`
44
- - `.click()`, `.fill(value)`, `.select(optionText)`
56
+ - `byRole(role, name?)` by ARIA role, optionally filtered by accessible name.
57
+ - `byLabel(text)` a form control by its `<label>` / `aria-label`.
58
+ - `byText(text)` — by visible text.
45
59
 
46
- Each action call auto-scrolls into view and sleeps 500ms to let the UI settle.
60
+ ### Assertions
61
+
62
+ - `expect(locator)` — Playwright's web-first, auto-retrying assertions:
63
+ `.toBeVisible()`, `.toHaveText()`, `.toContainText()`, `.toBeEnabled()`,
64
+ `.toHaveAttribute()`, … Prefer these over manual polling. Generic matchers
65
+ (`.toBe`, `.toEqual`) work too.
66
+
67
+ ### Navigation
68
+
69
+ - `open(url)`, `reload()`, `back()`, `forward()` — page navigation (each awaits
70
+ the load event).
71
+ - `currentUrl()`, `currentPath()` — read `location.href` / `location.pathname`
72
+ (synchronous).
47
73
 
48
74
  ### Waiting
49
75
 
50
- - `waitFor(condition, opts?)` — polls until the predicate is true; throws on timeout. Default 10s timeout, 200ms interval.
51
- - `wait(ms)` fixed sleep. Avoid when `waitFor` works.
76
+ - `waitFor(condition, opts?)` — polls a (possibly async) predicate until true;
77
+ throws on timeout (default 10s / 200ms). For predicates that don't map to a
78
+ retrying `expect` assertion.
79
+ - `wait(ms)` — fixed sleep. Avoid when `waitFor` or `expect` works.
52
80
 
53
81
  ### Scenarios
54
82
 
55
- - `browserTest(name, fn, options?)` — top-level scenario. Opens a fresh agent-browser session in `beforeAll`, closes in `afterAll`. Pass `{ hash: 'foo' }` for `PLAYGROUND_URL#foo`, or just a string shorthand: `browserTest(name, fn, 'foo')`.
56
- - `step(name, fn)` reportable step inside a scenario. Captures duration + screenshot. Reporter is a no-op until the opice platform is wired up.
83
+ - `browserTest(name, fn, options?)` — top-level scenario. Launches a fresh
84
+ isolated Playwright browser + context + page in `beforeAll`, navigates to the
85
+ scenario URL, tears down in `afterAll`. Pass `{ hash: 'foo' }` for
86
+ `PLAYGROUND_URL#foo`, or a string shorthand: `browserTest(name, fn, 'foo')`.
87
+ - `step(name, fn)` — reportable async step. `await step('…', async () => {…})`;
88
+ captures duration + screenshot. Reporter is a no-op until the platform is wired.
89
+
90
+ ### Custom verbs (user-land)
91
+
92
+ Define a domain verb once in `<repo>/browser-tools.ts` and use it in **both** the
93
+ authoring agent (`opice-browser`) and your tests:
94
+
95
+ ```ts
96
+ // browser-tools.ts
97
+ import { command, z } from '@opice/harness'
98
+
99
+ export const fullEnum = command('fullEnum',
100
+ z.object({ label: z.string(), option: z.string() }),
101
+ async ({ page }, { label, option }) => {
102
+ await page.getByLabel(label).press('Enter')
103
+ await page.getByRole('button', { name: option }).click()
104
+ })
105
+ ```
106
+
107
+ ```ts
108
+ // in a test
109
+ import { call } from '@opice/harness'
110
+ import { fullEnum } from '../browser-tools'
111
+ await call(fullEnum, { label: 'Typ', option: 'Faktura' })
112
+ ```
57
113
 
58
114
  ### Misc
59
115
 
60
- - `screenshot(path?)` — saves a PNG, returns the path. Default path under `/tmp/`.
61
- - `evalJs(js)` — `agent-browser eval` passthrough.
116
+ - `screenshot(path?)` — saves a PNG, returns the path (default under `/tmp/`).
117
+ - `evalJs(js)` — `page.evaluate` passthrough (returns the real JS value).
118
+ - `getPage()` / `getContext()` — the live Playwright `Page` / `BrowserContext`
119
+ for an escape hatch into the raw API.
62
120
 
63
121
  ## Configuration
64
122
 
65
123
  - `PLAYGROUND_URL` — base URL for `browserTest` (default `http://localhost:15180`).
66
- - `OPICE_ENDPOINT`, `OPICE_PROJECT`, `OPICE_API_KEY` — reporter config (currently no-op).
124
+ - `OPICE_HEADED` (or `PWDEBUG`)run headed for local debugging (default headless).
125
+ - `OPICE_ENDPOINT`, `OPICE_PROJECT`, `OPICE_API_KEY` — reporter config (or a single `OPICE_DSN`).
126
+ - `OPICE_REPORT` — `auto` (default: report only in CI), `always` (report locally too), or
127
+ `never`. Outside CI, reporting is opt-in so iterating with bare `bun test` doesn't stream
128
+ half-finished runs onto the shared dashboard. CI-detected runs are tagged `ci`, opted-in
129
+ local runs `local`.
130
+ ```
@@ -0,0 +1,28 @@
1
+ import type { Locator, Page } from 'playwright';
2
+ /** The ARIA role union accepted by Playwright's `getByRole`. */
3
+ type Role = Parameters<Page['getByRole']>[0];
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 map straight onto Playwright's accessibility-aware locators, which
10
+ * compute the real ARIA accessible name and fire real user gestures. No
11
+ * in-page resolver, no stamping — the previous engine (agent-browser) was
12
+ * CSS-only and couldn't do this, which is a large part of why opice moved to
13
+ * Playwright.
14
+ *
15
+ * All three return a `Locator`, so the full Locator API and `expect(locator)`
16
+ * assertions apply.
17
+ */
18
+ /**
19
+ * Find an element by ARIA role and (optionally) its accessible name.
20
+ * `name` does a substring, case-insensitive match by default.
21
+ */
22
+ export declare function byRole(role: Role, name?: string): Locator;
23
+ /** Find a form control by its associated `<label>` (or `aria-label`) text. */
24
+ export declare function byLabel(text: string): Locator;
25
+ /** Find an element by its visible text (substring, case-insensitive). */
26
+ export declare function byText(text: string): Locator;
27
+ export {};
28
+ //# sourceMappingURL=accessible.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accessible.d.ts","sourceRoot":"","sources":["../src/accessible.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAG/C,gEAAgE;AAChE,KAAK,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAE5C;;;;;;;;;;;;;GAaG;AAEH;;;GAGG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,8EAA8E;AAC9E,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED,yEAAyE;AACzE,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE5C"}
@@ -0,0 +1,31 @@
1
+ import { getPage } from './context.js';
2
+ /**
3
+ * Accessible-name selectors — `byRole` / `byLabel` / `byText`.
4
+ *
5
+ * opice prefers `data-testid` (see `el`), but real apps often can't be
6
+ * annotated — third-party UIs, generated form-field ids, components you don't
7
+ * own. These map straight onto Playwright's accessibility-aware locators, which
8
+ * compute the real ARIA accessible name and fire real user gestures. No
9
+ * in-page resolver, no stamping — the previous engine (agent-browser) was
10
+ * CSS-only and couldn't do this, which is a large part of why opice moved to
11
+ * Playwright.
12
+ *
13
+ * All three return a `Locator`, so the full Locator API and `expect(locator)`
14
+ * assertions apply.
15
+ */
16
+ /**
17
+ * Find an element by ARIA role and (optionally) its accessible name.
18
+ * `name` does a substring, case-insensitive match by default.
19
+ */
20
+ export function byRole(role, name) {
21
+ return getPage().getByRole(role, name == null ? undefined : { name });
22
+ }
23
+ /** Find a form control by its associated `<label>` (or `aria-label`) text. */
24
+ export function byLabel(text) {
25
+ return getPage().getByLabel(text);
26
+ }
27
+ /** Find an element by its visible text (substring, case-insensitive). */
28
+ export function byText(text) {
29
+ return getPage().getByText(text);
30
+ }
31
+ //# sourceMappingURL=accessible.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accessible.js","sourceRoot":"","sources":["../src/accessible.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAKtC;;;;;;;;;;;;;GAaG;AAEH;;;GAGG;AACH,MAAM,UAAU,MAAM,CAAC,IAAU,EAAE,IAAa;IAC/C,OAAO,OAAO,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAA;AACtE,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,OAAO,CAAC,IAAY;IACnC,OAAO,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAA;AAClC,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,MAAM,CAAC,IAAY;IAClC,OAAO,OAAO,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;AACjC,CAAC"}
@@ -0,0 +1,65 @@
1
+ import type { Locator, Page } from 'playwright';
2
+ import { z } from 'zod';
3
+ /**
4
+ * The shared command registry.
5
+ *
6
+ * A command is a named, schema-validated browser verb implemented once over a
7
+ * Playwright page. The same command object is used on both faces:
8
+ *
9
+ * - **authoring** — the `opice-browser` daemon loads it and exposes it to the
10
+ * agent (`opice-browser <name> …`),
11
+ * - **tests** — the harness loads the same module so a test can call the verb
12
+ * directly.
13
+ *
14
+ * Built-in verbs (open/click/fill/byRole/…) ship with the `opice-browser`
15
+ * daemon; user-land verbs live in a repo's `browser-tools.ts` and are picked up
16
+ * by `loadUserCommands`. Both are the *same* `Command` objects — that is the
17
+ * unification that closes the authoring↔test vocabulary gap.
18
+ */
19
+ /** Page + accessibility-aware helpers handed to every command implementation. */
20
+ export interface CommandCtx {
21
+ page: Page;
22
+ /** Resolve a test-id (bare word) or raw CSS selector to a locator. */
23
+ el(selectorOrTestId: string): Locator;
24
+ byRole(role: Parameters<Page['getByRole']>[0], name?: string): Locator;
25
+ byLabel(text: string): Locator;
26
+ byText(text: string): Locator;
27
+ }
28
+ export interface Command<S extends z.ZodType = z.ZodType> {
29
+ name: string;
30
+ /** One-line description, surfaced in `opice-browser commands`. */
31
+ description?: string;
32
+ params: S;
33
+ run: (ctx: CommandCtx, args: z.infer<S>) => Promise<unknown>;
34
+ }
35
+ /** Define a browser command. See `CommandCtx` for what `ctx` provides. */
36
+ export declare function command<S extends z.ZodType>(name: string, params: S, run: (ctx: CommandCtx, args: z.infer<S>) => Promise<unknown>, description?: string): Command<S>;
37
+ /** Build the command context bound to a specific page. */
38
+ export declare function makeCtx(page: Page): CommandCtx;
39
+ /** Validate args against a command's schema and run it on `page`. */
40
+ export declare function runCommand(page: Page, cmd: Command, rawArgs: unknown): Promise<unknown>;
41
+ /**
42
+ * Invoke a command against the active scenario page from inside a test. Pair
43
+ * with a direct import of the verb from `browser-tools.ts` so the args are
44
+ * type-checked against its schema:
45
+ *
46
+ * ```ts
47
+ * import { call } from '@opice/harness'
48
+ * import { fullEnum } from '../browser-tools'
49
+ * await call(fullEnum, { label: 'Typ', option: 'Faktura' })
50
+ * ```
51
+ */
52
+ export declare function call<S extends z.ZodType>(cmd: Command<S>, args: z.infer<S>): Promise<unknown>;
53
+ /**
54
+ * Locate a repo's `browser-tools.ts` (or `.js`/`.mjs`), walking up from `from`.
55
+ * Returns the absolute path, or null if none is found before the filesystem
56
+ * root or a `package.json` boundary without one above it.
57
+ */
58
+ export declare function findUserCommandsFile(from?: string): string | null;
59
+ /**
60
+ * Load user-land commands from a repo's `browser-tools.ts`. Returns a map keyed
61
+ * by command name (empty if the file is absent). Throws on a duplicate name.
62
+ */
63
+ export declare function loadUserCommands(from?: string): Promise<Map<string, Command>>;
64
+ export { z };
65
+ //# sourceMappingURL=command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAIvB;;;;;;;;;;;;;;;GAeG;AAEH,iFAAiF;AACjF,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,IAAI,CAAA;IACV,sEAAsE;IACtE,EAAE,CAAC,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAA;IACrC,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACtE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;IAC9B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAC7B;AAED,MAAM,WAAW,OAAO,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO;IACvD,IAAI,EAAE,MAAM,CAAA;IACZ,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,CAAC,CAAA;IACT,GAAG,EAAE,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC5D;AAED,0EAA0E;AAC1E,wBAAgB,OAAO,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,CAAC,EACT,GAAG,EAAE,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,EAC5D,WAAW,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,CAAC,CAAC,CAEZ;AAED,0DAA0D;AAC1D,wBAAgB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,UAAU,CAQ9C;AAED,qEAAqE;AACrE,wBAAsB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAG7F;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAEnG;AAaD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAWhF;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAanF;AAED,OAAO,EAAE,CAAC,EAAE,CAAA"}
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { z } from 'zod';
5
+ import { getPage } from './context.js';
6
+ import { locatorOn } from './element.js';
7
+ /** Define a browser command. See `CommandCtx` for what `ctx` provides. */
8
+ export function command(name, params, run, description) {
9
+ return { name, params, run, description };
10
+ }
11
+ /** Build the command context bound to a specific page. */
12
+ export function makeCtx(page) {
13
+ return {
14
+ page,
15
+ el: (sel) => locatorOn(page, sel),
16
+ byRole: (role, name) => page.getByRole(role, name == null ? undefined : { name }),
17
+ byLabel: (text) => page.getByLabel(text),
18
+ byText: (text) => page.getByText(text),
19
+ };
20
+ }
21
+ /** Validate args against a command's schema and run it on `page`. */
22
+ export async function runCommand(page, cmd, rawArgs) {
23
+ const args = cmd.params.parse(rawArgs);
24
+ return cmd.run(makeCtx(page), args);
25
+ }
26
+ /**
27
+ * Invoke a command against the active scenario page from inside a test. Pair
28
+ * with a direct import of the verb from `browser-tools.ts` so the args are
29
+ * type-checked against its schema:
30
+ *
31
+ * ```ts
32
+ * import { call } from '@opice/harness'
33
+ * import { fullEnum } from '../browser-tools'
34
+ * await call(fullEnum, { label: 'Typ', option: 'Faktura' })
35
+ * ```
36
+ */
37
+ export async function call(cmd, args) {
38
+ return runCommand(getPage(), cmd, args);
39
+ }
40
+ /** Duck-type check: is a module export a `Command`? */
41
+ function isCommand(value) {
42
+ return (typeof value === 'object'
43
+ && value !== null
44
+ && typeof value.name === 'string'
45
+ && typeof value.run === 'function'
46
+ && 'params' in value);
47
+ }
48
+ /**
49
+ * Locate a repo's `browser-tools.ts` (or `.js`/`.mjs`), walking up from `from`.
50
+ * Returns the absolute path, or null if none is found before the filesystem
51
+ * root or a `package.json` boundary without one above it.
52
+ */
53
+ export function findUserCommandsFile(from = process.cwd()) {
54
+ let dir = path.resolve(from);
55
+ for (;;) {
56
+ for (const name of ['browser-tools.ts', 'browser-tools.js', 'browser-tools.mjs']) {
57
+ const candidate = path.join(dir, name);
58
+ if (existsSync(candidate))
59
+ return candidate;
60
+ }
61
+ const parent = path.dirname(dir);
62
+ if (parent === dir)
63
+ return null;
64
+ dir = parent;
65
+ }
66
+ }
67
+ /**
68
+ * Load user-land commands from a repo's `browser-tools.ts`. Returns a map keyed
69
+ * by command name (empty if the file is absent). Throws on a duplicate name.
70
+ */
71
+ export async function loadUserCommands(from) {
72
+ const registry = new Map();
73
+ const file = findUserCommandsFile(from);
74
+ if (!file)
75
+ return registry;
76
+ const mod = (await import(pathToFileURL(file).href));
77
+ for (const value of Object.values(mod)) {
78
+ if (!isCommand(value))
79
+ continue;
80
+ if (registry.has(value.name)) {
81
+ throw new Error(`browser-tools.ts: duplicate command name "${value.name}" (${file})`);
82
+ }
83
+ registry.set(value.name, value);
84
+ }
85
+ return registry;
86
+ }
87
+ export { z };
88
+ //# sourceMappingURL=command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.js","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import { type BrowserContext, type Page } from 'playwright';
2
+ /** The active page, or throw if called outside a `browserTest` scenario. */
3
+ export declare function getPage(): Page;
4
+ /** The active browser context (for cookies/storage, new tabs, etc.). */
5
+ export declare function getContext(): BrowserContext;
6
+ /** Launch a fresh isolated browser + context + page. Called from `beforeAll`. */
7
+ export declare function launchPage(): Promise<Page>;
8
+ /** Close the page, context, and browser. Called from `afterAll`. */
9
+ export declare function closePage(): Promise<void>;
10
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,YAAY,CAAA;AAoBnF,4EAA4E;AAC5E,wBAAgB,OAAO,IAAI,IAAI,CAK9B;AAED,wEAAwE;AACxE,wBAAgB,UAAU,IAAI,cAAc,CAK3C;AAED,iFAAiF;AACjF,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAKhD;AAED,oEAAoE;AACpE,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAS/C"}
@@ -0,0 +1,50 @@
1
+ import { chromium } from 'playwright';
2
+ /**
3
+ * The live Playwright page for the running scenario. `browserTest` launches a
4
+ * fresh browser + context + page per scenario (`beforeAll`) and tears it down
5
+ * (`afterAll`); the DSL — `el`, `byRole`, navigation — reads the current page
6
+ * from here. This module replaces the old agent-browser CLI session handling:
7
+ * there is no shell-out and no daemon, the browser runs in-process under
8
+ * `bun test`.
9
+ */
10
+ let browser = null;
11
+ let context = null;
12
+ let page = null;
13
+ /** Headed mode for local debugging (`OPICE_HEADED=1` or Playwright's `PWDEBUG`). */
14
+ function headed() {
15
+ return !!(process.env['OPICE_HEADED'] || process.env['PWDEBUG']);
16
+ }
17
+ /** The active page, or throw if called outside a `browserTest` scenario. */
18
+ export function getPage() {
19
+ if (!page) {
20
+ throw new Error('opice: no active page — call DSL helpers inside a browserTest scenario.');
21
+ }
22
+ return page;
23
+ }
24
+ /** The active browser context (for cookies/storage, new tabs, etc.). */
25
+ export function getContext() {
26
+ if (!context) {
27
+ throw new Error('opice: no active browser context — call inside a browserTest scenario.');
28
+ }
29
+ return context;
30
+ }
31
+ /** Launch a fresh isolated browser + context + page. Called from `beforeAll`. */
32
+ export async function launchPage() {
33
+ browser = await chromium.launch({ headless: !headed() });
34
+ context = await browser.newContext();
35
+ page = await context.newPage();
36
+ return page;
37
+ }
38
+ /** Close the page, context, and browser. Called from `afterAll`. */
39
+ export async function closePage() {
40
+ try {
41
+ await context?.close();
42
+ }
43
+ finally {
44
+ await browser?.close();
45
+ page = null;
46
+ context = null;
47
+ browser = null;
48
+ }
49
+ }
50
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAgD,MAAM,YAAY,CAAA;AAEnF;;;;;;;GAOG;AAEH,IAAI,OAAO,GAAmB,IAAI,CAAA;AAClC,IAAI,OAAO,GAA0B,IAAI,CAAA;AACzC,IAAI,IAAI,GAAgB,IAAI,CAAA;AAE5B,oFAAoF;AACpF,SAAS,MAAM;IACd,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAA;AACjE,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,OAAO;IACtB,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,yEAAyE,CAAC,CAAA;IAC3F,CAAC;IACD,OAAO,IAAI,CAAA;AACZ,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,UAAU;IACzB,IAAI,CAAC,OAAO,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,wEAAwE,CAAC,CAAA;IAC1F,CAAC;IACD,OAAO,OAAO,CAAA;AACf,CAAC;AAED,iFAAiF;AACjF,MAAM,CAAC,KAAK,UAAU,UAAU;IAC/B,OAAO,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACxD,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,EAAE,CAAA;IACpC,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;IAC9B,OAAO,IAAI,CAAA;AACZ,CAAC;AAED,oEAAoE;AACpE,MAAM,CAAC,KAAK,UAAU,SAAS;IAC9B,IAAI,CAAC;QACJ,MAAM,OAAO,EAAE,KAAK,EAAE,CAAA;IACvB,CAAC;YAAS,CAAC;QACV,MAAM,OAAO,EAAE,KAAK,EAAE,CAAA;QACtB,IAAI,GAAG,IAAI,CAAA;QACX,OAAO,GAAG,IAAI,CAAA;QACd,OAAO,GAAG,IAAI,CAAA;IACf,CAAC;AACF,CAAC"}
package/dist/dsn.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * An opice DSN packs everything a project needs to report into one string:
3
+ *
4
+ * OPICE_DSN=https://<apiKey>@<host>/<slug>
5
+ *
6
+ * The api key rides in the userinfo, the host is the platform endpoint, and
7
+ * the first path segment is the project slug. It's the single value the
8
+ * dashboard hands you to drop into `.env`; the individual `OPICE_*` vars still
9
+ * win when set, so a DSN is purely a convenience fallback.
10
+ */
11
+ export interface OpiceDsn {
12
+ apiKey: string;
13
+ endpoint: string;
14
+ project: string;
15
+ }
16
+ export declare function parseOpiceDsn(raw: string | undefined | null): OpiceDsn | null;
17
+ //# sourceMappingURL=dsn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dsn.d.ts","sourceRoot":"","sources":["../src/dsn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,WAAW,QAAQ;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CACf;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,QAAQ,GAAG,IAAI,CAY7E"}
package/dist/dsn.js ADDED
@@ -0,0 +1,17 @@
1
+ export function parseOpiceDsn(raw) {
2
+ if (!raw)
3
+ return null;
4
+ let url;
5
+ try {
6
+ url = new URL(raw);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ const apiKey = decodeURIComponent(url.username);
12
+ const project = url.pathname.replace(/^\/+/, '').split('/')[0] ?? '';
13
+ if (!apiKey || !project)
14
+ return null;
15
+ return { apiKey, endpoint: `${url.protocol}//${url.host}`, project };
16
+ }
17
+ //# sourceMappingURL=dsn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dsn.js","sourceRoot":"","sources":["../src/dsn.ts"],"names":[],"mappings":"AAgBA,MAAM,UAAU,aAAa,CAAC,GAA8B;IAC3D,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAA;IACrB,IAAI,GAAQ,CAAA;IACZ,IAAI,CAAC;QACJ,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAA;IACnB,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAA;IACZ,CAAC;IACD,MAAM,MAAM,GAAG,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC/C,MAAM,OAAO,GAAG,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IACpE,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAA;IACpC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,CAAA;AACrE,CAAC"}
@@ -0,0 +1,50 @@
1
+ import type { Locator, Page } from 'playwright';
2
+ /**
3
+ * Resolve a selector into a `Locator` on an explicit page — the shared core
4
+ * behind `el()` and the command-registry context. Bare identifiers become
5
+ * test-ids (`getByTestId`, matching `data-testid`); anything with CSS-flavoured
6
+ * characters (`[ ] . # : > ` or a space) is a raw CSS selector.
7
+ */
8
+ export declare function locatorOn(page: Page, selectorOrTestId: string): Locator;
9
+ /**
10
+ * Resolve a selector into a Playwright `Locator`.
11
+ *
12
+ * Bare identifiers are auto-wrapped as test-ids (`page.getByTestId`, which
13
+ * matches `data-testid` by default); anything with CSS-flavoured characters
14
+ * (`[ ] . # : > ` or a space) is treated as a raw CSS selector. Heuristic — if
15
+ * you need a plain-tag selector (e.g. `h1`), give it structure (`main h1`).
16
+ *
17
+ * The returned value is a real Playwright `Locator`, so the full Locator API
18
+ * (`.click()`, `.fill()`, `.textContent()`, `.first()`, `.nth()`, …) and the
19
+ * web-first `expect(locator)` assertions are available. All actions auto-wait
20
+ * for actionability and fire real user gestures.
21
+ */
22
+ export declare function el(selectorOrTestId: string): Locator;
23
+ /**
24
+ * Build a `[data-testid="..."]` selector string, for composing into a larger
25
+ * CSS selector: `el(`${tid('row')} button`)`. For a plain test-id, prefer
26
+ * `el('row')` directly.
27
+ */
28
+ export declare function tid(testId: string): string;
29
+ /**
30
+ * Poll a (possibly async) condition until it returns true or times out.
31
+ *
32
+ * Prefer Playwright's retrying assertions — `await expect(el('x')).toBeVisible()`,
33
+ * `.toHaveText(...)` — which auto-wait and give better failure messages. Keep
34
+ * `waitFor` for arbitrary predicates that don't map to a locator assertion.
35
+ */
36
+ export declare function waitFor(condition: () => boolean | Promise<boolean>, { timeout, interval, message }?: {
37
+ timeout?: number;
38
+ interval?: number;
39
+ message?: string;
40
+ }): Promise<void>;
41
+ /** Fixed sleep. Avoid when possible — prefer `waitFor` or retrying assertions. */
42
+ export declare function wait(ms: number): Promise<void>;
43
+ /**
44
+ * Evaluate JavaScript in the page and return its result. Thin wrapper over
45
+ * `page.evaluate`; the value is the real JS value (not a JSON string).
46
+ */
47
+ export declare function evalJs<T = unknown>(js: string): Promise<T>;
48
+ /** Capture a screenshot to `path` (or a temp file) and return the path. */
49
+ export declare function screenshot(path?: string): Promise<string>;
50
+ //# sourceMappingURL=element.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"element.d.ts","sourceRoot":"","sources":["../src/element.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAM/C;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAKvE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,EAAE,CAAC,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE1C;AAED;;;;;;GAMG;AACH,wBAAsB,OAAO,CAC5B,SAAS,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,EAC3C,EAAE,OAAsB,EAAE,QAAwB,EAAE,OAAO,EAAE,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAC3H,OAAO,CAAC,IAAI,CAAC,CAef;AAED,kFAAkF;AAClF,wBAAsB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEpD;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAE1D;AAED,2EAA2E;AAC3E,wBAAsB,UAAU,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAI/D"}
@@ -0,0 +1,82 @@
1
+ import { getPage } from './context.js';
2
+ const POLL_INTERVAL = 200;
3
+ const POLL_TIMEOUT = 10_000;
4
+ /**
5
+ * Resolve a selector into a `Locator` on an explicit page — the shared core
6
+ * behind `el()` and the command-registry context. Bare identifiers become
7
+ * test-ids (`getByTestId`, matching `data-testid`); anything with CSS-flavoured
8
+ * characters (`[ ] . # : > ` or a space) is a raw CSS selector.
9
+ */
10
+ export function locatorOn(page, selectorOrTestId) {
11
+ if (/[\[\].#:> ]/.test(selectorOrTestId)) {
12
+ return page.locator(selectorOrTestId);
13
+ }
14
+ return page.getByTestId(selectorOrTestId);
15
+ }
16
+ /**
17
+ * Resolve a selector into a Playwright `Locator`.
18
+ *
19
+ * Bare identifiers are auto-wrapped as test-ids (`page.getByTestId`, which
20
+ * matches `data-testid` by default); anything with CSS-flavoured characters
21
+ * (`[ ] . # : > ` or a space) is treated as a raw CSS selector. Heuristic — if
22
+ * you need a plain-tag selector (e.g. `h1`), give it structure (`main h1`).
23
+ *
24
+ * The returned value is a real Playwright `Locator`, so the full Locator API
25
+ * (`.click()`, `.fill()`, `.textContent()`, `.first()`, `.nth()`, …) and the
26
+ * web-first `expect(locator)` assertions are available. All actions auto-wait
27
+ * for actionability and fire real user gestures.
28
+ */
29
+ export function el(selectorOrTestId) {
30
+ return locatorOn(getPage(), selectorOrTestId);
31
+ }
32
+ /**
33
+ * Build a `[data-testid="..."]` selector string, for composing into a larger
34
+ * CSS selector: `el(`${tid('row')} button`)`. For a plain test-id, prefer
35
+ * `el('row')` directly.
36
+ */
37
+ export function tid(testId) {
38
+ return `[data-testid="${testId}"]`;
39
+ }
40
+ /**
41
+ * Poll a (possibly async) condition until it returns true or times out.
42
+ *
43
+ * Prefer Playwright's retrying assertions — `await expect(el('x')).toBeVisible()`,
44
+ * `.toHaveText(...)` — which auto-wait and give better failure messages. Keep
45
+ * `waitFor` for arbitrary predicates that don't map to a locator assertion.
46
+ */
47
+ export async function waitFor(condition, { timeout = POLL_TIMEOUT, interval = POLL_INTERVAL, message } = {}) {
48
+ const start = Date.now();
49
+ while (Date.now() - start < timeout) {
50
+ try {
51
+ if (await condition())
52
+ return;
53
+ }
54
+ catch {
55
+ // condition threw — treat as not yet ready
56
+ }
57
+ await new Promise((resolve) => setTimeout(resolve, interval));
58
+ }
59
+ if (!(await condition())) {
60
+ const elapsed = Date.now() - start;
61
+ const hint = message ?? condition.toString().slice(0, 120);
62
+ throw new Error(`waitFor timed out after ${elapsed}ms: ${hint}`);
63
+ }
64
+ }
65
+ /** Fixed sleep. Avoid when possible — prefer `waitFor` or retrying assertions. */
66
+ export async function wait(ms) {
67
+ await new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ /**
70
+ * Evaluate JavaScript in the page and return its result. Thin wrapper over
71
+ * `page.evaluate`; the value is the real JS value (not a JSON string).
72
+ */
73
+ export function evalJs(js) {
74
+ return getPage().evaluate(js);
75
+ }
76
+ /** Capture a screenshot to `path` (or a temp file) and return the path. */
77
+ export async function screenshot(path) {
78
+ const target = path ?? `/tmp/opice-screenshot-${Date.now()}.png`;
79
+ await getPage().screenshot({ path: target });
80
+ return target;
81
+ }
82
+ //# sourceMappingURL=element.js.map