fix(web): hide the screenshot caret with a stylesheet by DeryFerd · Pull Request #1288 · tester-army/e2e · GitHub
Skip to content

fix(web): hide the screenshot caret with a stylesheet - #1288

Merged
okwasniewski merged 1 commit into
tester-army:mainfrom
DeryFerd:fix/web-screenshot-caret
Oct 28, 2026
Merged

okwasniewski merged 1 commit into
tester-army:mainfrom
DeryFerd:fix/web-screenshot-caret

Conversation

@DeryFerd

@DeryFerd DeryFerd commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What and why

The web engine captures the observation screenshot that follows an action, including the automatic trace screen after app.open(), in capturePixels (packages/web/src/observe.ts:62). It passed caret: 'hide' to page.screenshot(). Playwright implements that option by walking every input, textarea, and [contenteditable] in each root, storing the current inline caret-color, setting it to transparent with importance, and restoring it after the frame (playwright-core 1.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, and style: '* { caret-color: transparent !important; }' injects a stylesheet into the page and every frame Playwright can reach for the duration of the capture. caret-color is inherited, so the universal rule also covers elements inside closed shadow roots, which Playwright cannot inject into but the engine masks. No element's style attribute 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' becomes caret: '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: a capturePixels case that asserts the screenshot options. It fails on main because the call passes caret: "hide" and no style.
  • packages/web/tests/integration/screenshot-caret.test.ts: a real Chromium capture on a page with inputs and a textarea, with a MutationObserver watching style attributes across the document. It fails on main with eight mutations and passes here with none. The test launches through BrowserConnection and 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.
  • No docs change. docs/visual-testing.mdx:134 says 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 serves app.screenshot(), failure evidence, and other artifacts, passes no caret option 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:
✓ unit tests/unit/observe.test.ts (3 tests)
✓ integration tests/integration/screenshot-caret.test.ts (1 test)
Test Files 2 passed (2)
      Tests 4 passed (4)
  • The same tests against main: the unit case fails on the option value, and the integration case records eight style attribute mutations, one set and one restore for each of the four editable elements on the fixture page.
Check main this branch
capturePixels screenshot options caret: "hide", no style caret: "initial" plus * { caret-color: transparent !important; }
style attribute mutations during a real capture 8 0
secure-field masking proven during the same capture true true
  • Full web suite: 567 passed, 9 failed, 1 skipped. Six of the nine are the local Windows baseline: five in tests/unit/install.test.ts fail on ERR_UNSUPPORTED_ESM_URL_SCHEME for a d: path, and tests/integration/cli.test.ts expects exit code 143 while Windows reports SIGTERM. The other three (app-log, lifecycle, semantic-fallback) pass when rerun on their own and failed under the suite's three-worker load.
  • oxlint on the three touched files: 0 warnings, 0 errors. tsc --noEmit in packages/web: clean.

Checklist

  • Changeset added (pnpm changeset) for any change to a published package, or the change is testbed, docs, or CI only.
  • Breaking change: title carries !, the changeset body names what breaks and what replaces it, and the deprecation warning landed a window earlier (stability policy in CONTRIBUTING.md).
  • Docs updated in the same PR: the docs/**/*.mdx page for the behavior, and skills/e2e/ if the skill describes it.
  • Contract change: emitted .d.ts reviewed, tests/types/sdk-types.ts updated; wire change edits the schema and both fixtures.
  • Engine contract change (e2e/engine): changesets for e2e, @e2e-dev/web, and @e2e-dev/mobile.

View guided diff

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread packages/web/tests/integration/screenshot-caret.test.ts
origin: 'agent',
};
try {
await page.goto(`${app.url}/values`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>

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.

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.

Comment thread packages/web/src/observe.ts Outdated
Comment thread packages/web/src/observe.ts Outdated
@DeryFerd
DeryFerd force-pushed the fix/web-screenshot-caret branch from 66c2581 to d777226 Compare October 20, 2026 15:10

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread packages/web/src/observe.ts Outdated
Comment thread packages/web/src/screenshot-caret.ts Outdated

@okwasniewski okwasniewski 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.

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

  1. packages/web/src/screenshot-caret.ts: drop the global token. You can return the undo function and keep a JSHandle to it: frame.evaluateHandle(hideCaret), then handle.evaluate((show) => show()) and dispose() in the finally. That removes randomUUID, TOKEN_PREFIX, Adoption, ParkedSheet, the Reflect.get/set/deleteProperty calls and the shape checks in removeCaretStylesheet. It also fixes a real leak: Reflect.set(globalThis, token, ...) creates an enumerable window property for the length of the capture. I saw Object.keys(window) include it in all three frames, and page code can read or overwrite it. Other things that can go:

    • One CSSStyleSheet per document is enough, since shadow roots of the same document can adopt the same sheet.
    • The seen set isn't needed. An element has at most one shadow root, so element.shadowRoot ?? closed.get(element) reaches each root once.
    • The per-root try/catch isn't needed either, because assigning a same-document sheet can't throw.

    I prototyped this shape. screenshot-caret.ts drops 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()))));
      }
    }

    (inFrame is withTimeout(work, 1_000, ...).catch(() => undefined). This replaces the duplicated adopt and remove timeout blocks.)

  2. packages/web/src/observe.ts:79-93: the screenshot budget is now computed twice and there's an extra scope.check(). budgetMs only feeds the per-frame step, and that step is capped at 1s anyway. Keep the original single scope.read(() => withHiddenCaret(page, () => page.screenshot({...}))) with the original timeout: expression, and cut the 4-line comment down to one line.

  3. Tests: if you take ask 1, the in-page functions don't need to be exported. Test behavior through capturePixels instead. Wrap page.screenshot in the test so it reads getComputedStyle(...).caretColor on 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 call adoptCaretStylesheet directly, so they never prove that capturePixels hides anything. There's also no iframe coverage. The unit mocks in tests/unit/observe.test.ts tell adopt and remove apart by typeof 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.

  4. packages/web/tests/integration/screenshot-caret.test.ts:69: the shadowMutations assertion can't fail. Playwright's caret: 'hide' only walks open roots (collectRoots uses node.shadowRoot), so main never writes a style inside a closed root either. Either drop it or point it at an open root.

