codeceptjs 4.2.0-beta.4 → 4.2.1-beta.1

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 (65) hide show
  1. package/README.md +2 -0
  2. package/bin/codecept.js +11 -0
  3. package/docs/agents.md +26 -1
  4. package/docs/ai.md +57 -0
  5. package/docs/alternative-browsers.md +175 -148
  6. package/docs/assertions.md +74 -0
  7. package/docs/bootstrap.md +2 -0
  8. package/docs/commands.md +45 -0
  9. package/docs/configuration.md +37 -0
  10. package/docs/debugging.md +1 -1
  11. package/docs/helpers/ApiDataFactory.md +20 -20
  12. package/docs/helpers/Appium.md +1615 -670
  13. package/docs/helpers/Decision.md +146 -0
  14. package/docs/helpers/GraphQL.md +31 -31
  15. package/docs/helpers/GraphQLDataFactory.md +20 -20
  16. package/docs/helpers/Kitesurf.md +1808 -25
  17. package/docs/helpers/Lightpanda.md +139 -0
  18. package/docs/helpers/Obscura.md +1799 -74
  19. package/docs/helpers/Playwright.md +490 -428
  20. package/docs/helpers/Puppeteer.md +317 -255
  21. package/docs/helpers/REST.md +43 -43
  22. package/docs/helpers/WebDriver.md +237 -175
  23. package/docs/installation.md +16 -0
  24. package/docs/mcp.md +7 -1
  25. package/docs/parallel.md +35 -1
  26. package/docs/plugins/pause.md +2 -1
  27. package/docs/plugins/sitemap.md +48 -0
  28. package/docs/plugins.md +4 -0
  29. package/lib/ai.js +81 -2
  30. package/lib/codecept.js +32 -2
  31. package/lib/command/init.js +22 -0
  32. package/lib/command/initAgent.js +78 -0
  33. package/lib/command/run-multiple.js +3 -0
  34. package/lib/command/run-workers.js +10 -3
  35. package/lib/command/run.js +3 -2
  36. package/lib/command/utils.js +15 -0
  37. package/lib/command/workers/runTests.js +10 -10
  38. package/lib/container.js +4 -2
  39. package/lib/effects.js +19 -1
  40. package/lib/helper/Appium.js +23 -0
  41. package/lib/helper/CDPBrowser.js +387 -1338
  42. package/lib/helper/Decision.js +256 -0
  43. package/lib/helper/Kitesurf.js +3 -21
  44. package/lib/helper/Lightpanda.js +137 -0
  45. package/lib/helper/Obscura.js +21 -238
  46. package/lib/helper/Playwright.js +50 -4
  47. package/lib/helper/Puppeteer.js +62 -8
  48. package/lib/helper/WebDriver.js +45 -1
  49. package/lib/helper/extras/clipboard.js +31 -0
  50. package/lib/listener/result.js +4 -0
  51. package/lib/mocha/bdd.js +19 -1
  52. package/lib/plugin/junitReporter.js +4 -1
  53. package/lib/plugin/pause.js +4 -1
  54. package/lib/plugin/sitemap.js +228 -0
  55. package/lib/recorder.js +19 -18
  56. package/lib/result.js +16 -0
  57. package/lib/step/base.js +12 -0
  58. package/lib/utils.js +2 -10
  59. package/lib/webServer.js +193 -0
  60. package/lib/workers.js +118 -31
  61. package/package.json +4 -2
  62. package/typings/index.d.ts +60 -7
  63. package/typings/promiseBasedTypes.d.ts +985 -814
  64. package/typings/types.d.ts +1084 -815
  65. package/docs/helpers/CDPBrowser.md +0 -2138
@@ -1,43 +1,26 @@
1
- import { spawn } from 'child_process'
2
- import fs from 'fs'
3
- import net from 'net'
4
- import path from 'path'
5
- import axios from 'axios'
6
1
  import CDPBrowser from './CDPBrowser.js'
7
- import { isFile, isWindows } from '../utils.js'
8
2
 
