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