@opice/harness 0.0.4 → 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.
- package/README.md +80 -44
- package/dist/accessible.d.ts +28 -0
- package/dist/accessible.d.ts.map +1 -0
- package/dist/accessible.js +31 -0
- package/dist/accessible.js.map +1 -0
- package/dist/command.d.ts +65 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +88 -0
- package/dist/command.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +50 -0
- package/dist/context.js.map +1 -0
- package/dist/dsn.d.ts +17 -0
- package/dist/dsn.d.ts.map +1 -0
- package/dist/dsn.js +17 -0
- package/dist/dsn.js.map +1 -0
- package/dist/element.d.ts +50 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +82 -0
- package/dist/element.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/navigation.d.ts +22 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +35 -0
- package/dist/navigation.js.map +1 -0
- package/dist/reporter.d.ts +61 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +210 -0
- package/dist/reporter.js.map +1 -0
- package/dist/scenario.d.ts +30 -0
- package/dist/scenario.d.ts.map +1 -0
- package/dist/scenario.js +162 -0
- package/dist/scenario.js.map +1 -0
- package/package.json +11 -4
- package/src/accessible.ts +21 -187
- package/src/command.ts +134 -0
- package/src/context.ts +55 -0
- package/src/element.ts +56 -99
- package/src/index.ts +12 -1
- package/src/navigation.ts +16 -28
- package/src/scenario.ts +29 -22
- package/src/agent-browser.ts +0 -30
package/dist/scenario.js
ADDED
|
@@ -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
|
-
"description": "Runtime primitives for opice — AI-driven E2E browser tests on top of
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
-
"
|
|
12
|
-
"
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
* `
|
|
16
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
/**
|
|
183
|
-
function
|
|
184
|
-
|
|
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
|
+
}
|