Просмотр исходного кода

Keep page text working after a support screenshot by updating snapdom to 2.24.18 (#511)

* Keep page text working after a support screenshot by updating snapdom to 2.24.18

* Make the screenshot test prove the capture reached the label and fail if it never settles
Bernt Christian Egeland 2 дней назад
Родитель
Сommit
c1eb88a309
3 измененных файлов с 116 добавлено и 6 удалено
  1. 9 5
      package-lock.json
  2. 1 1
      package.json
  3. 106 0
      src/__tests__/features/support/screenshot-keeps-page-text.test.tsx

+ 9 - 5
package-lock.json

@@ -39,7 +39,7 @@
         "@types/archiver": "^8.0.0",
         "@types/qrcode": "^1.5.6",
         "@types/react-grid-layout": "^1.3.6",
-        "@zumer/snapdom": "^2.23.1",
+        "@zumer/snapdom": "^2.24.18",
         "archiver": "^8.0.0",
         "axios": "^1.20.0",
         "better-auth": "1.7.15",
@@ -9004,13 +9004,17 @@
       }
     },
     "node_modules/@zumer/snapdom": {
-      "version": "2.23.1",
-      "resolved": "https://registry.npmjs.org/@zumer/snapdom/-/snapdom-2.23.1.tgz",
-      "integrity": "sha512-r+6LRFXIpzTxlvGBjTr7mhvVW05TozgyE1QFmvO1hbchdbl4Km5/7DIkeyl2+nEVbz8Sp6/K2g4b86pXhCrFIQ==",
+      "version": "2.24.18",
+      "resolved": "https://registry.npmjs.org/@zumer/snapdom/-/snapdom-2.24.18.tgz",
+      "integrity": "sha512-wMw8lNY5l0pyarAv8jbe5ayOmrR2OUnK5KLtCwuhcLhFv88d2Y38Sertb+d9pn5g20Cet4eLCRXd3yFHcWiDIQ==",
       "license": "MIT",
       "workspaces": [
         "packages/*"
-      ]
+      ],
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tinchox5"
+      }
     },
     "node_modules/abort-controller": {
       "version": "3.0.0",

+ 1 - 1
package.json

@@ -51,7 +51,7 @@
     "@types/archiver": "^8.0.0",
     "@types/qrcode": "^1.5.6",
     "@types/react-grid-layout": "^1.3.6",
-    "@zumer/snapdom": "^2.23.1",
+    "@zumer/snapdom": "^2.24.18",
     "archiver": "^8.0.0",
     "axios": "^1.20.0",
     "better-auth": "1.7.15",

+ 106 - 0
src/__tests__/features/support/screenshot-keeps-page-text.test.tsx

@@ -0,0 +1,106 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+// The support form's "Take Screenshot" captures the live page with snapdom.
+// Before capturing, snapdom shortens every truncated text to bake in the "…"
+// and puts the text back afterwards. snapdom 2.23.1 did that through
+// textContent, which swaps React's text nodes for new ones: the text then
+// stopped updating, and the next change that removed part of it crashed the
+// page ("removeChild: the node to be removed is not a child of this node").
+
+function JobCount() {
+  const [count, setCount] = useState(2)
+  const [urgent, setUrgent] = useState(true)
+  return (
+    <div>
+      <span data-testid="label" className="truncate">
+        {count} open jobs{urgent && ' (urgent)'}
+      </span>
+      <button type="button" onClick={() => setCount((c) => c + 1)}>
+        Add job
+      </button>
+      <button type="button" onClick={() => setUrgent(false)}>
+        Clear urgent
+      </button>
+    </div>
+  )
+}
+
+// jsdom has no layout, so make the label look cut off with an ellipsis, the way
+// Tailwind's `truncate` renders it in a browser.
+function makeTruncated(element: HTMLElement) {
+  Object.defineProperty(element, 'scrollWidth', { configurable: true, get: () => 400 })
+  Object.defineProperty(element, 'clientWidth', { configurable: true, get: () => 80 })
+  const original = window.getComputedStyle.bind(window)
+  vi.spyOn(window, 'getComputedStyle').mockImplementation((target, pseudo) => {
+    const style = original(target, pseudo)
+    if (target !== element) return style
+    return new Proxy(style, {
+      get(base, key) {
+        if (key === 'textOverflow') return 'ellipsis'
+        if (key === 'whiteSpace') return 'nowrap'
+        if (key === 'overflowX') return 'hidden'
+        const value = Reflect.get(base, key)
+        return typeof value === 'function' ? value.bind(base) : value
+      },
+    })
+  })
+}
+
+// The same call the support form makes. In jsdom the capture cannot finish
+// rendering (no canvas), so a rejection is expected; what matters is that the
+// label was shortened and put back on the way. Returns every text the label
+// showed during the capture, so the test can prove that happened rather than
+// pass against a capture that gave up before reaching it. A capture that
+// never settles fails the test instead of passing quietly.
+async function takeScreenshot(label: HTMLElement) {
+  const { snapdom } = await import('@zumer/snapdom')
+  const shown: string[] = []
+  const observer = new MutationObserver(() => shown.push(label.textContent ?? ''))
+  observer.observe(label, { characterData: true, childList: true, subtree: true })
+  let timer: ReturnType<typeof setTimeout> | undefined
+  try {
+    await act(async () => {
+      await Promise.race([
+        snapdom
+          .toBlob(document.body, { type: 'jpeg', quality: 0.7, backgroundColor: '#ffffff' })
+          .catch(() => undefined),
+        new Promise((_, reject) => {
+          timer = setTimeout(() => reject(new Error('the capture did not settle')), 2000)
+        }),
+      ])
+    })
+  } finally {
+    clearTimeout(timer)
+    if (observer.takeRecords().length > 0) shown.push(label.textContent ?? '')
+    observer.disconnect()
+  }
+  return shown
+}
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+describe('Taking a support screenshot', () => {
+  it('leaves truncated text on the page working', async () => {
+    render(<JobCount />)
+    const label = screen.getByTestId('label')
+    makeTruncated(label)
+
+    const shownDuringCapture = await takeScreenshot(label)
+    // The capture really reached the label: it was cut to an ellipsis...
+    expect(shownDuringCapture.some((text) => text.endsWith('…'))).toBe(true)
+    // ...and put back afterwards
+    expect(label.textContent).toBe('2 open jobs (urgent)')
+
+    fireEvent.click(screen.getByRole('button', { name: 'Add job' }))
+    expect(label.textContent).toBe('3 open jobs (urgent)')
+
+    expect(() =>
+      fireEvent.click(screen.getByRole('button', { name: 'Clear urgent' }))
+    ).not.toThrow()
+    expect(label.textContent).toBe('3 open jobs')
+  })
+})