Skip to content

feat(browser-context): add a virtual clipboard#41741

Open
dcrousso wants to merge 1 commit into
microsoft:mainfrom
dcrousso:fix-15860
Open

feat(browser-context): add a virtual clipboard#41741
dcrousso wants to merge 1 commit into
microsoft:mainfrom
dcrousso:fix-15860

Conversation

@dcrousso

Copy link
Copy Markdown
Contributor

add a new browserContext.clipboard for reading and writing rich content (e.g. text/html), binary data (e.g. image/png), and plain text

it leverages a per-context store that shims and shares navigator.clipboard, native ControlOrMeta+c/ControlOrMeta+v (via trusted event interception), and document.execCommand across every page

copy, cut, and paste dispatch a real ClipboardEvent with a populated DataTransfer so that page handlers can read and override clipboardData like they would in a real browser

with one exception as Firefox does not expose clipboardData on a synthesized paste event

the shim is installed lazily on the first browserContext.clipboard use, or eagerly via browserContext.clipboard.install() (e.g. for pages that only exercise native keyboard shortcuts)

note that navigator.clipboard is only redirected where the browser already exposes it and rejects types it cannot write since that API has additional restrictions (unlike native keyboard interactions)

fixes #15860

@dcrousso dcrousso force-pushed the fix-15860 branch 2 times, most recently from aa7632c to ea31f10 Compare July 10, 2026 23:38
@dcrousso dcrousso changed the title feat(browser-context): add a virtual clipboard for pages feat(browser-context): add a virtual clipboard Jul 10, 2026
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@yury-s yury-s left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below are some discrepancies found by the agent, many of which seem important. I uploaded the scripts that poke at the features at https://github.com/yury-s/clipboard-emulation-probes


Compared the shim against stock Chromium — all "real browser" claims below were verified empirically.

Default actions diverge from the real browser

  • Cut deletes non-editable content. Real Chromium fires cut on a static-text selection but does not delete it (execCommand('cut') returns true without deleting). deleteSelection() calls getSelection().deleteFromDocument() unconditionally, so Ctrl+X / execCommand('cut') on a plain <div> selection mutates the page.
  • Paste mutates non-editable content. Real Chromium fires paste but the default action does nothing outside an editable context. The keyboard-paste path (insertClipboard) has no editability check for non-form targets — any live selection gets deleteContents() + insertNode(). Copy static text, press Ctrl+V, and the page is rewritten in place. Only the execCommand('paste') path checks isContentEditable.
  • readonly, disabled, maxlength are ignored. Real paste into a readonly input is a no-op, and maxlength=4 truncates "CLIP2" to "CLIP". The shim assigns target.value directly, bypassing all three.
  • Password fields leak into the clipboard. Real Chromium blocks clipboard writes from <input type=password>execCommand('copy') returns true but the clipboard stays unchanged. activeFormControl() doesn't check the input type, so the password text ends up on the virtual clipboard.
  • React controlled inputs never see the paste. Setting .value through the instance property updates React's value tracker, so the synthetic input event is deduped as "no change" and component state doesn't update. fill() avoids exactly this by typing text inputs via keyboard.insertText instead of setting .value.
  • Undo is not integrated — real paste/cut are undoable, programmatic mutation is not.

Missing / wrong events

  • No beforeinput for form controls. Real Chromium fires a cancelable beforeinput (insertFromPaste) before pasting into an <input>; the shim only fires input after the mutation, so pages cannot cancel the paste. Same for cut (deleteByCut).
  • Contenteditable cut fires no input events at all. The non-control branch of deleteSelection() is a bare deleteFromDocument() — no beforeinput/input, so editor frameworks won't observe the deletion.
  • change never fires. Real paste marks the control dirty and change fires on blur; a programmatic .value = doesn't.
  • Synthesized events are isTrusted: false and the trusted originals are swallowed via stopImmediatePropagation — pages that gate on isTrusted change behavior.
  • Contenteditable paste beforeinput is dispatched without dataTransfer and with empty getTargetRanges(); the synthetic paste's clipboardData is mutable, whereas a real paste DataTransfer is read-only.