9
3
  /**
10
4
  * ## Configuration
11
5
  *
12
- * This helper should be configured in codecept.conf.js. It accepts everything `CDPBrowser`
13
- * accepts (see its config table), plus:
6
+ * This helper should be configured in codecept.conf.js
14
7
  *
15
8
  * @typedef ObscuraConfig
16
9
  * @type {object}
17
- * @prop {string} [endpoint] - explicit CDP endpoint. Setting this switches the helper to ATTACH
18
- * mode: it only connects, and never spawns or kills a process, no matter what else is configured.
19
- * Leave it unset for SELF-MANAGED mode (see below).
20
- * @prop {string} [binaryPath] - path to the `obscura` executable, used in SELF-MANAGED mode
21
- * (`endpoint` unset). Checked before `OBSCURA_PATH` and `PATH`.
22
- * @prop {number} [port] - port `obscura serve` listens on, in SELF-MANAGED mode. When unset, a
23
- * free port is picked automatically, which is what makes `run-workers` collision-free — every
24
- * worker gets its own instance on its own port with zero config.
25
- * @prop {number} [serverStartTimeout=15000] - milliseconds to wait for a spawned `obscura serve`
26
- * to answer `/json/version` before `_connect` gives up.
10
+ * @prop {string} [endpoint] - connect to a running Obscura instead of starting one (ATTACH mode).
11
+ * @prop {string} [binaryPath] - path to the `obscura` executable. Checked before `OBSCURA_PATH` and `PATH`.
12
+ * @prop {number} [port] - port for `obscura serve`. A free port is picked when unset, so parallel workers never collide.
13
+ * @prop {number} [serverStartTimeout=15000] - how long to wait for `obscura serve` to start, in milliseconds.
27
14
  */
28
15
  const config = {}
29
16
 
30
17
  /**
31
- * Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a minimal headless
32
- * browser exposed over the Chrome DevTools Protocol. From v0.2.0, default release builds ship a
33
- * real rendering engine (layout, paint, screenshots); `-no-render` variants and v0.1.x builds keep
34
- * the original single-V8-isolate, nothing-rendered mode. This helper does not hardcode which mode a
35
- * given binary is in — `CDPBrowser._probeCapabilities` detects `layout`/`screenshot` per binary at
36
- * runtime, so the same helper works against either.
18
+ * Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a lightweight headless
19
+ * browser controlled over the Chrome DevTools Protocol. Default builds from v0.2.0 render pages
20
+ * (layout, screenshots); `-no-render` builds and v0.1.x run JavaScript and the DOM only. The helper
21
+ * detects which build it runs against, so the same config works for both.
37
22
  *
38
- * This helper is a thin `CDPBrowser` subclass: it changes nothing about how locating or acting on
39
- * elements works, it only pins the config presets Obscura requires and manages the `obscura serve`
40
- * process lifecycle, the same way Playwright manages its own browser process.
23
+ * The helper starts and stops `obscura serve` for you, the same way Playwright manages its browser.
41
24
  *
42
25
  * > Obscura support is experimental in CodeceptJS 4.2. Pin the browser version in CI and keep a
43
26
  * > Playwright/WebDriver job for browser-compatibility coverage.
@@ -57,8 +40,8 @@ const config = {}
57
40
  * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
58
41
  * the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
59
42
  * spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
60
- * the config, or a free port picked automatically), waits for it to answer, connects, and kills
61
- * it in `_finishTest`.
43
+ * the config, or a free port picked automatically), waits for it to answer, connects, and stops
44
+ * it when tests finish.
62
45
  * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
63
46
  * answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
64
47
  * before this process ever ran). The helper attaches to it and never kills it — it isn't the
@@ -97,13 +80,11 @@ const config = {}
97
80
  *
98
81
  * | option | value | why |
99
82
  * | --- | --- | --- |
100
- * | `input` | `synthetic` | coordinate-click navigation is unreliable over CDP on Obscura even on rendering builds (no `frameNavigated` event, stale `page.url()`); `click` always takes the `forceClick` path — on Obscura, `click` and `forceClick` are the same thing |
101
- * | `xpathPolyfill` | `auto` | probed per binary/page: Obscura's native `document.evaluate` still doesn't support attribute selection or `not()`, so the polyfill is used until that lands |
83
+ * | `input` | `synthetic` | clicks that navigate are unreliable in Obscura, so `click` behaves like `forceClick` |
84
+ * | `xpathPolyfill` | `auto` | Obscura's XPath lacks attribute selection and `not()`, so the polyfill is used when needed |
102
85
  *
103
- * `capabilities.layout`/`capabilities.screenshot`/`capabilities.xpath` are intentionally left
104
- * unset here — `CDPBrowser._probeCapabilities` detects them at runtime from the actual binary
105
- * (`'real'`/`true` on v0.2.0+ default builds, `'none'`/`false` on `-no-render` builds and v0.1.x).
106
- * Set them explicitly in your own config to skip probing or to force a mode.
86
+ * `capabilities` are detected at runtime from the Obscura build. Set them in your config to skip
87
+ * detection or to force a mode.
107
88
  *
108
89
  * ## Limitations
109
90
  *
@@ -153,216 +134,18 @@ class Obscura extends CDPBrowser {
153
134
  * @param {ObscuraConfig} config
154
135
  */