Nits

  1. .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.
  2. packages/web/src/surface.ts:1028: the artifact screenshot still uses Playwright's default caret: 'hide', so it writes the same inline styles. Consider wrapping it with withHiddenCaret and passing caret: 'initial' there too.

This was an automated review.

@DeryFerd
DeryFerd force-pushed the fix/web-screenshot-caret branch from 3d279ef to 874a8ee Compare October 27, 2026 19:25
@DeryFerd

Copy link
Copy Markdown
Contributor Author

@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:

  1. The global token is gone. hideCaret() runs in the page and returns its undo function, withHiddenCaret keeps one JSHandle per frame, and removal is handle.evaluate((show) => show()) plus dispose() in the finally. One sheet per frame, element.shadowRoot ?? tracker.get(element) instead of the seen set, and no per-root try/catch. screenshot-caret.ts is down from 138 to 68 lines, most of the remainder being the doc comment and the inFrame budget.
  2. capturePixels is back to one scope.read with the original single timeout: expression, and the comment is one line.
  3. The in-page function is no longer exported and the two unit mocks that told adopt and remove apart are gone. screenshot-caret.test.ts now wraps page.screenshot and reads getComputedStyle(...).caretColor during the capture: main document, closed root, iframe, and the CSP page, then checks every root is back, both when the screenshot succeeds and when it throws.
  4. The shadowMutations assertion is dropped.
  5. The changeset describes only the user-visible effect now.
  6. surface.screenshot is wrapped with withHiddenCaret and passes caret: 'initial', so artifact screenshots stop writing the inline styles too.

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 style mutations, which is the regression this PR fixes. And two frame mocks in observation-capture.test.ts and semantic-fallback.test.ts grew a one-line evaluateHandle stub, because withHiddenCaret now hands each frame a handle instead of reaching it through screenshot alone.

Verified: the caret tests pass, oxlint and tsc --noEmit are clean, and the full web suite shows only the known Windows baseline failures and load flakes that pass in isolation.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread packages/web/tests/helpers/fixture-app.ts
Comment thread packages/web/src/screenshot-caret.ts
Comment thread .changeset/screenshot-caret-stylesheet.md Outdated
@DeryFerd
DeryFerd requested a review from okwasniewski October 27, 2026 20:25
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.
@DeryFerd
DeryFerd force-pushed the fix/web-screenshot-caret branch from 874a8ee to 91e5ad6 Compare October 27, 2026 20:47
@okwasniewski

Copy link
Copy Markdown
Member

@okwasniewski
okwasniewski dismissed their stale review October 27, 2026 22:14

Addressed, see latest automated review.

@okwasniewski
okwasniewski merged commit 8ce2c65 into tester-army:main Oct 28, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web trace screenshots can cause React hydration warnings by hiding carets with inline styles

2 participants