feat(browser-context): add a virtual clipboard#41741
Conversation
aa7632c to
ea31f10
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
yury-s
left a comment
There was a problem hiding this comment.
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
cuton a static-text selection but does not delete it (execCommand('cut')returnstruewithout deleting).deleteSelection()callsgetSelection().deleteFromDocument()unconditionally, soCtrl+X/execCommand('cut')on a plain<div>selection mutates the page. - Paste mutates non-editable content. Real Chromium fires
pastebut 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 getsdeleteContents()+insertNode(). Copy static text, pressCtrl+V, and the page is rewritten in place. Only theexecCommand('paste')path checksisContentEditable. readonly,disabled,maxlengthare ignored. Real paste into a readonly input is a no-op, andmaxlength=4truncates"CLIP2"to"CLIP". The shim assignstarget.valuedirectly, bypassing all three.- Password fields leak into the clipboard. Real Chromium blocks clipboard writes from
<input type=password>—execCommand('copy')returnstruebut 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
.valuethrough the instance property updates React's value tracker, so the syntheticinputevent is deduped as "no change" and component state doesn't update.fill()avoids exactly this by typing text inputs viakeyboard.insertTextinstead of setting.value. - Undo is not integrated — real paste/cut are undoable, programmatic mutation is not.
Missing / wrong events
- No
beforeinputfor form controls. Real Chromium fires a cancelablebeforeinput(insertFromPaste) before pasting into an<input>; the shim only firesinputafter 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 baredeleteFromDocument()— nobeforeinput/input, so editor frameworks won't observe the deletion. changenever fires. Real paste marks the control dirty andchangefires on blur; a programmatic.value =doesn't.- Synthesized events are
isTrusted: falseand the trusted originals are swallowed viastopImmediatePropagation— pages that gate onisTrustedchange behavior. - Contenteditable paste
beforeinputis dispatched withoutdataTransferand with emptygetTargetRanges(); the synthetic paste'sclipboardDatais mutable, whereas a real pasteDataTransferis read-only.
No sanitization
- Pasted HTML is inserted verbatim and embedded scripts execute. Real Chromium sanitizes on paste —
<script>andon*attributes stripped, relative URLs absolutized. The shim usesrange.createContextualFragment(html), which — unlikeinnerHTML— executes<script>elements on insertion. Sotext/htmlset by a copy handler orcontext.clipboard.write()gets arbitrary script execution on paste. - Async
navigator.clipboard.read()is not sanitized. Real Chromium returns sanitizedtext/htmland only well-known types (custom types need thewebprefix); the shim returns stored bytes and MIME types verbatim. - Binary data is not validated. Real Chromium rejects an invalid PNG at
write()time withDataError; the shim accepts arbitrary bytes asimage/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()+innerHTMLdoesn't, so cross-page paste of links/images resolves against the destination page's base URL. - Multiple
ClipboardItems are silently merged. Real Chromium rejectswrite([item1, item2])withNotAllowedError: 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 thenavigator.clipboardoverride and the__pwClipboardInstalledguard 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 nativeCtrl+C/Ctrl+Vsilently 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.
| await binding.dispose().catch(() => {}); | ||
| throw error; | ||
| } | ||
| // addInitScript only covers future documents, so also inject into already-loaded frames (idempotent via the in-page guard). |
There was a problem hiding this comment.
Would it make sense to call context.extendInjectedScript instead?
There was a problem hiding this comment.
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)
Test results for "MCP"3 failed 7757 passed, 1249 skipped Merge workflow run. |
Test results for "tests 1"4 flaky49986 passed, 1157 skipped Merge workflow run. |
|
|
||
| ## property: TestOptions.clipboard | ||
| * since: v1.62 | ||
| - type: <[boolean]> |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
should it just wait for _installPromise or we want an implicit install?
add a new
browserContext.clipboardfor reading and writing rich content (e.g.text/html), binary data (e.g.image/png), and plain textit leverages a per-context store that shims and shares
navigator.clipboard, nativeControlOrMeta+c/ControlOrMeta+v(viatrustedevent interception), anddocument.execCommandacross every pagecopy, cut, and paste dispatch a real
ClipboardEventwith a populatedDataTransferso that page handlers can read and overrideclipboardDatalike they would in a real browserwith one exception as Firefox does not expose
clipboardDataon a synthesizedpasteeventthe shim is installed lazily on the first
browserContext.clipboarduse, or eagerly viabrowserContext.clipboard.install()(e.g. for pages that only exercise native keyboard shortcuts)note that
navigator.clipboardis 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