From 6774c6f7ef1bf5c1c6f80fb97540d28f8f866c0c Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 17 Sep 2026 16:39:13 +0300 Subject: [PATCH 1/2] feat(helpers): add seeInClipboard, seeClipboardEquals, clearClipboard Adds three clipboard actions to every browser helper: - Playwright, Puppeteer, WebDriver, CDPBrowser (inherited by Obscura and Kitesurf) read and write through `navigator.clipboard`, granting clipboard permission per call and bringing the page to front first, since Chromium rejects a read from an unfocused document and contexts are recreated by `session()` and `restart`. - Appium uses the native `getClipboard`/`setClipboard` device commands, falling back to the WebDriver implementation on a non-mobile session. The in-page read races against a 5s timeout so a browser that neither grants nor denies access fails the step instead of hanging it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/webapi/clearClipboard.mustache | 8 +++ docs/webapi/seeClipboardEquals.mustache | 12 ++++ docs/webapi/seeInClipboard.mustache | 12 ++++ lib/helper/Appium.js | 18 +++++ lib/helper/CDPBrowser.js | 94 +++++++++++++++++++++++++ lib/helper/Playwright.js | 56 +++++++++++++++ lib/helper/Puppeteer.js | 61 ++++++++++++++++ lib/helper/WebDriver.js | 70 ++++++++++++++++++ test/data/app/view/form/clipboard.php | 17 +++++ test/helper/webapi.js | 36 ++++++++++ 10 files changed, 384 insertions(+) create mode 100644 docs/webapi/clearClipboard.mustache create mode 100644 docs/webapi/seeClipboardEquals.mustache create mode 100644 docs/webapi/seeInClipboard.mustache create mode 100644 test/data/app/view/form/clipboard.php diff --git a/docs/webapi/clearClipboard.mustache b/docs/webapi/clearClipboard.mustache new file mode 100644 index 000000000..82fd51e4d --- /dev/null +++ b/docs/webapi/clearClipboard.mustache @@ -0,0 +1,8 @@ +Clears the system clipboard. + +```js +I.clearClipboard(); +I.seeClipboardEquals(''); +``` + +@returns {void} automatically synchronized promise through #recorder diff --git a/docs/webapi/seeClipboardEquals.mustache b/docs/webapi/seeClipboardEquals.mustache new file mode 100644 index 000000000..f31f8e3a8 --- /dev/null +++ b/docs/webapi/seeClipboardEquals.mustache @@ -0,0 +1,12 @@ +Checks that the system clipboard is equal to the given text. + +```js +I.click('Copy to clipboard'); +I.seeClipboardEquals('https://codecept.io'); +``` + +Reading the clipboard requires a secure context (`https` or `localhost`) and is supported +in Chromium-based browsers, where read access is granted automatically. + +@param {string} text value to check. +@returns {void} automatically synchronized promise through #recorder diff --git a/docs/webapi/seeInClipboard.mustache b/docs/webapi/seeInClipboard.mustache new file mode 100644 index 000000000..aa50c950d --- /dev/null +++ b/docs/webapi/seeInClipboard.mustache @@ -0,0 +1,12 @@ +Checks that the system clipboard contains the given text. + +```js +I.click('Copy to clipboard'); +I.seeInClipboard('https://codecept.io'); +``` + +Reading the clipboard requires a secure context (`https` or `localhost`) and is supported +in Chromium-based browsers, where read access is granted automatically. + +@param {string} text value to check. +@returns {void} automatically synchronized promise through #recorder diff --git a/lib/helper/Appium.js b/lib/helper/Appium.js index 222ada79d..37d89fa61 100644 --- a/lib/helper/Appium.js +++ b/lib/helper/Appium.js @@ -1534,6 +1534,24 @@ class Appium extends Webdriver { return this.browser.closeApp() } + /** + * {{> clearClipboard }} + * + * Appium: support both Android and iOS + */ + async clearClipboard() { + if (typeof this.browser.setClipboard !== 'function') return super.clearClipboard() + return this.browser.setClipboard('', 'plaintext') + } + + async _grabClipboard() { + if (typeof this.browser.getClipboard !== 'function') return super._grabClipboard() + const encoded = await this.browser.getClipboard('plaintext') + const clipboard = Buffer.from(encoded || '', 'base64').toString('utf8') + this.debugSection('Clipboard', clipboard) + return clipboard + } + /** * {{> appendField }} * diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 2121f9235..47870f213 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -73,6 +73,8 @@ const PAGE_LOAD_GRACE_MS = 300 // (common) non-navigating case, not a guess at how long a real navigation takes. const ACTION_SETTLE_GRACE_MS = 20 +const CLIPBOARD_READ_TIMEOUT_MS = 5000 + /** * CDPBrowser drives a browser directly over the raw Chrome DevTools Protocol, without depending * on Puppeteer, Playwright, or WebDriver. It opens its own WebSocket connection (via `CDPConnection`), @@ -1940,6 +1942,84 @@ class CDPBrowser extends Helper { } } + /** + * Checks that the system clipboard contains the given text. + * + * ```js + * I.click('Copy to clipboard'); + * I.seeInClipboard('https://codecept.io'); + * ``` + * + * Reading the clipboard requires a secure context (`https` or `localhost`). + * + * @param {string} text value to check. + * @returns {Promise} + */ + async seeInClipboard(text) { + const clipboard = await this._grabClipboard() + return stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * Checks that the system clipboard is equal to the given text. + * + * ```js + * I.click('Copy to clipboard'); + * I.seeClipboardEquals('https://codecept.io'); + * ``` + * + * Reading the clipboard requires a secure context (`https` or `localhost`). + * + * @param {string} text value to check. + * @returns {Promise} + */ + async seeClipboardEquals(text) { + const clipboard = await this._grabClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * Clears the system clipboard. + * + * ```js + * I.clearClipboard(); + * I.seeClipboardEquals(''); + * ``` + * + * @returns {Promise} + */ + async clearClipboard() { + await this._grantClipboardAccess() + await this._evaluate(`(${writeClipboardScript.toString()})('')`) + } + + /** + * Reads the system clipboard through `navigator.clipboard`, granting read access first. + * + * @returns {Promise} the current clipboard contents. + * @protected + */ + async _grabClipboard() { + await this._grantClipboardAccess() + const clipboard = await this._evaluate(`(${readClipboardScript.toString()})(${CLIPBOARD_READ_TIMEOUT_MS})`) + this.debugSection('Clipboard', clipboard) + return clipboard + } + + /** + * Brings the current target to front and grants it clipboard read/write access, so + * `navigator.clipboard` does not reject with a permission or focus error. Failures are ignored: + * a browser without `Browser.grantPermissions` surfaces its own error from the read instead. + * + * @protected + */ + async _grantClipboardAccess() { + await this.cdp.send('Page.bringToFront', {}, this.sessionId).catch(() => null) + const origin = await this._evaluate('window.location.origin').catch(() => null) + if (!origin || !origin.startsWith('http')) return + await this.cdp.send('Browser.grantPermissions', { origin, permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'] }).catch(() => null) + } + /** * Saves a screenshot to the output folder (set in codecept.conf.ts or codecept.conf.js). * Filename is relative to the output folder. @@ -3003,4 +3083,18 @@ class CDPBrowser extends Helper { } } +function readClipboardScript(timeout) { + if (!navigator.clipboard || !navigator.clipboard.readText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) +} + +function writeClipboardScript(text) { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return navigator.clipboard.writeText(text) +} + export default CDPBrowser diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index f09d44a9b..8d6834c55 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -2659,6 +2659,46 @@ class Playwright extends Helper { return this.browserContext.clearCookies() } + /** + * {{> seeInClipboard }} + */ + async seeInClipboard(text) { + const clipboard = await this._grabClipboard() + stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * {{> seeClipboardEquals }} + */ + async seeClipboardEquals(text) { + const clipboard = await this._grabClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * {{> clearClipboard }} + */ + async clearClipboard() { + await this._grantClipboardAccess() + return this.page.evaluate(writeClipboardScript, '') + } + + async _grabClipboard() { + await this._grantClipboardAccess() + const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_TIMEOUT) + this.debugSection('Clipboard', clipboard) + return clipboard + } + + async _grantClipboardAccess() { + await this.page.bringToFront().catch(() => {}) + if (this.options.browser !== 'chromium') return + await this.page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']) + .catch(() => {}) + } + /** * Executes a script on the page: * @@ -4902,3 +4942,19 @@ async function elToString(el, numberOfElements) { .trim() + '...' ) } + +const CLIPBOARD_TIMEOUT = 5000 + +function readClipboardScript(timeout) { + if (!navigator.clipboard || !navigator.clipboard.readText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) +} + +function writeClipboardScript(text) { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return navigator.clipboard.writeText(text) +} diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 4ba0b7974..404000d04 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -1960,6 +1960,52 @@ class Puppeteer extends Helper { return this.page.deleteCookie(cookie[0]) } + /** + * {{> seeInClipboard }} + */ + async seeInClipboard(text) { + const clipboard = await this._grabClipboard() + stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * {{> seeClipboardEquals }} + */ + async seeClipboardEquals(text) { + const clipboard = await this._grabClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * {{> clearClipboard }} + */ + async clearClipboard() { + await this._grantClipboardAccess() + return this.page.evaluate(writeClipboardScript, '') + } + + async _grabClipboard() { + await this._grantClipboardAccess() + const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_TIMEOUT) + this.debugSection('Clipboard', clipboard) + return clipboard + } + + async _grantClipboardAccess() { + await this.page.bringToFront().catch(() => {}) + let origin + try { + origin = new URL(this.page.url()).origin + } catch (err) { + return + } + if (!origin.startsWith('http')) return + await this.page + .browserContext() + .overridePermissions(origin, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']) + .catch(() => {}) + } + /** * If a function returns a Promise, tt will wait for its resolution. * @@ -3710,3 +3756,18 @@ async function proceedSelect(context, el, option) { return this._waitForAction() } +const CLIPBOARD_TIMEOUT = 5000 + +function readClipboardScript(timeout) { + if (!navigator.clipboard || !navigator.clipboard.readText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) +} + +function writeClipboardScript(text) { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return navigator.clipboard.writeText(text) +} diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 1d12b7f41..0e4c24153 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -2091,6 +2091,45 @@ class WebDriver extends Helper { return cookie[0] } + /** + * {{> seeInClipboard }} + */ + async seeInClipboard(text) { + const clipboard = await this._grabClipboard() + return stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * {{> seeClipboardEquals }} + */ + async seeClipboardEquals(text) { + const clipboard = await this._grabClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * {{> clearClipboard }} + */ + async clearClipboard() { + await this._grantClipboardAccess() + const result = (await this.browser.executeAsync(writeClipboardScript, '')) || {} + if (result.error) throw new Error(`Could not write to the clipboard: ${result.error}`) + } + + async _grabClipboard() { + await this._grantClipboardAccess() + const result = (await this.browser.executeAsync(readClipboardScript, CLIPBOARD_TIMEOUT)) || {} + if (result.error) throw new Error(`Could not read the clipboard: ${result.error}`) + this.debugSection('Clipboard', result.value) + return result.value + } + + async _grantClipboardAccess() { + if (typeof this.browser.setPermissions !== 'function') return + await this.browser.setPermissions({ name: 'clipboard-read' }, 'granted').catch(() => {}) + await this.browser.setPermissions({ name: 'clipboard-write' }, 'granted').catch(() => {}) + } + /** * {{> waitForCookie }} */ @@ -3610,4 +3649,35 @@ async function proceedSelectOption(elem, option) { return forEachAsync(els, clickOptionFn) } +const CLIPBOARD_TIMEOUT = 5000 + +function readClipboardScript(timeout, done) { + let finished = false + const finish = result => { + if (finished) return + finished = true + done(result) + } + if (!navigator.clipboard || !navigator.clipboard.readText) { + finish({ error: 'Clipboard API is not available on this page, it requires a secure context (https or localhost)' }) + return + } + setTimeout(() => finish({ error: 'timed out while reading the clipboard' }), timeout) + navigator.clipboard.readText().then( + text => finish({ value: text }), + err => finish({ error: (err && err.message) || String(err) }), + ) +} + +function writeClipboardScript(text, done) { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + done({ error: 'Clipboard API is not available on this page, it requires a secure context (https or localhost)' }) + return + } + navigator.clipboard.writeText(text).then( + () => done({}), + err => done({ error: (err && err.message) || String(err) }), + ) +} + export { WebDriver as default } diff --git a/test/data/app/view/form/clipboard.php b/test/data/app/view/form/clipboard.php new file mode 100644 index 000000000..9547837b5 --- /dev/null +++ b/test/data/app/view/form/clipboard.php @@ -0,0 +1,17 @@ + + +

