Skip to content

Commit 2dc2969

Browse files
DavertMikclaude
andcommitted
fix(cdp-browser): make Obscura run a real Ember app; fix aiTrace, step timing, role waits
Found while running the testomat.io e2e suite on Obscura. CDPBrowser (Obscura, Lightpanda, Chrome over CDP): - visibility: an inline or display:contents element with no box of its own (e.g. <a> wrapping a block card) is visible when a child is. Obscura reports no client rects for such inline elements. - synthetic click fires pointerdown/mousedown/focus/pointerup/mouseup before click, so widgets that react to mousedown/mouseup (ember power-select, Radix) work. - synthetic click on a non-interactive wrapper lands on the control at its center (elementFromPoint), like a coordinate click would. - traffic is recorded once the response finished loading (matching Playwright's requestfinished), and redirect hops are recorded. Playwright: waitFor* with role locators used [role="..."] css, ignoring native roles and the accessible name; build an internal:role selector. Core: HelperStep end time is taken when the async helper call settles, so step durations are no longer ~0ms. aiTrace: when a Before hook fails, only the running test writes the trace, instead of every test in the suite overwriting it. globalTimeout: ignore hook.finished for a suite hook that never fired hook.started (TypeError on __prevSuiteTimeout.slice). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0597db8 commit 2dc2969

12 files changed

Lines changed: 299 additions & 29 deletions

File tree

‎lib/helper/CDPBrowser.js‎

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,16 +1944,25 @@ class CDPBrowser extends Helper {
19441944
this._trafficListenersInstalled = true
19451945
this.cdp.on('Network.requestWillBeSent', (params, sessionId) => this._onTrafficRequest(params, sessionId))
19461946
this.cdp.on('Network.responseReceived', (params, sessionId) => this._onTrafficResponse(params, sessionId))
1947+
this.cdp.on('Network.loadingFinished', (params, sessionId) => this._onTrafficLoadingFinished(params, sessionId))
19471948
this.cdp.on('Network.loadingFailed', (params, sessionId) => this._onTrafficLoadingFailed(params, sessionId))
19481949
}
19491950

19501951
_onTrafficRequest(params, sessionId) {
1951-
if (!this.recording || sessionId !== this.sessionId) return
1952-
let resolveResponse
1952+
if (sessionId !== this.sessionId) return
1953+
if (params.redirectResponse) {
1954+
const redirected = this._pendingTrafficResponses.get(params.requestId)
1955+
if (redirected) {
1956+
this._pendingTrafficResponses.delete(params.requestId)
1957+
redirected.resolve(this._trafficResponse(params.redirectResponse, null))
1958+
if (this.recording) this.requests.push(redirected.request)
1959+
}
1960+
}
1961+
if (!this.recording) return
1962+
let resolve
19531963
const response = new Promise(r => {
1954-
resolveResponse = r
1964+
resolve = r
19551965
})
1956-
this._pendingTrafficResponses.set(params.requestId, resolveResponse)
19571966
let requestPostData = params.request.postData
19581967
if (requestPostData) {
19591968
try {
@@ -1962,42 +1971,56 @@ class CDPBrowser extends Helper {
19621971
// not JSON, keep as string
19631972
}
19641973
}
1965-
this.requests.push({
1966-
url: params.request.url,
1967-
method: params.request.method,
1968-
requestHeaders: params.request.headers,
1969-
requestPostData,
1970-
response,
1974+
this._pendingTrafficResponses.set(params.requestId, {
1975+
resolve,
1976+
request: {
1977+
url: params.request.url,
1978+
method: params.request.method,
1979+
requestHeaders: params.request.headers,
1980+
requestPostData,
1981+
response,
1982+
},
19711983
})
19721984
}
19731985

1974-
_onTrafficResponse(params, sessionId) {
1975-
if (sessionId !== this.sessionId) return
1976-
const resolve = this._pendingTrafficResponses.get(params.requestId)
1977-
if (!resolve) return
1978-
this._pendingTrafficResponses.delete(params.requestId)
1979-
const { response } = params
1980-
resolve({
1986+
_trafficResponse(response, requestId) {
1987+
return {
19811988
url: () => response.url,
19821989
status: () => response.status,
19831990
statusText: () => response.statusText,
19841991
body: async () => {
1992+
if (!requestId) return Buffer.from('')
19851993
try {
1986-
const res = await this.cdp.send('Network.getResponseBody', { requestId: params.requestId }, this.sessionId)
1994+
const res = await this.cdp.send('Network.getResponseBody', { requestId }, this.sessionId)
19871995
return res.base64Encoded ? Buffer.from(res.body, 'base64') : Buffer.from(res.body)
19881996
} catch (e) {
19891997
return Buffer.from('')
19901998
}
19911999
},
1992-
})
2000+
}
2001+
}
2002+
2003+
_onTrafficResponse(params, sessionId) {
2004+
if (sessionId !== this.sessionId) return
2005+
const pending = this._pendingTrafficResponses.get(params.requestId)
2006+
if (!pending) return
2007+
pending.resolve(this._trafficResponse(params.response, params.requestId))
2008+
}
2009+
2010+
_onTrafficLoadingFinished(params, sessionId) {
2011+
if (sessionId !== this.sessionId) return
2012+
const pending = this._pendingTrafficResponses.get(params.requestId)
2013+
if (!pending) return
2014+
this._pendingTrafficResponses.delete(params.requestId)
2015+
if (this.recording) this.requests.push(pending.request)
19932016
}
19942017

19952018
_onTrafficLoadingFailed(params, sessionId) {
19962019
if (sessionId !== this.sessionId) return
1997-
const resolve = this._pendingTrafficResponses.get(params.requestId)
1998-
if (!resolve) return
2020+
const pending = this._pendingTrafficResponses.get(params.requestId)
2021+
if (!pending) return
19992022
this._pendingTrafficResponses.delete(params.requestId)
2000-
resolve(null)
2023+
pending.resolve(null)
20012024
}
20022025

20032026
/**

‎lib/helper/Playwright.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4257,6 +4257,12 @@ export function buildLocatorString(locator) {
42574257
// { shadow: ['my-app', 'my-form', 'button'] } => 'my-app >> my-form >> button'
42584258
return locator.value.join(' >> ')
42594259
}
4260+
if (locator.isRole()) {
4261+
const { text, name, exact } = locator.locator || {}
4262+
const accessibleName = name ?? text
4263+
if (accessibleName === undefined) return `internal:role=${locator.value}`
4264+
return `internal:role=${locator.value}[name=${JSON.stringify(String(accessibleName))}${exact === true ? 's' : 'i'}]`
4265+
}
42604266
return locator.simplify()
42614267
}
42624268

‎lib/helper/clientscripts/cdpBrowserClient.js‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
3939
}
4040
return []
4141
}
42+
const isElementVisible = el => {
43+
const style = getComputedStyle(el)
44+
if (style.display === 'none' || style.visibility === 'hidden') return false
45+
const r = el.getBoundingClientRect()
46+
if (r.width > 0 && r.height > 0) return true
47+
if (style.display !== 'inline' && style.display !== 'contents') return false
48+
return Array.from(el.children).some(isElementVisible)
49+
}
50+
const INTERACTIVE = 'a[href], button, input, select, textarea, label, summary, option, [role="button"], [role="link"], [role="menuitem"], [role="option"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"]'
51+
const clickTarget = (el, x, y) => {
52+
if (el.matches(INTERACTIVE) || typeof document.elementFromPoint !== 'function') return el
53+
let hit = document.elementFromPoint(x, y)
54+
if (!hit || hit === el || !el.contains(hit)) return el
55+
const control = hit.closest(INTERACTIVE)
56+
if (control && el.contains(control)) return control
57+
while (hit && !(hit instanceof HTMLElement)) hit = hit.parentElement
58+
return hit && el.contains(hit) ? hit : el
59+
}
4260
const SKIP_TEXT_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1 }
4361
const BLOCK_TEXT_TAGS = { DIV: 1, P: 1, BR: 1, LI: 1, TR: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, SECTION: 1, ARTICLE: 1, UL: 1, OL: 1, TABLE: 1, FORM: 1 }
4462
const visibleText = root => {
@@ -327,8 +345,22 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
327345
},
328346
click: els => {
329347
const el = els[0]
330-
el.click()
331-
if (el.tagName === 'LABEL') focusLabelledControl(el)
348+
const r = el.getBoundingClientRect()
349+
const x = r.x + r.width / 2
350+
const y = r.y + r.height / 2
351+
const target = clickTarget(el, x, y)
352+
if (!target.disabled) {
353+
const init = { bubbles: true, cancelable: true, composed: true, view: window, button: 0, clientX: x, clientY: y }
354+
const fire = (Ctor, type, buttons) => target.dispatchEvent(new Ctor(type, { ...init, buttons, pointerId: 1, pointerType: 'mouse', isPrimary: true }))
355+
const Pointer = typeof PointerEvent === 'function' ? PointerEvent : null
356+
if (Pointer) fire(Pointer, 'pointerdown', 1)
357+
const focusAllowed = fire(MouseEvent, 'mousedown', 1)
358+
if (focusAllowed && typeof target.focus === 'function') target.focus()
359+
if (Pointer) fire(Pointer, 'pointerup', 0)
360+
fire(MouseEvent, 'mouseup', 0)
361+
}
362+
target.click()
363+
if (target.tagName === 'LABEL') focusLabelledControl(target)
332364
return true
333365
},
334366
fill: (els, p) => {
@@ -451,11 +483,7 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
451483
dispatch('drop')
452484
return true
453485
},
454-
visibleCount: els => els.filter(el => {
455-
const r = el.getBoundingClientRect()
456-
const style = getComputedStyle(el)
457-
return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
458-
}).length,
486+
visibleCount: els => els.filter(isElementVisible).length,
459487
}
460488
const candidatesNeedXPath = (candidates, within) => {
461489
const hasXPath = arr => Array.isArray(arr) && arr.some(c => c && c.type === 'xpath')

‎lib/listener/globalTimeout.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ export default function () {
4141

4242
event.dispatcher.on(event.hook.finished, hook => {
4343
if (hook instanceof BeforeSuiteHook || hook instanceof AfterSuiteHook) {
44+
if (__prevSuiteTimeout === undefined) return
4445
// restore previously stored values
4546
timeout = __prevTimeout
4647
suiteTimeout = __prevSuiteTimeout.slice()
48+
__prevTimeout = undefined
49+
__prevSuiteTimeout = undefined
4750
}
4851
})
4952

‎lib/plugin/aiTrace.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ export default function (config = {}) {
235235
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
236236
return
237237
}
238+
if (test !== currentTest) return
238239
recorder.add('aiTrace:persist failed', async () => {
239240
if (pendingArtifactCapture) {
240241
await pendingArtifactCapture

‎lib/step/helper.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ class HelperStep extends Step {
2929
}
3030
this.setStatus('success')
3131
this.endTime = +Date.now()
32+
if (result && typeof result.then === 'function') {
33+
const finish = () => {
34+
this.endTime = +Date.now()
35+
}
36+
result.then(finish, finish)
37+
}
3238
} catch (err) {
3339
this.endTime = +Date.now()
3440
this.setStatus('failed')

‎test/helper/CDPBrowser_chrome_test.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ describe('CDPBrowser (against Chrome)', function () {
5353
expect(await I.grabSource()).to.include('<html')
5454
})
5555

56+
it('records each redirect hop once its response arrived', async () => {
57+
await I.amOnPage('/')
58+
await I.startRecordingTraffic()
59+
await I.amOnPage('/redirect')
60+
await I.seeTraffic({ name: 'redirected page', url: '/info', timeout: 5 })
61+
const traffic = await I.grabRecordedNetworkTraffics()
62+
const redirect = traffic.find(t => t.url.endsWith('/redirect'))
63+
const info = traffic.find(t => t.url.endsWith('/info'))
64+
expect(redirect && redirect.response.status).to.equal(302)
65+
expect(info && info.response.status).to.equal(200)
66+
I.stopRecordingTraffic()
67+
I.recordedAtLeastOnce = false
68+
})
69+
5670
it('executes scripts with arguments', async () => {
5771
await I.amOnPage('/')
5872
const val = await I.executeScript((a, b) => a + b, 2, 3)

‎test/helper/CDPBrowser_obscura_test.js‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,71 @@ describe('Obscura helper (self-managed obscura serve)', function () {
8080
}
8181
})
8282

83+
it('sees an inline link that wraps only block children', async function () {
84+
await I.amOnPage('/')
85+
if (I.capabilities.layout !== 'real') this.skip()
86+
await I.executeScript(() => {
87+
const wrapper = document.createElement('div')
88+
wrapper.innerHTML = '<a id="card-link" href="#"><div style="height:20px">card</div></a><a id="empty-link" href="#"></a><span id="hidden-card" style="display:contents"><div style="display:none">x</div></span>'
89+
document.body.appendChild(wrapper)
90+
})
91+
await I.seeElement('#card-link')
92+
await I.waitForVisible('#card-link', 1)
93+
await I.dontSeeElement('#empty-link')
94+
await I.dontSeeElement('#hidden-card')
95+
})
96+
97+
it('clicks with the pointer and mouse events a real click fires', async () => {
98+
await I.amOnPage('/')
99+
await I.executeScript(() => {
100+
const wrapper = document.createElement('div')
101+
wrapper.innerHTML = '<div id="mousedown-trigger" tabindex="0" style="height:20px">open</div><div id="mousedown-menu" style="display:none">menu</div><button id="disabled-btn" disabled>off</button>'
102+
document.body.appendChild(wrapper)
103+
window.__clickEvents = []
104+
const trigger = document.getElementById('mousedown-trigger')
105+
for (const type of ['pointerdown', 'mousedown', 'focus', 'pointerup', 'mouseup', 'click']) {
106+
trigger.addEventListener(type, () => window.__clickEvents.push(type))
107+
}
108+
trigger.addEventListener('mousedown', () => {
109+
document.getElementById('mousedown-menu').style.display = 'block'
110+
})
111+
document.getElementById('disabled-btn').addEventListener('mousedown', () => window.__clickEvents.push('disabled-mousedown'))
112+
})
113+
await I.click('#mousedown-trigger')
114+
await I.forceClick('#disabled-btn')
115+
const events = await I.executeScript(() => window.__clickEvents)
116+
expect(events.filter(e => e !== 'pointerdown' && e !== 'pointerup')).to.deep.equal(['mousedown', 'focus', 'mouseup', 'click'])
117+
await I.seeElement('#mousedown-menu')
118+
})
119+
120+
it('clicks the control a wrapper element covers, like a real click lands', async function () {
121+
await I.amOnPage('/')
122+
if (I.capabilities.layout !== 'real') this.skip()
123+
await I.executeScript(() => {
124+
const wrapper = document.createElement('div')
125+
wrapper.innerHTML =
126+
'<ul><li id="menu-item"><button type="button" id="menu-button" style="display:block;width:100%;height:30px"><span>New suite</span></button></li></ul><div id="opened" style="display:none">opened</div>'
127+
document.body.prepend(wrapper)
128+
document.getElementById('menu-button').addEventListener('click', e => {
129+
if (e.target.closest('#menu-button')) document.getElementById('opened').style.display = 'block'
130+
})
131+
})
132+
await I.click('#menu-item')
133+
await I.seeElement('#opened')
134+
})
135+
136+
it('records traffic after the response finished loading', async () => {
137+
await I.amOnPage('/')
138+
await I.startRecordingTraffic()
139+
await I.amOnPage('/info')
140+
await I.seeTraffic({ name: 'info page', url: '/info', timeout: 5 })
141+
const traffic = await I.grabRecordedNetworkTraffics()
142+
const info = traffic.find(t => t.url.endsWith('/info'))
143+
expect(info && info.response.status).to.equal(200)
144+
I.stopRecordingTraffic()
145+
I.recordedAtLeastOnce = false
146+
})
147+
83148
it('skips screenshots without failing when there is no rendering engine', async function () {
84149
await I.amOnPage('/')
85150
if (I.capabilities.screenshot !== false) this.skip() // this binary can render; saveScreenshot writes a file instead

‎test/helper/Playwright_test.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,25 @@ describe('Playwright', function () {
317317
})
318318
})
319319

320+
describe('#waitForVisible #waitForInvisible - role locators', () => {
321+
it('should wait for a native button by its role and accessible name', async () => {
322+
await I.amOnPage('/form/example1')
323+
await I.waitForVisible({ role: 'button', text: 'Login', exact: true }, 2)
324+
await I.waitForElement({ role: 'button', text: 'Log' }, 2)
325+
await I.waitForInvisible({ role: 'button', text: 'Logout' }, 2)
326+
})
327+
328+
it('should not match a partial name when exact is set', async () => {
329+
await I.amOnPage('/form/example1')
330+
try {
331+
await I.waitForVisible({ role: 'button', text: 'Log', exact: true }, 1)
332+
throw new Error('should have thrown')
333+
} catch (err) {
334+
expect(err.message).to.include('still not visible')
335+
}
336+
})
337+
})
338+
320339
describe('#waitForVisible #waitForInvisible - within block', () => {
321340
it('should wait for visible element', async () => {
322341
await I.amOnPage('/iframe')
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect } from 'chai'
2+
import event from '../../../lib/event.js'
3+
import store from '../../../lib/store.js'
4+
import globalTimeoutListener from '../../../lib/listener/globalTimeout.js'
5+
import { AfterSuiteHook } from '../../../lib/mocha/hooks.js'
6+
7+
describe('globalTimeout listener - suite hooks', () => {
8+
let prevTimeouts
9+
10+
const removeHookListeners = () => {
11+
for (const name of Object.values(event.hook)) event.dispatcher.removeAllListeners(name)
12+
}
13+
14+
beforeEach(() => {
15+
prevTimeouts = store.timeouts
16+
store.timeouts = true
17+
event.cleanDispatcher()
18+
removeHookListeners()
19+
globalTimeoutListener()
20+
})
21+
22+
afterEach(() => {
23+
store.timeouts = prevTimeouts
24+
event.cleanDispatcher()
25+
removeHookListeners()
26+
})
27+
28+
it('ignores hook.finished for a suite hook that never fired hook.started', () => {
29+
const hook = new AfterSuiteHook({ suite: { title: 'suite' } })
30+
expect(() => event.dispatcher.emit(event.hook.finished, hook)).not.to.throw()
31+
})
32+
33+
it('restores saved timeouts only once per started suite hook', () => {
34+
const hook = new AfterSuiteHook({ suite: { title: 'suite' } })
35+
event.dispatcher.emit(event.hook.started, hook)
36+
event.dispatcher.emit(event.hook.finished, hook)
37+
expect(() => event.dispatcher.emit(event.hook.finished, hook)).not.to.throw()
38+
})
39+
})

0 commit comments

Comments
 (0)