155
136
  constructor(config) {
156
- const explicitEndpoint = !!(config && config.endpoint)
157
137
  super({
158
138
  input: 'synthetic',
159
139
  xpathPolyfill: 'auto',
160
140
  ...config,
161
141
  })
162
- this.mode = explicitEndpoint ? 'attach' : 'self-managed'
163
- if (!explicitEndpoint) {
164
- this.options.endpoint = null
165
- }
166
- this.serverProcess = null
167
- this.serverError = null
168
- this.binaryPath = null
169
- this._selfManagedResolved = false
170
- }
171
-
172
- /**
173
- * In ATTACH mode, connects exactly as `CDPBrowser._connect` would. In SELF-MANAGED mode,
174
- * resolves and spawns `obscura serve` (or courtesy-attaches to an already-running one on
175
- * :9222) exactly once via `_resolveSelfManaged`, then connects.
176
- *
177
- * A spawn failure (e.g. a bad binary) is delivered asynchronously by Node as an `error`
178
- * event; it is recorded on `this.serverError` and surfaced as a rejection from `_waitForServer`
179
- * instead of crashing the process as an uncaught exception.
180
- *
181
- * @protected
182
- */
183
- async _connect() {
184
- if (this.mode !== 'attach' && !this._selfManagedResolved) {
185
- await this._resolveSelfManaged()
186
- this._selfManagedResolved = true
187
- }
188
- return super._connect()
189
- }
190
-
191
- /**
192
- * Resolves how to reach Obscura when no explicit `endpoint` was configured, trying, in order:
193
- * spawn a binary (`binaryPath` config, then `OBSCURA_PATH` env, then `obscura` on `PATH`),
194
- * courtesy-attach to `http://127.0.0.1:9222` if something already answers there, or throw a
195
- * loud, actionable error. Sets `this.options.endpoint` as a side effect.
196
- *
197
- * @protected
198
- */
199
- async _resolveSelfManaged() {
200
- const binaryPath = this._resolveBinary()
201
- if (binaryPath) {
202
- this.binaryPath = binaryPath
203
- const port = this.options.port || (await this._findFreePort())
204
- this.options.port = port
205
- this.serverError = null
206
- this.serverProcess = spawn(binaryPath, ['serve', '--port', String(port), '--allow-private-network', '--allow-file-access'], { stdio: 'ignore' })
207
- this.serverProcess.on('error', err => {
208
- this.serverError = err
209
- })
210
- await this._waitForServer()
211
- this.options.endpoint = `ws://127.0.0.1:${port}/devtools/browser`
212
- return
213
- }
214
- if (await this._probeUp('http://127.0.0.1:9222/json/version')) {
215
- this.debugSection('Obscura', 'no binaryPath/OBSCURA_PATH/obscura-on-PATH found — courtesy-attaching to an already-running obscura serve on 127.0.0.1:9222 (this helper will not manage or kill it)')
216
- this.options.endpoint = 'http://127.0.0.1:9222'
217
- return
218
- }
219
- throw new Error(
220
- [
221
- 'Obscura has no endpoint configured, no binary could be resolved (checked config.binaryPath, OBSCURA_PATH, and PATH), and nothing answered http://127.0.0.1:9222/json/version.',
222
- 'Start it yourself: obscura serve --port 9222 --allow-private-network',
223
- 'Or let this helper launch it: put a release binary on PATH, set OBSCURA_PATH=/path/to/obscura, or pass binaryPath in the config.',
224
- 'Releases: https://github.com/h4ckf0r0day/obscura/releases',
225
- ].join('\n '),
226
- )
227
- }
228
-
229
- /**
230
- * Resolves the `obscura` binary to spawn, in priority order: `options.binaryPath`, then the
231
- * `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The `PATH` lookup walks the
232
- * directories itself instead of shelling out to `which`, which does not exist on Windows: on
233
- * Windows every `PATHEXT` suffix is tried, so an `obscura.exe` on `PATH` is found too.
234
- *
235
- * @returns {string|null} an absolute or relative path to the binary, or null if none resolved.
236
- * @protected
237
- */
238
- _resolveBinary() {
239
- if (this.options.binaryPath) return this.options.binaryPath
240
- if (process.env.OBSCURA_PATH) return process.env.OBSCURA_PATH
241
- const windows = isWindows()
242
- const extensions = windows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
243
- for (const entry of (process.env.PATH || '').split(path.delimiter)) {
244
- const dir = windows ? entry.replace(/^"|"$/g, '') : entry
245
- if (!dir) continue
246
- for (const extension of extensions) {
247
- const candidate = path.join(dir, `obscura${extension}`)
248
- if (!isFile(candidate)) continue
249
- try {
250
- fs.accessSync(candidate, fs.constants.X_OK)
251
- return candidate
252
- } catch (e) {
253
- continue
254
- }
255
- }
256
- }
257
- return null
258
- }
259
-
260
- /**
261
- * Picks a free TCP port on 127.0.0.1 by briefly listening on port 0 and reading back the OS-assigned
262
- * port. Used as the SELF-LAUNCH default when `options.port` isn't explicitly set, so multiple
263
- * `run-workers` workers never collide on the same port.
264
- *
265
- * @returns {Promise<number>} a free port.
266
- * @protected
267
- */
268
- async _findFreePort() {
269
- return new Promise((resolve, reject) => {
270
- const srv = net.createServer()
271
- srv.unref()
272
- srv.on('error', reject)
273
- srv.listen(0, '127.0.0.1', () => {
274
- const { port } = srv.address()
275
- srv.close(() => resolve(port))
276
- })
142
+ this._manageServer(config, {
143
+ name: 'obscura',
144
+ envVar: 'OBSCURA_PATH',
145
+ args: port => ['serve', '--port', String(port), '--allow-private-network', '--allow-file-access'],
146
+ releases: 'https://github.com/h4ckf0r0day/obscura/releases',
277
147
  })
278
148
  }
279
-
280
- /**
281
- * Probes a `/json/version`-style URL with a short timeout, used for the COURTESY-ATTACH check.
282
- *
283
- * @param {string} url
284
- * @returns {Promise<boolean>} true if the URL answered.
285
- * @protected
286
- */
287
- async _probeUp(url) {
288
- try {
289
- await axios.get(url, { timeout: 1000 })
290
- return true
291
- } catch (e) {
292
- return false
293
- }
294
- }
295
-
296
- /**
297
- * Polls `http://127.0.0.1:<port>/json/version` until `obscura serve` responds, `this.serverError`
298
- * is set by the spawned process' `error` event, or `options.serverStartTimeout` elapses. The
299
- * process typically comes up within tens of milliseconds — a 20ms retry interval (down from a
300
- * previous 200ms) keeps the wasted tail after the server is actually ready small, since this cost
301
- * is paid once per run and counts directly toward real-world startup latency.
302
- *
303
- * @protected
304
- */
305
- async _waitForServer() {
306
- const timeout = this.options.serverStartTimeout || 15000
307
- const deadline = Date.now() + timeout
308
- while (Date.now() < deadline) {
309
- if (this.serverError) {
310
- throw new Error(`Failed to start obscura at ${this.binaryPath}: ${this.serverError.message}`)
311
- }
312
- try {
313
- await axios.get(`http://127.0.0.1:${this.options.port}/json/version`)
314
- return
315
- } catch (e) {
316
- await new Promise(r => setTimeout(r, 20))
317
- }
318
- }
319
- if (this.serverError) {
320
- throw new Error(`Failed to start obscura at ${this.binaryPath}: ${this.serverError.message}`)
321
- }
322
- throw new Error(`obscura serve did not start on port ${this.options.port} within ${timeout}ms`)
323
- }
324
-
325
- /**
326
- * Closes the CDP connection (via `CDPBrowser._finishTest`), then kills the `obscura serve`
327
- * process spawned by `_connect`, if any (never runs in ATTACH or COURTESY-ATTACH mode, since
328
- * `this.serverProcess` is only ever set in SELF-LAUNCH mode). Runs in a `finally` so the process
329
- * is always reaped even if closing the CDP connection throws. Sends `SIGTERM` first and waits for
330
- * the process to exit; a process that ignores `SIGTERM` is escalated to `SIGKILL` after 5s. The
331
- * promise only resolves once the child has actually exited (confirmed via the `exit` event, not
332
- * merely once `SIGKILL` was sent — the kernel needs a moment to reap it), with a final safety-net
333
- * timeout so a stuck child can never keep the event loop alive even if that confirmation is
334
- * somehow lost.
335
- *
336
- * @protected
337
- */
338
- async _finishTest() {
339
- try {
340
- await super._finishTest()
341
- } finally {
342
- if (this.serverProcess) {
343
- const proc = this.serverProcess
344
- this.serverProcess = null
345
- await new Promise(resolve => {
346
- if (proc.exitCode !== null || proc.signalCode !== null) {
347
- resolve()
348
- return
349
- }
350
- let settled = false
351
- const finish = () => {
352
- if (settled) return
353
- settled = true
354
- clearTimeout(killTimer)
355
- clearTimeout(safetyTimer)
356
- resolve()
357
- }
358
- proc.once('exit', finish)
359
- const killTimer = setTimeout(() => proc.kill('SIGKILL'), 5000)
360
- const safetyTimer = setTimeout(finish, 5500)
361
- proc.kill()
362
- })
363
- }
364
- }
365
- }
366
149
  }
367
150
 
368
151
  export default Obscura
@@ -8,6 +8,7 @@ import Locator from '../locator.js'
8
8
  import recorder from '../recorder.js'
9
9
  import store from '../store.js'
10
10
  import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
11
+ import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript } from './extras/clipboard.js'
11
12
  import { includes as stringIncludes } from '../assert/include.js'
