codeceptjs 4.1.0 → 4.2.0-beta.2

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.
Files changed (49) hide show
  1. package/docs/advanced.md +1 -1
  2. package/docs/alternative-browsers.md +153 -0
  3. package/docs/basics.md +9 -1
  4. package/docs/configuration.md +2 -0
  5. package/docs/helpers/CDPBrowser.md +2138 -0
  6. package/docs/helpers/Kitesurf.md +118 -0
  7. package/docs/helpers/Obscura.md +210 -0
  8. package/docs/helpers/Playwright.md +5 -2
  9. package/docs/migration-4.md +3 -1
  10. package/docs/parallel.md +10 -0
  11. package/docs/plugins/screencast.md +18 -13
  12. package/docs/plugins.md +1 -1
  13. package/lib/command/info.js +11 -3
  14. package/lib/command/workers/runTests.js +14 -20
  15. package/lib/container.js +6 -0
  16. package/lib/data/context.js +7 -5
  17. package/lib/element/WebElement.js +5 -0
  18. package/lib/helper/Appium.js +14 -2
  19. package/lib/helper/CDPBrowser.js +3004 -0
  20. package/lib/helper/Kitesurf.js +139 -0
  21. package/lib/helper/Obscura.js +344 -0
  22. package/lib/helper/Playwright.js +41 -10
  23. package/lib/helper/Puppeteer.js +30 -7
  24. package/lib/helper/WebDriver.js +27 -6
  25. package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
  26. package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
  27. package/lib/helper/errors/MultipleElementsFound.js +60 -3
  28. package/lib/helper/extras/CDPConnection.js +92 -0
  29. package/lib/helper/extras/CDPElementHandle.js +27 -0
  30. package/lib/helper/extras/PlaywrightLocator.js +2 -2
  31. package/lib/helper/extras/apngAssembler.js +156 -0
  32. package/lib/html.js +9 -2
  33. package/lib/listener/retryEnhancer.js +2 -1
  34. package/lib/listener/steps.js +8 -0
  35. package/lib/locator.js +7 -2
  36. package/lib/mocha/asyncWrapper.js +7 -1
  37. package/lib/mocha/hooks.js +10 -0
  38. package/lib/parser.js +14 -2
  39. package/lib/plugin/junitReporter.js +17 -1
  40. package/lib/plugin/screencast.js +116 -24
  41. package/lib/step/base.js +15 -3
  42. package/lib/step/config.js +1 -0
  43. package/lib/store.js +6 -0
  44. package/lib/utils/loaderCheck.js +6 -0
  45. package/lib/utils.js +1 -1
  46. package/lib/workers.js +17 -0
  47. package/package.json +5 -2
  48. package/typings/promiseBasedTypes.d.ts +1835 -0
  49. package/typings/types.d.ts +1848 -0
