From 92a22f14fb9b6dc0c9825bb18ee0268a60bd2f12 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 00:53:39 +0300 Subject: [PATCH 01/47] fix(Puppeteer): tolerate missing bringToFront, refresh context on framenavigated, read innerText via evaluate Prevents runner hang when CDP endpoint lacks Page.bringToFront (startup aborted before isRunning=true, teardown skipped, open socket kept node alive). Context also refreshes on framenavigated for endpoints that do not emit Page.loadEventFired on click navigations. Text extraction via evaluate avoids getProperty remote-object round-trip. Co-Authored-By: Claude Fable 5 --- lib/helper/Puppeteer.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index ff00f6dd8..63648c5d5 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -560,7 +560,7 @@ class Puppeteer extends Helper { page.setDefaultNavigationTimeout(this.options.getPageTimeout) this.context = await this.page.$('body') if (this.options.browser === 'chrome') { - await page.bringToFront() + await page.bringToFront().catch(err => this.debugSection('Page', `bringToFront not supported: ${err.message}`)) } } @@ -1991,7 +1991,7 @@ class Puppeteer extends Helper { const els = await this._locate(locator) const texts = [] for (const el of els) { - texts.push(await (await el.getProperty('innerText')).jsonValue()) + texts.push(await el.evaluate(node => node.innerText)) } return texts } @@ -3159,14 +3159,14 @@ async function proceedSee(assertType, text, context, strict = false) { el = await this.context.$('body') } - allText = [await el.getProperty('innerText').then(p => p.jsonValue())] + allText = [await el.evaluate(node => node.innerText)] description = 'web application' } else { const locator = new Locator(context, 'css') description = `element ${locator.toString()}` const els = await this._locate(locator) assertElementExists(els, locator.toString()) - allText = await Promise.all(els.map(el => el.getProperty('innerText').then(p => p.jsonValue()))) + allText = await Promise.all(els.map(el => el.evaluate(node => node.innerText))) } if (store?.currentStep?.opts?.ignoreCase === true) { @@ -3456,6 +3456,14 @@ async function targetCreatedHandler(page) { .catch(() => null) .then(context => (this.context = context)) }) + page.on('framenavigated', frame => { + if (frame.parentFrame()) return + if (this.withinLocator) return + page + .$('body') + .catch(() => null) + .then(context => (this.context = context)) + }) page.on('console', msg => { this.debugSection(`Browser:${ucfirst(msg.type())}`, (msg._text || '') + msg.args().join(' ')) consoleLogStore.add(msg) From f8c34895a2b4fda336462022f34abbb8a16bfc51 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 00:59:23 +0300 Subject: [PATCH 02/47] feat(CDPBrowser): add raw CDP WebSocket transport Co-Authored-By: Claude Fable 5 --- lib/helper/extras/CDPConnection.js | 79 +++++++++++++++++++++++++++++ package.json | 1 + test/unit/cdpConnection_test.js | 81 ++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 lib/helper/extras/CDPConnection.js create mode 100644 test/unit/cdpConnection_test.js diff --git a/lib/helper/extras/CDPConnection.js b/lib/helper/extras/CDPConnection.js new file mode 100644 index 000000000..500ec3265 --- /dev/null +++ b/lib/helper/extras/CDPConnection.js @@ -0,0 +1,79 @@ +import { WebSocket } from 'ws' + +class CDPConnection { + constructor(endpoint, options = {}) { + this.endpoint = endpoint + this.headers = options.headers || {} + this.timeout = options.timeout || 10000 + this.ws = null + this.lastId = 0 + this.pending = new Map() + this.listeners = new Map() + } + + async connect() { + await new Promise((resolve, reject) => { + this.ws = new WebSocket(this.endpoint, { headers: this.headers }) + this.ws.once('open', resolve) + this.ws.once('error', reject) + }) + this.ws.on('message', raw => this._onMessage(JSON.parse(raw.toString()))) + this.ws.on('close', () => { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer) + reject(new Error('CDP connection closed')) + } + this.pending.clear() + }) + return this + } + + get isConnected() { + return !!this.ws && this.ws.readyState === WebSocket.OPEN + } + + send(method, params = {}, sessionId = undefined) { + const id = ++this.lastId + const message = { id, method, params } + if (sessionId) message.sessionId = sessionId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`CDP command ${method} timed out after ${this.timeout}ms`)) + }, this.timeout) + this.pending.set(id, { resolve, reject, timer }) + this.ws.send(JSON.stringify(message)) + }) + } + + on(method, fn) { + if (!this.listeners.has(method)) this.listeners.set(method, []) + this.listeners.get(method).push(fn) + } + + _onMessage(msg) { + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject, timer } = this.pending.get(msg.id) + clearTimeout(timer) + this.pending.delete(msg.id) + if (msg.error) reject(new Error(msg.error.message)) + else resolve(msg.result) + return + } + if (msg.method && this.listeners.has(msg.method)) { + for (const fn of this.listeners.get(msg.method)) fn(msg.params, msg.sessionId) + } + } + + async close() { + if (!this.ws) return + await new Promise(resolve => { + if (this.ws.readyState === WebSocket.CLOSED) return resolve() + this.ws.once('close', resolve) + this.ws.close() + }) + this.ws = null + } +} + +export default CDPConnection diff --git a/package.json b/package.json index 6a7ee9763..2128bae92 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "promise-retry": "1.1.1", "sprintf-js": "1.1.3", "uuid": "11.1.0", + "ws": "^8.21.2", "xpath": "0.0.34", "zod": "^4.1.11" }, diff --git a/test/unit/cdpConnection_test.js b/test/unit/cdpConnection_test.js new file mode 100644 index 000000000..e9b112510 --- /dev/null +++ b/test/unit/cdpConnection_test.js @@ -0,0 +1,81 @@ +import { expect } from 'chai' +import { WebSocketServer } from 'ws' +import CDPConnection from '../../lib/helper/extras/CDPConnection.js' + +describe('CDPConnection', () => { + let server + let port + + before(done => { + server = new WebSocketServer({ port: 0 }, () => { + port = server.address().port + done() + }) + server.on('connection', ws => { + ws.on('message', raw => { + const msg = JSON.parse(raw.toString()) + if (msg.method === 'Test.echo') { + ws.send(JSON.stringify({ id: msg.id, result: { echo: msg.params.value }, sessionId: msg.sessionId })) + } + if (msg.method === 'Test.fail') { + ws.send(JSON.stringify({ id: msg.id, error: { message: 'boom' } })) + } + if (msg.method === 'Test.event') { + ws.send(JSON.stringify({ method: 'Custom.fired', params: { ok: true }, sessionId: 's1' })) + ws.send(JSON.stringify({ id: msg.id, result: {} })) + } + }) + }) + }) + + after(() => server.close()) + + it('sends a command and resolves with result', async () => { + const cdp = await new CDPConnection(`ws://127.0.0.1:${port}`).connect() + const res = await cdp.send('Test.echo', { value: 42 }, 'sess-1') + expect(res.echo).to.equal(42) + await cdp.close() + }) + + it('rejects on CDP error response', async () => { + const cdp = await new CDPConnection(`ws://127.0.0.1:${port}`).connect() + try { + await cdp.send('Test.fail') + throw new Error('should have rejected') + } catch (err) { + expect(err.message).to.equal('boom') + } + await cdp.close() + }) + + it('dispatches events to listeners with sessionId', async () => { + const cdp = await new CDPConnection(`ws://127.0.0.1:${port}`).connect() + const fired = new Promise(resolve => cdp.on('Custom.fired', (params, sessionId) => resolve({ params, sessionId }))) + await cdp.send('Test.event') + const ev = await fired + expect(ev.params.ok).to.equal(true) + expect(ev.sessionId).to.equal('s1') + await cdp.close() + }) + + it('rejects pending commands when connection closes', async () => { + const cdp = await new CDPConnection(`ws://127.0.0.1:${port}`).connect() + const pending = cdp.send('Test.never') + await cdp.close() + try { + await pending + throw new Error('should have rejected') + } catch (err) { + expect(err.message).to.match(/closed/) + } + }) + + it('reports isConnected correctly', async () => { + const cdp = new CDPConnection(`ws://127.0.0.1:${port}`) + expect(cdp.isConnected).to.equal(false) + await cdp.connect() + expect(cdp.isConnected).to.equal(true) + await cdp.close() + expect(cdp.isConnected).to.equal(false) + }) +}) From 87ae3baa28ed9b82e31f5dca0dd7f0dc2fcd70cb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 01:07:35 +0300 Subject: [PATCH 03/47] fix(CDPConnection): guard message/listener dispatch and send guard - Wrap JSON.parse and listener invocations in try/catch to prevent uncaught exceptions from crashing the process or preventing other listeners from firing - Guard send() to reject immediately if connection not open, preventing timer leaks when called before connect() or after close() - Add test cases covering both fixes Co-Authored-By: Claude Fable 5 --- lib/helper/extras/CDPConnection.js | 17 +++++++++++++++-- test/unit/cdpConnection_test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib/helper/extras/CDPConnection.js b/lib/helper/extras/CDPConnection.js index 500ec3265..0254afb53 100644 --- a/lib/helper/extras/CDPConnection.js +++ b/lib/helper/extras/CDPConnection.js @@ -17,7 +17,12 @@ class CDPConnection { this.ws.once('open', resolve) this.ws.once('error', reject) }) - this.ws.on('message', raw => this._onMessage(JSON.parse(raw.toString()))) + this.ws.on('message', raw => { + try { + this._onMessage(JSON.parse(raw.toString())) + } catch (err) { + } + }) this.ws.on('close', () => { for (const { reject, timer } of this.pending.values()) { clearTimeout(timer) @@ -33,6 +38,9 @@ class CDPConnection { } send(method, params = {}, sessionId = undefined) { + if (!this.isConnected) { + return Promise.reject(new Error(`CDP connection is not open (sending ${method})`)) + } const id = ++this.lastId const message = { id, method, params } if (sessionId) message.sessionId = sessionId @@ -61,7 +69,12 @@ class CDPConnection { return } if (msg.method && this.listeners.has(msg.method)) { - for (const fn of this.listeners.get(msg.method)) fn(msg.params, msg.sessionId) + for (const fn of this.listeners.get(msg.method)) { + try { + fn(msg.params, msg.sessionId) + } catch (err) { + } + } } } diff --git a/test/unit/cdpConnection_test.js b/test/unit/cdpConnection_test.js index e9b112510..ceee9c711 100644 --- a/test/unit/cdpConnection_test.js +++ b/test/unit/cdpConnection_test.js @@ -78,4 +78,32 @@ describe('CDPConnection', () => { await cdp.close() expect(cdp.isConnected).to.equal(false) }) + + it('throwing listener does not prevent other listeners from firing', async () => { + const cdp = await new CDPConnection(`ws://127.0.0.1:${port}`).connect() + const secondListenerFired = new Promise(resolve => { + cdp.on('Custom.fired', () => { + throw new Error('listener error') + }) + cdp.on('Custom.fired', (params) => { + resolve(params) + }) + }) + await cdp.send('Test.event') + const params = await secondListenerFired + expect(params.ok).to.equal(true) + const res = await cdp.send('Test.echo', { value: 99 }) + expect(res.echo).to.equal(99) + await cdp.close() + }) + + it('send on never-connected instance rejects immediately', async () => { + const cdp = new CDPConnection(`ws://127.0.0.1:${port}`) + try { + await cdp.send('Test.echo', { value: 42 }) + throw new Error('should have rejected') + } catch (err) { + expect(err.message).to.match(/not open/) + } + }) }) From f392751e263ad330919d2c017cc35570a18c02cb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 01:11:36 +0300 Subject: [PATCH 04/47] feat(CDPBrowser): in-page query/action client and XPath polyfill builder Co-Authored-By: Claude Fable 5 --- lib/helper/clientscripts/cdpBrowserClient.js | 103 +++++++++++++++++++ lib/helper/clientscripts/xpathPolyfill.js | 31 ++++++ test/unit/cdpClientScript_test.js | 19 ++++ 3 files changed, 153 insertions(+) create mode 100644 lib/helper/clientscripts/cdpBrowserClient.js create mode 100644 lib/helper/clientscripts/xpathPolyfill.js create mode 100644 test/unit/cdpClientScript_test.js diff --git a/lib/helper/clientscripts/cdpBrowserClient.js b/lib/helper/clientscripts/cdpBrowserClient.js new file mode 100644 index 000000000..8731007ba --- /dev/null +++ b/lib/helper/clientscripts/cdpBrowserClient.js @@ -0,0 +1,103 @@ +export default function installCodeceptClient() { + if (window.__codecept) return + const strategies = { + css: value => Array.from(document.querySelectorAll(value)), + xpath: value => { + const out = [] + const res = document.evaluate(value, document.body || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null) + for (let i = 0; i < res.snapshotLength; i++) out.push(res.snapshotItem(i)) + return out + }, + } + const find = candidates => { + for (const c of candidates) { + let els = [] + try { + els = strategies[c.type](c.value) + } catch (e) { + els = [] + } + if (els.length) return els + } + return [] + } + const fire = (el, type) => el.dispatchEvent(new Event(type, { bubbles: true })) + const actions = { + count: els => els.length, + texts: els => els.map(el => el.innerText !== undefined ? String(el.innerText) : String(el.textContent)), + values: els => els.map(el => String(el.value)), + attrs: (els, p) => els.map(el => el.getAttribute(p.name)), + html: els => els.map(el => el.outerHTML), + rect: els => { + const r = els[0].getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height } + }, + click: els => { + els[0].click() + return true + }, + fill: (els, p) => { + const el = els[0] + if (el.focus) el.focus() + el.value = p.value + fire(el, 'input') + fire(el, 'change') + return true + }, + append: (els, p) => { + const el = els[0] + el.value = String(el.value) + p.value + fire(el, 'input') + fire(el, 'change') + return true + }, + clear: els => { + const el = els[0] + el.value = '' + fire(el, 'input') + fire(el, 'change') + return true + }, + check: els => { + const el = els[0] + if (!el.checked) el.click() + if (!el.checked) { + el.checked = true + fire(el, 'change') + } + return el.checked === true + }, + uncheck: els => { + const el = els[0] + if (el.checked) el.click() + if (el.checked) { + el.checked = false + fire(el, 'change') + } + return el.checked === false + }, + select: (els, p) => { + const el = els[0] + const opts = Array.from(el.options || []) + const opt = opts.find(o => o.value === p.value || o.textContent.trim() === p.value) + if (!opt) return false + el.value = opt.value + fire(el, 'input') + fire(el, 'change') + return true + }, + checked: els => els[0].checked === true, + visibleCount: els => els.filter(el => { + const r = el.getBoundingClientRect() + const style = getComputedStyle(el) + return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' + }).length, + } + window.__codecept = { + run(candidates, action, payload) { + const els = find(candidates) + if (!els.length && action !== 'count') return { found: 0 } + return { found: els.length, result: actions[action](els, payload || {}) } + }, + } +} diff --git a/lib/helper/clientscripts/xpathPolyfill.js b/lib/helper/clientscripts/xpathPolyfill.js new file mode 100644 index 000000000..7924b554a --- /dev/null +++ b/lib/helper/clientscripts/xpathPolyfill.js @@ -0,0 +1,31 @@ +import { readFileSync } from 'fs' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) +let cached + +export default function xpathPolyfillSource() { + if (cached) return cached + const engine = readFileSync(require.resolve('xpath/xpath.js'), 'utf8') + cached = `(function(){ + if (window.__codeceptXPathPolyfill) return + window.__codeceptXPathPolyfill = true + var module = { exports: {} } + var exports = module.exports + ${engine} + var parse = module.exports.parse + document.evaluate = function(expr, ctx, resolver, type, res) { + var nodes = parse(expr).select({ node: ctx || document, isHtml: true }) + var i = 0 + return { + resultType: type, + snapshotLength: nodes.length, + snapshotItem: function(idx) { return idx < nodes.length ? nodes[idx] : null }, + iterateNext: function() { return i < nodes.length ? nodes[i++] : null }, + singleNodeValue: nodes.length ? nodes[0] : null, + booleanValue: nodes.length > 0, + } + } +})()` + return cached +} diff --git a/test/unit/cdpClientScript_test.js b/test/unit/cdpClientScript_test.js new file mode 100644 index 000000000..cf47063dc --- /dev/null +++ b/test/unit/cdpClientScript_test.js @@ -0,0 +1,19 @@ +import { expect } from 'chai' +import installCodeceptClient from '../../lib/helper/clientscripts/cdpBrowserClient.js' +import xpathPolyfillSource from '../../lib/helper/clientscripts/xpathPolyfill.js' + +describe('cdpBrowserClient', () => { + it('is injectable as a stringified IIFE', () => { + const src = `(${installCodeceptClient.toString()})()` + expect(src).to.include('window.__codecept') + expect(() => new Function(src)).to.not.throw() + }) + + it('xpath polyfill source is self-contained and parseable', () => { + const src = xpathPolyfillSource() + expect(src).to.include('isHtml: true') + expect(src).to.include('document.evaluate =') + expect(() => new Function(src)).to.not.throw() + expect(xpathPolyfillSource()).to.equal(src) + }) +}) From fe02a7722be50d24da3bc712a19c66f86f163d73 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 01:20:33 +0300 Subject: [PATCH 05/47] fix(CDPBrowser): visibleCount action must return result on zero matches Co-Authored-By: Claude Fable 5 --- lib/helper/clientscripts/cdpBrowserClient.js | 2 +- test/unit/cdpClientScript_test.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/helper/clientscripts/cdpBrowserClient.js b/lib/helper/clientscripts/cdpBrowserClient.js index 8731007ba..f68404060 100644 --- a/lib/helper/clientscripts/cdpBrowserClient.js +++ b/lib/helper/clientscripts/cdpBrowserClient.js @@ -96,7 +96,7 @@ export default function installCodeceptClient() { window.__codecept = { run(candidates, action, payload) { const els = find(candidates) - if (!els.length && action !== 'count') return { found: 0 } + if (!els.length && action !== 'count' && action !== 'visibleCount') return { found: 0 } return { found: els.length, result: actions[action](els, payload || {}) } }, } diff --git a/test/unit/cdpClientScript_test.js b/test/unit/cdpClientScript_test.js index cf47063dc..bd1c2bf6c 100644 --- a/test/unit/cdpClientScript_test.js +++ b/test/unit/cdpClientScript_test.js @@ -16,4 +16,21 @@ describe('cdpBrowserClient', () => { expect(() => new Function(src)).to.not.throw() expect(xpathPolyfillSource()).to.equal(src) }) + + it('run() returns numeric result for count and visibleCount on zero matches', () => { + const sandbox = { + window: {}, + document: { querySelectorAll: () => [] }, + getComputedStyle: () => ({}), + Array, + String, + Event: class {}, + } + const fn = new Function('window', 'document', 'getComputedStyle', 'Event', `(${installCodeceptClient.toString()})()`) + fn(sandbox.window, sandbox.document, sandbox.getComputedStyle, sandbox.Event) + const count = sandbox.window.__codecept.run([{ type: 'css', value: '#nope' }], 'count') + const visible = sandbox.window.__codecept.run([{ type: 'css', value: '#nope' }], 'visibleCount') + expect(count).to.deep.equal({ found: 0, result: 0 }) + expect(visible).to.deep.equal({ found: 0, result: 0 }) + }) }) From c358d2f86026fb1de30826e8e44dc4b38118b1ab Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 01:27:08 +0300 Subject: [PATCH 06/47] feat(CDPBrowser): core helper with raw-CDP lifecycle, navigation, evaluate Co-Authored-By: Claude Fable 5 --- lib/helper/CDPBrowser.js | 399 ++++++++++++++++++++++++++ test/helper/CDPBrowser_chrome_test.js | 54 ++++ 2 files changed, 453 insertions(+) create mode 100644 lib/helper/CDPBrowser.js create mode 100644 test/helper/CDPBrowser_chrome_test.js diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js new file mode 100644 index 000000000..4518ea179 --- /dev/null +++ b/lib/helper/CDPBrowser.js @@ -0,0 +1,399 @@ +import Helper from '@codeceptjs/helper' +import CDPConnection from './extras/CDPConnection.js' +import installCodeceptClient from './clientscripts/cdpBrowserClient.js' +import xpathPolyfillSource from './clientscripts/xpathPolyfill.js' + +/** + * ## Configuration + * + * This helper should be configured in codecept.conf.js + * + * @typedef CDPBrowserConfig + * @type {object} + * @prop {string} [url=http://localhost] - base url of website to be tested. + * @prop {string} [endpoint=http://127.0.0.1:9222] - Chrome DevTools Protocol endpoint. Either an `http(s)://` address exposing `/json/version` (from which the `webSocketDebuggerUrl` is resolved) or a raw `ws(s)://` debugger URL. + * @prop {object} [headers={}] - headers sent with the endpoint resolution request and the WebSocket handshake. Useful for authenticated remote browser providers. + * @prop {string} [input=auto] - how synthetic user actions (click, fill, etc.) are dispatched by helpers built on top of this class. `auto` picks `cdp` when a real layout engine is detected and `synthetic` otherwise; can be pinned to `cdp` or `synthetic`. + * @prop {string|boolean} [xpathPolyfill=auto] - whether to inject the bundled XPath polyfill before installing the in-page client. `auto` probes the page and only injects when `document.evaluate` is unavailable or broken; `true`/`false` force the behavior. + * @prop {object} [capabilities={}] - pre-seed detected browser capabilities (`layout`, `xpath`, `screenshot`) to skip runtime probing. Values set here are never overwritten by `_probeCapabilities`/`_ensureClient`. + * @prop {number} [waitForTimeout=5] - default wait* timeout in seconds, used by helpers built on top of this class. + * @prop {number} [waitForAction=100] - poll interval in milliseconds used while waiting for a condition (e.g. page ready state). + * @prop {number} [getPageTimeout=30] - maximum time in seconds to wait for a page to reach `readyState === 'complete'` after navigation or reload; also used as the CDP command timeout (in ms, x1000). + */ + +/** + * CDPBrowser drives a browser directly over the raw Chrome DevTools Protocol, without depending + * on Puppeteer, Playwright, or WebDriver. It opens its own WebSocket connection (via `CDPConnection`), + * creates and attaches to a fresh target per test, and evaluates expressions through `Runtime.evaluate`. + * + * It is intended as the minimal, dependency-light base class for helpers that only need navigation, + * script evaluation, and simple in-page element interaction (installed lazily through the + * `window.__codecept` client script). It does not launch a browser itself — point `endpoint` at an + * already-running Chrome (or any CDP-compatible browser) started with `--remote-debugging-port`. + * + * ## Example + * + * ```js + * // inside codecept.conf.js + * { + * helpers: { + * CDPBrowser: { + * url: 'http://localhost', + * endpoint: 'http://127.0.0.1:9222', + * } + * } + * } + * ``` + * + * + * + * ## Methods + */ +class CDPBrowser extends Helper { + /** + * @param {CDPBrowserConfig} config + */ + constructor(config) { + super(config) + this.options = { + url: 'http://localhost', + endpoint: 'http://127.0.0.1:9222', + headers: {}, + input: 'auto', + xpathPolyfill: 'auto', + capabilities: {}, + waitForTimeout: 5, + waitForAction: 100, + getPageTimeout: 30, + ...config, + } + this.cdp = null + this.sessionId = null + this.targetId = null + this.capabilities = { layout: null, xpath: null, screenshot: null, ...this.options.capabilities } + } + + /** + * No-op hook kept for interface parity with other browser helpers. Connecting to the CDP + * endpoint is deferred to `_before`, since a fresh target/session is opened per test. + */ + _init() {} + + /** + * Resolves `options.endpoint` to a raw WebSocket debugger URL. If the configured endpoint is + * an `http(s)://` address, this fetches `/json/version` from it and reads `webSocketDebuggerUrl` + * from the response, matching the discovery flow exposed by Chrome's `--remote-debugging-port`. + * A `ws(s)://` endpoint is returned unchanged. + * + * This is the subclass override point for helpers that connect through a different discovery + * mechanism (e.g. a cloud browser provider with its own session-creation API). + * + * @returns {Promise} a `ws(s)://` debugger URL ready to be passed to `CDPConnection`. + * @protected + */ + async _resolveEndpoint() { + let endpoint = this.options.endpoint + if (endpoint.startsWith('http')) { + const res = await fetch(`${endpoint.replace(/\/$/, '')}/json/version`, { headers: this.options.headers }) + const data = await res.json() + endpoint = data.webSocketDebuggerUrl + } + return endpoint + } + + /** + * Resolves the CDP endpoint and opens the underlying `CDPConnection`, storing it on `this.cdp`. + * + * @protected + */ + async _connect() { + const endpoint = await this._resolveEndpoint() + this.cdp = new CDPConnection(endpoint, { headers: this.options.headers, timeout: this.options.getPageTimeout * 1000 }) + await this.cdp.connect() + } + + /** + * Hook executed before each test. Ensures a live `CDPConnection` exists (connecting lazily on + * first use, and reconnecting if a previous connection was closed), then creates a fresh + * `about:blank` target and attaches to it with `Target.attachToTarget`, storing `this.targetId` + * and `this.sessionId`. `Page` and `Runtime` domains are enabled on the new session. + * + * @protected + */ + async _before() { + if (!this.cdp || !this.cdp.isConnected) await this._connect() + const { targetId } = await this.cdp.send('Target.createTarget', { url: 'about:blank' }) + this.targetId = targetId + const { sessionId } = await this.cdp.send('Target.attachToTarget', { targetId, flatten: true }) + this.sessionId = sessionId + await this.cdp.send('Page.enable', {}, this.sessionId).catch(() => null) + await this.cdp.send('Runtime.enable', {}, this.sessionId).catch(() => null) + } + + /** + * Hook executed after each test. Closes the target opened in `_before` via `Target.closeTarget` + * and clears `this.targetId`/`this.sessionId`. The underlying `CDPConnection` is left open so it + * can be reused by the next test. + * + * @protected + */ + async _after() { + if (!this.targetId) return + await this.cdp.send('Target.closeTarget', { targetId: this.targetId }).catch(() => null) + this.targetId = null + this.sessionId = null + } + + /** + * Hook executed after all tests are run. Closes the underlying `CDPConnection` (and its + * WebSocket) and clears `this.cdp`. Must leave no open sockets or pending timers behind, so the + * process can exit on its own. + * + * @protected + */ + async _finishTest() { + if (this.cdp) await this.cdp.close() + this.cdp = null + } + + /** + * Evaluates a JavaScript expression in the page attached to the current session via + * `Runtime.evaluate`, awaiting any returned promise and returning the value by reference + * (`returnByValue: true`). If the expression throws, the browser-side exception description + * (or fallback text) is re-thrown as a JS `Error`. + * + * @param {string} expression - a JavaScript expression (or IIFE) to run in the page context. + * @returns {Promise} the evaluated value, or `undefined` if the expression has no result. + * @protected + */ + async _evaluate(expression) { + const res = await this.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, this.sessionId) + if (res.exceptionDetails) { + const detail = res.exceptionDetails.exception ? res.exceptionDetails.exception.description : res.exceptionDetails.text + throw new Error(`Error in browser script: ${detail}`) + } + return res.result ? res.result.value : undefined + } + + /** + * Ensures the in-page client (`window.__codecept`, installed from `cdpBrowserClient.js`) is + * present on the current page, installing it (and the XPath polyfill, if needed) exactly once. + * Safe to call repeatedly; it is a no-op once the client is detected. + * + * @protected + */ + async _ensureClient() { + const installed = await this._evaluate(`typeof window.__codecept !== 'undefined'`) + if (installed) return + if (await this._needsXPathPolyfill()) { + await this._evaluate(xpathPolyfillSource()) + } + await this._evaluate(`(${installCodeceptClient.toString()})()`) + } + + /** + * Determines whether the bundled XPath polyfill must be injected before the in-page client is + * installed. Honors an explicit `options.xpathPolyfill` boolean; otherwise reuses a previously + * probed `capabilities.xpath`, or probes the page's native `document.evaluate` by resolving a + * throwaway XPath expression and caches the result on `capabilities.xpath` (`'native'` or + * `'polyfill'`). + * + * @returns {Promise} `true` if the polyfill should be injected. + * @protected + */ + async _needsXPathPolyfill() { + if (this.options.xpathPolyfill === true) return true + if (this.options.xpathPolyfill === false) return false + if (this.capabilities.xpath) return this.capabilities.xpath === 'polyfill' + const ok = await this._evaluate(`(function(){ + try { + var r = document.evaluate('//*[normalize-space(string(.)) != "\\u0000"]', document.body || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null) + return r.snapshotLength > 0 + } catch (e) { return false } + })()`) + this.capabilities.xpath = ok ? 'native' : 'polyfill' + return !ok + } + + /** + * Probes and caches capabilities that depend on the actual browser environment (currently + * `capabilities.layout`, detected via `getComputedStyle(document.documentElement).display`). + * Already-known capabilities (pre-seeded through `options.capabilities`, or probed on a prior + * page) are never re-probed. When `options.input` is `'auto'`, resolves it to `'cdp'` for a real + * layout engine or `'synthetic'` otherwise. + * + * @protected + */ + async _probeCapabilities() { + if (this.capabilities.layout === null) { + const display = await this._evaluate(`getComputedStyle(document.documentElement).display`) + this.capabilities.layout = display === '' ? 'none' : 'real' + } + if (this.options.input === 'auto') { + this.options.input = this.capabilities.layout === 'real' ? 'cdp' : 'synthetic' + } + } + + /** + * Ensures the in-page client is installed, then delegates a find-and-act call to + * `window.__codecept.run(candidates, action, payload)`. This is the primary extension point + * used by helpers built on top of this class for element queries and interactions. + * + * @param {Array<{type: 'css'|'xpath', value: string}>} candidates - locator strategies to try, in order, until one matches at least one element. + * @param {string} action - name of the action to run against the matched elements (e.g. `count`, `click`, `fill`). + * @param {object} [payload] - extra data the action needs (e.g. `{ value }` for `fill`). + * @returns {Promise<{found: number, result: any}>} number of matched elements and the action's result. + * @protected + */ + async _run(candidates, action, payload) { + await this._ensureClient() + return this._evaluate(`window.__codecept.run(${JSON.stringify(candidates)}, ${JSON.stringify(action)}, ${JSON.stringify(payload || null)})`) + } + + /** + * Repeatedly calls `fn` until it returns a truthy value or `timeoutSec` elapses, waiting + * `options.waitForAction` milliseconds between attempts. + * + * @param {() => Promise} fn - the condition to poll; should resolve to a truthy value once satisfied. + * @param {number} timeoutSec - maximum time to poll, in seconds. + * @param {string} message - error message used when the timeout is reached. + * @returns {Promise} the truthy value returned by `fn`. + * @throws {Error} with `message` if `timeoutSec` elapses without `fn` returning a truthy value. + * @protected + */ + async _poll(fn, timeoutSec, message) { + const deadline = Date.now() + timeoutSec * 1000 + while (Date.now() < deadline) { + const result = await fn() + if (result) return result + await new Promise(r => setTimeout(r, this.options.waitForAction)) + } + throw new Error(message) + } + + /** + * Resolves a path against `options.url`. Absolute URLs (matching `scheme://`) are returned + * unchanged; anything else is appended to `options.url` with its trailing slash stripped. + * + * @param {string} path - an absolute URL or a path relative to `options.url`. + * @returns {string} the resolved, absolute URL. + * @protected + */ + _url(path) { + if (/^\w+:\/\//.test(path)) return path + return this.options.url.replace(/\/$/, '') + path + } + + /** + * Opens a web page in the current session. + * + * ```js + * I.amOnPage('/'); // opens main page of website + * I.amOnPage('https://github.com'); // opens github + * I.amOnPage('/login'); // opens a login page + * ``` + * + * Navigates via `Page.navigate`, then waits (up to `options.getPageTimeout` seconds) for + * `document.readyState` to reach `'complete'`. Once loaded, capabilities are (re-)probed and + * the in-page client is (re-)installed, since navigation discards any previously injected script. + * + * @param {string} url - url path or global url. + * @returns {Promise} + */ + async amOnPage(url) { + await this.cdp.send('Page.navigate', { url: this._url(url) }, this.sessionId) + await this._poll( + () => this._evaluate(`document.readyState === 'complete'`).catch(() => false), + this.options.getPageTimeout, + `Page did not reach readyState complete in ${this.options.getPageTimeout}s`, + ) + await this._probeCapabilities() + await this._ensureClient() + } + + /** + * Reloads the current page. + * + * ```js + * I.refreshPage(); + * ``` + * + * Triggers `Page.reload` and waits (up to `options.getPageTimeout` seconds) for + * `document.readyState` to reach `'complete'`. + * + * @returns {Promise} + */ + async refreshPage() { + await this.cdp.send('Page.reload', {}, this.sessionId) + await this._poll( + () => this._evaluate(`document.readyState === 'complete'`).catch(() => false), + this.options.getPageTimeout, + `Page did not reload in ${this.options.getPageTimeout}s`, + ) + } + + /** + * Executes a JavaScript function in the browser context and returns its result. + * + * If a function is passed, it is serialized with `Function.prototype.toString()`, so it must + * not reference variables from the outer (Node.js) scope — pass any needed data as arguments + * instead. A string is evaluated as-is. + * + * ```js + * let title = await I.executeScript(() => document.title); + * let sum = await I.executeScript((a, b) => a + b, 2, 3); + * ``` + * + * If the function returns a promise, `executeScript` waits for it to resolve. + * + * @param {(string|function)} fn - a JavaScript function to be executed in the browser context, or a string expression. + * @param {...any} args - arguments to pass into the function. + * @returns {Promise} the value returned (or resolved) by the function. + */ + async executeScript(fn, ...args) { + const body = typeof fn === 'function' ? `(${fn.toString()})(...${JSON.stringify(args)})` : fn + return this._evaluate(body) + } + + /** + * Retrieves the page URL of the current page. + * + * ```js + * let url = await I.grabCurrentUrl(); + * console.log(`Current URL is [${url}]`); + * ``` + * + * @returns {Promise} current URL. + */ + async grabCurrentUrl() { + return this._evaluate('window.location.href') + } + + /** + * Retrieves a page title. + * + * ```js + * let title = await I.grabTitle(); + * ``` + * + * @returns {Promise} title of the page. + */ + async grabTitle() { + return this._evaluate('document.title') + } + + /** + * Retrieves the source code of the current page. + * + * ```js + * let pageSource = await I.grabSource(); + * ``` + * + * @returns {Promise} source code of the current page (the outer HTML of ``). + */ + async grabSource() { + return this._evaluate('document.documentElement.outerHTML') + } +} + +export default CDPBrowser diff --git a/test/helper/CDPBrowser_chrome_test.js b/test/helper/CDPBrowser_chrome_test.js new file mode 100644 index 000000000..7cf36c5ce --- /dev/null +++ b/test/helper/CDPBrowser_chrome_test.js @@ -0,0 +1,54 @@ +import { expect } from 'chai' +import { spawn } from 'child_process' +import puppeteer from 'puppeteer' +import CDPBrowser from '../../lib/helper/CDPBrowser.js' +import TestHelper from '../support/TestHelper.js' + +const siteUrl = TestHelper.siteUrl() +let chrome +let I + +describe('CDPBrowser (against Chrome)', function () { + this.timeout(30000) + + before(async () => { + chrome = spawn(puppeteer.executablePath(), ['--headless=new', '--remote-debugging-port=9333', '--no-sandbox', '--disable-gpu', 'about:blank'], { stdio: 'ignore' }) + await new Promise(r => setTimeout(r, 2000)) + I = new CDPBrowser({ url: siteUrl, endpoint: 'http://127.0.0.1:9333' }) + await I._init() + }) + + after(async () => { + await I._finishTest() + chrome.kill() + }) + + beforeEach(async () => I._before()) + afterEach(async () => I._after()) + + it('opens a page and grabs url/title/source', async () => { + await I.amOnPage('/') + expect(await I.grabCurrentUrl()).to.include(':8000') + expect(await I.grabTitle()).to.equal('TestEd Beta 2.0') + expect(await I.grabSource()).to.include(' { + await I.amOnPage('/') + const val = await I.executeScript((a, b) => a + b, 2, 3) + expect(val).to.equal(5) + }) + + it('probes capabilities on a real browser', async () => { + await I.amOnPage('/') + expect(I.capabilities.layout).to.equal('real') + expect(I.capabilities.xpath).to.equal('native') + }) + + it('resolves relative and absolute urls', async () => { + await I.amOnPage('/info') + expect(await I.grabCurrentUrl()).to.include('/info') + await I.amOnPage(`${siteUrl}/login`) + expect(await I.grabCurrentUrl()).to.include('/login') + }) +}) From 7140ea4c0eb6fadbdabcb70873c9d54d8db723d7 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 01:35:19 +0300 Subject: [PATCH 07/47] fix(CDPBrowser): use axios instead of global fetch for endpoint resolution Global fetch is unavailable on Node 16, which package.json's engines field still supports. Every other HTTP-calling helper (REST.js, GraphQL.js, ApiDataFactory.js) already depends on axios, so _resolveEndpoint now uses it instead of relying on a Node 18+ global. Co-Authored-By: Claude Fable 5 --- lib/helper/CDPBrowser.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 4518ea179..52cbe20e2 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -1,3 +1,4 @@ +import axios from 'axios' import Helper from '@codeceptjs/helper' import CDPConnection from './extras/CDPConnection.js' import installCodeceptClient from './clientscripts/cdpBrowserClient.js' @@ -94,9 +95,8 @@ class CDPBrowser extends Helper { async _resolveEndpoint() { let endpoint = this.options.endpoint if (endpoint.startsWith('http')) { - const res = await fetch(`${endpoint.replace(/\/$/, '')}/json/version`, { headers: this.options.headers }) - const data = await res.json() - endpoint = data.webSocketDebuggerUrl + const res = await axios.get(`${endpoint.replace(/\/$/, '')}/json/version`, { headers: this.options.headers }) + endpoint = res.data.webSocketDebuggerUrl } return endpoint } From 0f60a88da71cb82298ae1d0169a8f081d54406c4 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 01:48:32 +0300 Subject: [PATCH 08/47] feat(CDPBrowser): assertions and grabbers Co-Authored-By: Claude Fable 5 --- lib/helper/CDPBrowser.js | 377 ++++++++++++++++++++++++++ test/helper/CDPBrowser_chrome_test.js | 41 +++ 2 files changed, 418 insertions(+) diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 52cbe20e2..d3f60c6fa 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -3,6 +3,12 @@ import Helper from '@codeceptjs/helper' import CDPConnection from './extras/CDPConnection.js' import installCodeceptClient from './clientscripts/cdpBrowserClient.js' import xpathPolyfillSource from './clientscripts/xpathPolyfill.js' +import Locator from '../locator.js' +import { xpathLocator } from '../utils.js' +import ElementNotFound from './errors/ElementNotFound.js' +import { includes as stringIncludes } from '../assert/include.js' +import { empty } from '../assert/empty.js' +import { truth } from '../assert/truth.js' /** * ## Configuration @@ -394,6 +400,377 @@ class CDPBrowser extends Helper { async grabSource() { return this._evaluate('document.documentElement.outerHTML') } + + /** + * Builds the list of `{type, value}` candidates `_run` should try, in order, for a given + * locator and `kind`. A strict locator (CSS/XPath/object form) resolves to a single candidate. + * A fuzzy (plain-text) locator is expanded into a strategy-specific list of XPath expressions + * mirroring the click/field/checkbox matching used by other browser helpers (matching by + * visible text, label, name, placeholder, ARIA attributes, etc.), falling back to treating the + * raw text as a CSS selector. + * + * @param {CodeceptJS.LocatorOrString} locator - element located by CSS|XPath|strict locator, or plain fuzzy text. + * @param {'element'|'clickable'|'field'|'checkable'} [kind='element'] - matching strategy to use when `locator` is fuzzy. + * @returns {Array<{type: 'css'|'xpath', value: string}>} candidates to pass to `_run`. + * @protected + */ + _candidates(locator, kind = 'element') { + locator = new Locator(locator) + if (!locator.isFuzzy()) { + return [{ type: locator.isXPath() ? 'xpath' : 'css', value: locator.simplify() || locator.value }] + } + const literal = xpathLocator.literal(locator.value) + if (kind === 'clickable') { + return [ + { type: 'xpath', value: Locator.clickable.narrow(literal) }, + { type: 'xpath', value: Locator.clickable.wide(literal) }, + { type: 'xpath', value: Locator.clickable.self(literal) }, + { type: 'css', value: locator.value }, + ] + } + if (kind === 'field') { + return [ + { type: 'xpath', value: Locator.field.labelEquals(literal) }, + { type: 'xpath', value: Locator.field.labelContains(literal) }, + { type: 'xpath', value: Locator.field.byName(literal) }, + { type: 'css', value: locator.value }, + ] + } + if (kind === 'checkable') { + return [ + { type: 'xpath', value: Locator.checkable.byText(literal) }, + { type: 'xpath', value: Locator.checkable.byName(literal) }, + { type: 'css', value: locator.value }, + ] + } + return [{ type: 'css', value: locator.value }] + } + + /** + * Checks that a page contains a visible text. + * Use context parameter to narrow down the search. + * + * ```js + * I.see('Welcome'); // text welcome on a page + * I.see('Welcome', '.content'); // text inside .content div + * I.see('Register', {css: 'form.register'}); // use strict locator + * ``` + * + * @param {string} text expected on page. + * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS|Xpath|strict locator in which to search for text. + * @returns {Promise} + */ + async see(text, context = null) { + const source = context + ? (await this._run(this._candidates(context), 'texts')).result?.join(' | ') || '' + : await this._evaluate('document.body ? document.body.innerText : ""') + return stringIncludes(context ? `element ${new Locator(context).toString()}` : 'web page').assert(text, source) + } + + /** + * Opposite to `see`. Checks that a text is not present on a page. + * Use context parameter to narrow down the search. + * + * ```js + * I.dontSee('Login'); // assume we are already logged in. + * I.dontSee('Login', '.nav'); // no login inside .nav element + * ``` + * + * @param {string} text which is not present. + * @param {?CodeceptJS.LocatorOrString} [context=null] (optional) element located by CSS|XPath|strict locator in which to perform search. + * @returns {Promise} + */ + async dontSee(text, context = null) { + const source = context + ? (await this._run(this._candidates(context), 'texts')).result?.join(' | ') || '' + : await this._evaluate('document.body ? document.body.innerText : ""') + return stringIncludes(context ? `element ${new Locator(context).toString()}` : 'web page').negate(text, source) + } + + /** + * Checks that the current page contains the given string in its raw source code. + * + * ```js + * I.seeInSource('

Green eggs & ham

'); + * ``` + * + * @param {string} text value to check. + * @returns {Promise} + */ + async seeInSource(text) { + return stringIncludes('HTML source of a page').assert(text, await this.grabSource()) + } + + /** + * Checks that current url contains a provided fragment. + * + * ```js + * I.seeInCurrentUrl('/register'); // we are on registration page + * ``` + * + * @param {string} url a fragment to check + * @returns {Promise} + */ + async seeInCurrentUrl(url) { + return stringIncludes('url').assert(url, await this.grabCurrentUrl()) + } + + /** + * Checks that current url does not contain a provided fragment. + * + * @param {string} url value to check. + * @returns {Promise} + */ + async dontSeeInCurrentUrl(url) { + return stringIncludes('url').negate(url, await this.grabCurrentUrl()) + } + + /** + * Checks that title contains text. + * + * ```js + * I.seeInTitle('Home Page'); + * ``` + * + * @param {string} text text value to check. + * @returns {Promise} + */ + async seeInTitle(text) { + return stringIncludes('web page title').assert(text, await this.grabTitle()) + } + + /** + * Checks that a given Element is present in the DOM. + * Element is located by CSS or XPath. + * + * ```js + * I.seeElementInDOM('#modal'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator. + * @returns {Promise} + */ + async seeElementInDOM(locator) { + const { found } = await this._run(this._candidates(locator), 'count') + return empty(`elements of ${new Locator(locator).toString()}`).negate(found === 0 ? null : found) + } + + /** + * Opposite to `seeElementInDOM`. Checks that element is not on page. + * + * ```js + * I.dontSeeElementInDOM('.nav'); // checks that element is not on page visible or not + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator. + * @returns {Promise} + */ + async dontSeeElementInDOM(locator) { + const { found } = await this._run(this._candidates(locator), 'count') + return empty(`elements of ${new Locator(locator).toString()}`).assert(found === 0 ? null : found) + } + + /** + * Throws if the current page has no real layout engine (`capabilities.layout === 'none'`), + * used to guard visibility-dependent assertions that cannot be evaluated without one. + * + * @param {string} action - name of the calling assertion, used in the error message. + * @throws {Error} if the page has no layout engine. + * @protected + */ + _assertLayoutSupported(action) { + if (this.capabilities.layout === 'none') { + throw new Error(`${action} requires a layout engine which this browser does not provide. Use ${action}InDOM instead.`) + } + } + + /** + * Checks that a given Element is visible. + * Element is located by CSS or XPath. + * + * ```js + * I.seeElement('#modal'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator. + * @returns {Promise} + */ + async seeElement(locator) { + this._assertLayoutSupported('seeElement') + const visible = await this._run(this._candidates(locator), 'visibleCount') + return empty(`visible elements of ${new Locator(locator).toString()}`).negate(visible.result === 0 ? null : visible.result) + } + + /** + * Opposite to `seeElement`. Checks that element is not visible. + * + * ```js + * I.dontSeeElement('.modal'); // modal is not shown + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator. + * @returns {Promise} + */ + async dontSeeElement(locator) { + this._assertLayoutSupported('dontSeeElement') + const visible = await this._run(this._candidates(locator), 'visibleCount') + return empty(`visible elements of ${new Locator(locator).toString()}`).assert(visible.result === 0 || visible.result === undefined ? null : visible.result) + } + + /** + * Verifies that the specified checkbox is checked. + * + * ```js + * I.seeCheckboxIsChecked('Agree'); + * I.seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms + * I.seeCheckboxIsChecked({css: '#signup_form input[type=checkbox]'}); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator located by label|name|CSS|XPath|strict locator. + * @returns {Promise} + */ + async seeCheckboxIsChecked(locator) { + const res = await this._run(this._candidates(locator, 'checkable'), 'checked') + if (!res.found) throw new ElementNotFound(locator, 'Checkbox') + return truth(`checkbox ${new Locator(locator).toString()}`, 'to be checked').assert(res.result) + } + + /** + * Verifies that the specified checkbox is not checked. + * + * ```js + * I.dontSeeCheckboxIsChecked('#agree'); // located by ID + * I.dontSeeCheckboxIsChecked('I agree to terms'); // located by label + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator located by label|name|CSS|XPath|strict locator. + * @returns {Promise} + */ + async dontSeeCheckboxIsChecked(locator) { + const res = await this._run(this._candidates(locator, 'checkable'), 'checked') + if (!res.found) throw new ElementNotFound(locator, 'Checkbox') + return truth(`checkbox ${new Locator(locator).toString()}`, 'to be checked').negate(res.result) + } + + /** + * Retrieves a text from an element located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside async with `await`** operator. + * + * ```js + * let pin = await I.grabTextFrom('#pin'); + * ``` + * If multiple elements found returns first element. + * + * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator. + * @returns {Promise} text value + */ + async grabTextFrom(locator) { + const res = await this._run(this._candidates(locator), 'texts') + if (!res.found) throw new ElementNotFound(locator) + return res.result[0] + } + + /** + * Retrieves all texts from elements located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside async with `await`** operator. + * + * ```js + * let pins = await I.grabTextFromAll('#pin li'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator. + * @returns {Promise} array of text values + */ + async grabTextFromAll(locator) { + const res = await this._run(this._candidates(locator), 'texts') + return res.found ? res.result : [] + } + + /** + * Retrieves a value from a form element located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside async function with `await`** operator. + * If more than one element is found - value of first element is returned. + * + * ```js + * let email = await I.grabValueFrom('input[name=email]'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator. + * @returns {Promise} attribute value + */ + async grabValueFrom(locator) { + const res = await this._run(this._candidates(locator, 'field'), 'values') + if (!res.found) throw new ElementNotFound(locator, 'Field') + return res.result[0] + } + + /** + * Retrieves an array of values from fields located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside async function with `await`** operator. + * + * ```js + * let inputs = await I.grabValueFromAll('//form/input'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator. + * @returns {Promise} array of attribute values + */ + async grabValueFromAll(locator) { + const res = await this._run(this._candidates(locator, 'field'), 'values') + return res.found ? res.result : [] + } + + /** + * Retrieves an attribute from an element located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside async with `await`** operator. + * If more than one element is found - attribute of first element is returned. + * + * ```js + * let hint = await I.grabAttributeFrom('#tooltip', 'title'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator. + * @param {string} attr attribute name. + * @returns {Promise} attribute value + */ + async grabAttributeFrom(locator, attr) { + const res = await this._run(this._candidates(locator), 'attrs', { name: attr }) + if (!res.found) throw new ElementNotFound(locator) + return res.result[0] + } + + /** + * Retrieves an array of attributes from elements located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside async with `await`** operator. + * + * ```js + * let hints = await I.grabAttributeFromAll('.tooltip', 'title'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator. + * @param {string} attr attribute name. + * @returns {Promise} array of attribute values + */ + async grabAttributeFromAll(locator, attr) { + const res = await this._run(this._candidates(locator), 'attrs', { name: attr }) + return res.found ? res.result : [] + } + + /** + * Grab number of elements by locator. + * Resumes test execution, so **should be used inside async function with `await`** operator. + * + * ```js + * let numOfElements = await I.grabNumberOfElements('p'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator. + * @returns {Promise} number of matched elements. + */ + async grabNumberOfElements(locator) { + const { found } = await this._run(this._candidates(locator), 'count') + return found + } } export default CDPBrowser diff --git a/test/helper/CDPBrowser_chrome_test.js b/test/helper/CDPBrowser_chrome_test.js index 7cf36c5ce..9e7ee551c 100644 --- a/test/helper/CDPBrowser_chrome_test.js +++ b/test/helper/CDPBrowser_chrome_test.js @@ -51,4 +51,45 @@ describe('CDPBrowser (against Chrome)', function () { await I.amOnPage(`${siteUrl}/login`) expect(await I.grabCurrentUrl()).to.include('/login') }) + + it('see / dontSee against page text', async () => { + await I.amOnPage('/') + await I.see('Welcome to test app!') + await I.dontSee('text that is not on the page') + try { + await I.see('text that is not on the page') + throw new Error('should have thrown') + } catch (err) { + expect(err.expected).to.equal('text that is not on the page') + } + }) + + it('seeElementInDOM and grabNumberOfElements', async () => { + await I.amOnPage('/') + await I.seeElementInDOM('#area1') + await I.dontSeeElementInDOM('#no-such-element') + expect(await I.grabNumberOfElements('#area1 a')).to.equal(1) + }) + + it('seeElement respects visibility on real-layout browsers', async () => { + await I.amOnPage('/form/field') + await I.seeElement('#name') + await I.dontSeeElement('#email') + }) + + it('grabs text, value, attribute', async () => { + await I.amOnPage('/form/field') + expect(await I.grabTextFrom({ css: 'label' })).to.equal('Name') + expect(await I.grabValueFrom('#name')).to.equal('OLD_VALUE') + expect(await I.grabAttributeFrom('#name', 'type')).to.equal('text') + expect(await I.grabTextFromAll('label')).to.be.an('array') + }) + + it('url and title assertions', async () => { + await I.amOnPage('/info') + await I.seeInCurrentUrl('/info') + await I.dontSeeInCurrentUrl('/form') + await I.amOnPage('/') + await I.seeInTitle('TestEd') + }) }) From 8cd1342b635639723a86725aee9c4b63748a454b Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 7 Aug 2026 02:04:23 +0300 Subject: [PATCH 09/47] feat(CDPBrowser): interactions, waits, cookies, screenshots Co-Authored-By: Claude Fable 5 --- lib/helper/CDPBrowser.js | 420 ++++++++++++++++++++++++++ test/helper/CDPBrowser_chrome_test.js | 60 ++++ 2 files changed, 480 insertions(+) diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index d3f60c6fa..9d27b813b 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -1,3 +1,5 @@ +import path from 'path' +import fs from 'fs' import axios from 'axios' import Helper from '@codeceptjs/helper' import CDPConnection from './extras/CDPConnection.js' @@ -771,6 +773,424 @@ class CDPBrowser extends Helper { const { found } = await this._run(this._candidates(locator), 'count') return found } + + /** + * Perform a click on a link or a button, given by a locator. + * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. + * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. + * For images, the "alt" attribute and inner text of any parent links are searched. + * + * When `options.input` is `'cdp'`, the click is dispatched as real `Input.dispatchMouseEvent` mouse events + * (`mouseMoved` / `mousePressed` / `mouseReleased`) at the center of the element's bounding box, so it + * exercises the same input pipeline a real user would. Otherwise it delegates to `forceClick`. + * + * ```js + * // simple link + * I.click('Logout'); + * // button of form + * I.click('Submit'); + * // CSS button + * I.click('#form input[type=submit]'); + * // XPath + * I.click('//form/*[@type=submit]'); + * // using strict locator + * I.click({css: 'nav a.login'}); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. + * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. + * @returns {Promise} + */ + async click(locator, context = null) { + if (this.options.input !== 'cdp') return this.forceClick(locator, context) + const candidates = this._candidates(locator, 'clickable') + const res = await this._run(candidates, 'rect') + if (!res.found) throw new ElementNotFound(locator, 'Clickable element') + const x = Math.round(res.result.x + res.result.width / 2) + const y = Math.round(res.result.y + res.result.height / 2) + await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, this.sessionId) + await this.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, this.sessionId) + await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, this.sessionId) + return this._waitForAction() + } + + /** + * Perform an emulated click on a link or a button, given by a locator. + * Unlike `click`, this always dispatches a synthetic in-page `el.click()` instead of sending native + * CDP input events. This works on hidden, animated or inactive elements as well. + * + * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. + * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. + * For images, the "alt" attribute and inner text of any parent links are searched. + * + * ```js + * // simple link + * I.forceClick('Logout'); + * // button of form + * I.forceClick('Submit'); + * // CSS button + * I.forceClick('#form input[type=submit]'); + * // XPath + * I.forceClick('//form/*[@type=submit]'); + * // using strict locator + * I.forceClick({css: 'nav a.login'}); + * ``` + * + * @param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. + * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. + * @returns {Promise} + */ + async forceClick(locator, context = null) { + const res = await this._run(this._candidates(locator, 'clickable'), 'click') + if (!res.found) throw new ElementNotFound(locator, 'Clickable element') + return this._waitForAction() + } + + /** + * Waits `options.waitForAction` milliseconds after an interaction, mirroring the pacing pause + * other browser helpers apply between actions. + * + * @returns {Promise} + * @protected + */ + async _waitForAction() { + return new Promise(r => setTimeout(r, this.options.waitForAction)) + } + + /** + * Fills a text field or textarea, after clearing its value, with the given string. + * Field is located by name, label, CSS, or XPath. + * + * ```js + * // by label + * I.fillField('Email', 'hello@world.com'); + * // by name + * I.fillField('password', secret('123456')); + * // by CSS + * I.fillField('form#login input[name=username]', 'John'); + * // or by strict locator + * I.fillField({css: 'form#login input[name=username]'}, 'John'); + * ``` + * + * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator. + * @param {CodeceptJS.StringOrSecret} value text value to fill. + * @returns {Promise} + */ + async fillField(field, value) { + const res = await this._run(this._candidates(field, 'field'), 'fill', { value: String(value) }) + if (!res.found) throw new ElementNotFound(field, 'Field') + } + + /** + * Appends text to a input field or textarea. + * Field is located by name, label, CSS or XPath + * + * ```js + * I.appendField('#myTextField', 'appended'); + * // typing secret + * I.appendField('password', secret('123456')); + * ``` + * + * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator + * @param {string} value text value to append. + * @returns {Promise} + */ + async appendField(field, value) { + const res = await this._run(this._candidates(field, 'field'), 'append', { value: String(value) }) + if (!res.found) throw new ElementNotFound(field, 'Field') + } + + /** + * Clears a `