Repository navigation
fix(web): hide the screenshot caret with a stylesheet - #1288
Conversation
There was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/web/tests/integration/screenshot-caret.test.ts">
<violation number="1" location="packages/web/tests/integration/screenshot-caret.test.ts:38">
P2: This test never exercises the closed-shadow case described by the regression: `/values` contains only light-DOM inputs, and a document-level `MutationObserver` does not observe mutations inside shadow roots. Add an editable element inside a closed root and observe that root before asserting no mutations.</violation>
</file>
Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.
View guided diff | Re-trigger cubic
| origin: 'agent', | ||
| }; | ||
| try { | ||
| await page.goto(`${app.url}/values`); |
There was a problem hiding this comment.
P2: This test never exercises the closed-shadow case described by the regression: /values contains only light-DOM inputs, and a document-level MutationObserver does not observe mutations inside shadow roots. Add an editable element inside a closed root and observe that root before asserting no mutations.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/web/tests/integration/screenshot-caret.test.ts, line 38:
<comment>This test never exercises the closed-shadow case described by the regression: `/values` contains only light-DOM inputs, and a document-level `MutationObserver` does not observe mutations inside shadow roots. Add an editable element inside a closed root and observe that root before asserting no mutations.</comment>
<file context>
@@ -0,0 +1,59 @@
+ origin: 'agent',
+ };
+ try {
+ await page.goto(`${app.url}/values`);
+ await page.evaluate(() => {
+ const mutations: string[] = [];
</file context>
There was a problem hiding this comment.
Fixed in d777226. The fixture renders an editable inside a closed shadow root that sets its own red caret-color, and the test observes that root through the engine's closed-root tracker as well as the document, then asserts both mutation lists are empty.
66c2581 to
d777226
Compare
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.
View guided diff | Re-trigger cubic
d777226 to
3d279ef
Compare
okwasniewski
left a comment
There was a problem hiding this comment.
Thanks @DeryFerd, the approach is right: a constructed sheet in adoptedStyleSheets leaves element style attributes alone and is not blocked by style-src. I checked it against a real Chromium page served with Content-Security-Policy: default-src 'none'; style-src 'self', a document <style> that the policy blocks, an open shadow root, and a same-origin and a cross-origin iframe, each with a closed shadow root. During the capture every caret was rgba(0, 0, 0, 0). Afterwards every root was back to its original color and had no adopted sheets left, both when the screenshot succeeded and when page.screenshot threw. Reverting observe.ts to main fails 4 of your 8 tests. Good.
The implementation can be a lot smaller, though.
Must fix
-
packages/web/src/screenshot-caret.ts: drop the global token. You can return the undo function and keep aJSHandleto it:frame.evaluateHandle(hideCaret), thenhandle.evaluate((show) => show())anddispose()in thefinally. That removesrandomUUID,TOKEN_PREFIX,Adoption,ParkedSheet, theReflect.get/set/deletePropertycalls and the shape checks inremoveCaretStylesheet. It also fixes a real leak:Reflect.set(globalThis, token, ...)creates an enumerablewindowproperty for the length of the capture. I sawObject.keys(window)include it in all three frames, and page code can read or overwrite it. Other things that can go:- One
CSSStyleSheetper document is enough, since shadow roots of the same document can adopt the same sheet. - The
seenset isn't needed. An element has at most one shadow root, soelement.shadowRoot ?? closed.get(element)reaches each root once. - The per-root
try/catchisn't needed either, because assigning a same-document sheet can't throw.
I prototyped this shape.
screenshot-caret.tsdrops from 132 lines to about 45, and it passed the same CSP, iframe, closed-root and failure checks with no page-visible global:function hideCaret(): () => void { const closed: unknown = Reflect.get(globalThis, Symbol.for('e2e.closedShadowRoots')); const sheet = new CSSStyleSheet(); sheet.replaceSync('input, textarea, [contenteditable] { caret-color: transparent !important; }'); const roots: (Document | ShadowRoot)[] = []; const visit = (root: Document | ShadowRoot): void => { roots.push(root); root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; for (const element of root.querySelectorAll('*')) { const shadow: unknown = element.shadowRoot ?? (closed instanceof WeakMap ? closed.get(element) : null); if (shadow instanceof ShadowRoot) visit(shadow); } }; visit(document); return () => { for (const root of roots) root.adoptedStyleSheets = root.adoptedStyleSheets.filter((other) => other !== sheet); }; } export async function withHiddenCaret<T>(page: Page, capture: () => Promise<T>): Promise<T> { const undos = await Promise.all(page.frames().map((frame) => inFrame(frame.evaluateHandle(hideCaret)))); try { return await capture(); } finally { await Promise.all(undos.map((undo) => undo && inFrame(undo.evaluate((show) => show()).finally(() => undo.dispose())))); } }
(
inFrameiswithTimeout(work, 1_000, ...).catch(() => undefined). This replaces the duplicated adopt and remove timeout blocks.) - One
-
packages/web/src/observe.ts:79-93: the screenshot budget is now computed twice and there's an extrascope.check().budgetMsonly feeds the per-frame step, and that step is capped at 1s anyway. Keep the original singlescope.read(() => withHiddenCaret(page, () => page.screenshot({...})))with the originaltimeout:expression, and cut the 4-line comment down to one line. -
Tests: if you take ask 1, the in-page functions don't need to be exported. Test behavior through
capturePixelsinstead. Wrappage.screenshotin the test so it readsgetComputedStyle(...).caretColoron the CSP page, inside the closed root and inside an iframe during the capture, and checks that everything is restored after. Right now the CSP and closed-root tests calladoptCaretStylesheetdirectly, so they never prove thatcapturePixelshides anything. There's also no iframe coverage. The unit mocks intests/unit/observe.test.tstell adopt and remove apart bytypeof argument === 'string', which breaks as soon as the signature changes. Once the integration test covers ordering and the failure path for real, they can go. -
packages/web/tests/integration/screenshot-caret.test.ts:69: theshadowMutationsassertion can't fail. Playwright'scaret: 'hide'only walks open roots (collectRootsusesnode.shadowRoot), so main never writes a style inside a closed root either. Either drop it or point it at an open root.
Nits
.changeset/screenshot-caret-stylesheet.md: describe the user-visible effect only: screenshots no longer change input styles, so React hydration on SSR pages doesn't warn. "Constructed stylesheet adopted into every frame and shadow root" is an internal detail.packages/web/src/surface.ts:1028: the artifact screenshot still uses Playwright's defaultcaret: 'hide', so it writes the same inline styles. Consider wrapping it withwithHiddenCaretand passingcaret: 'initial'there too.
This was an automated review.
3d279ef to
874a8ee
Compare
|
@okwasniewski Thanks for the review, and for checking the sheet against a real CSP page, that is more than my tests proved. All four must-fix items and both nits are in, pushed as 874a8ee:
Two notes. The test set is 6 now (3 unit, 3 integration), since the mocks coupled to the old signatures went away; on main the during-capture test still fails on the eight light-DOM Verified: the caret tests pass, |
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 9 files (changes from recent commits).
Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
View guided diff | Re-trigger cubic
Playwright's `caret: 'hide'` writes an inline `caret-color` on every editable element and restores it after the frame. A server-rendered app hydrating during a capture reads that temporary style as a server mismatch and logs a React hydration warning. A constructed stylesheet adopted into every frame and shadow root, including the closed roots the engine tracks, hides the caret without touching element styles, and a `style-src` policy does not block it.
874a8ee to
91e5ad6
Compare
Addressed, see latest automated review.

What and why
The web engine captures the observation screenshot that follows an action, including the automatic trace screen after
app.open(), incapturePixels(packages/web/src/observe.ts:62). It passedcaret: 'hide'topage.screenshot(). Playwright implements that option by walking everyinput,textarea, and[contenteditable]in each root, storing the current inlinecaret-color, setting it totransparentwith importance, and restoring it after the frame (playwright-core1.64,inPagePrepareForScreenshots). A server-rendered React page that is still hydrating during that window sees a style React did not render, compares it with the server HTML, and logs "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties" against its own<input>and<textarea>. The screenshot is fine, the warning is a side effect of the mechanism.The fix hides the caret with the temporary screenshot stylesheet Playwright already supports.
caret: 'initial'turns off the inline-style path, andstyle: '* { caret-color: transparent !important; }'injects a stylesheet into the page and every frame Playwright can reach for the duration of the capture.caret-coloris inherited, so the universal rule also covers elements inside closed shadow roots, which Playwright cannot inject into but the engine masks. No element'sstyleattribute changes, so a framework hydrating at that moment has nothing to flag, and the captured pixels still contain no caret.Fixes #1259.
What changed
packages/web/src/observe.ts:caret: 'hide'becomescaret: 'initial'plus the screenshot stylesheet, with a comment naming the reason so the next reader does not switch it back.packages/web/tests/unit/observe.test.ts: acapturePixelscase that asserts the screenshot options. It fails on main because the call passescaret: "hide"and nostyle.packages/web/tests/integration/screenshot-caret.test.ts: a real Chromium capture on a page with inputs and a textarea, with aMutationObserverwatchingstyleattributes across the document. It fails on main with eight mutations and passes here with none. The test launches throughBrowserConnectionand installs the closed-shadow init script, so the secure-field mask selectors resolve the way they do in a run..changeset/screenshot-caret-stylesheet.md: a patch for@e2e-dev/web.docs/visual-testing.mdx:134says the web engine hides the text caret, which is still true, and no page describes how it does it.One path is deliberately unchanged:
surface.screenshot(packages/web/src/surface.ts:1024), which servesapp.screenshot(), failure evidence, and other artifacts, passes nocaretoption at all, so Playwright's default applies there. The issue and its reproduction are about the observation and trace screen, so this PR leaves the artifact path alone.Verified
Ran it locally: yes
packages/web, unit and integration:styleattribute mutations, one set and one restore for each of the four editable elements on the fixture page.capturePixelsscreenshot optionscaret: "hide", nostylecaret: "initial"plus* { caret-color: transparent !important; }styleattribute mutations during a real capturetests/unit/install.test.tsfail onERR_UNSUPPORTED_ESM_URL_SCHEMEfor ad:path, andtests/integration/cli.test.tsexpects exit code 143 while Windows reportsSIGTERM. The other three (app-log,lifecycle,semantic-fallback) pass when rerun on their own and failed under the suite's three-worker load.oxlinton the three touched files: 0 warnings, 0 errors.tsc --noEmitinpackages/web: clean.Checklist
pnpm changeset) for any change to a published package, or the change is testbed, docs, or CI only.!, the changeset body names what breaks and what replaces it, and the deprecation warning landed a window earlier (stability policy in CONTRIBUTING.md).docs/**/*.mdxpage for the behavior, andskills/e2e/if the skill describes it..d.tsreviewed,tests/types/sdk-types.tsupdated; wire change edits the schema and both fixtures.e2e/engine): changesets fore2e,@e2e-dev/web, and@e2e-dev/mobile.