No sanitization

  • Pasted HTML is inserted verbatim and embedded scripts execute. Real Chromium sanitizes on paste — <script> and on* attributes stripped, relative URLs absolutized. The shim uses range.createContextualFragment(html), which — unlike innerHTML — executes <script> elements on insertion. So text/html set by a copy handler or context.clipboard.write() gets arbitrary script execution on paste.
  • Async navigator.clipboard.read() is not sanitized. Real Chromium returns sanitized text/html and only well-known types (custom types need the web prefix); the shim returns stored bytes and MIME types verbatim.
  • Binary data is not validated. Real Chromium rejects an invalid PNG at write() time with DataError; the shim accepts arbitrary bytes as image/png — the new test even asserts a byte-exact round-trip of an invalid PNG, which no real browser exhibits.
  • Copied HTML keeps relative URLs. Real copy rewrites them to absolute; cloneContents() + innerHTML doesn't, so cross-page paste of links/images resolves against the destination page's base URL.
  • Multiple ClipboardItems are silently merged. Real Chromium rejects write([item1, item2]) with NotAllowedError: Support for multiple ClipboardItems is not implemented; the shim flattens them into one store.

Half-virtualized state after page.setContent()

  • The docs say native interception "does not apply" after setContent(), but the actual state is worse: document.open() removes the window listeners, while the navigator.clipboard override and the __pwClipboardInstalled guard survive (same window/navigator objects), and init scripts don't re-run without a navigation. Result: the async API keeps hitting the virtual store while native Ctrl+C/Ctrl+V silently go to the real OS clipboard, and nothing reinstalls the interception until the next navigation. Tests would read stale virtual content while the page operates on the real clipboard.

Comment thread packages/playwright-core/src/server/clipboard.ts Outdated
await binding.dispose().catch(() => {});
throw error;
}
// addInitScript only covers future documents, so also inject into already-loaded frames (idempotent via the in-page guard).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to call context.extendInjectedScript instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

none of the other features like this (e.g. clock, credentials, etc.) do it that way

i think that might evaluate things too late (i.e. the page could've already used/captured navigator.clipboard) whereas addInitScript is guaranteed to run before that

add a new `browserContext.clipboard` for reading and writing rich content (e.g. `text/html`), binary data (e.g. `image/png`), and plain text

it leverages a per-context store that shims and shares `navigator.clipboard`, native `ControlOrMeta+c`/`ControlOrMeta+v` (via `trusted` event interception, with a `keydown` fallback where WebKit fires no copy/cut event), and `document.execCommand` across every page

copy, cut, and paste dispatch a real `ClipboardEvent` with a populated `DataTransfer` so that page handlers can read and override `clipboardData` like they would in a real browser

with one exception as Firefox does not expose `clipboardData` on a synthesized `paste` event

the default actions match a real browser too, so a cut over static content leaves it in place, a paste respects `readonly`/`disabled`/`maxLength`, password fields stay out of the store, and pasted `text/html` can't run script

the shim is installed lazily on the first `browserContext.clipboard` use, or eagerly via `browserContext.clipboard.install()` (e.g. for pages that only exercise native keyboard shortcuts)

in `@playwright/test`, set `clipboard: true` in the `use` options to install it into every test context up front, avoiding a per-test `install()` call

note that `navigator.clipboard` is only redirected where the browser already exposes it and rejects types it cannot write since that API has additional restrictions (unlike native keyboard interactions)
@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

3 failed
❌ [chrome] › mcp/annotate.spec.ts:230 › should capture annotations via show --annotate @mcp-ubuntu-latest-chrome
❌ [firefox] › mcp/cli-killall.spec.ts:42 › kill-all kills filtered dashboard pid @mcp-ubuntu-latest-firefox
❌ [firefox] › mcp/annotate.spec.ts:110 › should abort annotation when last screenshot is removed @mcp-macos-latest-firefox

7757 passed, 1249 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

4 flaky ⚠️ [chromium-library] › library/video.spec.ts:680 › screencast › should capture full viewport on hidpi `@frozen-time-library-chromium-linux`
⚠️ [chromium-library] › library/browsercontext-page-event.spec.ts:173 › should work with Ctrl-clicking `@chromium-ubuntu-22.04-arm-node20`
⚠️ [chromium-library] › library/har-websocket.spec.ts:231 › should embed websocket messages `@chromium-ubuntu-22.04-node22`
⚠️ [playwright-test] › ui-mode-trace.spec.ts:827 › should update state on subsequent run `@windows-latest-node22`

49986 passed, 1157 skipped


Merge workflow run.


## property: TestOptions.clipboard
* since: v1.62
- type: <[boolean]>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may be worth making it an object in case we want to add more options later

}

async readText(progress: Progress): Promise<string> {
await this.install(progress);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it just wait for _installPromise or we want an implicit install?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] a dedicated clipboard API

2 participants