12
13
  import { urlEquals, equals } from '../assert/equal.js'
13
14
  import { empty } from '../assert/empty.js'
@@ -26,6 +27,7 @@ import {
26
27
  normalizeSpacesInString,
27
28
  normalizePath,
28
29
  resolveUrl,
30
+ appendBaseUrl,
29
31
  relativeDir,
30
32
  getMimeType,
31
33
  base64EncodeFile,
@@ -1364,7 +1366,7 @@ class Playwright extends Helper {
1364
1366
  }
1365
1367
 
1366
1368
  if (!/^\w+\:(\/\/|.+)/.test(url)) {
1367
- url = this.options.url + (!this.options.url.endsWith('/') && url.startsWith('/') ? url : `/${url}`)
1369
+ url = appendBaseUrl(this.options.url, url)
1368
1370
  this.debug(`Changed URL to base url + relative path: ${url}`)
1369
1371
  }
1370
1372
 
@@ -2667,6 +2669,49 @@ class Playwright extends Helper {
2667
2669
  return this.browserContext.clearCookies()
2668
2670
  }
2669
2671
 
2672
+ /**
2673
+ * {{> seeInClipboard }}
2674
+ */
2675
+ async seeInClipboard(text) {
2676
+ const clipboard = await this.grabFromClipboard()
2677
+ stringIncludes('clipboard').assert(text, clipboard)
2678
+ }
2679
+
2680
+ /**
2681
+ * {{> seeClipboardEquals }}
2682
+ */
2683
+ async seeClipboardEquals(text) {
2684
+ const clipboard = await this.grabFromClipboard()
2685
+ return equals('clipboard').assert(clipboard, text)
2686
+ }
2687
+
2688
+ /**
2689
+ * {{> grabFromClipboard }}
2690
+ */
2691
+ async grabFromClipboard() {
2692
+ await this._grantClipboardAccess()
2693
+ const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS)
2694
+ this.debugSection('Clipboard', clipboard)
2695
+ return clipboard
2696
+ }
2697
+
2698
+ /**
2699
+ * {{> clearClipboard }}
2700
+ */
2701
+ async clearClipboard() {
2702
+ await this._grantClipboardAccess()
2703
+ return this.page.evaluate(writeClipboardScript, '')
2704
+ }
2705
+
2706
+ async _grantClipboardAccess() {
2707
+ await this.page.bringToFront().catch(() => {})
2708
+ if (this.options.browser !== 'chromium') return
2709
+ await this.page
2710
+ .context()
2711
+ .grantPermissions(['clipboard-read', 'clipboard-write'])
2712
+ .catch(() => {})
2713
+ }
2714
+
2670
2715
  /**
2671
2716
  * Executes a script on the page:
2672
2717
  *
@@ -3080,7 +3125,7 @@ class Playwright extends Helper {
3080
3125
 
3081
3126
  if (url.startsWith('/')) {
3082
3127
  // local url
3083
- url = this.options.url + url
3128
+ url = appendBaseUrl(this.options.url, url)
3084
3129
  this.debugSection('URL', url)
3085
3130
  }
3086
3131
 
@@ -4383,7 +4428,8 @@ async function proceedSee(assertType, text, context, strict = false) {
4383
4428
  description = `element ${locator.toString()}`
4384
4429
  const els = await this._locate(locator)
4385
4430
  assertElementExists(els, locator.toString())
4386
- allText = await Promise.all(els.map(el => el.innerText()))
4431
+ allText = (await Promise.all(els.map(el => el.evaluateAll(nodes => nodes.map(node => node.innerText ?? node.textContent))))).flat()
4432
+ assertElementExists(allText, locator.toString())
4387
4433
  }
4388
4434
 
4389
4435
  if (store?.currentStep?.opts?.ignoreCase === true) {
@@ -4901,7 +4947,7 @@ async function highlightActiveElement(element) {
4901
4947
  }
4902
4948
 
4903
4949
  async function elToString(el, numberOfElements) {
4904
- const html = await el.evaluate(node => node.outerHTML)
4950
+ const html = await el.evaluate(node => node.outerHTML, undefined, { timeout: 100 }).catch(() => '<unavailable>')
4905
4951
  return (
4906
4952
  html
4907
4953
  .replace(/\n/g, '')
@@ -9,6 +9,7 @@ import Locator from '../locator.js'
9
9
  import recorder from '../recorder.js'
10
10
  import store from '../store.js'
11
11
  import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
12
+ import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript } from './extras/clipboard.js'
12
13
  import { includes as stringIncludes } from '../assert/include.js'
13
14
  import { urlEquals, equals } from '../assert/equal.js'
14
15
  import { empty } from '../assert/empty.js'
@@ -29,6 +30,7 @@ import {
29
30
  normalizeSpacesInString,
30
31
  normalizePath,
31
32
  resolveUrl,
33
+ appendBaseUrl,
32
34
  getMimeType,
33
35
  base64EncodeFile,
34
36
  } from '../utils.js'
@@ -750,7 +752,7 @@ class Puppeteer extends Helper {
750
752
  */
