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/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/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..46dbdd81e 100644 --- a/lib/helper/Appium.js +++ b/lib/helper/Appium.js @@ -1534,6 +1534,29 @@ 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') + } + + /** + * {{> 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) + return clipboard + } + /** * {{> appendField }} * diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 2121f9235..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' @@ -1940,6 +1941,89 @@ 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.grabFromClipboard() + 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.grabFromClipboard() + 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(clipboardExpression(writeClipboardScript, '')) + } + + /** + * Grabs the text content of the system clipboard. + * Resumes test execution, so **should be used inside async function with `await`** operator. + * + * ```js + * I.click('Copy to clipboard'); + * const url = await I.grabFromClipboard(); + * ``` + * + * @returns {Promise} the system clipboard contents. + */ + async grabFromClipboard() { + await this._grantClipboardAccess() + const clipboard = await this._evaluate(clipboardExpression(readClipboardScript, 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. diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index f09d44a9b..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' @@ -2659,6 +2660,49 @@ class Playwright extends Helper { return this.browserContext.clearCookies() } + /** + * {{> seeInClipboard }} + */ + async seeInClipboard(text) { + const clipboard = await this.grabFromClipboard() + stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * {{> seeClipboardEquals }} + */ + async seeClipboardEquals(text) { + const clipboard = await this.grabFromClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * {{> grabFromClipboard }} + */ + async grabFromClipboard() { + await this._grantClipboardAccess() + const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS) + this.debugSection('Clipboard', clipboard) + return clipboard + } + + /** + * {{> clearClipboard }} + */ + async clearClipboard() { + await this._grantClipboardAccess() + return this.page.evaluate(writeClipboardScript, '') + } + + 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: * diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 4ba0b7974..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' @@ -1960,6 +1961,55 @@ class Puppeteer extends Helper { return this.page.deleteCookie(cookie[0]) } + /** + * {{> seeInClipboard }} + */ + async seeInClipboard(text) { + const clipboard = await this.grabFromClipboard() + stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * {{> seeClipboardEquals }} + */ + async seeClipboardEquals(text) { + const clipboard = await this.grabFromClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * {{> grabFromClipboard }} + */ + async grabFromClipboard() { + await this._grantClipboardAccess() + const clipboard = await this.page.evaluate(readClipboardScript, CLIPBOARD_READ_TIMEOUT_MS) + this.debugSection('Clipboard', clipboard) + return clipboard + } + + /** + * {{> clearClipboard }} + */ + async clearClipboard() { + await this._grantClipboardAccess() + return this.page.evaluate(writeClipboardScript, '') + } + + 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. * @@ -3709,4 +3759,3 @@ async function proceedSelect(context, el, option) { return this._waitForAction() } - diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 1d12b7f41..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' @@ -2091,6 +2092,48 @@ class WebDriver extends Helper { return cookie[0] } + /** + * {{> seeInClipboard }} + */ + async seeInClipboard(text) { + const clipboard = await this.grabFromClipboard() + return stringIncludes('clipboard').assert(text, clipboard) + } + + /** + * {{> seeClipboardEquals }} + */ + async seeClipboardEquals(text) { + const clipboard = await this.grabFromClipboard() + return equals('clipboard').assert(clipboard, text) + } + + /** + * {{> grabFromClipboard }} + */ + async grabFromClipboard() { + await this._grantClipboardAccess() + 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(() => {}) + await this.browser.setPermissions({ name: 'clipboard-write' }, 'granted').catch(() => {}) + } + /** * {{> waitForCookie }} */ 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/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..55a7d6cdf 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -956,6 +956,50 @@ export function tests() { }) }) + 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') + 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 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') + 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('') + assert.equal(await I.grabFromClipboard(), '') + }) + }) + describe('#fillField, #appendField', () => { it('should fill input fields', async () => { await I.amOnPage('/form/field')