@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
@@ -0,0 +1,162 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { closePage, launchPage } from './context.js';
4
+ import { screenshot } from './element.js';
5
+ import { getReporter } from './reporter.js';
6
+ /**
7
+ * `bun:test` is resolved lazily, at the moment `browserTest` registers a
8
+ * scenario — never at module load. That keeps `@opice/harness` importable
9
+ * under plain Node (the `opice-browser` authoring daemon imports the command
10
+ * registry from this package and runs on Node, where `bun:test` doesn't
11
+ * exist). Tests still register synchronously: `require` is sync under Bun.
12
+ */
13
+ const require = createRequire(import.meta.url);
14
+ function bunTest() {
15
+ return require('bun:test');
16
+ }
17
+ const PLAYGROUND_URL = process.env['PLAYGROUND_URL'] ?? 'http://localhost:15180';
18
+ /**
19
+ * Best-effort capture of the `*.test.ts` path that called `browserTest`, by
20
+ * walking the stack for the first `.test.` frame. Reported so a failed
21
+ * scenario links back to its source file. Repo-relative when possible.
22
+ */
23
+ function captureTestFile() {
24
+ const stack = new Error().stack;
25
+ if (!stack)
26
+ return undefined;
27
+ for (const line of stack.split('\n')) {
28
+ const match = line.match(/\(?((?:file:\/\/)?\/[^\s():]+\.test\.[tj]sx?)/);
29
+ if (match?.[1]) {
30
+ const abs = match[1].replace(/^file:\/\//, '');
31
+ try {
32
+ const rel = path.relative(process.cwd(), abs);
33
+ return rel.startsWith('..') ? abs : rel;
34
+ }
35
+ catch {
36
+ return abs;
37
+ }
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ function defaultScenarioFile(testFile) {
43
+ if (!testFile)
44
+ return undefined;
45
+ return testFile.replace(/\.test\.[tj]sx?$/, '.scenario.md');
46
+ }
47
+ let currentScenarioId = null;
48
+ let currentScenarioStart = 0;
49
+ let currentScenarioFailures = 0;
50
+ // Monotonic per-scenario step counter. Assigned synchronously at each step()
51
+ // call so order reflects authoring order — step records are POSTed
52
+ // fire-and-forget and would otherwise be sequenced by arrival order at the
53
+ // worker, which screenshot-encoding latency can reshuffle.
54
+ let currentScenarioStepSeq = 0;
55
+ /**
56
+ * Register a top-level browser test scenario.
57
+ *
58
+ * Each `browserTest(name, fn)` launches its own isolated Playwright browser +
59
+ * context + page, navigates to the playground URL, runs the given `fn` (which
60
+ * typically contains nested `describe`/`test` blocks), and tears the browser
61
+ * down in `afterAll`.
62
+ */
63
+ export function browserTest(name, fn, options = {}) {
64
+ const opts = typeof options === 'string' ? { hash: options } : options;
65
+ const reporter = getReporter();
66
+ const testFile = captureTestFile();
67
+ const scenarioFile = opts.scenarioFile ?? defaultScenarioFile(testFile);
68
+ const { describe, beforeAll, afterAll } = bunTest();
69
+ describe(name, () => {
70
+ beforeAll(async () => {
71
+ currentScenarioStart = Date.now();
72
+ currentScenarioFailures = 0;
73
+ currentScenarioStepSeq = 0;
74
+ try {
75
+ currentScenarioId = await reporter.startScenario({ name, hash: opts.hash, testFile, scenarioFile });
76
+ }
77
+ catch {
78
+ currentScenarioId = null;
79
+ }
80
+ const page = await launchPage();
81
+ const base = opts.url ?? PLAYGROUND_URL;
82
+ const url = opts.hash ? `${base}#${opts.hash}` : base;
83
+ await page.goto(url);
84
+ }, 30_000);
85
+ afterAll(async () => {
86
+ try {
87
+ await closePage();
88
+ }
89
+ catch {
90
+ // ignore close errors
91
+ }
92
+ if (currentScenarioId) {
93
+ // Drain pending step records (incl. their screenshot uploads)
94
+ // before marking the scenario done. step() fires recordStep
95
+ // fire-and-forget; the test process would otherwise exit while
96
+ // those requests were still in flight.
97
+ try {
98
+ await reporter.flush();
99
+ }
100
+ catch {
101
+ // best-effort
102
+ }
103
+ const durationMs = Date.now() - currentScenarioStart;
104
+ const status = currentScenarioFailures > 0 ? 'failed' : 'passed';
105
+ try {
106
+ await reporter.finishScenario({ scenarioId: currentScenarioId, status, durationMs });
107
+ }
108
+ catch {
109
+ // best-effort
110
+ }
111
+ }
112
+ currentScenarioId = null;
113
+ }, 30_000);
114
+ fn();
115
+ });
116
+ }
117
+ /**
118
+ * A reportable step inside a scenario. Captures duration + screenshot on
119
+ * finish, forwards to the active reporter (no-op unless configured via env).
120
+ *
121
+ * The body may be sync or async; `step` always returns a promise, so call it
122
+ * with `await step('…', async () => { … })`.
123
+ */
124
+ export async function step(name, fn) {
125
+ const reporter = getReporter();
126
+ // Capture order at call time, before the fire-and-forget record below.
127
+ const sequence = currentScenarioStepSeq++;
128
+ const start = Date.now();
129
+ let status = 'passed';
130
+ let error;
131
+ try {
132
+ await fn();
133
+ }
134
+ catch (e) {
135
+ status = 'failed';
136
+ error = e instanceof Error ? e.message : String(e);
137
+ currentScenarioFailures++;
138
+ throw e;
139
+ }
140
+ finally {
141
+ const durationMs = Date.now() - start;
142
+ let screenshotPath;
143
+ try {
144
+ screenshotPath = await screenshot();
145
+ }
146
+ catch {
147
+ // screenshot failure shouldn't fail the test
148
+ }
149
+ if (currentScenarioId) {
150
+ void reporter.recordStep({
151
+ scenarioId: currentScenarioId,
152
+ sequence,
153
+ name,
154
+ status,
155
+ durationMs,
156
+ error,
157
+ screenshotPath,
158
+ });
159
+ }
160
+ }
161
+ }
162
+ //# sourceMappingURL=scenario.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scenario.js","sourceRoot":"","sources":["../src/scenario.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "@opice/harness",
3
- "version": "0.0.4",
4
- "description": "Runtime primitives for opice — AI-driven E2E browser tests on top of agent-browser",
3
+ "version": "0.1.1",
4
+ "description": "Runtime primitives for opice — AI-driven E2E browser tests on top of Playwright",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./src/index.ts",
11
- "import": "./src/index.ts",
12
- "default": "./src/index.ts"
11
+ "bun": "./src/index.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
13
14
  }
14
15
  },
15
16
  "files": [
16
17
  "src",
18
+ "dist",
17
19
  "README.md"
18
20
  ],
19
21
  "scripts": {
@@ -31,5 +33,10 @@
31
33
  },
32
34
  "publishConfig": {
33
35
  "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@playwright/test": "^1.60.0",
39
+ "playwright": "^1.60.0",
40
+ "zod": "^4.4.3"
34
41
  }
35
42
  }
package/src/accessible.ts CHANGED
@@ -1,204 +1,38 @@
1
- import { exec, q } from './agent-browser.js'
2
- import { el, type ElementHandle, evalJs } from './element.js'
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]
3
6
 
4
7
  /**
5
8
  * Accessible-name selectors — `byRole` / `byLabel` / `byText`.
6
9
  *
7
10
  * opice prefers `data-testid` (see `el`), but real apps often can't be
8
11
  * 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'
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.
14
17
  *
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.
18
+ * All three return a `Locator`, so the full Locator API and `expect(locator)`
19
+ * assertions apply.
20
20
  */
21
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
22
  /**
119
23
  * 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'`.
24
+ * `name` does a substring, case-insensitive match by default.
142
25
  */
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
- })
26
+ export function byRole(role: Role, name?: string): Locator {
27
+ return getPage().getByRole(role, name == null ? undefined : { name })
166
28
  }
167
29
 
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
- })
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)
180
33
  }
181
34
 
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
- }
35
+ /** Find an element by its visible text (substring, case-insensitive). */
36
+ export function byText(text: string): Locator {
37
+ return getPage().getByText(text)
204
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
+ }