751
753
  async amOnPage(url) {
752
754
  if (!/^\w+\:\/\//.test(url)) {
753
- url = this.options.url + url
755
+ url = appendBaseUrl(this.options.url, url)
754
756
  }
755
757
 
756
758
  if (this.options.basicAuth && this.isAuthenticated !== true) {
@@ -1968,6 +1970,55 @@ class Puppeteer extends Helper {
1968
1970
  return this.page.deleteCookie(cookie[0])
1969
1971
  }
1970
1972
 
1973
+ /**
1974
+ * {{> seeInClipboard }}
1975
+ */
1976
+ async seeInClipboard(text) {
1977
+ const clipboard = await this.grabFromClipboard()
1978
+ stringIncludes('clipboard').assert(text, clipboard)
1979
+ }
1980
+
1981
+ /**
1982
+ * {{> seeClipboardEquals }}
1983
+ */
1984
+ async seeClipboardEquals(text) {
1985
+ const clipboard = await this.grabFromClipboard()
1986
+ return equals('clipboard').assert(clipboard, text)
1987
+ }
1988
+
1989
+ /**
1990
+ * {{> grabFromClipboard }}
1991
+ */
1992
+ async grabFromClipboard() {
1993
+ await this._grantClipboardAccess()
1994
+ const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS)
1995
+ this.debugSection('Clipboard', clipboard)
1996
+ return clipboard
1997
+ }
1998
+
1999
+ /**
2000
+ * {{> clearClipboard }}
2001
+ */
2002
+ async clearClipboard() {
2003
+ await this._grantClipboardAccess()
2004
+ return this.page.evaluate(writeClipboardScript, '')
2005
+ }
2006
+
2007
+ async _grantClipboardAccess() {
2008
+ await this.page.bringToFront().catch(() => {})
2009
+ let origin
2010
+ try {
2011
+ origin = new URL(this.page.url()).origin
2012
+ } catch (err) {
2013
+ return
2014
+ }
2015
+ if (!origin.startsWith('http')) return
2016
+ await this.page
2017
+ .browserContext()
2018
+ .overridePermissions(origin, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write'])
2019
+ .catch(() => {})
2020
+ }
2021
+
1971
2022
  /**
1972
2023
  * If a function returns a Promise, tt will wait for its resolution.
1973
2024
  *
@@ -3166,14 +3217,18 @@ async function proceedSee(assertType, text, context, strict = false) {
3166
3217
  let description
3167
3218
  let allText
3168
3219
  if (!context) {
3169
- let el = await this.context
3220
+ if (this.withinLocator) {
3221
+ let el = await this.context
3170
3222
 
3171
- if (el && !el.getProperty) {
3172
- // Fallback to body
3173
- el = await this.context.$('body')
3174
- }
3223
+ if (el && !el.getProperty) {
3224
+ el = await this.context.$('body')
3225
+ }
3175
3226
 
3176
- allText = [await el.evaluate(node => node.innerText)]
3227
+ allText = [await el.evaluate(node => node.innerText)]
3228
+ } else {
3229
+ const ctx = await this._getContext()
3230
+ allText = [await ctx.evaluate(() => document.body.innerText)]
3231
+ }
3177
3232
  description = 'web application'
3178
3233
  } else {
3179
3234
  const locator = new Locator(context, 'css')
@@ -3717,4 +3772,3 @@ async function proceedSelect(context, el, option) {
3717
3772
 
3718
3773
  return this._waitForAction()
3719
3774
  }
3720
-
@@ -11,6 +11,7 @@ import { includes as stringIncludes } from '../assert/include.js'
11
11
  import { urlEquals, equals } from '../assert/equal.js'
12
12
  import store from '../store.js'
13
13
  import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
14
+ import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript, clipboardAsyncScript } from './extras/clipboard.js'
14
15
  import output from '../output.js'
15
16
  const { debug } = output
16
17
  import { empty } from '../assert/empty.js'
@@ -26,6 +27,7 @@ import {
26
27
  modifierKeys,
27
28
  normalizePath,
28
29
  resolveUrl,
30
+ appendBaseUrl,
29
31
  getMimeType,
30
32
  base64EncodeFile,
31
33
  } from '../utils.js'
@@ -1062,7 +1064,7 @@ class WebDriver extends Helper {
1062
1064
  let split_url
1063
1065
  if (this.options.basicAuth) {
1064
1066
  if (url.startsWith('/')) {
1065
- url = this.options.url + url
1067
+ url = appendBaseUrl(this.options.url, url)
1066
1068
  }
1067
1069
  split_url = url.split('//')
1068
1070
  url = `${split_url[0]}//${this.options.basicAuth.username}:${this.options.basicAuth.password}@${split_url[1]}`
@@ -2091,6 +2093,48 @@ class WebDriver extends Helper {
2091
2093
  return cookie[0]
2092
2094
  }
2093
2095
 
2096
+ /**
2097
+ * {{> seeInClipboard }}
2098
+ */
2099
+ async seeInClipboard(text) {
2100
+ const clipboard = await this.grabFromClipboard()
2101
+ return stringIncludes('clipboard').assert(text, clipboard)
2102
+ }
2103
+
2104
+ /**
2105
+ * {{> seeClipboardEquals }}
2106
+ */
2107
+ async seeClipboardEquals(text) {
2108
+ const clipboard = await this.grabFromClipboard()
2109
+ return equals('clipboard').assert(clipboard, text)
2110
+ }
2111
+
2112
+ /**
2113
+ * {{> grabFromClipboard }}
2114
+ */
2115
+ async grabFromClipboard() {
2116
+ await this._grantClipboardAccess()
2117
+ const result = (await this.browser.executeAsync(clipboardAsyncScript(readClipboardScript), CLIPBOARD_READ_TIMEOUT_MS)) || {}
2118
+ if (result.error) throw new Error(`Could not read the clipboard: ${result.error}`)
2119
+ this.debugSection('Clipboard', result.value)
2120
+ return result.value
2121
+ }
2122
+
2123
+ /**
2124
+ * {{> clearClipboard }}
2125
+ */
2126
+ async clearClipboard() {
2127
+ await this._grantClipboardAccess()
2128
+ const result = (await this.browser.executeAsync(clipboardAsyncScript(writeClipboardScript), '')) || {}
2129
+ if (result.error) throw new Error(`Could not write to the clipboard: ${result.error}`)
2130
+ }
2131
+
2132
+ async _grantClipboardAccess() {
2133
+ if (typeof this.browser.setPermissions !== 'function') return
2134
+ await this.browser.setPermissions({ name: 'clipboard-read' }, 'granted').catch(() => {})
2135
+ await this.browser.setPermissions({ name: 'clipboard-write' }, 'granted').catch(() => {})
2136
+ }
2137
+
2094
2138
  /**
2095
2139
  * {{> waitForCookie }}
2096
2140
  */
@@ -0,0 +1,31 @@
1
+ export const CLIPBOARD_READ_TIMEOUT_MS = 5000
2
+
3
+ export function readClipboardScript(timeout) {
4
+ if (!navigator.clipboard || !navigator.clipboard.readText) {
5
+ throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)')
6
+ }
7
+ return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))])
8
+ }
9
+
10
+ export function writeClipboardScript(text) {
11
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
12
+ throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)')
13
+ }
14
+ return navigator.clipboard.writeText(text)
15
+ }
16
+
17
+ export function clipboardExpression(script, arg) {
18
+ return `(${script.toString()})(${JSON.stringify(arg)})`
19
+ }
20
+
21
+ export function clipboardAsyncScript(script) {
22
+ return `
23
+ var done = arguments[arguments.length - 1]
24
+ var fail = function (err) { done({ error: (err && err.message) || String(err) }) }
25
+ try {
26
+ (${script.toString()})(arguments[0]).then(function (value) { done({ value: value }) }, fail)
27
+ } catch (err) {
28
+ fail(err)
29
+ }
30
+ `
31
+ }
@@ -9,4 +9,8 @@ export default function () {
9
9
  event.dispatcher.on(event.test.before, test => {
10
10
  container.result().addTest(test)
11
11
  })
12
+
13
+ event.dispatcher.on(event.test.failed, test => {
14
+ container.result().addTest(test)
15
+ })
12
16
  }