@@ -0,0 +1,3004 @@
1
+ import path from 'path'
2
+ import fs from 'fs'
3
+ import axios from 'axios'
4
+ import Helper from '@codeceptjs/helper'
5
+ import CDPConnection from './extras/CDPConnection.js'
6
+ import installCodeceptClient from './clientscripts/cdpBrowserClient.js'
7
+ import xpathPolyfillSource from './clientscripts/xpathPolyfill.js'
8
+ import Locator from '../locator.js'
9
+ import store from '../store.js'
10
+ import { xpathLocator, normalizePath, resolveUrl, toCamelCase, convertCssPropertiesToCamelCase, normalizeSpacesInString, fileExists, base64EncodeFile, getMimeType } from '../utils.js'
11
+ import ElementNotFound from './errors/ElementNotFound.js'
12
+ import MultipleElementsFound from './errors/MultipleElementsFound.js'
13
+ import { includes as stringIncludes } from '../assert/include.js'
14
+ import { empty } from '../assert/empty.js'
15
+ import { truth } from '../assert/truth.js'
16
+ import { equals, urlEquals } from '../assert/equal.js'
17
+ import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
18
+ import WebElement from '../element/WebElement.js'
19
+ import CDPElementHandle from './extras/CDPElementHandle.js'
20
+ import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
21
+ import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, flushNetworkTraffics } from './network/actions.js'
22
+ import { assembleApng, isPng } from './extras/apngAssembler.js'
23
+
24
+ /**
25
+ * ## Configuration
26
+ *
27
+ * This helper should be configured in codecept.conf.js
28
+ *
29
+ * @typedef CDPBrowserConfig
30
+ * @type {object}
31
+ * @prop {string} [url=http://localhost] - base url of website to be tested.
32
+ * @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.
33
+ * @prop {object} [headers={}] - headers sent with the endpoint resolution request and the WebSocket handshake. Useful for authenticated remote browser providers.
34
+ * @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`.
35
+ * @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.
36
+ * @prop {object} [capabilities={}] - pre-seed detected browser capabilities (`layout`, `xpath`, `screenshot`, `innerText`) to skip runtime probing. Values set here are never overwritten by `_probeCapabilities`/`_ensureClient`.
37
+ * @prop {number} [waitForTimeout=5] - default wait* timeout in seconds, used by helpers built on top of this class.
38
+ * @prop {number} [waitForAction=100] - only takes effect when set explicitly: a literal fixed pacing sleep (in milliseconds) after click, type, or other interactions, mirroring other browser helpers. Left unset, actions settle in an event-aware way instead — near-instant when nothing navigates, waiting for the navigation to actually finish (not a guessed fixed delay) when one does.
39
+ * @prop {number} [pollInterval=25] - interval in milliseconds between retries while polling for a condition (e.g. page ready state, `waitFor*`). Distinct from `waitForAction`.
40
+ * @prop {number} [getPageTimeout=30] - maximum time in seconds to wait for a page to finish loading after navigation or reload; also used as the CDP command timeout (in ms, x1000).
41
+ * @prop {string} [waitForNavigation=load] - when to consider a navigation finished: `load`, `domcontentloaded`, or `networkidle`. Mirrors the Puppeteer helper's option name. `networkidle` waits for the CDP `networkIdle` lifecycle event, which on a busy page can lag `load` by a second or more — only opt in if the extra wait is actually needed.
42
+ */
43
+ const config = {}
44
+
45
+ // Maps a `waitForNavigation` value to the `Page.lifecycleEvent` name to wait for.
46
+ const LIFECYCLE_EVENT_BY_WAIT_UNTIL = {
47
+ load: 'load',
48
+ domcontentloaded: 'DOMContentLoaded',
49
+ networkidle: 'networkIdle',
50
+ }
51
+
52
+ // Maps a `waitForNavigation` value to the `document.readyState`-based fallback expression, used
53
+ // when no push-based lifecycle event arrives in time. `networkidle` has no `readyState`
54
+ // equivalent, so it falls back to the same check as `load`.
55
+ const READY_STATE_EXPR_BY_WAIT_UNTIL = {
56
+ load: `document.readyState === 'complete'`,
57
+ domcontentloaded: `document.readyState !== 'loading'`,
58
+ networkidle: `document.readyState === 'complete'`,
59
+ }
60
+
61
+ // How long `_waitForPageLoad` waits for the push-based lifecycle event alone before also starting
62
+ // the `document.readyState` poll as a fallback. Comfortably above both engines' observed
63
+ // lifecycle-event latency (Obscura: all events at once, tens of ms; Chrome: staggered, `load`
64
+ // within tens of ms even under real network load) — this keeps the common case free of any
65
+ // `_evaluate` calls competing with the page's own JavaScript during the load window, which is
66
+ // where they are most likely to queue behind a busy V8 isolate.
67
+ const PAGE_LOAD_GRACE_MS = 300
68
+
69
+ // How long `_waitForAction`'s event-aware settle waits, after an action, for a navigation to
70
+ // declare itself (via the `'init'` lifecycle event) before concluding none started. Confirmed via a
71
+ // raw probe (both engines) that a navigating action's `init` event fires within ~20ms; a
72
+ // non-navigating action never emits one at all, so this window is pure, bounded overhead on the
73
+ // (common) non-navigating case, not a guess at how long a real navigation takes.
74
+ const ACTION_SETTLE_GRACE_MS = 20
75
+
76
+ /**
77
+ * CDPBrowser drives a browser directly over the raw Chrome DevTools Protocol, without depending
78
+ * on Puppeteer, Playwright, or WebDriver. It opens its own WebSocket connection (via `CDPConnection`),
79
+ * creates and attaches to a fresh target per test, and evaluates expressions through `Runtime.evaluate`.
80
+ *
81
+ * It is intended as the minimal, dependency-light base class for helpers that only need navigation,
82
+ * script evaluation, and simple in-page element interaction (installed lazily through the
83
+ * `window.__codecept` client script). It does not launch a browser itself — point `endpoint` at an
84
+ * already-running Chrome (or any CDP-compatible browser) started with `--remote-debugging-port`.
85
+ *
86
+ * ## Example
87
+ *
88
+ * ```js
89
+ * // inside codecept.conf.js
90
+ * {
91
+ * helpers: {
92
+ * CDPBrowser: {
93
+ * url: 'http://localhost',
94
+ * endpoint: 'http://127.0.0.1:9222',
95
+ * }
96
+ * }
97
+ * }
98
+ * ```
99
+ *
100
+ * <!-- configuration -->
101
+ *
102
+ * ## Methods
103
+ */
104
+ class CDPBrowser extends Helper {
105
+ /**
106
+ * @param {CDPBrowserConfig} config
107
+ */
108
+ constructor(config) {
109
+ super(config)
110
+ this.options = {
111
+ url: 'http://localhost',
112
+ endpoint: 'http://127.0.0.1:9222',
113
+ headers: {},
114
+ input: 'auto',
115
+ xpathPolyfill: 'auto',
116
+ capabilities: {},
117
+ waitForTimeout: 5,
118
+ waitForAction: 100,
119
+ pollInterval: 25,
120
+ getPageTimeout: 30,
121
+ waitForNavigation: 'load',
122
+ ...config,
123
+ }
124
+ this.cdp = null
125
+ this.sessionId = null
126
+ this.targetId = null
127
+ this.capabilities = { layout: null, xpath: null, screenshot: null, innerText: null, ...this.options.capabilities }
128
+ this.withinCandidates = null
129
+ this.requests = []
130
+ this.recording = false
131
+ this.recordedAtLeastOnce = false
132
+ this._pendingTrafficResponses = new Map()
133
+ this._trafficListenersInstalled = false
134
+ this._screencastFrames = []
135
+ this._screencastActive = false
136
+ this._screencastListenerInstalled = false
137
+ this._lifecycleListenerInstalled = false
138
+ this._pageLoadWaiters = []
139
+ this._navStartWaiters = []
140
+ this._lastMainFrameNav = { loaderId: null, events: {} }
141
+ this._textCheckBootstrap = null
142
+ this._waitForActionExplicit = config?.waitForAction !== undefined
143
+ }
144
+
145
+ /**
146
+ * No-op hook kept for interface parity with other browser helpers. Connecting to the CDP
147
+ * endpoint is deferred to `_before`, since a fresh target/session is opened per test.
148
+ */
149
+ _init() {}
150
+
151
+ /**
152
+ * Resolves `options.endpoint` to a raw WebSocket debugger URL. If the configured endpoint is
153
+ * an `http(s)://` address, this fetches `/json/version` from it and reads `webSocketDebuggerUrl`
154
+ * from the response, matching the discovery flow exposed by Chrome's `--remote-debugging-port`.
155
+ * A `ws(s)://` endpoint is returned unchanged.
156
+ *
157
+ * This is the subclass override point for helpers that connect through a different discovery
158
+ * mechanism (e.g. a cloud browser provider with its own session-creation API).
159
+ *
160
+ * @returns {Promise<string>} a `ws(s)://` debugger URL ready to be passed to `CDPConnection`.
161
+ * @protected
162
+ */
163
+ async _resolveEndpoint() {
164
+ let endpoint = this.options.endpoint
165
+ if (endpoint.startsWith('http')) {
166
+ const res = await axios.get(`${endpoint.replace(/\/$/, '')}/json/version`, { headers: this.options.headers })
167
+ endpoint = res.data.webSocketDebuggerUrl
168
+ }
169
+ return endpoint
170
+ }
171
+
172
+ /**
173
+ * Resolves the CDP endpoint and opens the underlying `CDPConnection`, storing it on `this.cdp`.
174
+ *
175
+ * @protected
176
+ */
177
+ async _connect() {
178
+ const endpoint = await this._resolveEndpoint()
179
+ this.cdp = new CDPConnection(endpoint, { headers: this.options.headers, timeout: this.options.getPageTimeout * 1000 })
180
+ await this.cdp.connect()
181
+ }
182
+
183
+ /**
184
+ * Hook executed before each test. Ensures a live `CDPConnection` exists (connecting lazily on
185
+ * first use, and reconnecting if a previous connection was closed), then creates a fresh
186
+ * `about:blank` target and attaches to it with `Target.attachToTarget`, storing `this.targetId`
187
+ * and `this.sessionId`. `Page` and `Runtime` domains are enabled on the new session, and engine
188
+ * capabilities are probed here (against `about:blank`, uncontended) rather than only lazily on
189
+ * the first real page — see `_probeCapabilities`.
190
+ *
191
+ * Also resets `_navStartWaiters` and `_lastMainFrameNav`: both are scoped to a single
192
+ * `sessionId`/`targetId`, which are about to change, so anything left over from the previous test
193
+ * (e.g. an action-settle waiter still armed because its test threw between arming and settling)
194
+ * can never legitimately resolve against the new session — better to drop it here than leave it
195
+ * waiting for the rest of the run.
196
+ *
197
+ * @protected
198
+ */
199
+ async _before() {
200
+ if (!this.cdp || !this.cdp.isConnected) await this._connect()
201
+ this._navStartWaiters = []
202
+ this._lastMainFrameNav = { loaderId: null, events: {} }
203
+ const { targetId } = await this.cdp.send('Target.createTarget', { url: 'about:blank' })
204
+ this.targetId = targetId
205
+ const { sessionId } = await this.cdp.send('Target.attachToTarget', { targetId, flatten: true })
206
+ this.sessionId = sessionId
207
+ await this.cdp.send('Page.enable', {}, this.sessionId).catch(() => null)
208
+ await this.cdp.send('Runtime.enable', {}, this.sessionId).catch(() => null)
209
+ await this.cdp.send('Page.setLifecycleEventsEnabled', { enabled: true }, this.sessionId).catch(() => null)
210
+ await this._probeCapabilities()
211
+ }
212
+
213
+ /**
214
+ * Lazily installs a single, persistent `Page.lifecycleEvent` listener on the underlying
215
+ * `CDPConnection` and drains it into whichever `_waitForLoadEvent` calls are currently pending,
216
+ * matched by `loaderId`. Installed once per helper instance (the connection outlives individual
217
+ * tests), never removed — `CDPConnection` has no listener-removal API, so a single persistent
218
+ * dispatcher (rather than one listener per navigation) is what keeps this leak-free.
219
+ *
220
+ * Also, for the main frame (`params.frameId === this.targetId`, which holds for a page target's
221
+ * own top-level frame) only:
222
+ * - Drains `_navStartWaiters` (armed by `_armActionSettle`, before an action, for `_waitForAction`'s
223
+ * event-aware settle) on an `'init'` event — confirmed via a raw probe (against both Obscura and
224
+ * Chrome, through the actual CLI path) to be the earliest signal CDP emits when a new top-level
225
+ * navigation begins. Arming happens *before* the action is dispatched, not after: the same probe
226
+ * found `'init'` can arrive while the action's own CDP round trip is still in flight, sometimes
227
+ * only a millisecond or two after it started — a listener installed only once the action's
228
+ * promise resolves can already be too late, not merely unlucky.
229
+ * - Maintains `_lastMainFrameNav`, a rolling `{loaderId, events}` record of every lifecycle event
230
+ * name seen for the current main-frame navigation (reset whenever `loaderId` changes). On a
231
+ * fast/local navigation, the same raw probe found the *entire* sequence — `init` through
232
+ * `networkIdle` — arriving as one batch while the triggering action's own round trip was still
233
+ * in flight. Without this cache, `_waitForAction` would correctly detect that a navigation
234
+ * started, then arm a *fresh* wait for the `load` event specifically — which, in that common
235
+ * case, had already fired and will never fire again, paying the full grace-window-plus-poll cost
236
+ * of `_waitForPageLoad` on every single navigating action instead of settling immediately.
237
+ *
238
+ * @protected
239
+ */
240
+ _ensureLifecycleListener() {
241
+ if (this._lifecycleListenerInstalled) return
242
+ this._lifecycleListenerInstalled = true
243
+ this.cdp.on('Page.lifecycleEvent', (params, sessionId) => {
244
+ if (sessionId !== this.sessionId) return
245
+ this._pageLoadWaiters = this._pageLoadWaiters.filter(waiter => {
246
+ if (waiter.loaderId !== params.loaderId || waiter.eventName !== params.name) return true
247
+ waiter.resolve()
248
+ return false
249
+ })
250
+ if (params.frameId !== this.targetId) return
251
+ if (this._lastMainFrameNav.loaderId !== params.loaderId) {
252
+ this._lastMainFrameNav = { loaderId: params.loaderId, events: {} }
253
+ }
254
+ this._lastMainFrameNav.events[params.name] = true
255
+ if (params.name === 'init' && this._navStartWaiters.length) {
256
+ const waiters = this._navStartWaiters
257
+ this._navStartWaiters = []
258
+ waiters.forEach(w => w.resolve(params.loaderId))
259
+ }
260
+ })
261
+ }
262
+
263
+ /**
264
+ * Starts waiting for a `Page.lifecycleEvent` named `eventName` for the given `loaderId` on the
265
+ * current session. `loaderId` (from the `Page.navigate` response) discriminates the awaited
266
+ * navigation from any other in-flight or stale lifecycle events (e.g. the `about:blank` target
267
+ * created in `_before`), which is essential since Chrome emits the target's initial `about:blank`
268
+ * lifecycle sequence asynchronously, sometimes after this listener is already installed.
269
+ *
270
+ * Returns a `{promise, cancel}` pair rather than a bare promise: `_waitForPageLoad` races this
271
+ * against a readyState poll, and whichever side loses must be actively torn down (not just have
272
+ * its rejection swallowed) — an abandoned-but-still-pending wait would sit in `_pageLoadWaiters`
273
+ * for the full timeout on every single navigation, for no purpose.
274
+ *
275
+ * @param {string} loaderId - the loader id of the navigation to wait for, from `Page.navigate`'s response.
276
+ * @param {string} eventName - the `Page.lifecycleEvent` name to wait for (e.g. `load`, `DOMContentLoaded`, `networkIdle`).
277
+ * @param {number} timeoutSec - maximum time to wait, in seconds.
278
+ * @returns {{promise: Promise<void>, cancel: function}}
279
+ * @protected
280
+ */
281
+ _waitForLoadEvent(loaderId, eventName, timeoutSec) {
282
+ this._ensureLifecycleListener()
283
+ let waiter
284
+ const promise = new Promise((resolve, reject) => {
285
+ const timer = setTimeout(() => {
286
+ this._pageLoadWaiters = this._pageLoadWaiters.filter(w => w !== waiter)
287
+ reject(new Error('lifecycle load event timed out'))
288
+ }, timeoutSec * 1000)
289
+ waiter = {
290
+ loaderId,
291
+ eventName,
292
+ resolve: () => {
293
+ clearTimeout(timer)
294
+ resolve()
295
+ },
296
+ cancel: () => {
297
+ clearTimeout(timer)
298
+ this._pageLoadWaiters = this._pageLoadWaiters.filter(w => w !== waiter)
299
+ },
300
+ }
301
+ this._pageLoadWaiters.push(waiter)
302
+ })
303
+ return { promise, cancel: () => waiter.cancel() }
304
+ }
305
+
306
+ /**
307
+ * Arms the event-aware settle's navigation-start listener. Must be called *before* the action
308
+ * that might trigger a navigation is dispatched, not after — see `_ensureLifecycleListener` for
309
+ * why. Returns `null` when `options.waitForAction` was set explicitly, since `_waitForAction`
310
+ * ignores the armed listener entirely in that case (a literal fixed sleep, as before this round).
311
+ *
312
+ * No timeout here: `_waitForAction` applies the grace window itself, starting from when *it*
313
+ * runs (after the action's own dispatch already resolved), racing this already-armed listener
314
+ * against a fresh timer instead of one that started ticking before the action even began.
315
+ *
316
+ * @returns {({promise: Promise<string|null>, cancel: function}|null)}
317
+ * @protected
318
+ */
319
+ _armActionSettle() {
320
+ if (this._waitForActionExplicit) return null
321
+ this._ensureLifecycleListener()
322
+ let waiter
323
+ const promise = new Promise(resolve => {
324
+ waiter = { resolve }
325
+ this._navStartWaiters.push(waiter)
326
+ })
327
+ return { promise, cancel: () => { this._navStartWaiters = this._navStartWaiters.filter(w => w !== waiter) } }
328
+ }
329
+
330
+ /**
331
+ * Waits for a page to finish loading after `Page.navigate`/`Page.reload`, per `options.waitForNavigation`.
332
+ *
333
+ * Purely event-driven for the first `PAGE_LOAD_GRACE_MS`: only the push-based
334
+ * `Page.lifecycleEvent` signal (matched by `loaderId`) is awaited, issuing zero `_evaluate` calls
335
+ * — this matters because an `_evaluate` sent while the page's own JavaScript is still busy (e.g.
336
+ * a real-world page doing post-load hydration/analytics work) can queue behind it for hundreds of
337
+ * ms to multiple seconds, measured directly against a JS-heavy page. Only if the grace window
338
+ * elapses without the event (an engine that doesn't emit it, or a genuinely slow navigation) does
339
+ * the `document.readyState` poll (via `_poll`) start, racing the still-pending lifecycle wait —
340
+ * both bounded by the same `options.getPageTimeout`, so a lifecycle-less engine costs at most
341
+ * `PAGE_LOAD_GRACE_MS` more than the poll alone would have, never double the timeout. No
342
+ * `loaderId` (e.g. from `Page.reload`, which returns none) skips straight to the poll.
343
+ *
344
+ * Whichever side ultimately loses is actively cancelled, not merely abandoned — an abandoned poll
345
+ * or lifecycle wait would otherwise keep running (issuing readyState `_evaluate` calls every
346
+ * `pollInterval`, or holding a `_pageLoadWaiters` entry) for up to the full timeout on every
347
+ * navigation, competing for the same CDP connection with real work.
348
+ *
349
+ * @param {string|null} loaderId - loader id from the triggering `Page.navigate` response, if any.
350
+ * @param {string} timeoutMessage - error message used if the readyState poll times out.
351
+ * @returns {Promise<void>}
352
+ * @protected
353
+ */
354
+ async _waitForPageLoad(loaderId, timeoutMessage) {
355
+ const waitUntil = READY_STATE_EXPR_BY_WAIT_UNTIL[this.options.waitForNavigation] ? this.options.waitForNavigation : 'load'
356
+ const readyStateCheck = () => this._evaluate(READY_STATE_EXPR_BY_WAIT_UNTIL[waitUntil]).catch(() => false)
357
+ if (!loaderId) return this._poll(readyStateCheck, this.options.getPageTimeout, timeoutMessage)
358
+
359
+ const eventName = LIFECYCLE_EVENT_BY_WAIT_UNTIL[waitUntil]
360
+ const cancelToken = { cancelled: false }
361
+ const { promise: lifecyclePromise, cancel: cancelLifecycle } = this._waitForLoadEvent(loaderId, eventName, this.options.getPageTimeout)
362
+ let lifecycleLoaded = false
363
+ lifecyclePromise.then(
364
+ () => {
365
+ lifecycleLoaded = true
366
+ },
367
+ () => {},
368
+ )
369
+ let pollPromise = null
370
+ let graceTimer = null
371
+ const gracePromise = new Promise(resolve => {
372
+ graceTimer = setTimeout(resolve, PAGE_LOAD_GRACE_MS)
373
+ })
374
+ try {
375
+ await Promise.race([lifecyclePromise.catch(() => {}), gracePromise])
376
+ if (lifecycleLoaded) return
377
+ clearTimeout(graceTimer)
378
+ pollPromise = this._poll(readyStateCheck, this.options.getPageTimeout, timeoutMessage, cancelToken)
379
+ await Promise.race([lifecyclePromise, pollPromise])
380
+ } finally {
381
+ clearTimeout(graceTimer)
382
+ cancelToken.cancelled = true
383
+ cancelLifecycle()
384
+ lifecyclePromise.catch(() => {})
385
+ if (pollPromise) pollPromise.catch(() => {})
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Hook executed after each test. Closes the target opened in `_before` via `Target.closeTarget`
391
+ * and clears `this.targetId`/`this.sessionId`. The underlying `CDPConnection` is left open so it
392
+ * can be reused by the next test.
393
+ *
394
+ * @protected
395
+ */
396
+ async _after() {
397
+ if (!this.targetId) return
398
+ await this.cdp.send('Target.closeTarget', { targetId: this.targetId }).catch(() => null)
399
+ this.targetId = null
400
+ this.sessionId = null
401
+ }
402
+
403
+ /**
404
+ * Hook executed after all tests are run. Closes the underlying `CDPConnection` (and its
405
+ * WebSocket) and clears `this.cdp`. Must leave no open sockets or pending timers behind, so the
406
+ * process can exit on its own.
407
+ *
408
+ * @protected
409
+ */
410
+ async _finishTest() {
411
+ if (this.cdp) await this.cdp.close()
412
+ this.cdp = null
413
+ }
414
+
415
+ /**
416
+ * Evaluates a JavaScript expression in the page attached to the current session via
417
+ * `Runtime.evaluate`, awaiting any returned promise and returning the value by reference
418
+ * (`returnByValue: true`). If the expression throws, the browser-side exception description
419
+ * (or fallback text) is re-thrown as a JS `Error`.
420
+ *
421
+ * @param {string} expression - a JavaScript expression (or IIFE) to run in the page context.
422
+ * @returns {Promise<any>} the evaluated value, or `undefined` if the expression has no result.
423
+ * @protected
424
+ */
425
+ async _evaluate(expression) {
426
+ const res = await this.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, this.sessionId)
427
+ if (res.exceptionDetails) {
428
+ const detail = res.exceptionDetails.exception ? res.exceptionDetails.exception.description : res.exceptionDetails.text
429
+ throw new Error(`Error in browser script: ${detail}`)
430
+ }
431
+ return res.result ? res.result.value : undefined
432
+ }
433
+
434
+ /**
435
+ * Ensures the in-page client (`window.__codecept`, installed from `cdpBrowserClient.js`) is
436
+ * present on the current page, installing it (and the XPath polyfill, if needed) exactly once.
437
+ * Safe to call repeatedly; it is a no-op once the client is detected.
438
+ *
439
+ * @protected
440
+ */
441
+ async _ensureClient() {
442
+ const installed = await this._evaluate(`typeof window.__codecept !== 'undefined'`)
443
+ if (installed) return
444
+ await this._installClient()
445
+ }
446
+
447
+ /**
448
+ * Installs the in-page client unconditionally — no `typeof window.__codecept` presence check.
449
+ * Used by callers that already know, from a sentinel value returned alongside a failed action,
450
+ * that the client is missing on the current page, so re-checking would just be a redundant
451
+ * contended round trip.
452
+ *
453
+ * The 171KB XPath polyfill is only injected alongside the client when `needsXPath` is true (the
454
+ * default, for callers without candidate information) *and* the engine actually needs it
455
+ * (`capabilities.xpath === 'polyfill'`, cached from `_before`'s probe). Callers that know their
456
+ * candidates never resolve to an `xpath` strategy (e.g. `_runSelected`, once it has inspected
457
+ * `candidates`/`within`) can pass `false` to skip that inject — the client is still told, via the
458
+ * `xpathNeedsPolyfill` flag baked in at install time, that the *engine* will eventually need it,
459
+ * so a later call that does hit an xpath candidate gets a clean `'__NO_XPATH__'` miss signal
460
+ * (from `window.__codecept.run`) instead of silently falling through to a broken native
461
+ * `document.evaluate` — `_runSelected` reacts to that sentinel by injecting the polyfill and
462
+ * retrying once, mirroring the `'__NO_CLIENT__'` handling right next to it.
463
+ *
464
+ * @param {boolean} [needsXPath=true]
465
+ * @protected
466
+ */
467
+ async _installClient(needsXPath = true) {
468
+ const enginePolyfillNeeded = await this._needsXPathPolyfill()
469
+ if (needsXPath && enginePolyfillNeeded) {
470
+ await this._evaluate(xpathPolyfillSource())
471
+ }
472
+ await this._evaluate(`(${installCodeceptClient.toString()})(${JSON.stringify(enginePolyfillNeeded)})`)
473
+ }
474
+
475
+ /**
476
+ * Whether any candidate strategy — in `candidates` itself, or in any of the `within` scoping
477
+ * `layers` searched before it — is an `xpath` locator. Used to decide, before the client is even
478
+ * installed, whether the XPath polyfill needs to be bundled into that install or can be deferred.
479
+ *
480
+ * @param {?Array<{type: string, value: string}>} candidates
481
+ * @param {Array<Array<{type: string, value: string}>>} layers
482
+ * @returns {boolean}
483
+ * @protected
484
+ */
485
+ _candidatesNeedXPath(candidates, layers) {
486
+ const hasXPath = arr => Array.isArray(arr) && arr.some(c => c && c.type === 'xpath')
487
+ if (hasXPath(candidates)) return true
488
+ return layers.some(hasXPath)
489
+ }
490
+
491
+ /**
492
+ * Determines whether the bundled XPath polyfill must be injected before the in-page client is
493
+ * installed. Honors an explicit `options.xpathPolyfill` boolean; otherwise reuses a previously
494
+ * probed `capabilities.xpath`, or probes the page's native `document.evaluate`. The probe appends
495
+ * two throwaway elements distinguished only by text content and asserts that a text-value XPath
496
+ * predicate (`normalize-space(string(.))=...`, the basis of every fuzzy/clickable locator) resolves
497
+ * to exactly the matching one — merely checking that `document.evaluate` runs without throwing is
498
+ * not enough, since some engines execute a text-value predicate without actually filtering by it,
499
+ * silently returning every candidate node instead of none or one. The result is cached on
500
+ * `capabilities.xpath` (`'native'` or `'polyfill'`).
501
+ *
502
+ * @returns {Promise<boolean>} `true` if the polyfill should be injected.
503
+ * @protected
504
+ */
505
+ async _needsXPathPolyfill() {
506
+ if (this.options.xpathPolyfill === true) return true
507
+ if (this.options.xpathPolyfill === false) return false
508
+ if (this.capabilities.xpath) return this.capabilities.xpath === 'polyfill'
509
+ const ok = await this._evaluate(`(function(){
510
+ try {
511
+ var parent = document.body || document.documentElement
512
+ var probe = document.createElement('span')
513
+ probe.textContent = '\\u0001codecept-xpath-probe-match'
514
+ var decoy = document.createElement('span')
515
+ decoy.textContent = '\\u0001codecept-xpath-probe-nomatch'
516
+ parent.appendChild(probe)
517
+ parent.appendChild(decoy)
518
+ var r = document.evaluate(".//*[normalize-space(string(.))='\\u0001codecept-xpath-probe-match']", parent, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
519
+ var ok = r.snapshotLength === 1 && r.snapshotItem(0) === probe
520
+ probe.remove()
521
+ decoy.remove()
522
+ return ok
523
+ } catch (e) { return false }
524
+ })()`)
525
+ this.capabilities.xpath = ok ? 'native' : 'polyfill'
526
+ return !ok
527
+ }
528
+
529
+ /**
530
+ * Determines whether `see`/`dontSee`/`waitForText` should read whole-page text through the
531
+ * client's own visibility-aware `visibleText()` walker instead of the native
532
+ * `document.body.innerText`. Probes once per page by appending a `display:none` element and a
533
+ * `<script>` element, each with distinguishing text, and checking that native `innerText`
534
+ * excludes both — some engines return an `innerText` that does not honor computed visibility or
535
+ * exclude script/style content, even when `getComputedStyle`/layout are otherwise reliable. The
536
+ * result is cached on `capabilities.innerText` (`'native'` or `'computed'`).
537
+ *
538
+ * @returns {Promise<boolean>} `true` if the `visibleText()` fallback should be used.
539
+ * @protected
540
+ */
541
+ async _needsVisibleTextFallback() {
542
+ if (this.capabilities.innerText) return this.capabilities.innerText === 'computed'
543
+ const ok = await this._evaluate(`(function(){
544
+ try {
545
+ var parent = document.body || document.documentElement
546
+ var hidden = document.createElement('div')
547
+ hidden.style.display = 'none'
548
+ hidden.textContent = '\\u0001codecept-innertext-probe-hidden'
549
+ var script = document.createElement('script')
550
+ script.textContent = '/* \\u0001codecept-innertext-probe-script */'
551
+ parent.appendChild(hidden)
552
+ parent.appendChild(script)
553
+ var text = parent.innerText || ''
554
+ var ok = text.indexOf('codecept-innertext-probe-hidden') === -1 && text.indexOf('codecept-innertext-probe-script') === -1
555
+ hidden.remove()
556
+ script.remove()
557
+ return ok
558
+ } catch (e) { return false }
559
+ })()`)
560
+ this.capabilities.innerText = ok ? 'native' : 'computed'
561
+ return !ok
562
+ }
563
+
564
+ /**
565
+ * Probes and caches capabilities that depend on the actual browser *engine* rather than any
566
+ * particular page's content: `capabilities.layout` (via `getComputedStyle`), `capabilities.screenshot`
567
+ * (inferred from `layout`), `capabilities.xpath` (via `_needsXPathPolyfill`), and
568
+ * `capabilities.innerText` (via `_needsVisibleTextFallback`). Already-known capabilities
569
+ * (pre-seeded through `options.capabilities`, or probed earlier) are never re-probed — so, across
570
+ * a whole run, this issues a handful of `_evaluate` calls exactly once and is a no-op afterward.
571
+ *
572
+ * Called from `_before`, against the fresh `about:blank` target created there, specifically so
573
+ * these probes run before any real navigation — measured directly (a full stall ledger against
574
+ * `github.com`) that running them on the first *real* page instead can cost seconds each, since
575
+ * every one is an `_evaluate` competing with that page's own JavaScript for the V8 isolate.
576
+ * `about:blank` has no such competition. Also called (cheaply, already cached by then) from
577
+ * `amOnPage`, so a helper that skips `_before` for some reason still probes correctly.
578
+ *
579
+ * The `xpath`/`innerText` probes determine *whether* their respective fallback is needed; they
580
+ * do not install anything — injection stays deferred to `_ensureClient`'s reactive install and
581
+ * `_textSource`'s own read, matching `amOnPage` no longer eagerly installing the client.
582
+ *
583
+ * @protected
584
+ */
585
+ async _probeCapabilities() {
586
+ if (this.capabilities.layout === null) {
587
+ const display = await this._evaluate(`getComputedStyle(document.documentElement).display`)
588
+ this.capabilities.layout = display === '' ? 'none' : 'real'
589
+ }
590
+ if (this.capabilities.screenshot === null) {
591
+ this.capabilities.screenshot = this.capabilities.layout === 'real'
592
+ }
593
+ if (this.options.input === 'auto') {
594
+ this.options.input = this.capabilities.layout === 'real' ? 'cdp' : 'synthetic'
595
+ }
596
+ if (this.capabilities.xpath === null) {
597
+ await this._needsXPathPolyfill()
598
+ }
599
+ if (this.capabilities.innerText === null) {
600
+ await this._needsVisibleTextFallback()
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Delegates a find-and-act call to `window.__codecept.run(candidates, action, payload)`. This is
606
+ * the primary extension point used by helpers built on top of this class for element queries and
607
+ * interactions.
608
+ *
609
+ * A per-call `context` locator, when given, is resolved and layered on top of any active
610
+ * `within` block (searched inside it, not instead of it), so `context` narrows the search
611
+ * without breaking out of a surrounding `within`.
612
+ *
613
+ * @param {Array<{type: 'css'|'xpath', value: string}>} candidates - locator strategies to try, in order, until one matches at least one element.
614
+ * @param {string} action - name of the action to run against the matched elements (e.g. `count`, `click`, `fill`).
615
+ * @param {object} [payload] - extra data the action needs (e.g. `{ value }` for `fill`).
616
+ * @param {?CodeceptJS.LocatorOrString} [context=null] - element to search in, narrowing the candidates below it.
617
+ * @returns {Promise<{found: number, result: any}>} number of matched elements and the action's result.
618
+ * @protected
619
+ */
620
+ async _run(candidates, action, payload, context = null) {
621
+ return this._runSelected(candidates, action, payload, this._selectionDescriptor(), context)
622
+ }
623
+
624
+ /**
625
+ * Same as `_run`, but takes an explicit selection descriptor instead of reading one from
626
+ * `store.currentStep`/`options.strict`. Used internally by `CDPElementHandle` to address one
627
+ * specific element out of a candidate set by its 1-based index.
628
+ *
629
+ * The in-page client's presence is checked in the same round-trip as the action itself: the
630
+ * evaluated expression resolves to a sentinel string when `window.__codecept` is missing (e.g.
631
+ * right after a navigation the registered script didn't reach), in which case the client is
632
+ * installed and the call is retried exactly once. Separately, if the client is already present
633
+ * but reports (via its own `'__NO_XPATH__'` sentinel) that this call needs the XPath polyfill and
634
+ * it was not bundled into that earlier install, the polyfill is injected and the call is retried
635
+ * once more — see `_installClient`.
636
+ *
637
+ * @param {Array<{type: 'css'|'xpath', value: string}>} candidates
638
+ * @param {string} action
639
+ * @param {object|null} payload
640
+ * @param {object|null} selection - `{index}` or `{strict: true}`, mirroring `_selectionDescriptor`.
641
+ * @param {?CodeceptJS.LocatorOrString} [context=null]
642
+ * @returns {Promise<{found: number, result: any}>}
643
+ * @protected
644
+ */
645
+ async _runSelected(candidates, action, payload, selection, context = null) {
646
+ const layers = []
647
+ if (this.withinCandidates) layers.push(this.withinCandidates)
648
+ if (context) layers.push(this._candidates(context))
649
+ const scope = layers.length ? layers : null
650
+ const expression = `window.__codecept ? window.__codecept.run(${JSON.stringify(candidates)}, ${JSON.stringify(action)}, ${JSON.stringify(payload || null)}, ${JSON.stringify(scope)}, ${JSON.stringify(selection)}) : '__NO_CLIENT__'`
651
+ let res = await this._evaluate(expression)
652
+ if (res === '__NO_CLIENT__') {
653
+ await this._installClient(this._candidatesNeedXPath(candidates, layers))
654
+ res = await this._evaluate(expression)
655
+ if (res === '__NO_CLIENT__') throw new Error('Failed to install the CodeceptJS in-page client')
656
+ }
657
+ if (res === '__NO_XPATH__') {
658
+ await this._evaluate(xpathPolyfillSource())
659
+ res = await this._evaluate(expression)
660
+ if (res === '__NO_XPATH__') throw new Error('Failed to install the CodeceptJS XPath polyfill')
661
+ }
662
+ if (res?.outOfBounds) {
663
+ throw new Error(`elementIndex ${res.requestedIndex} exceeds the number of elements found (${res.found}) for "${this._candidatesLabel(candidates)}"`)
664
+ }
665
+ if (res?.strictViolation) {
666
+ const webElements = Array.from({ length: res.found }, (_, i) => new WebElement(new CDPElementHandle(this, candidates, i + 1), this))
667
+ throw new MultipleElementsFound(this._candidatesLabel(candidates), webElements)
668
+ }
669
+ return res
670
+ }
671
+
672
+ /**
673
+ * Builds the `{index, strict}` element-selection descriptor from the current step's options
674
+ * (`store.currentStep.opts`) and `options.strict`, mirroring the semantics of
675
+ * `lib/helper/extras/elementSelection.js` (used by Puppeteer/WebDriver): a per-step
676
+ * `elementIndex` (numeric, or the `'first'`/`'last'` aliases) always takes precedence and
677
+ * disables strict mode for that step; otherwise `exact`/`strictMode` per-step options
678
+ * override `options.strict` to enable or cancel strict mode.
679
+ *
680
+ * @returns {object|null} descriptor with optional `index` and `strict` keys, or `null` when neither applies.
681
+ * @protected
682
+ */
683
+ _selectionDescriptor() {
684
+ const opts = store.currentStep?.opts
685
+ let index = opts?.elementIndex
686
+ if (index === 'first') index = 1
687
+ else if (index === 'last') index = -1
688
+ if (index !== undefined && index !== null) return { index }
689
+ let strict = !!this.options.strict
690
+ if (opts?.exact === true || opts?.strictMode === true) strict = true
691
+ else if (opts?.exact === false || opts?.strictMode === false) strict = false
692
+ return strict ? { strict: true } : null
693
+ }
694
+
695
+ /**
696
+ * A short, human-readable label built from `candidates`, used in `_run`'s elementIndex/strict
697
+ * error messages when no locator string is otherwise available.
698
+ *
699
+ * @param {?Array<{type: 'css'|'xpath', value: string}>} candidates
700
+ * @returns {string}
701
+ * @protected
702
+ */
703
+ _candidatesLabel(candidates) {
704
+ return (candidates || []).map(c => c.value).join(' | ')
705
+ }
706
+
707
+ /**
708
+ * Starts a `within` block, scoping every subsequent `_run` call (and therefore every element
709
+ * lookup performed by this helper) to the descendants of the element matched by `locator`.
710
+ * Verifies the element exists (against the full document, i.e. unscoped) before narrowing.
711
+ *
712
+ * @param {CodeceptJS.LocatorOrString} locator - element located by CSS|XPath|strict locator.
713
+ * @returns {Promise<void>}
714
+ * @throws {ElementNotFound} if no element matches `locator`.
715
+ */
716
+ async _withinBegin(locator) {
717
+ const candidates = this._candidates(locator)
718
+ const { found } = await this._run(candidates, 'count')
719
+ if (!found) throw new ElementNotFound(locator, 'Element for within context')
720
+ this.withinCandidates = candidates
721
+ }
722
+
723
+ /**
724
+ * Ends the current `within` block, restoring unscoped element lookups.
725
+ *
726
+ * @returns {Promise<void>}
727
+ */
728
+ async _withinEnd() {
729
+ this.withinCandidates = null
730
+ }
731
+
732
+ /**
733
+ * Repeatedly calls `fn` until it returns a truthy value or `timeoutSec` elapses, checking
734
+ * immediately and waiting `options.pollInterval` milliseconds between subsequent attempts.
735
+ *
736
+ * @param {function} fn - the condition to poll; should resolve to a truthy value once satisfied.
737
+ * @param {number} timeoutSec - maximum time to poll, in seconds.
738
+ * @param {string} message - error message used when the timeout is reached.
739
+ * @param {?{cancelled: boolean}} [cancelToken] - when `cancelled` becomes `true` (set by the caller from outside), polling stops early with an error instead of continuing to `timeoutSec`. Used by `_waitForPageLoad` to tear down the losing side of a race instead of leaving it running.
740
+ * @returns {Promise<any>} the truthy value returned by `fn`.
741
+ * @throws {Error} with `message` if `timeoutSec` elapses without `fn` returning a truthy value, or a cancellation error if `cancelToken.cancelled` is set first.
742
+ * @protected
743
+ */
744
+ async _poll(fn, timeoutSec, message, cancelToken) {
745
+ const deadline = Date.now() + timeoutSec * 1000
746
+ while (Date.now() < deadline) {
747
+ if (cancelToken?.cancelled) throw new Error('polling cancelled')
748
+ const result = await fn()
749
+ if (result) return result
750
+ if (cancelToken?.cancelled) throw new Error('polling cancelled')
751
+ await new Promise(r => setTimeout(r, this.options.pollInterval))
752
+ }
753
+ throw new Error(message)
754
+ }
755
+
756
+ /**
757
+ * Resolves a path against `options.url`. Absolute URLs (matching `scheme://`) are returned
758
+ * unchanged; anything else is appended to `options.url` with its trailing slash stripped.
759
+ *
760
+ * @param {string} path - an absolute URL or a path relative to `options.url`.
761
+ * @returns {string} the resolved, absolute URL.
762
+ * @protected
763
+ */
764
+ _url(path) {
765
+ if (/^\w+:\/\//.test(path)) return path
766
+ return this.options.url.replace(/\/$/, '') + path
767
+ }
768
+
769
+ /**
770
+ * Opens a web page in the current session.
771
+ *
772
+ * ```js
773
+ * I.amOnPage('/'); // opens main page of website
774
+ * I.amOnPage('https://github.com'); // opens github
775
+ * I.amOnPage('/login'); // opens a login page
776
+ * ```
777
+ *
778
+ * Navigates via `Page.navigate`, then waits (up to `options.getPageTimeout` seconds) for the page
779
+ * to finish loading, preferring the push-based `Page.lifecycleEvent` signal (per
780
+ * `options.waitForNavigation`) over polling `document.readyState`. Capabilities are (re-)probed
781
+ * (a no-op after the first page, since they're cached for the helper's lifetime).
782
+ *
783
+ * The in-page client is deliberately *not* eagerly (re-)installed here — navigation discards any
784
+ * previously injected script, but installing it is deferred to the first actual action after
785
+ * this call, via `_runSelected`'s sentinel-and-retry. This keeps `amOnPage` itself down to the
786
+ * navigate command plus the push-based wait: no `_evaluate` call is issued on this hot path,
787
+ * which matters most right when the page's own JavaScript may still be busy (measured directly:
788
+ * an `_evaluate` sent in that window can queue behind it for hundreds of ms to multiple seconds
789
+ * on a JS-heavy real-world page, regardless of how small the evaluated expression is).
790
+ *
791
+ * @param {string} url - url path or global url.
792
+ * @returns {Promise<void>}
793
+ */
794
+ async amOnPage(url) {
795
+ const navRes = await this.cdp.send('Page.navigate', { url: this._url(url) }, this.sessionId)
796
+ await this._waitForPageLoad(navRes && navRes.loaderId, `Page did not reach readyState complete in ${this.options.getPageTimeout}s`)
797
+ await this._probeCapabilities()
798
+ }
799
+
800
+ /**
801
+ * Reloads the current page.
802
+ *
803
+ * ```js
804
+ * I.refreshPage();
805
+ * ```
806
+ *
807
+ * Triggers `Page.reload` and waits (up to `options.getPageTimeout` seconds) for
808
+ * `document.readyState` to reach `'complete'`.
809
+ *
810
+ * @returns {Promise<void>}
811
+ */
812
+ async refreshPage() {
813
+ await this.cdp.send('Page.reload', {}, this.sessionId)
814
+ await this._waitForPageLoad(null, `Page did not reload in ${this.options.getPageTimeout}s`)
815
+ }
816
+
817
+ /**
818
+ * Executes a JavaScript function in the browser context and returns its result.
819
+ *
820
+ * If a function is passed, it is serialized with `Function.prototype.toString()`, so it must
821
+ * not reference variables from the outer (Node.js) scope — pass any needed data as arguments
822
+ * instead. A string is evaluated as-is.
823
+ *
824
+ * ```js
825
+ * let title = await I.executeScript(() => document.title);
826
+ * let sum = await I.executeScript((a, b) => a + b, 2, 3);
827
+ * ```
828
+ *
829
+ * If the function returns a promise, `executeScript` waits for it to resolve.
830
+ *
831
+ * @param {(string|function)} fn - a JavaScript function to be executed in the browser context, or a string expression.
832
+ * @param {...any} args - arguments to pass into the function.
833
+ * @returns {Promise<any>} the value returned (or resolved) by the function.
834
+ */
835
+ async executeScript(fn, ...args) {
836
+ const body = typeof fn === 'function' ? `(${fn.toString()})(...${JSON.stringify(args)})` : fn
837
+ return this._evaluate(body)
838
+ }
839
+
840
+ /**
841
+ * Retrieves the page URL of the current page.
842
+ *
843
+ * ```js
844
+ * let url = await I.grabCurrentUrl();
845
+ * console.log(`Current URL is [${url}]`);
846
+ * ```
847
+ *
848
+ * @returns {Promise<string>} current URL.
849
+ */
850
+ async grabCurrentUrl() {
851
+ return this._evaluate('window.location.href')
852
+ }
853
+
854
+ /**
855
+ * Retrieves a page title.
856
+ *
857
+ * ```js
858
+ * let title = await I.grabTitle();
859
+ * ```
860
+ *
861
+ * @returns {Promise<string>} title of the page.
862
+ */
863
+ async grabTitle() {
864
+ return this._evaluate('document.title')
865
+ }
866
+
867
+ /**
868
+ * Retrieves the source code of the current page.
869
+ *
870
+ * ```js
871
+ * let pageSource = await I.grabSource();
872
+ * ```
873
+ *
874
+ * @returns {Promise<string>} source code of the current page (the outer HTML of `<html>`).
875
+ */
876
+ async grabSource() {
877
+ return this._evaluate('document.documentElement.outerHTML')
878
+ }
879
+
880
+ /**
881
+ * Builds the list of `{type, value}` candidates `_run` should try, in order, for a given
882
+ * locator and `kind`. A strict locator (CSS/XPath/object form) resolves to a single candidate.
883
+ * A fuzzy (plain-text) locator is expanded into a strategy-specific list of XPath expressions
884
+ * mirroring the click/field/checkbox matching used by other browser helpers (matching by
885
+ * visible text, label, name, placeholder, ARIA attributes, etc.), falling back to treating the
886
+ * raw text as a CSS selector.
887
+ *
888
+ * A role locator (`{role, text, exact}`) resolves to a single `role`-type candidate, resolved
889
+ * in-page by the client's implicit ARIA role mapping (native elements) plus explicit `role`
890
+ * attributes, filtered by accessible name/text when `text` is given.
891
+ *
892
+ * @param {CodeceptJS.LocatorOrString} locator - element located by CSS|XPath|strict locator, or plain fuzzy text.
893
+ * @param {'element'|'clickable'|'field'|'checkable'} [kind='element'] - matching strategy to use when `locator` is fuzzy.
894
+ * @returns {Array<{type: 'css'|'xpath'|'role', value: (string|object)}>} candidates to pass to `_run`.
895
+ * @protected
896
+ */
897
+ _candidates(locator, kind = 'element') {
898
+ locator = new Locator(locator)
899
+ if (locator.isShadow()) {
900
+ return [{ type: 'shadow', value: locator.value }]
901
+ }
902
+ if (locator.isRole()) {
903
+ const { text, exact } = locator.locator || {}
904
+ return [{ type: 'role', value: { role: locator.value, text, exact: exact === true } }]
905
+ }
906
+ if (!locator.isFuzzy()) {
907
+ return [{ type: locator.isXPath() ? 'xpath' : 'css', value: locator.simplify() || locator.value }]
908
+ }
909
+ const literal = xpathLocator.literal(locator.value)
910
+ if (kind === 'clickable') {
911
+ return [
912
+ { type: 'xpath', value: Locator.clickable.narrow(literal) },
913
+ { type: 'xpath', value: Locator.clickable.wide(literal) },
914
+ { type: 'xpath', value: Locator.clickable.self(literal) },
915
+ { type: 'css', value: locator.value },
916
+ ]
917
+ }
918
+ if (kind === 'field') {
919
+ return [
920
+ { type: 'xpath', value: Locator.field.labelEquals(literal) },
921
+ { type: 'xpath', value: Locator.field.labelContains(literal) },
922
+ { type: 'xpath', value: Locator.field.byName(literal) },
923
+ { type: 'css', value: locator.value },
924
+ ]
925
+ }
926
+ if (kind === 'checkable') {
927
+ return [
928
+ { type: 'xpath', value: Locator.checkable.byText(literal) },
929
+ { type: 'xpath', value: Locator.checkable.byName(literal) },
930
+ { type: 'css', value: locator.value },
931
+ ]
932
+ }
933
+ return [{ type: 'css', value: locator.value }]
934
+ }
935
+
936
+ /**
937
+ * Resolves the text to search `see`/`dontSee`/`waitForText` against, when no explicit `context`
938
+ * locator is given. An explicit `context` is always resolved through `_run`, so it is implicitly
939
+ * scoped to the active `within` block, if any. Without a `context`, this reads the `within` root's
940
+ * text when a `within` block is active, or the whole page's text otherwise — via native
941
+ * `document.body.innerText`, or the client's `visibleText()` walker when
942
+ * `_needsVisibleTextFallback` determines native `innerText` is not trustworthy.
943
+ *
944
+ * @param {?CodeceptJS.LocatorOrString} context
945
+ * @returns {Promise<string>}
946
+ * @protected
947
+ */
948
+ async _textSource(context) {
949
+ if (context) return (await this._texts(this._candidates(context))).result?.join(' | ') || ''
950
+ if (this.withinCandidates) return (await this._texts(null)).result?.join(' | ') || ''
951
+ await this._ensureClient()
952
+ if (await this._needsVisibleTextFallback()) {
953
+ return this._evaluate('document.body ? window.__codecept.visibleText(document.body) : ""')
954
+ }
955
+ return this._evaluate('document.body ? document.body.innerText : ""')
956
+ }
957
+
958
+ /**
959
+ * Runs the in-page `containsText` check against the whole page (no `context`/`within` scoping —
960
+ * those stay on `_textSource`'s per-element path, already small). Returns only `{found, snippet}`
961
+ * instead of the full haystack: on a page with a large body, serializing that whole string across
962
+ * the CDP wire (and, on engines where `capabilities.innerText` requires the `visibleText()`
963
+ * walker, holding it in memory) is avoidable work `see`/`dontSee`/`waitForText` don't actually
964
+ * need on their common, non-throwing path.
965
+ *
966
+ * Also folds the client's install into the very same round trip when it is still missing, instead
967
+ * of a separate presence-check evaluate followed by an install evaluate before the check itself
968
+ * can even run — mirroring `_runSelected`'s sentinel-retry design, but collapsed into one evaluate
969
+ * since the install source itself is known and cacheable up front. That install is client-only,
970
+ * never the 171KB XPath polyfill: `containsText` never calls `document.evaluate`, so bundling it
971
+ * here would be pure waste for a scenario that never resolves an xpath locator. The client is
972
+ * still told, via `installCodeceptClient`'s `xpathNeedsPolyfill` flag, whether the *engine*
973
+ * (`capabilities.xpath`, cached by `_before`'s `about:blank` probe) will eventually need it, so a
974
+ * later xpath-resolving action on the same page gets a clean `'__NO_XPATH__'` miss signal from
975
+ * `window.__codecept.run` instead of silently hitting a broken native `document.evaluate` —
976
+ * `_runSelected` reacts to that sentinel already. The bootstrap source is built once and reused
977
+ * for the life of the instance, since whether the engine needs the polyfill never changes.
978
+ *
979
+ * @param {string} text
980
+ * @param {{ignoreCase: boolean, useWalker: boolean}} opts
981
+ * @returns {Promise<{found: boolean, snippet: (string|null)}>}
982
+ * @protected
983
+ */
984
+ async _runTextCheck(text, opts) {
985
+ if (!this._textCheckBootstrap) {
986
+ const needsPolyfill = await this._needsXPathPolyfill()
987
+ this._textCheckBootstrap = `(${installCodeceptClient.toString()})(${JSON.stringify(needsPolyfill)});`
988
+ }
989
+ const expression = `(function(){ if (!window.__codecept) { ${this._textCheckBootstrap} } return window.__codecept.containsText(${JSON.stringify(text)}, ${JSON.stringify(opts)}) })()`
990
+ return this._evaluate(expression)
991
+ }
992
+
993
+ /**
994
+ * Shared implementation for `see`/`dontSee`. Without a `context` locator and outside any `within`
995
+ * block, checks presence via `_runTextCheck`'s fast, boolean-only round trip; the full haystack is
996
+ * only fetched (one extra, rare evaluate) when the assertion is about to fail, to build the same
997
+ * `stringIncludes` error as before this optimization. With a `context` or inside `within`, this is
998
+ * unchanged from before — already a small, per-element read, not the identified cost.
999
+ *
1000
+ * @param {string} text
1001
+ * @param {?CodeceptJS.LocatorOrString} context
1002
+ * @param {boolean} negate
1003
+ * @returns {Promise<void>}
1004
+ * @protected
1005
+ */
1006
+ async _checkText(text, context, negate) {
1007
+ const label = context ? `element ${new Locator(context).toString()}` : 'web page'
1008
+ const ignoreCase = store.currentStep?.opts?.ignoreCase === true
1009
+ if (context || this.withinCandidates) {
1010
+ let source = await this._textSource(context)
1011
+ let needle = text
1012
+ if (ignoreCase) {
1013
+ needle = needle.toLowerCase()
1014
+ source = source.toLowerCase()
1015
+ }
1016
+ const assertion = stringIncludes(label)
1017
+ return negate ? assertion.negate(normalizeSpacesInString(needle), normalizeSpacesInString(source)) : assertion.assert(normalizeSpacesInString(needle), normalizeSpacesInString(source))
1018
+ }
1019
+ const useWalker = await this._needsVisibleTextFallback()
1020
+ const res = await this._runTextCheck(text, { ignoreCase, useWalker })
1021
+ if (negate ? !res.found : res.found) return
1022
+ let source = await this._textSource(null)
1023
+ let needle = text
1024
+ if (ignoreCase) {
1025
+ needle = needle.toLowerCase()
1026
+ source = source.toLowerCase()
1027
+ }
1028
+ const assertion = stringIncludes(label)
1029
+ return negate ? assertion.negate(normalizeSpacesInString(needle), normalizeSpacesInString(source)) : assertion.assert(normalizeSpacesInString(needle), normalizeSpacesInString(source))
1030
+ }
1031
+
1032
+ /**
1033
+ * Checks that a page contains a visible text.
1034
+ * Use context parameter to narrow down the search.
1035
+ *
1036
+ * ```js
1037
+ * I.see('Welcome'); // text welcome on a page
1038
+ * I.see('Welcome', '.content'); // text inside .content div
1039
+ * I.see('Register', {css: 'form.register'}); // use strict locator
1040
+ * ```
1041
+ *
1042
+ * @param {string} text expected on page.
1043
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS|Xpath|strict locator in which to search for text.
1044
+ * @returns {Promise<void>}
1045
+ */
1046
+ async see(text, context = null) {
1047
+ return this._checkText(text, context, false)
1048
+ }
1049
+
1050
+ /**
1051
+ * Opposite to `see`. Checks that a text is not present on a page.
1052
+ * Use context parameter to narrow down the search.
1053
+ *
1054
+ * ```js
1055
+ * I.dontSee('Login'); // assume we are already logged in.
1056
+ * I.dontSee('Login', '.nav'); // no login inside .nav element
1057
+ * ```
1058
+ *
1059
+ * @param {string} text which is not present.
1060
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional) element located by CSS|XPath|strict locator in which to perform search.
1061
+ * @returns {Promise<void>}
1062
+ */
1063
+ async dontSee(text, context = null) {
1064
+ return this._checkText(text, context, true)
1065
+ }
1066
+
1067
+ /**
1068
+ * Checks that the current page contains the given string in its raw source code.
1069
+ *
1070
+ * ```js
1071
+ * I.seeInSource('<h1>Green eggs &amp; ham</h1>');
1072
+ * ```
1073
+ *
1074
+ * @param {string} text value to check.
1075
+ * @returns {Promise<void>}
1076
+ */
1077
+ async seeInSource(text) {
1078
+ return stringIncludes('HTML source of a page').assert(text, await this.grabSource())
1079
+ }
1080
+
1081
+ /**
1082
+ * Checks that the current page does not contain the given string in its raw source code.
1083
+ *
1084
+ * @param {string} text value to check.
1085
+ * @returns {Promise<void>}
1086
+ */
1087
+ async dontSeeInSource(text) {
1088
+ return stringIncludes('HTML source of a page').negate(text, await this.grabSource())
1089
+ }
1090
+
1091
+ /**
1092
+ * Checks that current url contains a provided fragment.
1093
+ *
1094
+ * ```js
1095
+ * I.seeInCurrentUrl('/register'); // we are on registration page
1096
+ * ```
1097
+ *
1098
+ * @param {string} url a fragment to check
1099
+ * @returns {Promise<void>}
1100
+ */
1101
+ async seeInCurrentUrl(url) {
1102
+ return stringIncludes('url').assert(url, await this.grabCurrentUrl())
1103
+ }
1104
+
1105
+ /**
1106
+ * Checks that current url does not contain a provided fragment.
1107
+ *
1108
+ * @param {string} url value to check.
1109
+ * @returns {Promise<void>}
1110
+ */
1111
+ async dontSeeInCurrentUrl(url) {
1112
+ return stringIncludes('url').negate(url, await this.grabCurrentUrl())
1113
+ }
1114
+
1115
+ /**
1116
+ * Checks that title contains text.
1117
+ *
1118
+ * ```js
1119
+ * I.seeInTitle('Home Page');
1120
+ * ```
1121
+ *
1122
+ * @param {string} text text value to check.
1123
+ * @returns {Promise<void>}
1124
+ */
1125
+ async seeInTitle(text) {
1126
+ return stringIncludes('web page title').assert(text, await this.grabTitle())
1127
+ }
1128
+
1129
+ /**
1130
+ * Checks that a given Element is present in the DOM.
1131
+ * Element is located by CSS or XPath.
1132
+ *
1133
+ * ```js
1134
+ * I.seeElementInDOM('#modal');
1135
+ * ```
1136
+ *
1137
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1138
+ * @returns {Promise<void>}
1139
+ */
1140
+ async seeElementInDOM(locator) {
1141
+ const { found } = await this._run(this._candidates(locator), 'count')
1142
+ return empty(`elements of ${new Locator(locator).toString()}`).negate(found === 0 ? null : found)
1143
+ }
1144
+
1145
+ /**
1146
+ * Opposite to `seeElementInDOM`. Checks that element is not on page.
1147
+ *
1148
+ * ```js
1149
+ * I.dontSeeElementInDOM('.nav'); // checks that element is not on page visible or not
1150
+ * ```
1151
+ *
1152
+ * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
1153
+ * @returns {Promise<void>}
1154
+ */
1155
+ async dontSeeElementInDOM(locator) {
1156
+ const { found } = await this._run(this._candidates(locator), 'count')
1157
+ return empty(`elements of ${new Locator(locator).toString()}`).assert(found === 0 ? null : found)
1158
+ }
1159
+
1160
+ /**
1161
+ * Throws if the current page has no real layout engine (`capabilities.layout === 'none'`),
1162
+ * used to guard visibility-dependent assertions that cannot be evaluated without one.
1163
+ *
1164
+ * @param {string} action - name of the calling assertion, used in the error message.
1165
+ * @throws {Error} if the page has no layout engine.
1166
+ * @protected
1167
+ */
1168
+ _assertLayoutSupported(action) {
1169
+ if (this.capabilities.layout === 'none') {
1170
+ throw new Error(`${action} requires a layout engine which this browser does not provide. Use ${action}InDOM instead.`)
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Checks that a given Element is visible.
1176
+ * Element is located by CSS or XPath.
1177
+ *
1178
+ * ```js
1179
+ * I.seeElement('#modal');
1180
+ * ```
1181
+ *
1182
+ * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
1183
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1184
+ * @returns {Promise<void>}
1185
+ */
1186
+ async seeElement(locator, context = null) {
1187
+ this._assertLayoutSupported('seeElement')
1188
+ const visible = await this._run(this._candidates(locator), 'visibleCount', null, context)
1189
+ return empty(`visible elements of ${new Locator(locator).toString()}`).negate(visible.result === 0 ? null : visible.result)
1190
+ }
1191
+
1192
+ /**
1193
+ * Opposite to `seeElement`. Checks that element is not visible.
1194
+ *
1195
+ * ```js
1196
+ * I.dontSeeElement('.modal'); // modal is not shown
1197
+ * ```
1198
+ *
1199
+ * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
1200
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1201
+ * @returns {Promise<void>}
1202
+ */
1203
+ async dontSeeElement(locator, context = null) {
1204
+ this._assertLayoutSupported('dontSeeElement')
1205
+ const visible = await this._run(this._candidates(locator), 'visibleCount', null, context)
1206
+ return empty(`visible elements of ${new Locator(locator).toString()}`).assert(visible.result === 0 || visible.result === undefined ? null : visible.result)
1207
+ }
1208
+
1209
+ /**
1210
+ * Verifies that the specified checkbox is checked.
1211
+ *
1212
+ * ```js
1213
+ * I.seeCheckboxIsChecked('Agree');
1214
+ * I.seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms
1215
+ * I.seeCheckboxIsChecked({css: '#signup_form input[type=checkbox]'});
1216
+ * ```
1217
+ *
1218
+ * @param {CodeceptJS.LocatorOrString} locator located by label|name|CSS|XPath|strict locator.
1219
+ * @returns {Promise<void>}
1220
+ */
1221
+ async seeCheckboxIsChecked(locator) {
1222
+ const res = await this._run(this._candidates(locator, 'checkable'), 'checked')
1223
+ if (!res.found) throw new ElementNotFound(locator, 'Checkbox')
1224
+ return truth(`checkbox ${new Locator(locator).toString()}`, 'to be checked').assert(res.result)
1225
+ }
1226
+
1227
+ /**
1228
+ * Verifies that the specified checkbox is not checked.
1229
+ *
1230
+ * ```js
1231
+ * I.dontSeeCheckboxIsChecked('#agree'); // located by ID
1232
+ * I.dontSeeCheckboxIsChecked('I agree to terms'); // located by label
1233
+ * ```
1234
+ *
1235
+ * @param {CodeceptJS.LocatorOrString} locator located by label|name|CSS|XPath|strict locator.
1236
+ * @returns {Promise<void>}
1237
+ */
1238
+ async dontSeeCheckboxIsChecked(locator) {
1239
+ const res = await this._run(this._candidates(locator, 'checkable'), 'checked')
1240
+ if (!res.found) throw new ElementNotFound(locator, 'Checkbox')
1241
+ return truth(`checkbox ${new Locator(locator).toString()}`, 'to be checked').negate(res.result)
1242
+ }
1243
+
1244
+ /**
1245
+ * Retrieves a text from an element located by CSS or XPath and returns it to test.
1246
+ * Resumes test execution, so **should be used inside async with `await`** operator.
1247
+ *
1248
+ * ```js
1249
+ * let pin = await I.grabTextFrom('#pin');
1250
+ * ```
1251
+ * If multiple elements found returns first element.
1252
+ *
1253
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1254
+ * @returns {Promise<string>} text value
1255
+ */
1256
+ async grabTextFrom(locator) {
1257
+ const res = await this._texts(this._candidates(locator))
1258
+ if (!res.found) throw new ElementNotFound(locator)
1259
+ return res.result[0]
1260
+ }
1261
+
1262
+ /**
1263
+ * Retrieves all texts from elements located by CSS or XPath and returns it to test.
1264
+ * Resumes test execution, so **should be used inside async with `await`** operator.
1265
+ *
1266
+ * ```js
1267
+ * let pins = await I.grabTextFromAll('#pin li');
1268
+ * ```
1269
+ *
1270
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1271
+ * @returns {Promise<string[]>} array of text values
1272
+ */
1273
+ async grabTextFromAll(locator) {
1274
+ const res = await this._texts(this._candidates(locator))
1275
+ return res.found ? res.result : []
1276
+ }
1277
+
1278
+ /**
1279
+ * Retrieves an array of `WebElement`s matching a locator (`lib/element/WebElement.js`,
1280
+ * wrapping a `CDPElementHandle`). Element handles are re-resolved on demand by re-running
1281
+ * `candidates` and picking the matching index, since `CDPBrowser` never keeps a persistent
1282
+ * handle to a DOM node on the Node side.
1283
+ *
1284
+ * ```js
1285
+ * const buttons = await I.grabWebElements({ role: 'button' });
1286
+ * ```
1287
+ *
1288
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1289
+ * @returns {Promise<object[]>} array of WebElement instances.
1290
+ */
1291
+ async grabWebElements(locator) {
1292
+ const candidates = this._candidates(locator)
1293
+ const { found } = await this._run(candidates, 'count')
1294
+ return Array.from({ length: found }, (_, i) => new WebElement(new CDPElementHandle(this, candidates, i + 1), this))
1295
+ }
1296
+
1297
+ /**
1298
+ * Retrieves the first `WebElement` matching a locator.
1299
+ *
1300
+ * ```js
1301
+ * const button = await I.grabWebElement({ role: 'button', text: 'Submit' });
1302
+ * ```
1303
+ *
1304
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1305
+ * @returns {Promise<object>} a WebElement instance.
1306
+ * @throws {ElementNotFound} if no element matches `locator`.
1307
+ */
1308
+ async grabWebElement(locator) {
1309
+ const elements = await this.grabWebElements(locator)
1310
+ if (!elements.length) throw new ElementNotFound(locator, 'Element')
1311
+ return elements[0]
1312
+ }
1313
+
1314
+ /**
1315
+ * Resolves whether the `texts` action should read via the client's `visibleText()` walker
1316
+ * (probed once via `_needsVisibleTextFallback` and cached on `capabilities.innerText`) instead
1317
+ * of each element's native `innerText`, then runs it.
1318
+ *
1319
+ * @param {Array<{type: string, value: (string|object)}>} candidates
1320
+ * @returns {Promise<{found: number, result: any}>}
1321
+ * @protected
1322
+ */
1323
+ async _texts(candidates) {
1324
+ const visible = await this._needsVisibleTextFallback()
1325
+ return this._run(candidates, 'texts', { visible })
1326
+ }
1327
+
1328
+ /**
1329
+ * Retrieves a value from a form element located by CSS or XPath and returns it to test.
1330
+ * Resumes test execution, so **should be used inside async function with `await`** operator.
1331
+ * If more than one element is found - value of first element is returned.
1332
+ *
1333
+ * ```js
1334
+ * let email = await I.grabValueFrom('input[name=email]');
1335
+ * ```
1336
+ *
1337
+ * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
1338
+ * @returns {Promise<string>} attribute value
1339
+ */
1340
+ async grabValueFrom(locator) {
1341
+ const res = await this._run(this._candidates(locator, 'field'), 'values')
1342
+ if (!res.found) throw new ElementNotFound(locator, 'Field')
1343
+ return res.result[0]
1344
+ }
1345
+
1346
+ /**
1347
+ * Retrieves an array of values from fields located by CSS or XPath and returns it to test.
1348
+ * Resumes test execution, so **should be used inside async function with `await`** operator.
1349
+ *
1350
+ * ```js
1351
+ * let inputs = await I.grabValueFromAll('//form/input');
1352
+ * ```
1353
+ *
1354
+ * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
1355
+ * @returns {Promise<string[]>} array of attribute values
1356
+ */
1357
+ async grabValueFromAll(locator) {
1358
+ const res = await this._run(this._candidates(locator, 'field'), 'values')
1359
+ return res.found ? res.result : []
1360
+ }
1361
+
1362
+ /**
1363
+ * Retrieves an attribute from an element located by CSS or XPath and returns it to test.
1364
+ * Resumes test execution, so **should be used inside async with `await`** operator.
1365
+ * If more than one element is found - attribute of first element is returned.
1366
+ *
1367
+ * ```js
1368
+ * let hint = await I.grabAttributeFrom('#tooltip', 'title');
1369
+ * ```
1370
+ *
1371
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1372
+ * @param {string} attr attribute name.
1373
+ * @returns {Promise<string>} attribute value
1374
+ */
1375
+ async grabAttributeFrom(locator, attr) {
1376
+ const res = await this._run(this._candidates(locator), 'attrs', { name: attr })
1377
+ if (!res.found) throw new ElementNotFound(locator)
1378
+ return res.result[0]
1379
+ }
1380
+
1381
+ /**
1382
+ * Retrieves an array of attributes from elements located by CSS or XPath and returns it to test.
1383
+ * Resumes test execution, so **should be used inside async with `await`** operator.
1384
+ *
1385
+ * ```js
1386
+ * let hints = await I.grabAttributeFromAll('.tooltip', 'title');
1387
+ * ```
1388
+ *
1389
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1390
+ * @param {string} attr attribute name.
1391
+ * @returns {Promise<string[]>} array of attribute values
1392
+ */
1393
+ async grabAttributeFromAll(locator, attr) {
1394
+ const res = await this._run(this._candidates(locator), 'attrs', { name: attr })
1395
+ return res.found ? res.result : []
1396
+ }
1397
+
1398
+ /**
1399
+ * Grab number of elements by locator.
1400
+ * Resumes test execution, so **should be used inside async function with `await`** operator.
1401
+ *
1402
+ * ```js
1403
+ * let numOfElements = await I.grabNumberOfElements('p');
1404
+ * ```
1405
+ *
1406
+ * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
1407
+ * @returns {Promise<number>} number of matched elements.
1408
+ */
1409
+ async grabNumberOfElements(locator) {
1410
+ const { found } = await this._run(this._candidates(locator), 'count')
1411
+ return found
1412
+ }
1413
+
1414
+ /**
1415
+ * Perform a click on a link or a button, given by a locator.
1416
+ * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string.
1417
+ * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched.
1418
+ * For images, the "alt" attribute and inner text of any parent links are searched.
1419
+ *
1420
+ * When `options.input` is `'cdp'`, the click is dispatched as real `Input.dispatchMouseEvent` mouse events
1421
+ * (`mouseMoved` / `mousePressed` / `mouseReleased`) at the center of the element's bounding box, so it
1422
+ * exercises the same input pipeline a real user would. Otherwise it delegates to `forceClick`. A matched
1423
+ * element with a zero-size bounding box (e.g. `display: none`) has no valid coordinate to click and throws;
1424
+ * use `forceClick` to dispatch a synthetic click on such elements instead.
1425
+ *
1426
+ * ```js
1427
+ * // simple link
1428
+ * I.click('Logout');
1429
+ * // button of form
1430
+ * I.click('Submit');
1431
+ * // CSS button
1432
+ * I.click('#form input[type=submit]');
1433
+ * // XPath
1434
+ * I.click('//form/*[@type=submit]');
1435
+ * // using strict locator
1436
+ * I.click({css: 'nav a.login'});
1437
+ * ```
1438
+ *
1439
+ * @param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator.
1440
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1441
+ * @returns {Promise<void>}
1442
+ * @throws {Error} if the matched element has a zero-size bounding box.
1443
+ */
1444
+ async click(locator, context = null) {
1445
+ if (this.options.input !== 'cdp') return this.forceClick(locator, context)
1446
+ const candidates = this._candidates(locator, 'clickable')
1447
+ const res = await this._run(candidates, 'rect', null, context)
1448
+ if (!res.found) throw new ElementNotFound(locator, 'Clickable element')
1449
+ if (!res.result.width && !res.result.height) {
1450
+ throw new Error(`Clickable element ${new Locator(locator).toString()} has zero size and cannot receive a coordinate click. Use forceClick to dispatch a synthetic click.`)
1451
+ }
1452
+ const x = Math.round(res.result.x + res.result.width / 2)
1453
+ const y = Math.round(res.result.y + res.result.height / 2)
1454
+ const armed = this._armActionSettle()
1455
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, this.sessionId)
1456
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, this.sessionId)
1457
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, this.sessionId)
1458
+ return this._waitForAction(armed)
1459
+ }
1460
+
1461
+ /**
1462
+ * Perform an emulated click on a link or a button, given by a locator.
1463
+ * Unlike `click`, this always dispatches a synthetic in-page `el.click()` instead of sending native
1464
+ * CDP input events. This works on hidden, animated or inactive elements as well.
1465
+ *
1466
+ * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string.
1467
+ * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched.
1468
+ * For images, the "alt" attribute and inner text of any parent links are searched.
1469
+ *
1470
+ * ```js
1471
+ * // simple link
1472
+ * I.forceClick('Logout');
1473
+ * // button of form
1474
+ * I.forceClick('Submit');
1475
+ * // CSS button
1476
+ * I.forceClick('#form input[type=submit]');
1477
+ * // XPath
1478
+ * I.forceClick('//form/*[@type=submit]');
1479
+ * // using strict locator
1480
+ * I.forceClick({css: 'nav a.login'});
1481
+ * ```
1482
+ *
1483
+ * @param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator.
1484
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1485
+ * @returns {Promise<void>}
1486
+ */
1487
+ async forceClick(locator, context = null) {
1488
+ const armed = this._armActionSettle()
1489
+ const res = await this._run(this._candidates(locator, 'clickable'), 'click', null, context)
1490
+ if (!res.found) throw new ElementNotFound(locator, 'Clickable element')
1491
+ return this._waitForAction(armed)
1492
+ }
1493
+
1494
+ /**
1495
+ * Settles after an interaction (click, key press, etc.) before the next step runs, using the
1496
+ * listener `_armActionSettle` started *before* the interaction was dispatched (`armed`; a fresh
1497
+ * one is armed here too, as a safety net, if a call site forgot to — but arming this late can
1498
+ * only miss a navigation that already started during the action's own dispatch, exactly the race
1499
+ * this design exists to avoid, so every call site should pass its own pre-armed `armed`, not rely
1500
+ * on this fallback).
1501
+ *
1502
+ * If `options.waitForAction` was set explicitly in the config, honors it literally as a fixed
1503
+ * pacing sleep, exactly as before this round — an explicit value is a deliberate choice
1504
+ * (slow-motion debugging, a known-slow app) this never second-guesses.
1505
+ *
1506
+ * Otherwise, event-aware: races the armed listener against a *fresh* `ACTION_SETTLE_GRACE_MS`
1507
+ * window (started now, not when it was armed — the action's own dispatch already ran concurrently
1508
+ * with the arm, so this is genuinely bounded extra time, not a guess). If nothing declares a
1509
+ * navigation, returns immediately once the window elapses — the common case for most actions
1510
+ * (typing, toggling a checkbox, focusing a field) — instead of a fixed `options.waitForAction`
1511
+ * (100ms by default) sleep on every single action regardless of whether anything is happening.
1512
+ *
1513
+ * If a navigation *did* start, `_lastMainFrameNav` (see `_ensureLifecycleListener`) is checked
1514
+ * first: on a fast/local page, the entire lifecycle sequence through the target event has
1515
+ * typically already arrived in the same batch that announced the navigation started, in which
1516
+ * case this returns immediately. Only a navigation still genuinely in flight falls through to
1517
+ * `_waitForPageLoad` (the same mechanism `amOnPage`/`refreshPage` use) — which waits for it to
1518
+ * actually finish, rather than a fixed sleep that has no relationship to how long the navigation
1519
+ * actually takes: strictly more correct for a slow navigation, not just faster for a fast one.
1520
+ *
1521
+ * @param {({promise: Promise<string|null>, cancel: function}|null)} [armed] - from `_armActionSettle`, called before the action.
1522
+ * @returns {Promise<void>}
1523
+ * @protected
1524
+ */
1525
+ async _waitForAction(armed) {
1526
+ if (this._waitForActionExplicit) {
1527
+ return new Promise(r => setTimeout(r, this.options.waitForAction))
1528
+ }
1529
+ if (!armed) armed = this._armActionSettle()
1530
+ let timer
1531
+ const graceTimeout = new Promise(resolve => {
1532
+ timer = setTimeout(() => resolve(null), ACTION_SETTLE_GRACE_MS)
1533
+ })
1534
+ const loaderId = await Promise.race([armed.promise, graceTimeout])
1535
+ clearTimeout(timer)
1536
+ if (!loaderId) {
1537
+ armed.cancel()
1538
+ return
1539
+ }
1540
+ const waitUntil = READY_STATE_EXPR_BY_WAIT_UNTIL[this.options.waitForNavigation] ? this.options.waitForNavigation : 'load'
1541
+ const eventName = LIFECYCLE_EVENT_BY_WAIT_UNTIL[waitUntil]
1542
+ if (this._lastMainFrameNav.loaderId === loaderId && this._lastMainFrameNav.events[eventName]) {
1543
+ return
1544
+ }
1545
+ await this._waitForPageLoad(loaderId, `Page did not finish loading after an action within ${this.options.getPageTimeout}s`)
1546
+ }
1547
+
1548
+ /**
1549
+ * Fills a text field or textarea, after clearing its value, with the given string.
1550
+ * Field is located by name, label, CSS, or XPath.
1551
+ *
1552
+ * ```js
1553
+ * // by label
1554
+ * I.fillField('Email', 'hello@world.com');
1555
+ * // by name
1556
+ * I.fillField('password', secret('123456'));
1557
+ * // by CSS
1558
+ * I.fillField('form#login input[name=username]', 'John');
1559
+ * // or by strict locator
1560
+ * I.fillField({css: 'form#login input[name=username]'}, 'John');
1561
+ * ```
1562
+ *
1563
+ * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator.
1564
+ * @param {CodeceptJS.StringOrSecret} value text value to fill.
1565
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1566
+ * @returns {Promise<void>}
1567
+ */
1568
+ async fillField(field, value, context = null) {
1569
+ const res = await this._run(this._candidates(field, 'field'), 'fill', { value: String(value) }, context)
1570
+ if (!res.found) throw new ElementNotFound(field, 'Field')
1571
+ }
1572
+
1573
+ /**
1574
+ * Appends text to a input field or textarea.
1575
+ * Field is located by name, label, CSS or XPath
1576
+ *
1577
+ * ```js
1578
+ * I.appendField('#myTextField', 'appended');
1579
+ * // typing secret
1580
+ * I.appendField('password', secret('123456'));
1581
+ * ```
1582
+ *
1583
+ * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator
1584
+ * @param {string} value text value to append.
1585
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1586
+ * @returns {Promise<void>}
1587
+ */
1588
+ async appendField(field, value, context = null) {
1589
+ const res = await this._run(this._candidates(field, 'field'), 'append', { value: String(value) }, context)
1590
+ if (!res.found) throw new ElementNotFound(field, 'Field')
1591
+ }
1592
+
1593
+ /**
1594
+ * Clears a `<textarea>` or text `<input>` element's value.
1595
+ *
1596
+ * ```js
1597
+ * I.clearField('Email');
1598
+ * I.clearField('user[email]');
1599
+ * I.clearField('#email');
1600
+ * ```
1601
+ *
1602
+ * @param {CodeceptJS.LocatorOrString} field editable field located by label|name|CSS|XPath|strict locator.
1603
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1604
+ * @returns {Promise<void>}
1605
+ */
1606
+ async clearField(field, context = null) {
1607
+ const res = await this._run(this._candidates(field, 'field'), 'clear', null, context)
1608
+ if (!res.found) throw new ElementNotFound(field, 'Field')
1609
+ }
1610
+
1611
+ /**
1612
+ * Selects an option in a drop-down select.
1613
+ * Field is searched by label | name | CSS | XPath.
1614
+ * Option is selected by visible text or by value.
1615
+ *
1616
+ * ```js
1617
+ * I.selectOption('Choose Plan', 'Monthly'); // select by label
1618
+ * I.selectOption('subscription', 'Monthly'); // match option by text
1619
+ * I.selectOption('subscription', '0'); // or by value
1620
+ * I.selectOption('//form/select[@name=account]','Premium');
1621
+ * I.selectOption('form select[name=account]', 'Premium');
1622
+ * I.selectOption({css: 'form select[name=account]'}, 'Premium');
1623
+ * ```
1624
+ *
1625
+ * @param {CodeceptJS.LocatorOrString} select field located by label|name|CSS|XPath|strict locator.
1626
+ * @param {string|string[]} option visible text or value of option, or an array of them for a multi-select.
1627
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1628
+ * @returns {Promise<void>}
1629
+ */
1630
+ async selectOption(select, option, context = null) {
1631
+ const value = Array.isArray(option) ? option.map(String) : String(option)
1632
+ const res = await this._run(this._candidates(select, 'field'), 'select', { value }, context)
1633
+ if (!res.found) throw new ElementNotFound(select, 'Selectable field')
1634
+ if (res.result === false) throw new Error(`Option "${Array.isArray(option) ? option.join(',') : option}" not found in ${new Locator(select).toString()}`)
1635
+ }
1636
+
1637
+ /**
1638
+ * Selects a checkbox or radio button.
1639
+ * Element is located by label or name or CSS or XPath.
1640
+ *
1641
+ * ```js
1642
+ * I.checkOption('#agree');
1643
+ * I.checkOption('I Agree to Terms and Conditions');
1644
+ * I.checkOption('agree', '//form');
1645
+ * ```
1646
+ *
1647
+ * @param {CodeceptJS.LocatorOrString} field checkbox located by label | name | CSS | XPath | strict locator.
1648
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
1649
+ * @returns {Promise<void>}
1650
+ */
1651
+ async checkOption(field, context = null) {
1652
+ const res = await this._run(this._candidates(field, 'checkable'), 'check', null, context)
1653
+ if (!res.found) throw new ElementNotFound(field, 'Checkable')
1654
+ }
1655
+
1656
+ /**
1657
+ * Unselects a checkbox or radio button.
1658
+ * Element is located by label or name or CSS or XPath.
1659
+ *
1660
+ * ```js
1661
+ * I.uncheckOption('#agree');
1662
+ * I.uncheckOption('I Agree to Terms and Conditions');
1663
+ * I.uncheckOption('agree', '//form');
1664
+ * ```
1665
+ *
1666
+ * @param {CodeceptJS.LocatorOrString} field checkbox located by label | name | CSS | XPath | strict locator.
1667
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
1668
+ * @returns {Promise<void>}
1669
+ */
1670
+ async uncheckOption(field, context = null) {
1671
+ const res = await this._run(this._candidates(field, 'checkable'), 'uncheck', null, context)
1672
+ if (!res.found) throw new ElementNotFound(field, 'Checkable')
1673
+ }
1674
+
1675
+ /**
1676
+ * Attaches a file to a file input field, or drops it onto a drag-and-drop dropzone element,
1677
+ * resolved by label|name|CSS|XPath|strict locator. `pathToFile` is resolved relative to
1678
+ * `codecept_dir` (matching Puppeteer/WebDriver). Since `CDPBrowser` never brings element handles
1679
+ * back to Node, the resolved element is marked with a throwaway `data-codecept-upload` attribute
1680
+ * in-page (respecting `context`/`within`/elementIndex exactly like every other action). A real
1681
+ * `<input type="file">` is then addressed by that attribute through the CDP `DOM` domain, which
1682
+ * `CDPBrowser` otherwise never uses, to call `DOM.setFileInputFiles`; any other element (a
1683
+ * drag-and-drop dropzone) instead gets a synthetic `dragenter`/`dragover`/`drop` sequence with a
1684
+ * `DataTransfer` built from the file's contents, entirely in-page. The marker is removed again in
1685
+ * a `finally`.
1686
+ *
1687
+ * ```js
1688
+ * I.attachFile('Avatar', 'data/avatar.jpg');
1689
+ * I.attachFile('#file', 'data/avatar.jpg');
1690
+ * I.attachFile('#dropzone', 'data/avatar.jpg');
1691
+ * ```
1692
+ *
1693
+ * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator.
1694
+ * @param {string} pathToFile path to file, relative to `codecept_dir`.
1695
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
1696
+ * @returns {Promise<void>}
1697
+ */
1698
+ async attachFile(field, pathToFile, context = null) {
1699
+ const file = path.join(store.codeceptDir, pathToFile)
1700
+ if (!fileExists(file)) {
1701
+ throw new Error(`File at ${file} can not be found on local system`)
1702
+ }
1703
+ const candidates = this._candidates(field, 'field')
1704
+ const marker = 'data-codecept-upload'
1705
+ const marked = await this._run(candidates, 'mark', { attr: marker }, context)
1706
+ if (!marked.found) throw new ElementNotFound(field, 'Field')
1707
+ const armed = this._armActionSettle()
1708
+ try {
1709
+ if (marked.result?.isFileInput) {
1710
+ await this._ensureClient()
1711
+ const { root } = await this.cdp.send('DOM.getDocument', { depth: -1 }, this.sessionId)
1712
+ const { nodeId } = await this.cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: `[${marker}]` }, this.sessionId)
1713
+ if (!nodeId) throw new ElementNotFound(field, 'File input')
1714
+ await this.cdp.send('DOM.setFileInputFiles', { files: [file], nodeId }, this.sessionId)
1715
+ } else {
1716
+ await this._run(
1717
+ candidates,
1718
+ 'dropFile',
1719
+ { base64Content: base64EncodeFile(file), fileName: path.basename(file), mimeType: getMimeType(path.basename(file)) },
1720
+ context,
1721
+ )
1722
+ }
1723
+ } finally {
1724
+ await this._run(candidates, 'unmark', { attr: marker }, context).catch(() => null)
1725
+ }
1726
+ return this._waitForAction(armed)
1727
+ }
1728
+
1729
+ /**
1730
+ * Waits for element to be present on page (by default waits for `options.waitForTimeout` seconds).
1731
+ * Element can be located by CSS or XPath.
1732
+ *
1733
+ * ```js
1734
+ * I.waitForElement('.btn.continue');
1735
+ * I.waitForElement('.btn.continue', 5); // wait for 5 secs
1736
+ * ```
1737
+ *
1738
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1739
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
1740
+ * @returns {Promise<void>}
1741
+ */
1742
+ async waitForElement(locator, sec = null) {
1743
+ const timeout = sec || this.options.waitForTimeout
1744
+ const candidates = this._candidates(locator)
1745
+ return this._poll(
1746
+ async () => (await this._run(candidates, 'count').catch(() => ({ found: 0 }))).found > 0,
1747
+ timeout,
1748
+ `Element ${new Locator(locator).toString()} was not found on page after ${timeout} sec`,
1749
+ )
1750
+ }
1751
+
1752
+ /**
1753
+ * Waits for a text to appear (by default waits for `options.waitForTimeout` seconds).
1754
+ * Element can be located by CSS or XPath.
1755
+ * Narrow down search results by providing context.
1756
+ *
1757
+ * ```js
1758
+ * I.waitForText('Thank you, form has been submitted');
1759
+ * I.waitForText('Thank you, form has been submitted', 5, '#modal');
1760
+ * ```
1761
+ *
1762
+ * @param {string} text to wait for.
1763
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
1764
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional) element located by CSS|XPath|strict locator.
1765
+ * @returns {Promise<void>}
1766
+ */
1767
+ async waitForText(text, sec = null, context = null) {
1768
+ const timeout = sec || this.options.waitForTimeout
1769
+ if (context || this.withinCandidates) {
1770
+ return this._poll(
1771
+ async () => {
1772
+ try {
1773
+ const source = await this._textSource(context)
1774
+ return source.includes(text)
1775
+ } catch (e) {
1776
+ return false
1777
+ }
1778
+ },
1779
+ timeout,
1780
+ `Text "${text}" was not found on page after ${timeout} sec`,
1781
+ )
1782
+ }
1783
+ // No context/within: poll the fast, boolean-only in-page check instead of re-fetching (and, on
1784
+ // engines needing the `visibleText()` fallback, re-walking) the whole page's text on every tick.
1785
+ return this._poll(
1786
+ async () => {
1787
+ try {
1788
+ const useWalker = await this._needsVisibleTextFallback()
1789
+ const res = await this._runTextCheck(text, { ignoreCase: false, useWalker })
1790
+ return res.found
1791
+ } catch (e) {
1792
+ return false
1793
+ }
1794
+ },
1795
+ timeout,
1796
+ `Text "${text}" was not found on page after ${timeout} sec`,
1797
+ )
1798
+ }
1799
+
1800
+ /**
1801
+ * Waiting for the part of the URL to match the expected. Useful for SPA to understand that page was changed.
1802
+ *
1803
+ * ```js
1804
+ * I.waitInUrl('/info', 2);
1805
+ * ```
1806
+ *
1807
+ * @param {string} urlPart value to check.
1808
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
1809
+ * @returns {Promise<void>}
1810
+ */
1811
+ async waitInUrl(urlPart, sec = null) {
1812
+ const timeout = sec || this.options.waitForTimeout
1813
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
1814
+ let lastUrl = ''
1815
+ try {
1816
+ return await this._poll(
1817
+ async () => {
1818
+ lastUrl = await this.grabCurrentUrl().catch(() => lastUrl)
1819
+ return lastUrl.includes(urlPart)
1820
+ },
1821
+ timeout,
1822
+ 'placeholder',
1823
+ )
1824
+ } catch (e) {
1825
+ throw new Error(`expected url to include ${expectedUrl}, but found ${lastUrl}`)
1826
+ }
1827
+ }
1828
+
1829
+ /**
1830
+ * Waits for a function to return true (waits for `options.waitForTimeout` seconds by default).
1831
+ * Running in browser context.
1832
+ *
1833
+ * ```js
1834
+ * I.waitForFunction(() => window.requests == 0);
1835
+ * I.waitForFunction(() => window.requests == 0, 5); // waits for 5 sec
1836
+ * I.waitForFunction((count) => window.requests == count, [3], 5) // pass args and wait for 5 sec
1837
+ * ```
1838
+ *
1839
+ * @param {(string|function)} fn to be executed in browser context.
1840
+ * @param {any[]|number} [argsOrSec] (optional) arguments for function or, if a number, seconds to wait.
1841
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
1842
+ * @returns {Promise<void>}
1843
+ */
1844
+ async waitForFunction(fn, argsOrSec = null, sec = null) {
1845
+ let args = []
1846
+ if (Array.isArray(argsOrSec)) args = argsOrSec
1847
+ else if (typeof argsOrSec === 'number') sec = argsOrSec
1848
+ const timeout = sec || this.options.waitForTimeout
1849
+ const body = typeof fn === 'function' ? `(${fn.toString()})(...${JSON.stringify(args)})` : fn
1850
+ return this._poll(
1851
+ () => this._evaluate(body).catch(() => false),
1852
+ timeout,
1853
+ `Function did not return truthy within ${timeout} sec`,
1854
+ )
1855
+ }
1856
+
1857
+ /**
1858
+ * Sets cookie(s).
1859
+ *
1860
+ * Can be a single cookie object or an array of cookies:
1861
+ *
1862
+ * ```js
1863
+ * I.setCookie({name: 'auth', value: true});
1864
+ *
1865
+ * // as array
1866
+ * I.setCookie([
1867
+ * {name: 'auth', value: true},
1868
+ * {name: 'agree', value: true}
1869
+ * ]);
1870
+ * ```
1871
+ *
1872
+ * @param {CodeceptJS.Cookie|Array<CodeceptJS.Cookie>} cookie a cookie object or array of cookie objects.
1873
+ * @returns {Promise<void>}
1874
+ */
1875
+ async setCookie(cookie) {
1876
+ const cookies = Array.isArray(cookie) ? cookie : [cookie]
1877
+ const url = await this.grabCurrentUrl()
1878
+ for (const c of cookies) {
1879
+ await this.cdp.send('Network.setCookie', { url, ...c }, this.sessionId).catch(async () => {
1880
+ await this.cdp.send('Storage.setCookies', { cookies: [{ url, ...c }] }, this.sessionId)
1881
+ })
1882
+ }
1883
+ }
1884
+
1885
+ /**
1886
+ * Retrieves all cookies visible to the current page.
1887
+ * Resumes test execution, so **should be used inside async function with `await`** operator.
1888
+ *
1889
+ * ```js
1890
+ * let cookies = await I.grabCookies();
1891
+ * ```
1892
+ *
1893
+ * @returns {Promise<Array<CodeceptJS.Cookie>>} array of cookie objects.
1894
+ */
1895
+ async grabCookies() {
1896
+ const res = await this.cdp.send('Network.getCookies', {}, this.sessionId).catch(() => this.cdp.send('Storage.getCookies', {}, this.sessionId))
1897
+ return res.cookies || []
1898
+ }
1899
+
1900
+ /**
1901
+ * Gets a cookie object by name.
1902
+ * If none provided gets all cookies.
1903
+ * Resumes test execution, so **should be used inside async function with `await`** operator.
1904
+ *
1905
+ * ```js
1906
+ * let cookie = await I.grabCookie('auth');
1907
+ * assert(cookie.value, '123456');
1908
+ * ```
1909
+ *
1910
+ * @param {string|null} [name=null] cookie name.
1911
+ * @returns {Promise<CodeceptJS.Cookie|Array<CodeceptJS.Cookie>>} a cookie object, or an array of all cookies when `name` is not provided.
1912
+ */
1913
+ async grabCookie(name) {
1914
+ const cookies = await this.grabCookies()
1915
+ if (!name) return cookies
1916
+ return cookies.find(c => c.name === name)
1917
+ }
1918
+
1919
+ /**
1920
+ * Clears a cookie by name,
1921
+ * if none provided clears all cookies.
1922
+ *
1923
+ * ```js
1924
+ * I.clearCookie();
1925
+ * I.clearCookie('test');
1926
+ * ```
1927
+ *
1928
+ * @param {string|null} [name=null] (optional, `null` by default) cookie name
1929
+ * @returns {Promise<void>}
1930
+ */
1931
+ async clearCookie(name) {
1932
+ const cookies = await this.grabCookies()
1933
+ for (const c of cookies) {
1934
+ if (name && c.name !== name) continue
1935
+ await this.cdp.send('Network.deleteCookies', { name: c.name, domain: c.domain, path: c.path }, this.sessionId).catch(async () => {
1936
+ await this.cdp.send('Storage.deleteCookies', { name: c.name, domain: c.domain, path: c.path }, this.sessionId)
1937
+ })
1938
+ }
1939
+ }
1940
+
1941
+ /**
1942
+ * Saves a screenshot to the output folder (set in codecept.conf.ts or codecept.conf.js).
1943
+ * Filename is relative to the output folder.
1944
+ *
1945
+ * ```js
1946
+ * I.saveScreenshot('debug.png');
1947
+ * ```
1948
+ *
1949
+ * @param {string} fileName file name to save.
1950
+ * @returns {Promise<void>}
1951
+ */
1952
+ async saveScreenshot(fileName) {
1953
+ if (this.capabilities.screenshot === false) {
1954
+ throw new Error('saveScreenshot is not supported: this browser has no rendering engine')
1955
+ }
1956
+ const outputDir = global.output_dir || '.'
1957
+ const res = await this.cdp.send('Page.captureScreenshot', { format: 'png' }, this.sessionId)
1958
+ fs.writeFileSync(path.join(outputDir, fileName), Buffer.from(res.data, 'base64'))
1959
+ }
1960
+
1961
+ /**
1962
+ * Saves a screenshot of a single element to the output folder.
1963
+ *
1964
+ * ```js
1965
+ * I.saveElementScreenshot('#logo', 'logo.png');
1966
+ * ```
1967
+ *
1968
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
1969
+ * @param {string} fileName file name to save.
1970
+ * @returns {Promise<void>}
1971
+ */
1972
+ async saveElementScreenshot(locator, fileName) {
1973
+ if (this.capabilities.screenshot === false) {
1974
+ throw new Error('saveElementScreenshot is not supported: this browser has no rendering engine')
1975
+ }
1976
+ const res = await this._run(this._candidates(locator), 'rect')
1977
+ if (!res.found) throw new ElementNotFound(locator)
1978
+ const outputDir = global.output_dir || '.'
1979
+ const { x, y, width, height } = res.result
1980
+ const shot = await this.cdp.send('Page.captureScreenshot', { format: 'png', clip: { x, y, width, height, scale: 1 } }, this.sessionId)
1981
+ fs.writeFileSync(path.join(outputDir, fileName), Buffer.from(shot.data, 'base64'))
1982
+ }
1983
+
1984
+ /**
1985
+ * Pauses execution for a number of seconds.
1986
+ *
1987
+ * ```js
1988
+ * I.wait(2); // waits 2 secs
1989
+ * ```
1990
+ *
1991
+ * @param {number} sec number of seconds to wait.
1992
+ * @returns {Promise<void>}
1993
+ */
1994
+ async wait(sec) {
1995
+ return new Promise(r => setTimeout(r, sec * 1000))
1996
+ }
1997
+
1998
+ /**
1999
+ * Checks that title does not contain text.
2000
+ *
2001
+ * @param {string} text value to check.
2002
+ * @returns {Promise<void>}
2003
+ */
2004
+ async dontSeeInTitle(text) {
2005
+ return stringIncludes('web page title').negate(text, await this.grabTitle())
2006
+ }
2007
+
2008
+ /**
2009
+ * Checks that current url is equal to provided one.
2010
+ * Unlike `seeInCurrentUrl` performs a strict comparison.
2011
+ *
2012
+ * ```js
2013
+ * I.seeCurrentUrlEquals('/register');
2014
+ * ```
2015
+ *
2016
+ * @param {string} url value to check.
2017
+ * @returns {Promise<void>}
2018
+ */
2019
+ async seeCurrentUrlEquals(url) {
2020
+ return urlEquals(this.options.url).assert(url, await this.grabCurrentUrl())
2021
+ }
2022
+
2023
+ /**
2024
+ * Checks that current url is not equal to provided one.
2025
+ * Unlike `dontSeeInCurrentUrl` performs a strict comparison.
2026
+ *
2027
+ * @param {string} url value to check.
2028
+ * @returns {Promise<void>}
2029
+ */
2030
+ async dontSeeCurrentUrlEquals(url) {
2031
+ return urlEquals(this.options.url).negate(url, await this.grabCurrentUrl())
2032
+ }
2033
+
2034
+ /**
2035
+ * Resolves the current page URL to a `pathname`, ignoring the origin, query string, and hash.
2036
+ *
2037
+ * @returns {Promise<string>} the pathname of the current page.
2038
+ * @protected
2039
+ */
2040
+ async _grabCurrentPath() {
2041
+ const currentUrl = await this.grabCurrentUrl()
2042
+ const baseUrl = this.options.url || 'http://localhost'
2043
+ return new URL(currentUrl, baseUrl).pathname
2044
+ }
2045
+
2046
+ /**
2047
+ * Checks that current url path (ignoring query string and hash) equals to provided one.
2048
+ *
2049
+ * ```js
2050
+ * I.seeCurrentPathEquals('/info');
2051
+ * ```
2052
+ *
2053
+ * @param {string} path value to check.
2054
+ * @returns {Promise<void>}
2055
+ */
2056
+ async seeCurrentPathEquals(path) {
2057
+ return equals('url path').assert(normalizePath(path), normalizePath(await this._grabCurrentPath()))
2058
+ }
2059
+
2060
+ /**
2061
+ * Opposite to `seeCurrentPathEquals`.
2062
+ *
2063
+ * @param {string} path value to check.
2064
+ * @returns {Promise<void>}
2065
+ */
2066
+ async dontSeeCurrentPathEquals(path) {
2067
+ return equals('url path').negate(normalizePath(path), normalizePath(await this._grabCurrentPath()))
2068
+ }
2069
+
2070
+ /**
2071
+ * Waits for the entire URL to match the expected (by default waits for `options.waitForTimeout` seconds).
2072
+ *
2073
+ * ```js
2074
+ * I.waitUrlEquals('/info', 2);
2075
+ * ```
2076
+ *
2077
+ * @param {string} urlPart value to check.
2078
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2079
+ * @returns {Promise<void>}
2080
+ */
2081
+ async waitUrlEquals(urlPart, sec = null) {
2082
+ const timeout = sec || this.options.waitForTimeout
2083
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
2084
+ let lastUrl = ''
2085
+ try {
2086
+ return await this._poll(
2087
+ async () => {
2088
+ lastUrl = await this.grabCurrentUrl().catch(() => lastUrl)
2089
+ return lastUrl === expectedUrl
2090
+ },
2091
+ timeout,
2092
+ 'placeholder',
2093
+ )
2094
+ } catch (e) {
2095
+ throw new Error(`expected url to be ${expectedUrl}, but found ${lastUrl}`)
2096
+ }
2097
+ }
2098
+
2099
+ /**
2100
+ * Waits for current url path (ignoring query string and hash) to equal to the expected.
2101
+ *
2102
+ * ```js
2103
+ * I.waitCurrentPathEquals('/info', 2);
2104
+ * ```
2105
+ *
2106
+ * @param {string} path value to check.
2107
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2108
+ * @returns {Promise<void>}
2109
+ */
2110
+ async waitCurrentPathEquals(path, sec = null) {
2111
+ const timeout = sec || this.options.waitForTimeout
2112
+ const normalizedPath = normalizePath(path)
2113
+ let lastPath = ''
2114
+ try {
2115
+ return await this._poll(
2116
+ async () => {
2117
+ lastPath = await this._grabCurrentPath().catch(() => lastPath)
2118
+ return normalizePath(lastPath) === normalizedPath
2119
+ },
2120
+ timeout,
2121
+ 'placeholder',
2122
+ )
2123
+ } catch (e) {
2124
+ throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(lastPath)}`)
2125
+ }
2126
+ }
2127
+
2128
+ /**
2129
+ * Checks that the given input field or textarea equals (contains) the given value.
2130
+ * For fuzzy locators, the field is searched by label|name|CSS|XPath|strict locator.
2131
+ *
2132
+ * ```js
2133
+ * I.seeInField('Username', 'davert');
2134
+ * ```
2135
+ *
2136
+ * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator.
2137
+ * @param {CodeceptJS.StringOrSecret} value value to check.
2138
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
2139
+ * @returns {Promise<void>}
2140
+ */
2141
+ async seeInField(field, value, context = null) {
2142
+ return this._seeInField('assert', field, value, context)
2143
+ }
2144
+
2145
+ /**
2146
+ * Opposite to `seeInField`.
2147
+ *
2148
+ * @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator.
2149
+ * @param {CodeceptJS.StringOrSecret} value value to check.
2150
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
2151
+ * @returns {Promise<void>}
2152
+ */
2153
+ async dontSeeInField(field, value, context = null) {
2154
+ return this._seeInField('negate', field, value, context)
2155
+ }
2156
+
2157
+ /**
2158
+ * Shared implementation for `seeInField`/`dontSeeInField`.
2159
+ *
2160
+ * @param {'assert'|'negate'} assertType
2161
+ * @param {CodeceptJS.LocatorOrString} field
2162
+ * @param {CodeceptJS.StringOrSecret} value
2163
+ * @param {?CodeceptJS.LocatorOrString} [context=null]
2164
+ * @returns {Promise<void>}
2165
+ * @protected
2166
+ */
2167
+ async _seeInField(assertType, field, value, context = null) {
2168
+ const locatorText = new Locator(field).toString()
2169
+ if (typeof value === 'boolean') {
2170
+ const res = await this._run(this._candidates(field, 'field'), 'checked', null, context)
2171
+ if (!res.found) throw new ElementNotFound(field, 'Field')
2172
+ return truth(`checkbox ${locatorText}`, 'to be checked')[assertType](res.result === value)
2173
+ }
2174
+ const res = await this._run(this._candidates(field, 'field'), 'values', null, context)
2175
+ if (!res.found) throw new ElementNotFound(field, 'Field')
2176
+ const values = res.result || []
2177
+ const expected = String(value)
2178
+ return stringIncludes(`fields by ${locatorText}`)[assertType](expected, values[0])
2179
+ }
2180
+
2181
+ /**
2182
+ * Grab number of visible elements by locator.
2183
+ *
2184
+ * ```js
2185
+ * let numOfVisibleElements = await I.grabNumberOfVisibleElements('p');
2186
+ * ```
2187
+ *
2188
+ * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
2189
+ * @returns {Promise<number>} number of visible matched elements.
2190
+ */
2191
+ async grabNumberOfVisibleElements(locator) {
2192
+ this._assertLayoutSupported('grabNumberOfVisibleElements')
2193
+ const res = await this._run(this._candidates(locator), 'visibleCount')
2194
+ return res.result || 0
2195
+ }
2196
+
2197
+ /**
2198
+ * Asserts that an element appears a given number of times on the page, and that all matching elements are visible.
2199
+ *
2200
+ * ```js
2201
+ * I.seeNumberOfVisibleElements('.buttons', 3);
2202
+ * ```
2203
+ *
2204
+ * @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
2205
+ * @param {number} num expected number of elements.
2206
+ * @returns {Promise<void>}
2207
+ */
2208
+ async seeNumberOfVisibleElements(locator, num) {
2209
+ const found = await this.grabNumberOfVisibleElements(locator)
2210
+ return equals(`expected number of visible elements (${new Locator(locator).toString()}) is ${num}, but found ${found}`).assert(found, num)
2211
+ }
2212
+
2213
+ /**
2214
+ * Retrieves the current page scroll position.
2215
+ *
2216
+ * ```js
2217
+ * let { x, y } = await I.grabPageScrollPosition();
2218
+ * ```
2219
+ *
2220
+ * @returns {Promise<{x: number, y: number}>} scroll position.
2221
+ */
2222
+ async grabPageScrollPosition() {
2223
+ return this.executeScript(() => ({ x: window.pageXOffset, y: window.pageYOffset }))
2224
+ }
2225
+
2226
+ /**
2227
+ * Scrolls to the top of the page.
2228
+ *
2229
+ * ```js
2230
+ * I.scrollPageToTop();
2231
+ * ```
2232
+ *
2233
+ * @returns {Promise<void>}
2234
+ */
2235
+ scrollPageToTop() {
2236
+ return this.executeScript(() => window.scrollTo(0, 0))
2237
+ }
2238
+
2239
+ /**
2240
+ * Scrolls to the bottom of the page.
2241
+ *
2242
+ * ```js
2243
+ * I.scrollPageToBottom();
2244
+ * ```
2245
+ *
2246
+ * @returns {Promise<void>}
2247
+ */
2248
+ scrollPageToBottom() {
2249
+ return this.executeScript(() => {
2250
+ const body = document.body
2251
+ const html = document.documentElement
2252
+ window.scrollTo(0, Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight))
2253
+ })
2254
+ }
2255
+
2256
+ /**
2257
+ * Scrolls to the element matched by locator, or to given coordinates.
2258
+ *
2259
+ * ```js
2260
+ * I.scrollTo('#submit');
2261
+ * I.scrollTo(100, 200);
2262
+ * ```
2263
+ *
2264
+ * @param {CodeceptJS.LocatorOrString|number} locator element to scroll to, or an X coordinate if no element.
2265
+ * @param {number} [offsetX=0] X offset, or Y coordinate if `locator` is a number.
2266
+ * @param {number} [offsetY=0] Y offset applied when scrolling to an element.
2267
+ * @returns {Promise<void>}
2268
+ */
2269
+ async scrollTo(locator, offsetX = 0, offsetY = 0) {
2270
+ if (typeof locator === 'number' && typeof offsetX === 'number') {
2271
+ offsetY = offsetX
2272
+ offsetX = locator
2273
+ locator = null
2274
+ }
2275
+ const armed = this._armActionSettle()
2276
+ if (locator) {
2277
+ const res = await this._run(this._candidates(locator), 'rect')
2278
+ if (!res.found) throw new ElementNotFound(locator, 'Element to scroll into view')
2279
+ await this.executeScript((x, y) => window.scrollBy(x, y), res.result.x + offsetX, res.result.y + offsetY)
2280
+ } else {
2281
+ await this.executeScript((x, y) => window.scrollTo(x, y), offsetX, offsetY)
2282
+ }
2283
+ return this._waitForAction(armed)
2284
+ }
2285
+
2286
+ /**
2287
+ * Retrieves a CSS property from an element located by CSS or XPath.
2288
+ * If more than one element is found - value of first element is returned.
2289
+ *
2290
+ * ```js
2291
+ * const value = await I.grabCssPropertyFrom('h3', 'font-weight');
2292
+ * ```
2293
+ *
2294
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2295
+ * @param {string} cssProperty CSS property name.
2296
+ * @returns {Promise<string>} CSS value
2297
+ */
2298
+ async grabCssPropertyFrom(locator, cssProperty) {
2299
+ const values = await this.grabCssPropertyFromAll(locator, cssProperty)
2300
+ if (!values.length) throw new ElementNotFound(locator)
2301
+ return values[0]
2302
+ }
2303
+
2304
+ /**
2305
+ * Retrieves an array of CSS properties from elements located by CSS or XPath.
2306
+ *
2307
+ * ```js
2308
+ * const values = await I.grabCssPropertyFromAll('h3', 'font-weight');
2309
+ * ```
2310
+ *
2311
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2312
+ * @param {string} cssProperty CSS property name.
2313
+ * @returns {Promise<string[]>} array of CSS values
2314
+ */
2315
+ async grabCssPropertyFromAll(locator, cssProperty) {
2316
+ const camelProperty = toCamelCase(cssProperty)
2317
+ const res = await this._run(this._candidates(locator), 'cssProps', { props: [camelProperty] })
2318
+ if (!res.found) return []
2319
+ return res.result.map(props => props[camelProperty])
2320
+ }
2321
+
2322
+ /**
2323
+ * Checks that all elements matched by locator have the given CSS properties.
2324
+ *
2325
+ * ```js
2326
+ * I.seeCssPropertiesOnElements('h3', { 'font-weight': 'bold', display: 'block' });
2327
+ * ```
2328
+ *
2329
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2330
+ * @param {object} cssProperties object with CSS properties and their values to check.
2331
+ * @returns {Promise<void>}
2332
+ */
2333
+ async seeCssPropertiesOnElements(locator, cssProperties) {
2334
+ const cssPropertiesCamelCase = convertCssPropertiesToCamelCase(cssProperties)
2335
+ const keys = Object.keys(cssPropertiesCamelCase)
2336
+ const res = await this._run(this._candidates(locator), 'cssProps', { props: keys })
2337
+ if (!res.found) throw new ElementNotFound(locator)
2338
+ const matching = res.result.filter(props =>
2339
+ keys.every(key => {
2340
+ let actual = props[key]
2341
+ if (isColorProperty(key)) actual = convertColorToRGBA(actual)
2342
+ return actual == cssPropertiesCamelCase[key]
2343
+ }),
2344
+ ).length
2345
+ return equals(`all elements (${new Locator(locator).toString()}) to have CSS property ${JSON.stringify(cssProperties)}`).assert(matching, res.result.length)
2346
+ }
2347
+
2348
+ /**
2349
+ * Checks that all elements matched by locator have the given attribute values.
2350
+ * An expected value is matched either as an exact match or as a regular expression against the actual value.
2351
+ *
2352
+ * ```js
2353
+ * I.seeAttributesOnElements('//form', { method: 'post' });
2354
+ * ```
2355
+ *
2356
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2357
+ * @param {object} attributes object with attribute names and expected values.
2358
+ * @returns {Promise<void>}
2359
+ */
2360
+ async seeAttributesOnElements(locator, attributes) {
2361
+ const attrs = Object.keys(attributes)
2362
+ const res = await this._run(this._candidates(locator), 'attrsMap', { attrs })
2363
+ if (!res.found) throw new ElementNotFound(locator)
2364
+ const matching = res.result.filter(elAttrs =>
2365
+ attrs.every(attr => {
2366
+ const actual = elAttrs[attr]
2367
+ const expected = attributes[attr]
2368
+ if (!actual) return false
2369
+ if (actual.toString().match(new RegExp(expected.toString()))) return true
2370
+ return expected === actual
2371
+ }),
2372
+ ).length
2373
+ return equals(`all elements (${new Locator(locator).toString()}) to have attributes ${JSON.stringify(attributes)}`).assert(matching, res.result.length)
2374
+ }
2375
+
2376
+ /**
2377
+ * Focuses a given element.
2378
+ *
2379
+ * ```js
2380
+ * I.focus('#name');
2381
+ * ```
2382
+ *
2383
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2384
+ * @returns {Promise<void>}
2385
+ */
2386
+ async focus(locator) {
2387
+ const armed = this._armActionSettle()
2388
+ const res = await this._run(this._candidates(locator), 'focus')
2389
+ if (!res.found) throw new ElementNotFound(locator, 'Element to focus')
2390
+ return this._waitForAction(armed)
2391
+ }
2392
+
2393
+ /**
2394
+ * Removes focus from a given element.
2395
+ *
2396
+ * ```js
2397
+ * I.blur('#name');
2398
+ * ```
2399
+ *
2400
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2401
+ * @returns {Promise<void>}
2402
+ */
2403
+ async blur(locator) {
2404
+ const armed = this._armActionSettle()
2405
+ const res = await this._run(this._candidates(locator), 'blur')
2406
+ if (!res.found) throw new ElementNotFound(locator, 'Element to blur')
2407
+ return this._waitForAction(armed)
2408
+ }
2409
+
2410
+ /**
2411
+ * Types characters into the currently focused element (as set by `click`, `focus`, etc). Each
2412
+ * character dispatches a real `keydown` → `keypress` → (value mutated) → `input` → `keyup`
2413
+ * sequence, and mutates a `contenteditable` host's `textContent` instead of `.value`, so this
2414
+ * works on rich-text/contenteditable targets as well as `input`/`textarea`. Mirrors Puppeteer's
2415
+ * `type(text, options)` semantics.
2416
+ *
2417
+ * Without a `delay`, every character is dispatched in a single round-trip to the page. With a
2418
+ * `delay`, characters are dispatched one round-trip at a time so the requested pause actually
2419
+ * elapses between key presses.
2420
+ *
2421
+ * ```js
2422
+ * I.click('Name');
2423
+ * I.type('CodeceptJS');
2424
+ * I.type(['C', 'o', 'd', 'e']);
2425
+ * ```
2426
+ *
2427
+ * @param {string|string[]} keys characters to type, either as a string or an array of characters.
2428
+ * @param {number} [delay] (optional) delay in milliseconds between key presses.
2429
+ * @returns {Promise<void>}
2430
+ */
2431
+ async type(keys, delay = null) {
2432
+ await checkFocusBeforeType(this)
2433
+ if (!Array.isArray(keys)) keys = String(keys).split('')
2434
+ const dispatchKey = key => `(function(){
2435
+ var el = document.activeElement
2436
+ if (!el) return false
2437
+ var key = ${JSON.stringify(key)}
2438
+ var opts = { key: key, bubbles: true, cancelable: true }
2439
+ el.dispatchEvent(new KeyboardEvent('keydown', opts))
2440
+ el.dispatchEvent(new KeyboardEvent('keypress', opts))
2441
+ var isEditable = el.isContentEditable === true || el.getAttribute('contenteditable') === 'true'
2442
+ if (isEditable) el.textContent = (el.textContent || '') + key
2443
+ else el.value = (el.value || '') + key
2444
+ el.dispatchEvent(new Event('input', { bubbles: true }))
2445
+ el.dispatchEvent(new KeyboardEvent('keyup', opts))
2446
+ return true
2447
+ })()`
2448
+ if (!delay) {
2449
+ const ok = await this._evaluate(`(function(){
2450
+ if (!document.activeElement) return false
2451
+ var keys = ${JSON.stringify(keys)}
2452
+ for (var i = 0; i < keys.length; i++) {
2453
+ var el = document.activeElement
2454
+ if (!el) return false
2455
+ var key = keys[i]
2456
+ var opts = { key: key, bubbles: true, cancelable: true }
2457
+ el.dispatchEvent(new KeyboardEvent('keydown', opts))
2458
+ el.dispatchEvent(new KeyboardEvent('keypress', opts))
2459
+ var isEditable = el.isContentEditable === true || el.getAttribute('contenteditable') === 'true'
2460
+ if (isEditable) el.textContent = (el.textContent || '') + key
2461
+ else el.value = (el.value || '') + key
2462
+ el.dispatchEvent(new Event('input', { bubbles: true }))
2463
+ el.dispatchEvent(new KeyboardEvent('keyup', opts))
2464
+ }
2465
+ return true
2466
+ })()`)
2467
+ if (!ok) throw new Error('No element is in focus. Use click or focus to set the active element before typing.')
2468
+ return
2469
+ }
2470
+ for (const key of keys) {
2471
+ const ok = await this._evaluate(dispatchKey(key))
2472
+ if (!ok) throw new Error('No element is in focus. Use click or focus to set the active element before typing.')
2473
+ await new Promise(r => setTimeout(r, delay))
2474
+ }
2475
+ }
2476
+
2477
+ /**
2478
+ * Presses a key or key combination on the currently focused element.
2479
+ * Under `options.strict`, a modifier+editing-key combination (e.g. `Ctrl+A`) dispatched with no
2480
+ * element focused throws `NonFocusedType`, mirroring `focusCheck.js`'s behavior on other helpers.
2481
+ *
2482
+ * ```js
2483
+ * I.pressKey('Enter');
2484
+ * I.pressKey(['Control', 'a']);
2485
+ * ```
2486
+ *
2487
+ * @param {string|string[]} key a key or an array of keys to combine (modifiers first).
2488
+ * @returns {Promise<void>}
2489
+ */
2490
+ async pressKey(key) {
2491
+ const originalKey = Array.isArray(key) ? key : [key]
2492
+ await checkFocusBeforePressKey(this, originalKey)
2493
+ const mainKey = originalKey[originalKey.length - 1]
2494
+ const modifiers = originalKey.slice(0, -1)
2495
+ const armed = this._armActionSettle()
2496
+ await this._evaluate(`(function(){
2497
+ var el = document.activeElement || document.body
2498
+ var modifiers = ${JSON.stringify(modifiers)}
2499
+ var key = ${JSON.stringify(mainKey)}
2500
+ var opts = { key: key, bubbles: true, cancelable: true }
2501
+ modifiers.forEach(function(m){
2502
+ if (/^(control|ctrl)$/i.test(m)) opts.ctrlKey = true
2503
+ if (/^(meta|cmd|command)$/i.test(m)) opts.metaKey = true
2504
+ if (/^(alt|option)$/i.test(m)) opts.altKey = true
2505
+ if (/^shift$/i.test(m)) opts.shiftKey = true
2506
+ })
2507
+ el.dispatchEvent(new KeyboardEvent('keydown', opts))
2508
+ el.dispatchEvent(new KeyboardEvent('keyup', opts))
2509
+ })()`)
2510
+ return this._waitForAction(armed)
2511
+ }
2512
+
2513
+ /**
2514
+ * Resizes the browser viewport.
2515
+ *
2516
+ * ```js
2517
+ * I.resizeWindow(1024, 768);
2518
+ * ```
2519
+ *
2520
+ * @param {number|'maximize'} width window width, or `'maximize'`.
2521
+ * @param {number} [height] window height.
2522
+ * @returns {Promise<void>}
2523
+ */
2524
+ async resizeWindow(width, height) {
2525
+ if (width === 'maximize') {
2526
+ throw new Error("CDPBrowser can't control windows, so it can't maximize it")
2527
+ }
2528
+ const armed = this._armActionSettle()
2529
+ await this.cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 0, mobile: false }, this.sessionId)
2530
+ return this._waitForAction(armed)
2531
+ }
2532
+
2533
+ /**
2534
+ * Performs a double-click on an element matched by locator.
2535
+ *
2536
+ * ```js
2537
+ * I.doubleClick('Edit');
2538
+ * ```
2539
+ *
2540
+ * @param {CodeceptJS.LocatorOrString} locator clickable element located by text, or any element located by CSS|XPath|strict locator.
2541
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default, currently ignored by this helper).
2542
+ * @returns {Promise<void>}
2543
+ */
2544
+ async doubleClick(locator, context = null) {
2545
+ const armed = this._armActionSettle()
2546
+ const res = await this._run(this._candidates(locator, 'clickable'), 'dblclick')
2547
+ if (!res.found) throw new ElementNotFound(locator, 'Clickable element')
2548
+ return this._waitForAction(armed)
2549
+ }
2550
+
2551
+ /**
2552
+ * Performs a right-click on an element matched by locator.
2553
+ *
2554
+ * ```js
2555
+ * I.rightClick('Menu');
2556
+ * ```
2557
+ *
2558
+ * @param {CodeceptJS.LocatorOrString} locator clickable element located by text, or any element located by CSS|XPath|strict locator.
2559
+ * @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default, currently ignored by this helper).
2560
+ * @returns {Promise<void>}
2561
+ */
2562
+ async rightClick(locator, context = null) {
2563
+ const armed = this._armActionSettle()
2564
+ const res = await this._run(this._candidates(locator, 'clickable'), 'rightclick')
2565
+ if (!res.found) throw new ElementNotFound(locator, 'Clickable element')
2566
+ return this._waitForAction(armed)
2567
+ }
2568
+
2569
+ /**
2570
+ * Clicks at global page coordinates, or at coordinates relative to an element.
2571
+ * Dispatches a real CDP mouse click and therefore requires a real layout engine.
2572
+ *
2573
+ * ```js
2574
+ * I.clickXY(100, 200); // global coordinates
2575
+ * I.clickXY('#area', 50, 30); // relative to #area
2576
+ * ```
2577
+ *
2578
+ * @param {CodeceptJS.LocatorOrString|number} locator element to click relative to, or a global X coordinate.
2579
+ * @param {number} [x] X coordinate relative to element, or global Y coordinate if `locator` is a number.
2580
+ * @param {number} [y] Y coordinate relative to element.
2581
+ * @returns {Promise<void>}
2582
+ */
2583
+ async clickXY(locator, x, y) {
2584
+ this._assertLayoutSupported('clickXY')
2585
+ let px
2586
+ let py
2587
+ if (typeof locator === 'number') {
2588
+ px = locator
2589
+ py = x
2590
+ } else {
2591
+ const res = await this._run(this._candidates(locator), 'rect')
2592
+ if (!res.found) throw new ElementNotFound(locator, 'Element to click')
2593
+ px = res.result.x + x
2594
+ py = res.result.y + y
2595
+ }
2596
+ px = Math.round(px)
2597
+ py = Math.round(py)
2598
+ const armed = this._armActionSettle()
2599
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: px, y: py, button: 'none', buttons: 0 }, this.sessionId)
2600
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: px, y: py, button: 'left', buttons: 1, clickCount: 1 }, this.sessionId)
2601
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: px, y: py, button: 'left', buttons: 0, clickCount: 1 }, this.sessionId)
2602
+ return this._waitForAction(armed)
2603
+ }
2604
+
2605
+ /**
2606
+ * Waits for an element to become visible (by default waits for `options.waitForTimeout` seconds).
2607
+ *
2608
+ * ```js
2609
+ * I.waitForVisible('#popup', 5);
2610
+ * ```
2611
+ *
2612
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2613
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2614
+ * @returns {Promise<void>}
2615
+ */
2616
+ async waitForVisible(locator, sec = null) {
2617
+ this._assertLayoutSupported('waitForVisible')
2618
+ const timeout = sec || this.options.waitForTimeout
2619
+ const candidates = this._candidates(locator)
2620
+ return this._poll(
2621
+ async () => (await this._run(candidates, 'visibleCount').catch(() => ({ result: 0 }))).result > 0,
2622
+ timeout,
2623
+ `element (${new Locator(locator).toString()}) still not visible after ${timeout} sec`,
2624
+ )
2625
+ }
2626
+
2627
+ /**
2628
+ * Waits for an element to become invisible (by default waits for `options.waitForTimeout` seconds).
2629
+ *
2630
+ * ```js
2631
+ * I.waitForInvisible('#popup', 5);
2632
+ * ```
2633
+ *
2634
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2635
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2636
+ * @returns {Promise<void>}
2637
+ */
2638
+ async waitForInvisible(locator, sec = null) {
2639
+ this._assertLayoutSupported('waitForInvisible')
2640
+ const timeout = sec || this.options.waitForTimeout
2641
+ const candidates = this._candidates(locator)
2642
+ return this._poll(
2643
+ async () => (await this._run(candidates, 'visibleCount').catch(() => ({ result: 0 }))).result === 0,
2644
+ timeout,
2645
+ `element (${new Locator(locator).toString()}) still visible after ${timeout} sec`,
2646
+ )
2647
+ }
2648
+
2649
+ /**
2650
+ * Waits for an element to be hidden. Alias of `waitForInvisible`.
2651
+ *
2652
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2653
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2654
+ * @returns {Promise<void>}
2655
+ */
2656
+ async waitToHide(locator, sec = null) {
2657
+ return this.waitForInvisible(locator, sec)
2658
+ }
2659
+
2660
+ /**
2661
+ * Waits for an element to be removed from the DOM (by default waits for `options.waitForTimeout` seconds).
2662
+ *
2663
+ * ```js
2664
+ * I.waitForDetached('#popup', 5);
2665
+ * ```
2666
+ *
2667
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2668
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2669
+ * @returns {Promise<void>}
2670
+ */
2671
+ async waitForDetached(locator, sec = null) {
2672
+ const timeout = sec || this.options.waitForTimeout
2673
+ const candidates = this._candidates(locator)
2674
+ return this._poll(
2675
+ async () => (await this._run(candidates, 'count').catch(() => ({ found: 0 }))).found === 0,
2676
+ timeout,
2677
+ `element (${new Locator(locator).toString()}) still on page after ${timeout} sec`,
2678
+ )
2679
+ }
2680
+
2681
+ /**
2682
+ * Checks that a cookie with the given name is set.
2683
+ *
2684
+ * @param {string} name cookie name.
2685
+ * @returns {Promise<void>}
2686
+ */
2687
+ async seeCookie(name) {
2688
+ const cookies = await this.grabCookies()
2689
+ return empty(`cookie ${name} to be set`).negate(cookies.filter(c => c.name === name))
2690
+ }
2691
+
2692
+ /**
2693
+ * Checks that a cookie with the given name is not set.
2694
+ *
2695
+ * @param {string} name cookie name.
2696
+ * @returns {Promise<void>}
2697
+ */
2698
+ async dontSeeCookie(name) {
2699
+ const cookies = await this.grabCookies()
2700
+ return empty(`cookie ${name} not to be set`).assert(cookies.filter(c => c.name === name))
2701
+ }
2702
+
2703
+ /**
2704
+ * Waits for a cookie with the given name to be set (by default waits for `options.waitForTimeout` seconds).
2705
+ *
2706
+ * ```js
2707
+ * I.waitForCookie('auth', 5);
2708
+ * ```
2709
+ *
2710
+ * @param {string} name cookie name.
2711
+ * @param {number} [sec] (optional, `options.waitForTimeout` by default) time in seconds to wait
2712
+ * @returns {Promise<void>}
2713
+ */
2714
+ async waitForCookie(name, sec = null) {
2715
+ const timeout = sec || this.options.waitForTimeout
2716
+ return this._poll(async () => (await this.grabCookies()).some(c => c.name === name), timeout, `Cookie ${name} is not found after ${timeout}s`)
2717
+ }
2718
+
2719
+ /**
2720
+ * Retrieves the inner HTML from an element located by CSS or XPath.
2721
+ * If more than one element is found - HTML of first element is returned.
2722
+ *
2723
+ * ```js
2724
+ * let postHTML = await I.grabHTMLFrom('#post');
2725
+ * ```
2726
+ *
2727
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2728
+ * @returns {Promise<string>} HTML code for an element
2729
+ */
2730
+ async grabHTMLFrom(locator) {
2731
+ const html = await this.grabHTMLFromAll(locator)
2732
+ if (!html.length) throw new ElementNotFound(locator)
2733
+ return html[0]
2734
+ }
2735
+
2736
+ /**
2737
+ * Retrieves the inner HTML from elements located by CSS or XPath.
2738
+ *
2739
+ * ```js
2740
+ * let postHTMLs = await I.grabHTMLFromAll('.post');
2741
+ * ```
2742
+ *
2743
+ * @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
2744
+ * @returns {Promise<string[]>} HTML code for matched elements
2745
+ */
2746
+ async grabHTMLFromAll(locator) {
2747
+ const res = await this._run(this._candidates(locator), 'innerHtml')
2748
+ return res.found ? res.result : []
2749
+ }
2750
+
2751
+ /**
2752
+ * Executes an asynchronous script (callback-style, in the same way `window.setTimeout` works)
2753
+ * in the browser context and returns the value passed to `done`.
2754
+ *
2755
+ * ```js
2756
+ * const val = await I.executeAsyncScript(function(val, done) {
2757
+ * setTimeout(() => done(val + 1), 100)
2758
+ * }, 5)
2759
+ * ```
2760
+ *
2761
+ * @param {function} fn - an asynchronous function to be executed in the browser context; its last argument is a `done` callback.
2762
+ * @param {...any} args - arguments to pass into the function (before `done`).
2763
+ * @returns {Promise<any>} the value passed to `done`.
2764
+ */
2765
+ async executeAsyncScript(fn, ...args) {
2766
+ const fnBody = typeof fn === 'function' ? fn.toString() : fn
2767
+ return this._evaluate(`new Promise((done) => { (${fnBody})(...${JSON.stringify(args)}, done) })`)
2768
+ }
2769
+
2770
+ /**
2771
+ * Starts recording network traffic via CDP's `Network.requestWillBeSent`/`responseReceived`
2772
+ * events, in the same shape (`{url, method, requestHeaders, requestPostData, response}` per
2773
+ * request, `response` a promise of `{url(), status(), statusText(), body()}`) the shared
2774
+ * `lib/helper/network` actions expect from Puppeteer/Playwright. The CDP listeners are
2775
+ * installed once (lazily) and left in place afterwards, since `CDPConnection` has no listener
2776
+ * removal and `this.cdp` is reused across tests; they filter by `this.sessionId`, so only the
2777
+ * currently active test/page's requests are recorded.
2778
+ *
2779
+ * ```js
2780
+ * I.startRecordingTraffic();
2781
+ * ```
2782
+ *
2783
+ * @returns {Promise<void>}
2784
+ */
2785
+ async startRecordingTraffic() {
2786
+ this.flushNetworkTraffics()
2787
+ this.recording = true
2788
+ this.recordedAtLeastOnce = true
2789
+ await this._ensureClient()
2790
+ await this.cdp.send('Network.enable', {}, this.sessionId).catch(() => null)
2791
+ if (this._trafficListenersInstalled) return
2792
+ this._trafficListenersInstalled = true
2793
+ this.cdp.on('Network.requestWillBeSent', (params, sessionId) => this._onTrafficRequest(params, sessionId))
2794
+ this.cdp.on('Network.responseReceived', (params, sessionId) => this._onTrafficResponse(params, sessionId))
2795
+ this.cdp.on('Network.loadingFailed', (params, sessionId) => this._onTrafficLoadingFailed(params, sessionId))
2796
+ }
2797
+
2798
+ /**
2799
+ * `Network.requestWillBeSent` handler, pushed into `this.requests` when it belongs to the
2800
+ * currently active session and recording is on.
2801
+ *
2802
+ * @protected
2803
+ */
2804
+ _onTrafficRequest(params, sessionId) {
2805
+ if (!this.recording || sessionId !== this.sessionId) return
2806
+ let resolveResponse
2807
+ const response = new Promise(r => {
2808
+ resolveResponse = r
2809
+ })
2810
+ this._pendingTrafficResponses.set(params.requestId, resolveResponse)
2811
+ let requestPostData = params.request.postData
2812
+ if (requestPostData) {
2813
+ try {
2814
+ requestPostData = JSON.parse(requestPostData)
2815
+ } catch (e) {
2816
+ // not JSON, keep as string
2817
+ }
2818
+ }
2819
+ this.requests.push({
2820
+ url: params.request.url,
2821
+ method: params.request.method,
2822
+ requestHeaders: params.request.headers,
2823
+ requestPostData,
2824
+ response,
2825
+ })
2826
+ }
2827
+
2828
+ /**
2829
+ * `Network.responseReceived` handler, resolving the matching pending `response` promise pushed
2830
+ * by `_onTrafficRequest` with a Puppeteer-`HTTPResponse`-like object.
2831
+ *
2832
+ * @protected
2833
+ */
2834
+ _onTrafficResponse(params, sessionId) {
2835
+ if (sessionId !== this.sessionId) return
2836
+ const resolve = this._pendingTrafficResponses.get(params.requestId)
2837
+ if (!resolve) return
2838
+ this._pendingTrafficResponses.delete(params.requestId)
2839
+ const { response } = params
2840
+ resolve({
2841
+ url: () => response.url,
2842
+ status: () => response.status,
2843
+ statusText: () => response.statusText,
2844
+ body: async () => {
2845
+ try {
2846
+ const res = await this.cdp.send('Network.getResponseBody', { requestId: params.requestId }, this.sessionId)
2847
+ return res.base64Encoded ? Buffer.from(res.body, 'base64') : Buffer.from(res.body)
2848
+ } catch (e) {
2849
+ return Buffer.from('')
2850
+ }
2851
+ },
2852
+ })
2853
+ }
2854
+
2855
+ /**
2856
+ * `Network.loadingFailed` handler, resolving a still-pending `response` promise to `null`
2857
+ * (matching Puppeteer's `request.response()` for a failed request) so `grabRecordedNetworkTraffics`
2858
+ * never awaits a promise that would otherwise never settle.
2859
+ *
2860
+ * @protected
2861
+ */
2862
+ _onTrafficLoadingFailed(params, sessionId) {
2863
+ if (sessionId !== this.sessionId) return
2864
+ const resolve = this._pendingTrafficResponses.get(params.requestId)
2865
+ if (!resolve) return
2866
+ this._pendingTrafficResponses.delete(params.requestId)
2867
+ resolve(null)
2868
+ }
2869
+
2870
+ /**
2871
+ * {{> grabRecordedNetworkTraffics }}
2872
+ */
2873
+ async grabRecordedNetworkTraffics() {
2874
+ return grabRecordedNetworkTraffics.call(this)
2875
+ }
2876
+
2877
+ /**
2878
+ * {{> seeTraffic }}
2879
+ */
2880
+ async seeTraffic(opts) {
2881
+ return seeTraffic.call(this, opts)
2882
+ }
2883
+
2884
+ /**
2885
+ * {{> dontSeeTraffic }}
2886
+ */
2887
+ dontSeeTraffic(opts) {
2888
+ return dontSeeTraffic.call(this, opts)
2889
+ }
2890
+
2891
+ /**
2892
+ * Stops recording network traffic started by `startRecordingTraffic`. Already-recorded requests
2893
+ * in `this.requests` are kept; only new requests stop being appended.
2894
+ *
2895
+ * ```js
2896
+ * I.stopRecordingTraffic();
2897
+ * ```
2898
+ *
2899
+ * @returns {void}
2900
+ */
2901
+ stopRecordingTraffic() {
2902
+ this.recording = false
2903
+ }
2904
+
2905
+ /**
2906
+ * {{> flushNetworkTraffics }}
2907
+ */
2908
+ flushNetworkTraffics() {
2909
+ return flushNetworkTraffics.call(this)
2910
+ }
2911
+
2912
+ /**
2913
+ * Starts recording a CDP `Page.startScreencast` session for the current test's target: frames
2914
+ * arrive as `Page.screencastFrame` events, are acknowledged immediately (`Page.screencastFrameAck`,
2915
+ * required or the browser stops sending more), and buffered in `this._screencastFrames`. The
2916
+ * underlying `Page.screencastFrame` listener is installed once (lazily) and left in place, like
2917
+ * `startRecordingTraffic`'s listeners, since `CDPConnection` has no listener-removal API; it
2918
+ * filters by `this.sessionId` so only the currently active test's frames are buffered. Call
2919
+ * `stopScreencast` to end the capture and assemble the buffered frames into an APNG.
2920
+ *
2921
+ * ```js
2922
+ * I.startScreencast();
2923
+ * ```
2924
+ *
2925
+ * @param {object} [options] {maxWidth: number, maxHeight: number, quality: number, everyNthFrame: number} — CDP `Page.startScreencast` pass-throughs. `format` is always `'png'`.
2926
+ * @returns {Promise<void>}
2927
+ */
2928
+ async startScreencast(options = {}) {
2929
+ if (!this._screencastListenerInstalled) {
2930
+ this._screencastListenerInstalled = true
2931
+ this.cdp.on('Page.screencastFrame', (params, sessionId) => this._onScreencastFrame(params, sessionId))
2932
+ }
2933
+ this._screencastFrames = []
2934
+ this._screencastActive = true
2935
+ const params = { format: 'png' }
2936
+ if (options.maxWidth) params.maxWidth = options.maxWidth
2937
+ if (options.maxHeight) params.maxHeight = options.maxHeight
2938
+ if (options.quality != null) params.quality = options.quality
2939
+ if (options.everyNthFrame) params.everyNthFrame = options.everyNthFrame
2940
+ await this.cdp.send('Page.startScreencast', params, this.sessionId)
2941
+ }
2942
+
2943
+ /**
2944
+ * `Page.screencastFrame` handler: ignores frames from a session other than the currently active
2945
+ * one (stale frames from a previous test, since the listener is never removed), acknowledges the
2946
+ * frame so the browser keeps sending more, and buffers `{data, timestamp}` for `stopScreencast`
2947
+ * to assemble.
2948
+ *
2949
+ * @protected
2950
+ */
2951
+ _onScreencastFrame(params, sessionId) {
2952
+ if (!this._screencastActive || sessionId !== this.sessionId) return
2953
+ this._screencastFrames.push({ data: params.data, timestamp: params.metadata && params.metadata.timestamp })
2954
+ this.cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }, this.sessionId).catch(() => null)
2955
+ }
2956
+
2957
+ /**
2958
+ * Stops the screencast started by `startScreencast` and assembles the buffered frames into a
2959
+ * single APNG (Animated PNG) file, returned as a Buffer. Frame delays are derived from the CDP
2960
+ * frame metadata's `timestamp` deltas (frame arrival is activity-driven — Obscura and Chrome both
2961
+ * only emit a frame on damage — so this reproduces the actual pacing of what happened, not a
2962
+ * fixed frame rate); the last frame is held for `options.lastFrameDelayMs` (default 1000ms) since
2963
+ * it has no "next" frame to derive a delay from. Every frame is checked for the PNG signature
2964
+ * before assembly — CDP's `format: 'png'` is honored by both Chrome and Obscura (verified
2965
+ * directly), but if some other engine ever sends a different format regardless, this reports it
2966
+ * via `debugSection` and returns `null` instead of muxing a broken file. Returns `null` if no
2967
+ * frames were captured (screencast never started, or stopped immediately after starting).
2968
+ *
2969
+ * ```js
2970
+ * const apngBuffer = await I.stopScreencast();
2971
+ * ```
2972
+ *
2973
+ * @param {object} [options] {lastFrameDelayMs: number} — hold time in milliseconds for the final frame (default 1000).
2974
+ * @returns {Promise<object>} a Buffer with the assembled APNG, or null if there was nothing to assemble.
2975
+ */
2976
+ async stopScreencast(options = {}) {
2977
+ this._screencastActive = false
2978
+ if (this.cdp && this.cdp.isConnected && this.sessionId) {
2979
+ await this.cdp.send('Page.stopScreencast', {}, this.sessionId).catch(() => null)
2980
+ }
2981
+ const frames = this._screencastFrames
2982
+ this._screencastFrames = []
2983
+ if (!frames.length) return null
2984
+
2985
+ const buffers = frames.map(f => Buffer.from(f.data, 'base64'))
2986
+ const nonPngIndex = buffers.findIndex(b => !isPng(b))
2987
+ if (nonPngIndex !== -1) {
2988
+ this.debugSection('Screencast', `frame ${nonPngIndex} is not a PNG (first bytes: ${buffers[nonPngIndex].subarray(0, 8).toString('hex')}) — this engine isn't honoring format: 'png'; skipping APNG assembly`)
2989
+ return null
2990
+ }
2991
+
2992
+ const apngFrames = buffers.map((buffer, i) => {
2993
+ const delayMs = i < frames.length - 1 && frames[i + 1].timestamp && frames[i].timestamp ? Math.max(1, Math.round((frames[i + 1].timestamp - frames[i].timestamp) * 1000)) : options.lastFrameDelayMs ?? 1000
2994
+ return { buffer, delayMs }
2995
+ })
2996
+
2997
+ return assembleApng(apngFrames, {
2998
+ lastFrameDelayMs: options.lastFrameDelayMs ?? 1000,
2999
+ onDropFrame: info => this.debugSection('Screencast', `dropped a frame with size ${info.width}x${info.height}, expected ${info.expectedWidth}x${info.expectedHeight}`),
3000
+ })
3001
+ }
3002
+ }
3003
+
3004
+ export default CDPBrowser