diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index a5679cf5a96a3..6709aafb11c42 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -68,6 +68,13 @@ await context.CloseAsync(); This event is not emitted. +## property: BrowserContext.clipboard +* since: v1.62 +- type: <[Clipboard]> + +Provides a virtual clipboard that replaces `navigator.clipboard` and `document.execCommand` in every +page of the context. + ## property: BrowserContext.clock * since: v1.45 - type: <[Clock]> diff --git a/docs/src/api/class-clipboard.md b/docs/src/api/class-clipboard.md new file mode 100644 index 0000000000000..81bd377392308 --- /dev/null +++ b/docs/src/api/class-clipboard.md @@ -0,0 +1,113 @@ +# class: Clipboard +* since: v1.62 + +Provides a virtual clipboard for the [BrowserContext]. Access it via +[`property: BrowserContext.clipboard`]. + +The virtual clipboard is installed into every page in the context on first use — call +[`method: Clipboard.install`] to opt in explicitly, or use any other [Clipboard] method. Until it is +installed, pages keep using the real clipboard, so tests that only exercise keyboard shortcuts are +unaffected. It needs no clipboard permissions or secure context and never touches the operating system +clipboard. + +Once installed, it redirects `navigator.clipboard` (only where the browser already exposes it, i.e. +secure contexts), replaces `document.execCommand('copy'|'cut'|'paste')`, and intercepts native +copy/cut/paste keyboard shortcuts (for example `Control+C`/`Control+V`). Copy, cut, and paste dispatch +a real `ClipboardEvent` whose `clipboardData` page handlers can read and override, matching the browser. +Plain text, rich `text/html`, and binary content such as images are supported, and the store is shared +by every page in the context, so a value written in one page can be read in another. + +Playwright does not fabricate `navigator.clipboard` or `ClipboardItem` where the browser does not +expose them, so feature detection and code paths that handle a missing clipboard API keep behaving as +they would without Playwright. + +Native copy/paste interception relies on event listeners installed when a document loads, so it does +not apply to documents replaced via [`method: Page.setContent`] (which resets the document); navigate +to a page instead. In Firefox, a `paste` handler cannot read `clipboardData` from the synthesized +event — read the pasted value from the DOM or [`method: Clipboard.read`] instead. + +```js +await context.clipboard.writeText('hello'); +const text = await context.clipboard.readText(); +``` + +Because the clipboard is virtual, it does not interact with the real operating system clipboard. + +## async method: Clipboard.install +* since: v1.62 + +Installs the virtual clipboard into the context's pages. Any other [Clipboard] method installs it +implicitly, so this is only needed to opt in before a page performs clipboard actions (for example +native `Control+C`/`Control+V`) without otherwise using [`property: BrowserContext.clipboard`]. + +**Usage** + +```js +await context.clipboard.install(); +``` + +## async method: Clipboard.readText +* since: v1.62 +- returns: <[string]> + +Returns the current plain-text contents of the virtual clipboard. + +**Usage** + +```js +const text = await context.clipboard.readText(); +``` + +## async method: Clipboard.writeText +* since: v1.62 + +Writes the given plain text to the virtual clipboard. + +**Usage** + +```js +await context.clipboard.writeText('hello'); +``` + +### param: Clipboard.writeText.text +* since: v1.62 +- `text` <[string]> + +Text to write to the clipboard. + +## async method: Clipboard.read +* since: v1.62 +- returns: <[Array]<[Object]>> + - `mimeType` <[string]> The MIME type of the entry, for example `text/plain` or `image/png`. + - `buffer` <[Buffer]> The entry's contents. + +Returns every entry currently on the virtual clipboard, including binary types such as images. For +plain text, [`method: Clipboard.readText`] is a convenient shortcut. + +**Usage** + +```js +const items = await context.clipboard.read(); +``` + +## async method: Clipboard.write +* since: v1.62 + +Replaces the virtual clipboard contents with the given entries. Use this for binary types such as +images; for plain text, [`method: Clipboard.writeText`] is a convenient shortcut. + +**Usage** + +```js +await context.clipboard.write([ + { mimeType: 'image/png', buffer: await page.screenshot() }, +]); +``` + +### param: Clipboard.write.items +* since: v1.62 +- `items` <[Array]<[Object]>> + - `mimeType` <[string]> The MIME type of the entry, for example `text/plain` or `image/png`. + - `buffer` <[Buffer]> The entry's contents. + +Entries to write to the clipboard. diff --git a/packages/injected/src/clipboard.ts b/packages/injected/src/clipboard.ts new file mode 100644 index 0000000000000..c185b6637d523 --- /dev/null +++ b/packages/injected/src/clipboard.ts @@ -0,0 +1,255 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Wire format for the store; binary values are base64-encoded. +export type ClipboardEntry = { type: string, value: string, base64?: boolean }; + +export type BindingPayload = { action: 'read' } | { action: 'write', items: ClipboardEntry[] }; + +type GlobalThis = typeof globalThis; + +const kBindingName = '__pwClipboardBinding'; + +export function inject(globalThis: GlobalThis) { + if ((globalThis as any).__pwClipboardInstalled) + return; + + function defineHidden(obj: any, name: string, value: any) { + Object.defineProperty(obj, name, { configurable: true, value }); + } + defineHidden(globalThis, '__pwClipboardInstalled', true); + + const binding = (globalThis as any)[kBindingName] as ((payload: BindingPayload) => Promise) | undefined; + const ClipboardItemCtor: any = (globalThis as any).ClipboardItem; + + function read() { + return binding?.({ action: 'read' }) ?? Promise.resolve([]); + } + + function write(items: ClipboardEntry[]) { + return binding?.({ action: 'write', items }) ?? Promise.resolve([]); + } + + let mirror: ClipboardEntry[] = []; + void read().then(items => { + mirror = items; + }); + defineHidden(globalThis, '__pwClipboardSet', (items: ClipboardEntry[]) => { + mirror = items; + }); + + function valueOf(items: ClipboardEntry[], type: string) { + return items.find(item => item.type === type)?.value ?? ''; + } + + function setStore(items: ClipboardEntry[]) { + mirror = items; + return write(items); + } + + function entriesToDataTransfer(items: ClipboardEntry[]) { + const dataTransfer = new globalThis.DataTransfer(); + for (const item of items) { + if (item.base64) + dataTransfer.items.add(new globalThis.File([Uint8Array.from(globalThis.atob(item.value), c => c.charCodeAt(0))], 'clipboard', { type: item.type })); + else + dataTransfer.setData(item.type, item.value); + } + return dataTransfer; + } + + function dataTransferToEntries(dataTransfer: DataTransfer): ClipboardEntry[] { + return [...dataTransfer.types].filter(type => type !== 'Files').map(type => ({ type, value: dataTransfer.getData(type) })); + } + + // Selections inside form controls are not part of the document selection. + function activeFormControl(): HTMLInputElement | HTMLTextAreaElement | null { + const active = globalThis.document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; + if ((active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA') && active.selectionStart !== active.selectionEnd) + return active; + return null; + } + + function captureSelection() { + const control = activeFormControl(); + if (control) { + void setStore([{ type: 'text/plain', value: control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0) }]); + return; + } + const selection = globalThis.document.getSelection(); + const container = globalThis.document.createElement('div'); + for (let i = 0; i < (selection?.rangeCount ?? 0); i++) + container.appendChild(selection!.getRangeAt(i).cloneContents()); + void setStore([ + { type: 'text/plain', value: selection?.toString() ?? '' }, + { type: 'text/html', value: container.innerHTML }, + ]); + } + + function deleteSelection() { + const control = activeFormControl(); + if (control) { + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + control.value = control.value.slice(0, start) + control.value.slice(end); + control.setSelectionRange(start, start); + control.dispatchEvent(new globalThis.InputEvent('input', { bubbles: true, inputType: 'deleteByCut' })); + return; + } + globalThis.document.getSelection()?.deleteFromDocument(); + } + + function insertClipboard(target: any) { + if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') { + const text = valueOf(mirror, 'text/plain'); + const start = target.selectionStart ?? target.value.length; + const end = target.selectionEnd ?? target.value.length; + target.value = target.value.slice(0, start) + text + target.value.slice(end); + // Non-text inputs (number, email, ...) expose a null selectionStart and throw on setSelectionRange. + if (target.selectionStart !== null) + target.setSelectionRange(start + text.length, start + text.length); + target.dispatchEvent(new globalThis.InputEvent('input', { bubbles: true, inputType: 'insertFromPaste' })); + } else { + const selection = globalThis.document.getSelection(); + if (!selection?.rangeCount) + return; + const host = target?.closest?.('[contenteditable]') || target; + const text = valueOf(mirror, 'text/plain'); + const html = valueOf(mirror, 'text/html'); + // contenteditable editors may cancel the paste via beforeinput. + if (host?.dispatchEvent(new globalThis.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + inputType: 'insertFromPaste', + data: html ? null : text, + })) === false) + return; + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(html ? range.createContextualFragment(html) : globalThis.document.createTextNode(text)); + range.collapse(false); + host?.dispatchEvent(new globalThis.InputEvent('input', { bubbles: true, composed: true, inputType: 'insertFromPaste' })); + } + } + + // Fire a real ClipboardEvent so page handlers can read/override clipboardData; the synthetic event is untrusted, so the isTrusted-guarded native listeners skip it (no recursion). + function dispatchCopy(target: any, cut: boolean) { + const event = new globalThis.ClipboardEvent(cut ? 'cut' : 'copy', { clipboardData: new globalThis.DataTransfer(), bubbles: true, cancelable: true, composed: true }); + if (target?.dispatchEvent(event) === false) { + if (event.clipboardData?.types.length) + void setStore(dataTransferToEntries(event.clipboardData)); + return; + } + captureSelection(); + if (cut) + deleteSelection(); + } + + function dispatchPaste(target: any, items: ClipboardEntry[]) { + const event = new globalThis.ClipboardEvent('paste', { clipboardData: entriesToDataTransfer(items), bubbles: true, cancelable: true, composed: true }); + if (target?.dispatchEvent(event)) + insertClipboard(target); + } + + const clipboard = Object.assign(new globalThis.EventTarget(), { + async writeText(text: string) { + await setStore([{ type: 'text/plain', value: String(text) }]); + }, + async readText() { + mirror = await read(); + return valueOf(mirror, 'text/plain'); + }, + async write(clipboardItems: ClipboardItem[]) { + const items: ClipboardEntry[] = []; + for (const item of clipboardItems) { + for (const type of item.types) { + if (ClipboardItemCtor.supports?.(type) === false) + throw new globalThis.DOMException('Type ' + type + ' not supported on write.', 'NotAllowedError'); + const blob = await item.getType(type); + if (type.startsWith('text/')) { + items.push({ type, value: await blob.text() }); + } else { + const bytes = new Uint8Array(await blob.arrayBuffer()); + const binary = Array.from(bytes, b => String.fromCharCode(b)).join(''); + items.push({ type, value: globalThis.btoa(binary), base64: true }); + } + } + } + await setStore(items); + }, + async read() { + mirror = await read(); + if (!mirror.length) + return []; + const parts: Record = {}; + for (const item of mirror) + parts[item.type] = new globalThis.Blob([item.base64 ? Uint8Array.from(globalThis.atob(item.value), c => c.charCodeAt(0)) : item.value], { type: item.type }); + return [new ClipboardItemCtor(parts)]; + }, + }); + // Only redirect where the browser already exposes it, so feature-detection still works. + if (globalThis.navigator.clipboard) + Object.defineProperty(globalThis.navigator, 'clipboard', { configurable: true, get: () => clipboard }); + + const originalExecCommand = globalThis.document.execCommand.bind(globalThis.document); + (globalThis.document as any).execCommand = function(commandId: string, showUI?: boolean, value?: string) { + const command = String(commandId).toLowerCase(); + if (command === 'copy' || command === 'cut') { + const selection = globalThis.document.getSelection(); + const hasSelection = !!activeFormControl() || (!!selection && !selection.isCollapsed); + if (!globalThis.document.queryCommandSupported(command) || !hasSelection) + return false; + dispatchCopy(globalThis.document.activeElement, command === 'cut'); + return true; + } + if (command === 'paste') { + const target = globalThis.document.activeElement as HTMLElement | null; + if (!globalThis.document.queryCommandSupported('paste') || !target || (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable)) + return false; + dispatchPaste(target, mirror); + return true; + } + return originalExecCommand(commandId, showUI, value); + }; + + // Capture-phase listeners; document.open() (page.setContent()) tears them down, so this doesn't survive setContent. + globalThis.addEventListener('copy', e => { + if (!e.isTrusted) + return; + e.preventDefault(); + e.stopImmediatePropagation(); + dispatchCopy(e.target, false); + }, { capture: true }); + globalThis.addEventListener('cut', e => { + if (!e.isTrusted) + return; + e.preventDefault(); + e.stopImmediatePropagation(); + dispatchCopy(e.target, true); + }, { capture: true }); + globalThis.addEventListener('paste', e => { + if (!e.isTrusted) + return; + e.preventDefault(); + e.stopImmediatePropagation(); + const target = e.target; + void read().then(items => { + mirror = items; + dispatchPaste(target, items); + }); + }, { capture: true }); +} diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index cf93e3f62f25d..7a58df202bcd3 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -109,6 +109,11 @@ export const methodMetainfo = new Map([ ['BrowserContext.clockRunFor', { title: 'Run clock "{ticksNumber|ticksString}"', }], ['BrowserContext.clockSetFixedTime', { title: 'Set fixed time "{timeNumber|timeString}"', }], ['BrowserContext.clockSetSystemTime', { title: 'Set system time "{timeNumber|timeString}"', }], + ['BrowserContext.clipboardInstall', { title: 'Install clipboard', }], + ['BrowserContext.clipboardReadText', { title: 'Read clipboard text', }], + ['BrowserContext.clipboardWriteText', { title: 'Write clipboard text', }], + ['BrowserContext.clipboardRead', { title: 'Read clipboard', }], + ['BrowserContext.clipboardWrite', { title: 'Write clipboard', }], ['BrowserContext.credentialsInstall', { title: 'Install virtual WebAuthn authenticator', group: 'configuration', }], ['BrowserContext.credentialsCreate', { title: 'Create virtual credential for "{rpId}"', group: 'configuration', }], ['BrowserContext.credentialsGet', { title: 'Get virtual credentials', group: 'configuration', }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index ed7f5c099bee9..35a1076e6bfda 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -10765,6 +10765,12 @@ export interface BrowserContext { waitForEvent(event: 'weberror', optionsOrPredicate?: { predicate?: (webError: WebError) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((webError: WebError) => boolean | Promise)): Promise; + /** + * Provides a virtual clipboard that replaces `navigator.clipboard` and `document.execCommand` in every page of the + * context. + */ + clipboard: Clipboard; + /** * Playwright has ability to mock clock and passage of time. */ @@ -19995,6 +20001,134 @@ export interface BrowserServer { [Symbol.asyncDispose](): Promise; } +/** + * Provides a virtual clipboard for the [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). Access + * it via [browserContext.clipboard](https://playwright.dev/docs/api/class-browsercontext#browser-context-clipboard). + * + * The virtual clipboard is installed into every page in the context on first use — call + * [clipboard.install()](https://playwright.dev/docs/api/class-clipboard#clipboard-install) to opt in explicitly, or + * use any other [Clipboard](https://playwright.dev/docs/api/class-clipboard) method. Until it is installed, pages + * keep using the real clipboard, so tests that only exercise keyboard shortcuts are unaffected. It needs no clipboard + * permissions or secure context and never touches the operating system clipboard. + * + * Once installed, it redirects `navigator.clipboard` (only where the browser already exposes it, i.e. secure + * contexts), replaces `document.execCommand('copy'|'cut'|'paste')`, and intercepts native copy/cut/paste keyboard + * shortcuts (for example `Control+C`/`Control+V`). Copy, cut, and paste dispatch a real `ClipboardEvent` whose + * `clipboardData` page handlers can read and override, matching the browser. Plain text, rich `text/html`, and binary + * content such as images are supported, and the store is shared by every page in the context, so a value written in + * one page can be read in another. + * + * Playwright does not fabricate `navigator.clipboard` or `ClipboardItem` where the browser does not expose them, so + * feature detection and code paths that handle a missing clipboard API keep behaving as they would without + * Playwright. + * + * Native copy/paste interception relies on event listeners installed when a document loads, so it does not apply to + * documents replaced via + * [page.setContent(html[, options])](https://playwright.dev/docs/api/class-page#page-set-content) (which resets the + * document); navigate to a page instead. In Firefox, a `paste` handler cannot read `clipboardData` from the + * synthesized event — read the pasted value from the DOM or + * [clipboard.read()](https://playwright.dev/docs/api/class-clipboard#clipboard-read) instead. + * + * ```js + * await context.clipboard.writeText('hello'); + * const text = await context.clipboard.readText(); + * ``` + * + * Because the clipboard is virtual, it does not interact with the real operating system clipboard. + */ +export interface Clipboard { + /** + * Installs the virtual clipboard into the context's pages. Any other + * [Clipboard](https://playwright.dev/docs/api/class-clipboard) method installs it implicitly, so this is only needed + * to opt in before a page performs clipboard actions (for example native `Control+C`/`Control+V`) without otherwise + * using [browserContext.clipboard](https://playwright.dev/docs/api/class-browsercontext#browser-context-clipboard). + * + * **Usage** + * + * ```js + * await context.clipboard.install(); + * ``` + * + */ + install(): Promise; + + /** + * Returns every entry currently on the virtual clipboard, including binary types such as images. For plain text, + * [clipboard.readText()](https://playwright.dev/docs/api/class-clipboard#clipboard-read-text) is a convenient + * shortcut. + * + * **Usage** + * + * ```js + * const items = await context.clipboard.read(); + * ``` + * + */ + read(): Promise>; + + /** + * Returns the current plain-text contents of the virtual clipboard. + * + * **Usage** + * + * ```js + * const text = await context.clipboard.readText(); + * ``` + * + */ + readText(): Promise; + + /** + * Replaces the virtual clipboard contents with the given entries. Use this for binary types such as images; for plain + * text, [clipboard.writeText(text)](https://playwright.dev/docs/api/class-clipboard#clipboard-write-text) is a + * convenient shortcut. + * + * **Usage** + * + * ```js + * await context.clipboard.write([ + * { mimeType: 'image/png', buffer: await page.screenshot() }, + * ]); + * ``` + * + * @param items Entries to write to the clipboard. + */ + write(items: ReadonlyArray<{ + /** + * The MIME type of the entry, for example `text/plain` or `image/png`. + */ + mimeType: string; + + /** + * The entry's contents. + */ + buffer: Buffer; + }>): Promise; + + /** + * Writes the given plain text to the virtual clipboard. + * + * **Usage** + * + * ```js + * await context.clipboard.writeText('hello'); + * ``` + * + * @param text Text to write to the clipboard. + */ + writeText(text: string): Promise; +} + /** * Accurately simulating time-dependent behavior is essential for verifying the correctness of applications. Learn * more about [clock emulation](https://playwright.dev/docs/clock). diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 70ada2e326876..d1e3a57f3569f 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -27,6 +27,7 @@ import { CDPSession } from './cdpSession'; import { ChannelOwner } from './channelOwner'; import { evaluationScript } from './clientHelper'; import { Clock } from './clock'; +import { Clipboard } from './clipboard'; import { ConsoleMessage } from './consoleMessage'; import { Credentials } from './credentials'; import { Debugger } from './debugger'; @@ -76,6 +77,7 @@ export class BrowserContext extends ChannelOwner readonly request: APIRequestContext; readonly tracing: Tracing; readonly clock: Clock; + readonly clipboard: Clipboard; readonly credentials: Credentials; readonly _serviceWorkers = new Set(); @@ -102,6 +104,7 @@ export class BrowserContext extends ChannelOwner this.request = APIRequestContext.from(initializer.requestContext); this.request._timeoutSettings = this._timeoutSettings; this.clock = new Clock(this); + this.clipboard = new Clipboard(this); this.credentials = new Credentials(this); this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 6a2902e0f5982..35f9c61275419 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -1302,6 +1302,11 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe clockRunFor(params: BrowserContextClockRunForParams, signal: AbortSignal | undefined): Promise; clockSetFixedTime(params: BrowserContextClockSetFixedTimeParams, signal: AbortSignal | undefined): Promise; clockSetSystemTime(params: BrowserContextClockSetSystemTimeParams, signal: AbortSignal | undefined): Promise; + clipboardInstall(params: BrowserContextClipboardInstallParams, signal: AbortSignal | undefined): Promise; + clipboardReadText(params: BrowserContextClipboardReadTextParams, signal: AbortSignal | undefined): Promise; + clipboardWriteText(params: BrowserContextClipboardWriteTextParams, signal: AbortSignal | undefined): Promise; + clipboardRead(params: BrowserContextClipboardReadParams, signal: AbortSignal | undefined): Promise; + clipboardWrite(params: BrowserContextClipboardWriteParams, signal: AbortSignal | undefined): Promise; credentialsInstall(params: BrowserContextCredentialsInstallParams, signal: AbortSignal | undefined): Promise; credentialsCreate(params: BrowserContextCredentialsCreateParams, signal: AbortSignal | undefined): Promise; credentialsGet(params: BrowserContextCredentialsGetParams, signal: AbortSignal | undefined): Promise; @@ -1714,6 +1719,39 @@ export type BrowserContextClockSetSystemTimeOptions = { timeString?: string, }; export type BrowserContextClockSetSystemTimeResult = void; +export type BrowserContextClipboardInstallParams = {}; +export type BrowserContextClipboardInstallOptions = {}; +export type BrowserContextClipboardInstallResult = void; +export type BrowserContextClipboardReadTextParams = {}; +export type BrowserContextClipboardReadTextOptions = {}; +export type BrowserContextClipboardReadTextResult = { + text: string, +}; +export type BrowserContextClipboardWriteTextParams = { + text: string, +}; +export type BrowserContextClipboardWriteTextOptions = { + +}; +export type BrowserContextClipboardWriteTextResult = void; +export type BrowserContextClipboardReadParams = {}; +export type BrowserContextClipboardReadOptions = {}; +export type BrowserContextClipboardReadResult = { + items: { + mimeType: string, + buffer: Binary, + }[], +}; +export type BrowserContextClipboardWriteParams = { + items: { + mimeType: string, + buffer: Binary, + }[], +}; +export type BrowserContextClipboardWriteOptions = { + +}; +export type BrowserContextClipboardWriteResult = void; export type BrowserContextCredentialsInstallParams = {}; export type BrowserContextCredentialsInstallOptions = {}; export type BrowserContextCredentialsInstallResult = void; diff --git a/packages/playwright-core/src/client/clipboard.ts b/packages/playwright-core/src/client/clipboard.ts new file mode 100644 index 0000000000000..18205a08e1300 --- /dev/null +++ b/packages/playwright-core/src/client/clipboard.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContext } from './browserContext'; +import type * as api from '../../types/types'; + +export class Clipboard implements api.Clipboard { + private _browserContext: BrowserContext; + + constructor(browserContext: BrowserContext) { + this._browserContext = browserContext; + } + + async install(): Promise { + await this._browserContext._channel.clipboardInstall({}, undefined); + } + + async readText(): Promise { + const { text } = await this._browserContext._channel.clipboardReadText({}, undefined); + return text; + } + + async writeText(text: string): Promise { + await this._browserContext._channel.clipboardWriteText({ text }, undefined); + } + + async read(): Promise<{ mimeType: string, buffer: Buffer }[]> { + const { items } = await this._browserContext._channel.clipboardRead({}, undefined); + return items; + } + + async write(items: { mimeType: string, buffer: Buffer }[]): Promise { + await this._browserContext._channel.clipboardWrite({ items }, undefined); + } +} diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 96d5c2154c3d0..84bb3a634b8b3 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import { rewriteErrorMessage } from '@utils/stackTrace'; import { debugMode, isUnderTest } from '@utils/debug'; import { Clock } from './clock'; +import { Clipboard } from './clipboard'; import { Credentials } from './credentials'; import { Debugger } from './debugger'; import { DialogManager } from './dialog'; @@ -117,6 +118,7 @@ export abstract class BrowserContext extends Sdk private _debugger!: Debugger; _closeReason: string | undefined; readonly clock: Clock; + readonly clipboard: Clipboard; readonly credentials: Credentials; _clientCertificatesProxy: ClientCertificatesProxy | undefined; private _playwrightBindingExposed?: Promise; @@ -137,6 +139,7 @@ export abstract class BrowserContext extends Sdk this.fetchRequest = new BrowserContextAPIRequestContext(this); this.tracing = new Tracing(this, browser.options.tracesDir); this.clock = new Clock(this); + this.clipboard = new Clipboard(this); this.credentials = new Credentials(this); this.dialogManager = new DialogManager(this.instrumentation); } @@ -248,6 +251,7 @@ export abstract class BrowserContext extends Sdk // Note: we only need to reset properties from the "paramsThatAllowContextReuse" list. // All other properties force a new context. await this.clock.uninstall(progress); + await this.clipboard.resetForReuse(progress); await progress.race(this.setUserAgent(this._options.userAgent)); await progress.race(this.doUpdateDefaultEmulatedMedia()); await progress.race(this.doUpdateDefaultViewport()); diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 9d55ce6535a64..919fe203fc5ab 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -1305,6 +1305,11 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe clockRunFor(params: BrowserContextClockRunForParams, progress: Progress): Promise; clockSetFixedTime(params: BrowserContextClockSetFixedTimeParams, progress: Progress): Promise; clockSetSystemTime(params: BrowserContextClockSetSystemTimeParams, progress: Progress): Promise; + clipboardInstall(params: BrowserContextClipboardInstallParams, progress: Progress): Promise; + clipboardReadText(params: BrowserContextClipboardReadTextParams, progress: Progress): Promise; + clipboardWriteText(params: BrowserContextClipboardWriteTextParams, progress: Progress): Promise; + clipboardRead(params: BrowserContextClipboardReadParams, progress: Progress): Promise; + clipboardWrite(params: BrowserContextClipboardWriteParams, progress: Progress): Promise; credentialsInstall(params: BrowserContextCredentialsInstallParams, progress: Progress): Promise; credentialsCreate(params: BrowserContextCredentialsCreateParams, progress: Progress): Promise; credentialsGet(params: BrowserContextCredentialsGetParams, progress: Progress): Promise; @@ -1717,6 +1722,39 @@ export type BrowserContextClockSetSystemTimeOptions = { timeString?: string, }; export type BrowserContextClockSetSystemTimeResult = void; +export type BrowserContextClipboardInstallParams = {}; +export type BrowserContextClipboardInstallOptions = {}; +export type BrowserContextClipboardInstallResult = void; +export type BrowserContextClipboardReadTextParams = {}; +export type BrowserContextClipboardReadTextOptions = {}; +export type BrowserContextClipboardReadTextResult = { + text: string, +}; +export type BrowserContextClipboardWriteTextParams = { + text: string, +}; +export type BrowserContextClipboardWriteTextOptions = { + +}; +export type BrowserContextClipboardWriteTextResult = void; +export type BrowserContextClipboardReadParams = {}; +export type BrowserContextClipboardReadOptions = {}; +export type BrowserContextClipboardReadResult = { + items: { + mimeType: string, + buffer: Binary, + }[], +}; +export type BrowserContextClipboardWriteParams = { + items: { + mimeType: string, + buffer: Binary, + }[], +}; +export type BrowserContextClipboardWriteOptions = { + +}; +export type BrowserContextClipboardWriteResult = void; export type BrowserContextCredentialsInstallParams = {}; export type BrowserContextCredentialsInstallOptions = {}; export type BrowserContextCredentialsInstallResult = void; diff --git a/packages/playwright-core/src/server/clipboard.ts b/packages/playwright-core/src/server/clipboard.ts new file mode 100644 index 0000000000000..4a34d9fe3a728 --- /dev/null +++ b/packages/playwright-core/src/server/clipboard.ts @@ -0,0 +1,114 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as rawClipboardSource from '../generated/clipboardSource'; +import { nullProgress } from './progress'; + +import type { BrowserContext } from './browserContext'; +import type { Progress } from './progress'; +import type { BindingPayload, ClipboardEntry } from '@injected/clipboard'; + +const kBindingName = '__pwClipboardBinding'; + +export class Clipboard { + private _browserContext: BrowserContext; + private _installPromise: Promise | undefined; + private _items: ClipboardEntry[] = []; + + constructor(browserContext: BrowserContext) { + this._browserContext = browserContext; + } + + async install(progress: Progress): Promise { + await this._install(progress); + } + + async readText(progress: Progress): Promise { + await this._install(progress); + return this._items.find(item => item.type === 'text/plain')?.value ?? ''; + } + + async writeText(progress: Progress, text: string): Promise { + await this._install(progress); + this._items = [{ type: 'text/plain', value: text }]; + await progress.race(this._broadcast()); + } + + async read(progress: Progress): Promise<{ mimeType: string, buffer: Buffer }[]> { + await this._install(progress); + return this._items.map(item => ({ + mimeType: item.type, + buffer: Buffer.from(item.value, item.base64 ? 'base64' : 'utf-8'), + })); + } + + async write(progress: Progress, items: { mimeType: string, buffer: Buffer }[]): Promise { + await this._install(progress); + this._items = items.map(item => { + if (item.mimeType.startsWith('text/')) + return { type: item.mimeType, value: item.buffer.toString('utf-8') }; + return { type: item.mimeType, value: item.buffer.toString('base64'), base64: true }; + }); + await progress.race(this._broadcast()); + } + + async resetForReuse(progress: Progress): Promise { + // Reused contexts start empty; the binding and init script are idempotent and kept, freed with the context. + this._items = []; + if (this._installPromise) + await progress.race(this._broadcast()); + } + + private _install(progress: Progress): Promise { + // Memoize so that concurrent operations don't race to register the binding twice. + if (!this._installPromise) { + this._installPromise = this._doInstall().catch(error => { + this._installPromise = undefined; + throw error; + }); + } + return progress.race(this._installPromise); + } + + private async _doInstall(): Promise { + const script = `(() => { + const module = {}; + ${rawClipboardSource.source} + module.exports.inject()(globalThis); + })();`; + const binding = await this._browserContext.exposeBinding(nullProgress, kBindingName, async (_source, payload: BindingPayload) => { + if (payload.action === 'write') { + this._items = payload.items; + await this._broadcast(); + } + return this._items; + }); + try { + await this._browserContext.addInitScript(nullProgress, script); + } catch (error) { + await binding.dispose().catch(() => {}); + throw error; + } + // addInitScript only covers future documents, so also inject into already-loaded frames (idempotent via the in-page guard). + await this._browserContext.safeNonStallingEvaluateInAllFrames(script, 'main', { throwOnJSErrors: false }).catch(() => {}); + } + + // Synchronous handlers (native Ctrl+V, execCommand) can't await, so push the store into each frame's mirror. + private async _broadcast(): Promise { + const script = `globalThis.__pwClipboardSet?.(${JSON.stringify(this._items)})`; + await this._browserContext.safeNonStallingEvaluateInAllFrames(script, 'main', { throwOnJSErrors: false }); + } +} diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index e8da143bfbf18..7cdaa9c57d571 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -439,6 +439,26 @@ export class BrowserContextDispatcher extends Dispatcher { + await this._context.clipboard.install(progress); + } + + async clipboardReadText(params: channels.BrowserContextClipboardReadTextParams, progress: Progress): Promise { + return { text: await this._context.clipboard.readText(progress) }; + } + + async clipboardWriteText(params: channels.BrowserContextClipboardWriteTextParams, progress: Progress): Promise { + await this._context.clipboard.writeText(progress, params.text); + } + + async clipboardRead(params: channels.BrowserContextClipboardReadParams, progress: Progress): Promise { + return { items: await this._context.clipboard.read(progress) }; + } + + async clipboardWrite(params: channels.BrowserContextClipboardWriteParams, progress: Progress): Promise { + await this._context.clipboard.write(progress, params.items); + } + async credentialsInstall(params: channels.BrowserContextCredentialsInstallParams, progress: Progress): Promise { await this._context.credentials.install(progress); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index ed7f5c099bee9..35a1076e6bfda 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -10765,6 +10765,12 @@ export interface BrowserContext { waitForEvent(event: 'weberror', optionsOrPredicate?: { predicate?: (webError: WebError) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((webError: WebError) => boolean | Promise)): Promise; + /** + * Provides a virtual clipboard that replaces `navigator.clipboard` and `document.execCommand` in every page of the + * context. + */ + clipboard: Clipboard; + /** * Playwright has ability to mock clock and passage of time. */ @@ -19995,6 +20001,134 @@ export interface BrowserServer { [Symbol.asyncDispose](): Promise; } +/** + * Provides a virtual clipboard for the [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). Access + * it via [browserContext.clipboard](https://playwright.dev/docs/api/class-browsercontext#browser-context-clipboard). + * + * The virtual clipboard is installed into every page in the context on first use — call + * [clipboard.install()](https://playwright.dev/docs/api/class-clipboard#clipboard-install) to opt in explicitly, or + * use any other [Clipboard](https://playwright.dev/docs/api/class-clipboard) method. Until it is installed, pages + * keep using the real clipboard, so tests that only exercise keyboard shortcuts are unaffected. It needs no clipboard + * permissions or secure context and never touches the operating system clipboard. + * + * Once installed, it redirects `navigator.clipboard` (only where the browser already exposes it, i.e. secure + * contexts), replaces `document.execCommand('copy'|'cut'|'paste')`, and intercepts native copy/cut/paste keyboard + * shortcuts (for example `Control+C`/`Control+V`). Copy, cut, and paste dispatch a real `ClipboardEvent` whose + * `clipboardData` page handlers can read and override, matching the browser. Plain text, rich `text/html`, and binary + * content such as images are supported, and the store is shared by every page in the context, so a value written in + * one page can be read in another. + * + * Playwright does not fabricate `navigator.clipboard` or `ClipboardItem` where the browser does not expose them, so + * feature detection and code paths that handle a missing clipboard API keep behaving as they would without + * Playwright. + * + * Native copy/paste interception relies on event listeners installed when a document loads, so it does not apply to + * documents replaced via + * [page.setContent(html[, options])](https://playwright.dev/docs/api/class-page#page-set-content) (which resets the + * document); navigate to a page instead. In Firefox, a `paste` handler cannot read `clipboardData` from the + * synthesized event — read the pasted value from the DOM or + * [clipboard.read()](https://playwright.dev/docs/api/class-clipboard#clipboard-read) instead. + * + * ```js + * await context.clipboard.writeText('hello'); + * const text = await context.clipboard.readText(); + * ``` + * + * Because the clipboard is virtual, it does not interact with the real operating system clipboard. + */ +export interface Clipboard { + /** + * Installs the virtual clipboard into the context's pages. Any other + * [Clipboard](https://playwright.dev/docs/api/class-clipboard) method installs it implicitly, so this is only needed + * to opt in before a page performs clipboard actions (for example native `Control+C`/`Control+V`) without otherwise + * using [browserContext.clipboard](https://playwright.dev/docs/api/class-browsercontext#browser-context-clipboard). + * + * **Usage** + * + * ```js + * await context.clipboard.install(); + * ``` + * + */ + install(): Promise; + + /** + * Returns every entry currently on the virtual clipboard, including binary types such as images. For plain text, + * [clipboard.readText()](https://playwright.dev/docs/api/class-clipboard#clipboard-read-text) is a convenient + * shortcut. + * + * **Usage** + * + * ```js + * const items = await context.clipboard.read(); + * ``` + * + */ + read(): Promise>; + + /** + * Returns the current plain-text contents of the virtual clipboard. + * + * **Usage** + * + * ```js + * const text = await context.clipboard.readText(); + * ``` + * + */ + readText(): Promise; + + /** + * Replaces the virtual clipboard contents with the given entries. Use this for binary types such as images; for plain + * text, [clipboard.writeText(text)](https://playwright.dev/docs/api/class-clipboard#clipboard-write-text) is a + * convenient shortcut. + * + * **Usage** + * + * ```js + * await context.clipboard.write([ + * { mimeType: 'image/png', buffer: await page.screenshot() }, + * ]); + * ``` + * + * @param items Entries to write to the clipboard. + */ + write(items: ReadonlyArray<{ + /** + * The MIME type of the entry, for example `text/plain` or `image/png`. + */ + mimeType: string; + + /** + * The entry's contents. + */ + buffer: Buffer; + }>): Promise; + + /** + * Writes the given plain text to the virtual clipboard. + * + * **Usage** + * + * ```js + * await context.clipboard.writeText('hello'); + * ``` + * + * @param text Text to write to the clipboard. + */ + writeText(text: string): Promise; +} + /** * Accurately simulating time-dependent behavior is essential for verifying the correctness of applications. Learn * more about [clock emulation](https://playwright.dev/docs/clock). diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index 753e9d6a2c225..c472787327ed1 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -340,6 +340,41 @@ BrowserContext: timeNumber: float? timeString: string? + clipboardInstall: + title: Install clipboard + + clipboardReadText: + title: Read clipboard text + returns: + text: string + + clipboardWriteText: + title: Write clipboard text + parameters: + text: string + + clipboardRead: + title: Read clipboard + returns: + items: + type: array + items: + type: object + properties: + mimeType: string + buffer: binary + + clipboardWrite: + title: Write clipboard + parameters: + items: + type: array + items: + type: object + properties: + mimeType: string + buffer: binary + credentialsInstall: title: Install virtual WebAuthn authenticator group: configuration diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 6e102e9cfb6fa..08ed1b37c8d3a 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -952,6 +952,30 @@ scheme.BrowserContextClockSetSystemTimeParams = tObject({ timeString: tOptional(tString), }); scheme.BrowserContextClockSetSystemTimeResult = tOptional(tObject({})); +scheme.BrowserContextClipboardInstallParams = tOptional(tObject({})); +scheme.BrowserContextClipboardInstallResult = tOptional(tObject({})); +scheme.BrowserContextClipboardReadTextParams = tOptional(tObject({})); +scheme.BrowserContextClipboardReadTextResult = tObject({ + text: tString, +}); +scheme.BrowserContextClipboardWriteTextParams = tObject({ + text: tString, +}); +scheme.BrowserContextClipboardWriteTextResult = tOptional(tObject({})); +scheme.BrowserContextClipboardReadParams = tOptional(tObject({})); +scheme.BrowserContextClipboardReadResult = tObject({ + items: tArray(tObject({ + mimeType: tString, + buffer: tBinary, + })), +}); +scheme.BrowserContextClipboardWriteParams = tObject({ + items: tArray(tObject({ + mimeType: tString, + buffer: tBinary, + })), +}); +scheme.BrowserContextClipboardWriteResult = tOptional(tObject({})); scheme.BrowserContextCredentialsInstallParams = tOptional(tObject({})); scheme.BrowserContextCredentialsInstallResult = tOptional(tObject({})); scheme.BrowserContextCredentialsCreateParams = tObject({ diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index 202b423000888..9a805189758cb 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -336,6 +336,15 @@ for (const scenario of ['launch', 'connect'] as const) { expect(await page.evaluate('new Date().toISOString()')).toBe('2020-01-01T00:00:00.000Z'); }); + test('should reset the clipboard', async ({ reusedContext }) => { + let context = await reusedContext(); + await context.clipboard.writeText('from first run'); + expect(await context.clipboard.readText()).toBe('from first run'); + + context = await reusedContext(); + expect(await context.clipboard.readText()).toBe(''); + }); + test('should continue issuing events after closing the reused page', async ({ reusedContext, server }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24574' }); diff --git a/tests/library/browsercontext-virtual-clipboard.spec.ts b/tests/library/browsercontext-virtual-clipboard.spec.ts new file mode 100644 index 0000000000000..6bc049be14ddc --- /dev/null +++ b/tests/library/browsercontext-virtual-clipboard.spec.ts @@ -0,0 +1,307 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { contextTest as it, expect } from '../config/browserTest'; + +import type { Page } from 'playwright-core'; + +// Use innerHTML rather than page.setContent(), whose document.open() would wipe the virtual clipboard's native-event listeners. +async function setBody(page: Page, html: string) { + await page.evaluate(content => { document.body.innerHTML = content; }, html); +} + +it('should virtualize navigator.clipboard without permissions', async ({ context, server }) => { + await context.clipboard.readText(); // install before creating the page + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); // localhost is a secure context, so navigator.clipboard exists to be virtualized + await page.evaluate(() => navigator.clipboard.writeText('from page')); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('from page'); + expect(await context.clipboard.readText()).toBe('from page'); +}); + +it('should expose context.clipboard writes to a page', async ({ context, server }) => { + await context.clipboard.writeText('context value'); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('context value'); +}); + +it('should virtualize document.execCommand copy, and paste where the browser supports it', async ({ context }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await page.setContent(`
execCommand text
`); + await page.locator('#src').selectText(); + expect(await page.evaluate(() => document.execCommand('copy'))).toBe(true); + await page.locator('#dst').focus(); + const pasteSupported = await page.evaluate(() => document.queryCommandSupported('paste')); + expect(await page.evaluate(() => document.execCommand('paste'))).toBe(pasteSupported); + await expect(page.locator('#dst')).toHaveValue(pasteSupported ? 'execCommand text' : ''); +}); + +it('should virtualize document.execCommand copy from a form control', async ({ context }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await page.setContent(``); + await page.locator('#src').selectText(); + expect(await page.evaluate(() => document.execCommand('copy'))).toBe(true); + expect(await context.clipboard.readText()).toBe('input field text'); +}); + +it('should support rich text via ClipboardItem', async ({ context, server }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const roundtrip = await page.evaluate(async () => { + await navigator.clipboard.write([new ClipboardItem({ + 'text/plain': new Blob(['plain'], { type: 'text/plain' }), + 'text/html': new Blob(['rich'], { type: 'text/html' }), + })]); + const items = await navigator.clipboard.read(); + const out: Record = {}; + for (const item of items) { + for (const type of item.types) + out[type] = await (await item.getType(type)).text(); + } + return out; + }); + expect(roundtrip).toEqual({ 'text/plain': 'plain', 'text/html': 'rich' }); +}); + +it('should round-trip binary content for image types the browser supports', async ({ context, server }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + for (const type of ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml']) { + const result = await page.evaluate(async type => { + const supported = ClipboardItem.supports(type); + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255, 128, 64, 32]); + try { + await navigator.clipboard.write([new ClipboardItem({ [type]: new Blob([bytes], { type }) })]); + } catch (e) { + return { supported, error: (e as Error).name }; + } + const [item] = await navigator.clipboard.read(); + const out = new Uint8Array(await (await item.getType(type)).arrayBuffer()); + return { supported, outType: item.types[0], roundTrip: out.join(',') === bytes.join(',') }; + }, type); + if (result.supported) { + expect(result.roundTrip, `${type} should round-trip byte-accurately`).toBe(true); + expect(result.outType).toBe(type); + } else { + expect(result.error, `${type} should reject like the real browser`).toBe('NotAllowedError'); + } + } +}); + +it('should share the clipboard across pages', async ({ context, server }) => { + await context.clipboard.readText(); + const page1 = await context.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.evaluate(() => navigator.clipboard.writeText('cross page')); + const page2 = await context.newPage(); + await page2.goto(server.EMPTY_PAGE); + expect(await page2.evaluate(() => navigator.clipboard.readText())).toBe('cross page'); +}); + +it('should paste context.clipboard content via native Ctrl+V', async ({ context }) => { + await context.clipboard.writeText('native pasted'); + const page = await context.newPage(); + await setBody(page, ``); + await page.locator('#dst').focus(); + await page.keyboard.press('ControlOrMeta+v'); + await expect(page.locator('#dst')).toHaveValue('native pasted'); +}); + +it('should paste into a non-text input without throwing', async ({ context }) => { + await context.clipboard.writeText('someone@example.com'); + const page = await context.newPage(); + await setBody(page, ``); + await page.locator('#dst').focus(); + await page.evaluate(() => { + (window as any).__inputEvents = 0; + document.addEventListener('input', () => (window as any).__inputEvents++); + }); + await page.keyboard.press('ControlOrMeta+v'); + await expect(page.locator('#dst')).toHaveValue('someone@example.com'); + expect(await page.evaluate(() => (window as any).__inputEvents)).toBeGreaterThan(0); +}); + +it('should capture native Ctrl+C into context.clipboard', async ({ context }) => { + await context.clipboard.readText(); // install the virtual clipboard before the page loads + const page = await context.newPage(); + await setBody(page, `
copied natively
`); + await page.locator('#src').selectText(); + await page.keyboard.press('ControlOrMeta+c'); + await expect.poll(() => context.clipboard.readText()).toBe('copied natively'); +}); + +it('should round-trip rich content via native Ctrl+C/V in contenteditable', async ({ context }) => { + await context.clipboard.readText(); // install the virtual clipboard before the page loads + const page = await context.newPage(); + await setBody(page, `
bold and italic
`); + await page.locator('#src').selectText(); + await page.keyboard.press('ControlOrMeta+c'); + await page.locator('#dst').focus(); + await page.keyboard.press('ControlOrMeta+v'); + expect(await page.locator('#dst').innerHTML()).toContain('bold'); + expect(await page.locator('#dst').innerHTML()).toContain('italic'); +}); + +it('should native-paste in one page what another page set', async ({ context }) => { + const page1 = await context.newPage(); + await setBody(page1, `
page 1
`); + await context.clipboard.writeText('from another page'); + const page2 = await context.newPage(); + await setBody(page2, ``); + await page2.locator('#dst').focus(); + await page2.keyboard.press('ControlOrMeta+v'); + await expect(page2.locator('#dst')).toHaveValue('from another page'); +}); + +it('should install into an already-open page', async ({ context, server }) => { + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await context.clipboard.writeText('late install'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('late install'); +}); + +it('should capture native Ctrl+X (cut)', async ({ context }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await setBody(page, `
cut me
`); + await page.locator('#src').selectText(); + await page.keyboard.press('ControlOrMeta+x'); + await expect.poll(() => context.clipboard.readText()).toBe('cut me'); + await expect(page.locator('#src')).toHaveText(''); +}); + +it('should delete a form control selection on native Ctrl+X (cut)', async ({ context }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await setBody(page, ``); + await page.locator('#src').selectText(); + await page.keyboard.press('ControlOrMeta+x'); + await expect.poll(() => context.clipboard.readText()).toBe('cut this field'); + await expect(page.locator('#src')).toHaveValue(''); +}); + +it('should read and write binary content via the context.clipboard API', async ({ context, server }) => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 250, 255]); + await context.clipboard.write([{ mimeType: 'image/png', buffer: png }]); + expect(await context.clipboard.read()).toEqual([{ mimeType: 'image/png', buffer: png }]); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const fromPage = await page.evaluate(async () => { + const [item] = await navigator.clipboard.read(); + const buffer = await (await item.getType(item.types[0])).arrayBuffer(); + return { type: item.types[0], bytes: [...new Uint8Array(buffer)] }; + }); + expect(fromPage).toEqual({ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 250, 255] }); +}); + +it('should not fail when concurrent operations trigger the first install', async ({ context }) => { + const [, text] = await Promise.all([ + context.clipboard.writeText('concurrent'), + context.clipboard.readText(), + ]); + expect(typeof text).toBe('string'); + expect(await context.clipboard.readText()).toBe('concurrent'); +}); + +it('should install the virtual clipboard via install()', async ({ context }) => { + await context.clipboard.install(); + const page = await context.newPage(); + await setBody(page, `
explicit install
`); + await page.locator('#src').selectText(); + await page.keyboard.press('ControlOrMeta+c'); + await expect.poll(() => context.clipboard.readText()).toBe('explicit install'); +}); + +it('should let a page copy handler override the copied content', async ({ context }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await setBody(page, `
original selection
`); + await page.locator('#src').selectText(); + await page.evaluate(() => document.addEventListener('copy', e => { + e.clipboardData.setData('text/plain', 'overridden by handler'); + e.preventDefault(); + })); + await page.keyboard.press('ControlOrMeta+c'); + await expect.poll(() => context.clipboard.readText()).toBe('overridden by handler'); +}); + +it('should let a page cut handler override the content and suppress the delete', async ({ context }) => { + await context.clipboard.readText(); + const page = await context.newPage(); + await setBody(page, `
cut original
`); + await page.locator('#src').selectText(); + await page.evaluate(() => document.addEventListener('cut', e => { + e.clipboardData.setData('text/plain', 'cut override'); + e.preventDefault(); + })); + await page.keyboard.press('ControlOrMeta+x'); + await expect.poll(() => context.clipboard.readText()).toBe('cut override'); + await expect(page.locator('#src')).toHaveText('cut original'); +}); + +it('should expose clipboardData to a page paste handler', async ({ context, browserName }) => { + await context.clipboard.writeText('handler reads this'); + const page = await context.newPage(); + await setBody(page, ``); + await page.locator('#dst').focus(); + await page.evaluate(() => { + (window as any).__pasted = null; + document.addEventListener('paste', e => (window as any).__pasted = e.clipboardData.getData('text/plain')); + }); + await page.keyboard.press('ControlOrMeta+v'); + await expect(page.locator('#dst')).toHaveValue('handler reads this'); + // Firefox does not expose clipboardData on a synthesized paste event; other browsers do. + expect(await page.evaluate(() => (window as any).__pasted)).toBe(browserName === 'firefox' ? '' : 'handler reads this'); +}); + +it('should read one ClipboardItem containing all types', async ({ context, server }) => { + await context.clipboard.write([ + { mimeType: 'text/plain', buffer: Buffer.from('plain') }, + { mimeType: 'text/html', buffer: Buffer.from('rich') }, + ]); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async () => { + const items = await navigator.clipboard.read(); + return { count: items.length, types: items[0].types }; + }); + expect(result.count).toBe(1); + expect(result.types).toContain('text/plain'); + expect(result.types).toContain('text/html'); +}); + +it('should expose pasted binary content via clipboardData files', async ({ context, browserName }) => { + it.skip(browserName === 'firefox', 'Firefox does not expose clipboardData on a synthesized paste event'); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 5, 6, 7, 8]); + await context.clipboard.write([{ mimeType: 'image/png', buffer: png }]); + const page = await context.newPage(); + await setBody(page, ``); + await page.locator('#dst').focus(); + await page.evaluate(() => { + (window as any).__file = null; + document.addEventListener('paste', async e => { + const file = e.clipboardData.files[0]; + (window as any).__file = { type: file.type, bytes: [...new Uint8Array(await file.arrayBuffer())] }; + }); + }); + await page.keyboard.press('ControlOrMeta+v'); + await expect.poll(() => page.evaluate(() => (window as any).__file)).toEqual({ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 5, 6, 7, 8] }); +}); diff --git a/utils/generate_injected.js b/utils/generate_injected.js index f62221f0d06cd..495c0416e10c6 100644 --- a/utils/generate_injected.js +++ b/utils/generate_injected.js @@ -80,6 +80,12 @@ const injectedScripts = [ path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), true, ], + [ + path.join(ROOT, 'packages', 'injected', 'src', 'clipboard.ts'), + path.join(ROOT, 'packages', 'injected', 'lib'), + path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), + true, + ], [ path.join(ROOT, 'packages', 'injected', 'src', 'webview', 'webViewInput.ts'), path.join(ROOT, 'packages', 'injected', 'lib'),