Clipboard

+ + +
+ + + diff --git a/test/helper/webapi.js b/test/helper/webapi.js index a851c8650..e4a4dc58e 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -956,6 +956,42 @@ export function tests() { }) }) + describe('#seeInClipboard, #seeClipboardEquals, #clearClipboard', () => { + beforeEach(async function () { + if (isHelper('Obscura')) this.skip() // Obscura's navigator.clipboard is a stub: writeText() resolves but stores nothing, so readText() always returns '' (verified: a write/read round trip inside one evaluate reads back an empty string) + await I.amOnPage('/form/clipboard') + await I.clearClipboard() + }) + + it('should see text copied to the clipboard', async () => { + await I.click('#copy') + await I.see('copied') + await I.seeInClipboard('Copy') + await I.seeClipboardEquals('Copy me') + }) + + it('should not see text which was not copied', async () => { + await I.click('#copy') + await I.see('copied') + let err + try { + await I.seeInClipboard('Paste me') + } catch (e) { + err = e + } + assert.ok(err, 'seeInClipboard should have failed') + assert.include(err.inspect ? err.inspect() : err.message, 'expected clipboard to include "Paste me"') + }) + + it('should clear the clipboard', async () => { + await I.click('#copy') + await I.see('copied') + await I.seeClipboardEquals('Copy me') + await I.clearClipboard() + await I.seeClipboardEquals('') + }) + }) + describe('#fillField, #appendField', () => { it('should fill input fields', async () => { await I.amOnPage('/form/field') From bc29903c94e19957f1ab84c58fe11de90e039e9f Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 18 Sep 2026 02:31:41 +0300 Subject: [PATCH 2/2] feat(helpers): add grabFromClipboard, share clipboard scripts Promotes the private clipboard read to a public `grabFromClipboard()` action on every browser helper, and moves the in-page clipboard scripts that were duplicated across four helpers into `lib/helper/extras/clipboard.js`. The shared scripts are promise-based and self-contained, so each helper consumes them the way its engine expects: Playwright and Puppeteer pass the function to `page.evaluate`, CDPBrowser interpolates it into a `Runtime.evaluate` expression, and WebDriver wraps it in a W3C async script body rather than keeping a second callback-style copy. Co-Authored-By: Claude Opus 5 (1M context) --- docs/webapi/grabFromClipboard.mustache | 12 ++++++ lib/helper/Appium.js | 9 +++- lib/helper/CDPBrowser.js | 38 +++++++---------- lib/helper/Playwright.js | 38 ++++++----------- lib/helper/Puppeteer.js | 38 ++++++----------- lib/helper/WebDriver.js | 57 +++++++------------------- lib/helper/extras/clipboard.js | 31 ++++++++++++++ test/helper/webapi.js | 10 ++++- 8 files changed, 114 insertions(+), 119 deletions(-) create mode 100644 docs/webapi/grabFromClipboard.mustache create mode 100644 lib/helper/extras/clipboard.js diff --git a/docs/webapi/grabFromClipboard.mustache b/docs/webapi/grabFromClipboard.mustache new file mode 100644 index 000000000..7a3f1e266 --- /dev/null +++ b/docs/webapi/grabFromClipboard.mustache @@ -0,0 +1,12 @@ +Grabs the text content of the system clipboard and returns it to test. +Resumes test execution, so **should be used inside async function with `await`** operator. + +```js +I.click('Copy to clipboard'); +let url = await I.grabFromClipboard(); +``` + +Reading the clipboard requires a secure context (`https` or `localhost`) and is supported +in Chromium-based browsers, where read access is granted automatically. + +@returns {Promise} the system clipboard contents. diff --git a/lib/helper/Appium.js b/lib/helper/Appium.js index 37d89fa61..46dbdd81e 100644 --- a/lib/helper/Appium.js +++ b/lib/helper/Appium.js @@ -1544,8 +1544,13 @@ class Appium extends Webdriver { return this.browser.setClipboard('', 'plaintext') } - async _grabClipboard() { - if (typeof this.browser.getClipboard !== 'function') return super._grabClipboard() + /** + * {{> grabFromClipboard }} + * + * Appium: support both Android and iOS + */ + async grabFromClipboard() { + if (typeof this.browser.getClipboard !== 'function') return super.grabFromClipboard() const encoded = await this.browser.getClipboard('plaintext') const clipboard = Buffer.from(encoded || '', 'base64').toString('utf8') this.debugSection('Clipboard', clipboard) diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 47870f213..164ce715d 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -18,6 +18,7 @@ import { isColorProperty, convertColorToRGBA } from '../colorUtils.js' import WebElement from '../element/WebElement.js' import CDPElementHandle from './extras/CDPElementHandle.js' import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js' +import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript, clipboardExpression } from './extras/clipboard.js' import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, flushNetworkTraffics } from './network/actions.js' import { assembleApng, isPng } from './extras/apngAssembler.js' @@ -73,8 +74,6 @@ const PAGE_LOAD_GRACE_MS = 300 // (common) non-navigating case, not a guess at how long a real navigation takes. const ACTION_SETTLE_GRACE_MS = 20 -const CLIPBOARD_READ_TIMEOUT_MS = 5000 - /** * CDPBrowser drives a browser directly over the raw Chrome DevTools Protocol, without depending * on Puppeteer, Playwright, or WebDriver. It opens its own WebSocket connection (via `CDPConnection`), @@ -1956,7 +1955,7 @@ class CDPBrowser extends Helper { * @returns {Promise} */ async seeInClipboard(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() return stringIncludes('clipboard').assert(text, clipboard) } @@ -1974,7 +1973,7 @@ class CDPBrowser extends Helper { * @returns {Promise} */ async seeClipboardEquals(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() return equals('clipboard').assert(clipboard, text) } @@ -1990,18 +1989,23 @@ class CDPBrowser extends Helper { */ async clearClipboard() { await this._grantClipboardAccess() - await this._evaluate(`(${writeClipboardScript.toString()})('')`) + await this._evaluate(clipboardExpression(writeClipboardScript, '')) } /** - * Reads the system clipboard through `navigator.clipboard`, granting read access first. + * Grabs the text content of the system clipboard. + * Resumes test execution, so **should be used inside async function with `await`** operator. * - * @returns {Promise} the current clipboard contents. - * @protected + * ```js + * I.click('Copy to clipboard'); + * const url = await I.grabFromClipboard(); + * ``` + * + * @returns {Promise} the system clipboard contents. */ - async _grabClipboard() { + async grabFromClipboard() { await this._grantClipboardAccess() - const clipboard = await this._evaluate(`(${readClipboardScript.toString()})(${CLIPBOARD_READ_TIMEOUT_MS})`) + const clipboard = await this._evaluate(clipboardExpression(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS)) this.debugSection('Clipboard', clipboard) return clipboard } @@ -3083,18 +3087,4 @@ class CDPBrowser extends Helper { } } -function readClipboardScript(timeout) { - if (!navigator.clipboard || !navigator.clipboard.readText) { - throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') - } - return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) -} - -function writeClipboardScript(text) { - if (!navigator.clipboard || !navigator.clipboard.writeText) { - throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') - } - return navigator.clipboard.writeText(text) -} - export default CDPBrowser diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 8d6834c55..3962fd517 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -8,6 +8,7 @@ import Locator from '../locator.js' import recorder from '../recorder.js' import store from '../store.js' import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js' +import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript } from './extras/clipboard.js' import { includes as stringIncludes } from '../assert/include.js' import { urlEquals, equals } from '../assert/equal.js' import { empty } from '../assert/empty.js' @@ -2663,7 +2664,7 @@ class Playwright extends Helper { * {{> seeInClipboard }} */ async seeInClipboard(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() stringIncludes('clipboard').assert(text, clipboard) } @@ -2671,23 +2672,26 @@ class Playwright extends Helper { * {{> seeClipboardEquals }} */ async seeClipboardEquals(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() return equals('clipboard').assert(clipboard, text) } /** - * {{> clearClipboard }} + * {{> grabFromClipboard }} */ - async clearClipboard() { + async grabFromClipboard() { await this._grantClipboardAccess() - return this.page.evaluate(writeClipboardScript, '') + const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS) + this.debugSection('Clipboard', clipboard) + return clipboard } - async _grabClipboard() { + /** + * {{> clearClipboard }} + */ + async clearClipboard() { await this._grantClipboardAccess() - const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_TIMEOUT) - this.debugSection('Clipboard', clipboard) - return clipboard + return this.page.evaluate(writeClipboardScript, '') } async _grantClipboardAccess() { @@ -4942,19 +4946,3 @@ async function elToString(el, numberOfElements) { .trim() + '...' ) } - -const CLIPBOARD_TIMEOUT = 5000 - -function readClipboardScript(timeout) { - if (!navigator.clipboard || !navigator.clipboard.readText) { - throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') - } - return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) -} - -function writeClipboardScript(text) { - if (!navigator.clipboard || !navigator.clipboard.writeText) { - throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') - } - return navigator.clipboard.writeText(text) -} diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 404000d04..0d6824e16 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -9,6 +9,7 @@ import Locator from '../locator.js' import recorder from '../recorder.js' import store from '../store.js' import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js' +import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript } from './extras/clipboard.js' import { includes as stringIncludes } from '../assert/include.js' import { urlEquals, equals } from '../assert/equal.js' import { empty } from '../assert/empty.js' @@ -1964,7 +1965,7 @@ class Puppeteer extends Helper { * {{> seeInClipboard }} */ async seeInClipboard(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() stringIncludes('clipboard').assert(text, clipboard) } @@ -1972,23 +1973,26 @@ class Puppeteer extends Helper { * {{> seeClipboardEquals }} */ async seeClipboardEquals(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() return equals('clipboard').assert(clipboard, text) } /** - * {{> clearClipboard }} + * {{> grabFromClipboard }} */ - async clearClipboard() { + async grabFromClipboard() { await this._grantClipboardAccess() - return this.page.evaluate(writeClipboardScript, '') + const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS) + this.debugSection('Clipboard', clipboard) + return clipboard } - async _grabClipboard() { + /** + * {{> clearClipboard }} + */ + async clearClipboard() { await this._grantClipboardAccess() - const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_TIMEOUT) - this.debugSection('Clipboard', clipboard) - return clipboard + return this.page.evaluate(writeClipboardScript, '') } async _grantClipboardAccess() { @@ -3755,19 +3759,3 @@ async function proceedSelect(context, el, option) { return this._waitForAction() } - -const CLIPBOARD_TIMEOUT = 5000 - -function readClipboardScript(timeout) { - if (!navigator.clipboard || !navigator.clipboard.readText) { - throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') - } - return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) -} - -function writeClipboardScript(text) { - if (!navigator.clipboard || !navigator.clipboard.writeText) { - throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') - } - return navigator.clipboard.writeText(text) -} diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 0e4c24153..847af2eee 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -11,6 +11,7 @@ import { includes as stringIncludes } from '../assert/include.js' import { urlEquals, equals } from '../assert/equal.js' import store from '../store.js' import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js' +import { CLIPBOARD_READ_TIMEOUT_MS, readClipboardScript, writeClipboardScript, clipboardAsyncScript } from './extras/clipboard.js' import output from '../output.js' const { debug } = output import { empty } from '../assert/empty.js' @@ -2095,7 +2096,7 @@ class WebDriver extends Helper { * {{> seeInClipboard }} */ async seeInClipboard(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() return stringIncludes('clipboard').assert(text, clipboard) } @@ -2103,27 +2104,30 @@ class WebDriver extends Helper { * {{> seeClipboardEquals }} */ async seeClipboardEquals(text) { - const clipboard = await this._grabClipboard() + const clipboard = await this.grabFromClipboard() return equals('clipboard').assert(clipboard, text) } /** - * {{> clearClipboard }} + * {{> grabFromClipboard }} */ - async clearClipboard() { - await this._grantClipboardAccess() - const result = (await this.browser.executeAsync(writeClipboardScript, '')) || {} - if (result.error) throw new Error(`Could not write to the clipboard: ${result.error}`) - } - - async _grabClipboard() { + async grabFromClipboard() { await this._grantClipboardAccess() - const result = (await this.browser.executeAsync(readClipboardScript, CLIPBOARD_TIMEOUT)) || {} + const result = (await this.browser.executeAsync(clipboardAsyncScript(readClipboardScript), CLIPBOARD_READ_TIMEOUT_MS)) || {} if (result.error) throw new Error(`Could not read the clipboard: ${result.error}`) this.debugSection('Clipboard', result.value) return result.value } + /** + * {{> clearClipboard }} + */ + async clearClipboard() { + await this._grantClipboardAccess() + const result = (await this.browser.executeAsync(clipboardAsyncScript(writeClipboardScript), '')) || {} + if (result.error) throw new Error(`Could not write to the clipboard: ${result.error}`) + } + async _grantClipboardAccess() { if (typeof this.browser.setPermissions !== 'function') return await this.browser.setPermissions({ name: 'clipboard-read' }, 'granted').catch(() => {}) @@ -3649,35 +3653,4 @@ async function proceedSelectOption(elem, option) { return forEachAsync(els, clickOptionFn) } -const CLIPBOARD_TIMEOUT = 5000 - -function readClipboardScript(timeout, done) { - let finished = false - const finish = result => { - if (finished) return - finished = true - done(result) - } - if (!navigator.clipboard || !navigator.clipboard.readText) { - finish({ error: 'Clipboard API is not available on this page, it requires a secure context (https or localhost)' }) - return - } - setTimeout(() => finish({ error: 'timed out while reading the clipboard' }), timeout) - navigator.clipboard.readText().then( - text => finish({ value: text }), - err => finish({ error: (err && err.message) || String(err) }), - ) -} - -function writeClipboardScript(text, done) { - if (!navigator.clipboard || !navigator.clipboard.writeText) { - done({ error: 'Clipboard API is not available on this page, it requires a secure context (https or localhost)' }) - return - } - navigator.clipboard.writeText(text).then( - () => done({}), - err => done({ error: (err && err.message) || String(err) }), - ) -} - export { WebDriver as default } diff --git a/lib/helper/extras/clipboard.js b/lib/helper/extras/clipboard.js new file mode 100644 index 000000000..ebc3e4fc5 --- /dev/null +++ b/lib/helper/extras/clipboard.js @@ -0,0 +1,31 @@ +export const CLIPBOARD_READ_TIMEOUT_MS = 5000 + +export function readClipboardScript(timeout) { + if (!navigator.clipboard || !navigator.clipboard.readText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return Promise.race([navigator.clipboard.readText(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out while reading the clipboard')), timeout))]) +} + +export function writeClipboardScript(text) { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + throw new Error('Clipboard API is not available on this page, it requires a secure context (https or localhost)') + } + return navigator.clipboard.writeText(text) +} + +export function clipboardExpression(script, arg) { + return `(${script.toString()})(${JSON.stringify(arg)})` +} + +export function clipboardAsyncScript(script) { + return ` + var done = arguments[arguments.length - 1] + var fail = function (err) { done({ error: (err && err.message) || String(err) }) } + try { + (${script.toString()})(arguments[0]).then(function (value) { done({ value: value }) }, fail) + } catch (err) { + fail(err) + } + ` +} diff --git a/test/helper/webapi.js b/test/helper/webapi.js index e4a4dc58e..55a7d6cdf 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -956,7 +956,7 @@ export function tests() { }) }) - describe('#seeInClipboard, #seeClipboardEquals, #clearClipboard', () => { + describe('#seeInClipboard, #seeClipboardEquals, #grabFromClipboard, #clearClipboard', () => { beforeEach(async function () { if (isHelper('Obscura')) this.skip() // Obscura's navigator.clipboard is a stub: writeText() resolves but stores nothing, so readText() always returns '' (verified: a write/read round trip inside one evaluate reads back an empty string) await I.amOnPage('/form/clipboard') @@ -970,6 +970,13 @@ export function tests() { await I.seeClipboardEquals('Copy me') }) + it('should grab text from the clipboard', async () => { + await I.click('#copy') + await I.see('copied') + const clipboard = await I.grabFromClipboard() + assert.equal(clipboard, 'Copy me') + }) + it('should not see text which was not copied', async () => { await I.click('#copy') await I.see('copied') @@ -989,6 +996,7 @@ export function tests() { await I.seeClipboardEquals('Copy me') await I.clearClipboard() await I.seeClipboardEquals('') + assert.equal(await I.grabFromClipboard(), '') }) })