package/lib/mocha/bdd.js CHANGED
@@ -145,15 +145,33 @@ const Before = fn => {
145
145
  event.dispatcher.on(event.test.started, fn)
146
146
  }
147
147
 
148
+ const GherkinBefore = Before
149
+
148
150
  const After = fn => {
149
151
  event.dispatcher.on(event.test.finished, fn)
150
152
  }
151
153
 
154
+ const GherkinAfter = After
155
+
152
156
  const Fail = fn => {
153
157
  event.dispatcher.on(event.test.failed, fn)
154
158
  }
155
159
 
156
- export { Given, When, Then, And, Before, After, Fail, matchStep, getSteps, clearSteps, defineParameterType }
160
+ export {
161
+ Given,
162
+ When,
163
+ Then,
164
+ And,
165
+ Before,
166
+ GherkinBefore,
167
+ After,
168
+ GherkinAfter,
169
+ Fail,
170
+ matchStep,
171
+ getSteps,
172
+ clearSteps,
173
+ defineParameterType
174
+ }
157
175
 
158
176
  export default {
159
177
  Given: addStep,
@@ -122,7 +122,10 @@ export default function (config = {}) {
122
122
 
123
123
  function buildXml(result, config, hookFailures = []) {
124
124
  const doc = new DOMImplementation().createDocument(null, null, null)
125
- const allTests = result.tests.concat(hookFailures)
125
+ const failureKey = (suiteTitle, message) => `${suiteTitle || ''}::${String(message || '').replace(/\s+/g, ' ').trim()}`
126
+ const hookFailureKeys = new Set(hookFailures.map(hf => failureKey(hf.parent && hf.parent.title, hf.err && hf.err.message)))
127
+ const tests = result.tests.filter(test => !hookFailureKeys.has(failureKey(test.parent && test.parent.title, test.err && test.err.message)))
128
+ const allTests = tests.concat(hookFailures)
126
129
  const suites = groupBySuite(allTests)
127
130
 
128
131
  const root = doc.createElement('testsuites')