@opice/harness 0.0.3 → 0.0.4
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 +30 -2
- package/package.json +1 -1
- package/src/accessible.ts +204 -0
- package/src/element.ts +18 -0
- package/src/index.ts +4 -0
- package/src/navigation.ts +53 -0
- package/src/reporter.ts +18 -0
- package/src/scenario.ts +9 -0
package/README.md
CHANGED
|
@@ -41,9 +41,33 @@ browserTest('DataGrid', () => {
|
|
|
41
41
|
`ElementHandle` properties:
|
|
42
42
|
|
|
43
43
|
- `.exists`, `.text`, `.value`, `.isDisabled`, `.attr(name)`, `.count()`
|
|
44
|
-
- `.click()`, `.fill(value)`, `.select(optionText)`
|
|
44
|
+
- `.click()`, `.fill(value)`, `.select(optionText)`, `.focus()`, `.hover()`, `.press(key)`
|
|
45
45
|
|
|
46
46
|
Each action call auto-scrolls into view and sleeps 500ms to let the UI settle.
|
|
47
|
+
`.press(key)` focuses first, then sends the key (`Enter`, `Tab`, `Control+a`).
|
|
48
|
+
|
|
49
|
+
### Accessible-name selectors
|
|
50
|
+
|
|
51
|
+
For apps you can't annotate with `data-testid` (third-party UIs, generated form
|
|
52
|
+
ids). These wrap agent-browser's `find` locators, so a test reads the same way
|
|
53
|
+
the authoring dry-run drives the page (`byRole('button','Save').click()` ⇄
|
|
54
|
+
`agent-browser find role button click --name 'Save'`). Each returns an
|
|
55
|
+
`ElementHandle`.
|
|
56
|
+
|
|
57
|
+
- `byRole(role, name?)` — by ARIA role, optionally filtered by accessible name.
|
|
58
|
+
- `byLabel(text)` — a form control by its `<label>` (resolved via `for`/nesting).
|
|
59
|
+
- `byText(text)` — a leaf element by its visible text.
|
|
60
|
+
|
|
61
|
+
Actions go through `find`; queries (`.exists`, `.text`, …) and the focus/press
|
|
62
|
+
path fall back to a small `eval`. Prefer `data-testid` + `el()` when you own the
|
|
63
|
+
markup.
|
|
64
|
+
|
|
65
|
+
### Navigation
|
|
66
|
+
|
|
67
|
+
- `open(url)`, `reload()`, `back()`, `forward()` — page navigation. Use
|
|
68
|
+
`reload()` after writing auth to localStorage/cookies (an `eval`-triggered
|
|
69
|
+
reload is dropped by agent-browser).
|
|
70
|
+
- `currentUrl()`, `currentPath()` — read `location.href` / `location.pathname`.
|
|
47
71
|
|
|
48
72
|
### Waiting
|
|
49
73
|
|
|
@@ -63,4 +87,8 @@ Each action call auto-scrolls into view and sleeps 500ms to let the UI settle.
|
|
|
63
87
|
## Configuration
|
|
64
88
|
|
|
65
89
|
- `PLAYGROUND_URL` — base URL for `browserTest` (default `http://localhost:15180`).
|
|
66
|
-
- `OPICE_ENDPOINT`, `OPICE_PROJECT`, `OPICE_API_KEY` — reporter config (
|
|
90
|
+
- `OPICE_ENDPOINT`, `OPICE_PROJECT`, `OPICE_API_KEY` — reporter config (or a single `OPICE_DSN`).
|
|
91
|
+
- `OPICE_REPORT` — `auto` (default: report only in CI), `always` (report locally too), or
|
|
92
|
+
`never`. Outside CI, reporting is opt-in so iterating with bare `bun test` doesn't stream
|
|
93
|
+
half-finished runs onto the shared dashboard. CI-detected runs are tagged `ci`, opted-in
|
|
94
|
+
local runs `local`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { exec, q } from './agent-browser.js'
|
|
2
|
+
import { el, type ElementHandle, evalJs } from './element.js'
|
|
3
|
+
|
|
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 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'
|
|
14
|
+
*
|
|
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.
|
|
20
|
+
*/
|
|
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
|
+
/**
|
|
119
|
+
* 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'`.
|
|
142
|
+
*/
|
|
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
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
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
|
+
}
|
|
204
|
+
}
|
package/src/element.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface ElementHandle {
|
|
|
51
51
|
click(): void
|
|
52
52
|
fill(value: string): void
|
|
53
53
|
select(optionText: string): void
|
|
54
|
+
focus(): void
|
|
55
|
+
hover(): void
|
|
56
|
+
/** Focus the element, then send a key (e.g. `Enter`, `Escape`, `ArrowDown`). */
|
|
57
|
+
press(key: string): void
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
export function el(selector: string): ElementHandle {
|
|
@@ -90,6 +94,20 @@ export function el(selector: string): ElementHandle {
|
|
|
90
94
|
exec(`agent-browser select ${quoted} ${q(optionText)}`)
|
|
91
95
|
Bun.sleepSync(ACTION_SETTLE_MS)
|
|
92
96
|
},
|
|
97
|
+
focus(): void {
|
|
98
|
+
exec(`agent-browser scrollintoview ${quoted}`)
|
|
99
|
+
exec(`agent-browser focus ${quoted}`)
|
|
100
|
+
},
|
|
101
|
+
hover(): void {
|
|
102
|
+
exec(`agent-browser scrollintoview ${quoted}`)
|
|
103
|
+
exec(`agent-browser hover ${quoted}`)
|
|
104
|
+
Bun.sleepSync(ACTION_SETTLE_MS)
|
|
105
|
+
},
|
|
106
|
+
press(key: string): void {
|
|
107
|
+
exec(`agent-browser focus ${quoted}`)
|
|
108
|
+
exec(`agent-browser press ${key}`)
|
|
109
|
+
Bun.sleepSync(ACTION_SETTLE_MS)
|
|
110
|
+
},
|
|
93
111
|
}
|
|
94
112
|
}
|
|
95
113
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { el, tid, waitFor, wait, evalJs, screenshot } from './element.js'
|
|
2
2
|
export type { ElementHandle } from './element.js'
|
|
3
3
|
|
|
4
|
+
export { byLabel, byRole, byText } from './accessible.js'
|
|
5
|
+
|
|
6
|
+
export { back, currentPath, currentUrl, forward, open, reload } from './navigation.js'
|
|
7
|
+
|
|
4
8
|
export { browserTest, step } from './scenario.js'
|
|
5
9
|
export type { BrowserTestOptions } from './scenario.js'
|
|
6
10
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { exec } from './agent-browser.js'
|
|
2
|
+
import { evalJs } from './element.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Page navigation primitives. `browserTest` opens the scenario URL for you in
|
|
6
|
+
* `beforeAll`; these are for mid-scenario navigation — following a hard link,
|
|
7
|
+
* reloading after mutating storage/cookies, or going back/forward.
|
|
8
|
+
*
|
|
9
|
+
* Note on reload: a reload triggered from inside `evalJs('location.reload()')`
|
|
10
|
+
* is dropped by agent-browser (the eval's execution context is torn down before
|
|
11
|
+
* the navigation commits), so `reload()` shells out to the CLI instead. Use it
|
|
12
|
+
* after writing auth tokens to localStorage/cookies so the app re-reads them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Navigate to a URL in the current session. */
|
|
16
|
+
export function open(url: string): void {
|
|
17
|
+
exec(`agent-browser open ${url}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reload the current page (and wait for the CLI to settle). */
|
|
21
|
+
export function reload(): void {
|
|
22
|
+
exec('agent-browser reload')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Go back in history. */
|
|
26
|
+
export function back(): void {
|
|
27
|
+
exec('agent-browser back')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Go forward in history. */
|
|
31
|
+
export function forward(): void {
|
|
32
|
+
exec('agent-browser forward')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The current full URL (`location.href`). */
|
|
36
|
+
export function currentUrl(): string {
|
|
37
|
+
return readLocation('href')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The current path (`location.pathname`). */
|
|
41
|
+
export function currentPath(): string {
|
|
42
|
+
return readLocation('pathname')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readLocation(prop: 'href' | 'pathname'): string {
|
|
46
|
+
const raw = evalJs(`location.${prop}`)
|
|
47
|
+
try {
|
|
48
|
+
const value: unknown = JSON.parse(raw)
|
|
49
|
+
return typeof value === 'string' ? value : raw
|
|
50
|
+
} catch {
|
|
51
|
+
return raw
|
|
52
|
+
}
|
|
53
|
+
}
|
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
|
@@ -51,6 +51,11 @@ function defaultScenarioFile(testFile: string | undefined): string | undefined {
|
|
|
51
51
|
let currentScenarioId: string | null = null
|
|
52
52
|
let currentScenarioStart: number = 0
|
|
53
53
|
let currentScenarioFailures = 0
|
|
54
|
+
// Monotonic per-scenario step counter. Assigned synchronously at each step()
|
|
55
|
+
// call so order reflects authoring order — step records are POSTed
|
|
56
|
+
// fire-and-forget and would otherwise be sequenced by arrival order at the
|
|
57
|
+
// worker, which screenshot-encoding latency can reshuffle.
|
|
58
|
+
let currentScenarioStepSeq = 0
|
|
54
59
|
|
|
55
60
|
/**
|
|
56
61
|
* Register a top-level browser test scenario.
|
|
@@ -71,6 +76,7 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
|
|
|
71
76
|
setSession(session)
|
|
72
77
|
currentScenarioStart = Date.now()
|
|
73
78
|
currentScenarioFailures = 0
|
|
79
|
+
currentScenarioStepSeq = 0
|
|
74
80
|
try {
|
|
75
81
|
currentScenarioId = await reporter.startScenario({ name, hash: opts.hash, testFile, scenarioFile })
|
|
76
82
|
} catch {
|
|
@@ -126,6 +132,8 @@ export function browserTest(name: string, fn: () => void, options: BrowserTestOp
|
|
|
126
132
|
*/
|
|
127
133
|
export function step(name: string, fn: () => void): void {
|
|
128
134
|
const reporter = getReporter()
|
|
135
|
+
// Capture order at call time, before the fire-and-forget record below.
|
|
136
|
+
const sequence = currentScenarioStepSeq++
|
|
129
137
|
const start = Date.now()
|
|
130
138
|
let status: 'passed' | 'failed' = 'passed'
|
|
131
139
|
let error: string | undefined
|
|
@@ -147,6 +155,7 @@ export function step(name: string, fn: () => void): void {
|
|
|
147
155
|
if (currentScenarioId) {
|
|
148
156
|
void reporter.recordStep({
|
|
149
157
|
scenarioId: currentScenarioId,
|
|
158
|
+
sequence,
|
|
150
159
|
name,
|
|
151
160
|
status,
|
|
152
161
|
durationMs,
|