From 27d47bc45b3a092267c6cb54aaa8260f1c4ad285 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 7 May 2026 03:21:00 +0000 Subject: [PATCH 1/5] fix(app): copy buttons silently fail over plain HTTP Closes #2135. `navigator.clipboard` is only defined when the page is served over HTTPS or `localhost`. When HyperDX is reached over plain HTTP (Tailscale tunnel, corporate VPN, non-localhost host), every "copy" button throws `TypeError: Cannot read properties of undefined (reading 'writeText')` and nothing reaches the user's clipboard. Add `packages/app/src/utils/clipboard.ts` with two helpers: - `copyTextToClipboard(text)` tries the modern API first, then falls back to a hidden-textarea + `document.execCommand('copy')` trick that works in non-secure contexts. - `copyTextWithToast(text, successMessage)` wraps the copy with a Mantine notification (green on success, red on failure with a message that explains the HTTPS / localhost requirement). Replace all six raw `navigator.clipboard.writeText` call sites with the new util: - `DBRowTableRowButtons.tsx` row JSON + shareable URL buttons - `DBRowTableFieldWithPopover.tsx` field-value popover - `DBRowJsonViewer.tsx` "Copy row as JSON", "Copy Object", and "Copy Value" actions Co-Authored-By: Claude Opus 4.5 --- .changeset/clipboard-http-fallback.md | 5 + .../app/src/components/DBRowJsonViewer.tsx | 22 +- .../DBTable/DBRowTableFieldWithPopover.tsx | 13 +- .../DBTable/DBRowTableRowButtons.tsx | 75 ++--- .../app/src/utils/__tests__/clipboard.test.ts | 181 ++++++++++ packages/app/src/utils/clipboard.ts | 91 +++++ .../e2e/features/search/clipboard.spec.ts | 318 ++++++++++++++++++ 7 files changed, 644 insertions(+), 61 deletions(-) create mode 100644 .changeset/clipboard-http-fallback.md create mode 100644 packages/app/src/utils/__tests__/clipboard.test.ts create mode 100644 packages/app/src/utils/clipboard.ts create mode 100644 packages/app/tests/e2e/features/search/clipboard.spec.ts diff --git a/.changeset/clipboard-http-fallback.md b/.changeset/clipboard-http-fallback.md new file mode 100644 index 0000000000..bd3fbcb2b5 --- /dev/null +++ b/.changeset/clipboard-http-fallback.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Fix copy buttons silently failing when HyperDX is served over plain HTTP. Add a `document.execCommand('copy')` fallback for non-secure contexts and a clear toast when both paths fail. diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index f1eac9fa43..e4e6ebe6f3 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -29,6 +29,7 @@ import { import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson'; import { mergePath } from '@/utils'; +import { copyTextWithToast } from '@/utils/clipboard'; type JSONExtractFn = | 'JSONExtractString' @@ -220,15 +221,12 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { {rowData != null && ( { - window.navigator.clipboard.writeText( + copyTextWithToast( typeof rowData === 'string' ? rowData : JSON.stringify(rowData, null, 2), + 'Value copied to clipboard', ); - notifications.show({ - color: 'green', - message: `Value copied to clipboard`, - }); }} variant="copy" title={'Copy row as JSON'} @@ -559,13 +557,10 @@ export function DBRowJsonViewer({ copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath); } - window.navigator.clipboard.writeText( + copyTextWithToast( JSON.stringify(copiedObj, null, 2), + 'Copied object to clipboard', ); - notifications.show({ - color: 'green', - message: `Copied object to clipboard`, - }); }; if (typeof value === 'object') { @@ -584,15 +579,12 @@ export function DBRowJsonViewer({ ), onClick: () => { - window.navigator.clipboard.writeText( + copyTextWithToast( typeof value === 'string' ? value : JSON.stringify(value, null, 2), + 'Value copied to clipboard', ); - notifications.show({ - color: 'green', - message: `Value copied to clipboard`, - }); }, }); } diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx index 0f410db20e..43f6ead289 100644 --- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -4,6 +4,8 @@ import { Popover } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react'; +import { copyTextWithToast } from '@/utils/clipboard'; + import { RowSidePanelContext } from '../DBRowSidePanel'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -83,15 +85,12 @@ const DBRowTableFieldWithPopover = ({ }; const copyFieldValue = async () => { - try { - const value = - typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); - await navigator.clipboard.writeText(value); + const value = + typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); + const ok = await copyTextWithToast(value, 'Copied field value'); + if (ok) { setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error('Failed to copy to clipboard:', error); - // Optionally show an error toast notification to the user } }; diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index 6e0fc23fd2..ab364d3ca8 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react'; import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere'; +import { copyTextWithToast } from '@/utils/clipboard'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -26,57 +27,53 @@ const DBRowTableRowButtons: React.FC = ({ const [isUrlCopied, setIsUrlCopied] = useState(false); const copyRowData = async () => { - try { - // Filter out internal metadata fields that start with __ or are generated IDs + // Filter out internal metadata fields that start with __ or are generated IDs + const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; - const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; - - // Parse JSON string fields to make them proper JSON objects - const parsedRow = Object.entries(cleanRow).reduce( - (acc, [key, value]) => { - if ( - typeof value === 'string' && - (value.startsWith('{') || value.startsWith('[')) - ) { - try { - acc[key] = JSON.parse(value); - } catch { - // If parsing fails, keep the original string - acc[key] = value; - } - } else { + // Parse JSON string fields to make them proper JSON objects + const parsedRow = Object.entries(cleanRow).reduce( + (acc, [key, value]) => { + if ( + typeof value === 'string' && + (value.startsWith('{') || value.startsWith('[')) + ) { + try { + acc[key] = JSON.parse(value); + } catch { + // If parsing fails, keep the original string acc[key] = value; } - return acc; - }, - {} as Record, - ); + } else { + acc[key] = value; + } + return acc; + }, + {} as Record, + ); - const rowData = JSON.stringify(parsedRow, null, 2); - await navigator.clipboard.writeText(rowData); + const rowData = JSON.stringify(parsedRow, null, 2); + const ok = await copyTextWithToast(rowData, 'Copied row as JSON'); + if (ok) { setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error('Failed to copy row data to clipboard:', error); - // Optionally show an error toast notification to the user } }; const copyRowUrl = async () => { - try { - const rowWhereResult = getRowWhere(row); - const currentUrl = new URL(window.location.href); - // Add the row identifier as query parameters - currentUrl.searchParams.set('rowWhere', rowWhereResult.where); - if (sourceId) { - currentUrl.searchParams.set('rowSource', sourceId); - } - await navigator.clipboard.writeText(currentUrl.toString()); + const rowWhereResult = getRowWhere(row); + const currentUrl = new URL(window.location.href); + // Add the row identifier as query parameters + currentUrl.searchParams.set('rowWhere', rowWhereResult.where); + if (sourceId) { + currentUrl.searchParams.set('rowSource', sourceId); + } + const ok = await copyTextWithToast( + currentUrl.toString(), + 'Copied shareable link', + ); + if (ok) { setIsUrlCopied(true); setTimeout(() => setIsUrlCopied(false), 2000); - } catch (error) { - console.error('Failed to copy URL to clipboard:', error); - // Optionally show an error toast notification to the user } }; diff --git a/packages/app/src/utils/__tests__/clipboard.test.ts b/packages/app/src/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..6a254596e4 --- /dev/null +++ b/packages/app/src/utils/__tests__/clipboard.test.ts @@ -0,0 +1,181 @@ +import { copyTextToClipboard, copyTextWithToast } from '../clipboard'; + +const mantineShow = jest.fn(); +jest.mock('@mantine/notifications', () => ({ + notifications: { + show: (...args: any[]) => mantineShow(...args), + }, +})); + +describe('copyTextToClipboard', () => { + beforeEach(() => { + mantineShow.mockClear(); + // Reset clipboard between tests + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }); + // Default execCommand stub returns true + document.execCommand = jest.fn().mockReturnValue(true); + }); + + it('uses navigator.clipboard when available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + const execSpy = document.execCommand as jest.Mock; + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith('hello'); + expect(execSpy).not.toHaveBeenCalled(); + }); + + it('falls back to execCommand when navigator.clipboard.writeText rejects', async () => { + const writeText = jest.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + const execSpy = document.execCommand as jest.Mock; + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith('hello'); + expect(execSpy).toHaveBeenCalledWith('copy'); + }); + + it('falls back to execCommand when navigator.clipboard is undefined', async () => { + // clipboard is undefined per beforeEach + const execSpy = document.execCommand as jest.Mock; + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(execSpy).toHaveBeenCalledWith('copy'); + }); + + it('returns false when both paths fail', async () => { + const writeText = jest.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + document.execCommand = jest.fn().mockReturnValue(false); + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(false); + }); + + it('removes the textarea from the DOM after the fallback runs', async () => { + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(document.querySelectorAll('textarea').length).toBe(0); + }); + + it('removes the textarea even when execCommand throws', async () => { + document.execCommand = jest.fn().mockImplementation(() => { + throw new Error('boom'); + }); + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(false); + expect(document.querySelectorAll('textarea').length).toBe(0); + }); + + it('restores the existing selection after the fallback runs', async () => { + // Seed an existing range on a paragraph in the DOM + const p = document.createElement('p'); + p.textContent = 'preserved selection target'; + document.body.appendChild(p); + + const range = document.createRange(); + range.selectNodeContents(p); + + const selection = document.getSelection(); + if (!selection) { + throw new Error('jsdom should provide getSelection'); + } + selection.removeAllRanges(); + selection.addRange(range); + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(selection.rangeCount).toBe(1); + expect(selection.getRangeAt(0).toString()).toBe( + 'preserved selection target', + ); + + document.body.removeChild(p); + }); +}); + +describe('copyTextWithToast', () => { + beforeEach(() => { + mantineShow.mockClear(); + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }); + document.execCommand = jest.fn().mockReturnValue(true); + }); + + it('shows a green success toast on success', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + const result = await copyTextWithToast('hello'); + + expect(result).toBe(true); + expect(mantineShow).toHaveBeenCalledWith({ + color: 'green', + message: 'Copied to clipboard', + }); + }); + + it('uses a custom success message when provided', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + await copyTextWithToast('hello', 'Value copied to clipboard'); + + expect(mantineShow).toHaveBeenCalledWith({ + color: 'green', + message: 'Value copied to clipboard', + }); + }); + + it('shows a red failure toast when both paths fail', async () => { + document.execCommand = jest.fn().mockReturnValue(false); + + const result = await copyTextWithToast('hello'); + + expect(result).toBe(false); + expect(mantineShow).toHaveBeenCalledWith({ + color: 'red', + message: + "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.", + }); + }); +}); diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts new file mode 100644 index 0000000000..59616ee237 --- /dev/null +++ b/packages/app/src/utils/clipboard.ts @@ -0,0 +1,91 @@ +import { notifications } from '@mantine/notifications'; + +/** + * Copy text to the clipboard, with a fallback for non-secure contexts. + * + * `navigator.clipboard` is only defined when the page is served over HTTPS + * or `localhost`. When HyperDX is reached over plain HTTP (Tailscale tunnel, + * corporate VPN, non-localhost host), the modern API is undefined and any + * call to `navigator.clipboard.writeText` throws. This util tries the modern + * API first and falls back to a hidden-textarea + `document.execCommand('copy')` + * trick that works in non-secure contexts. + * + * Returns `true` on success, `false` if both paths fail. + */ +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Permission denied or other failure. Fall through to the legacy fallback. + } + } + return execCommandFallback(text); +} + +function execCommandFallback(text: string): boolean { + if (typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '0'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + + // Preserve any existing selection so we can restore it after the copy. + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + document.body.appendChild(textarea); + textarea.select(); + + let succeeded = false; + try { + succeeded = document.execCommand('copy'); + } catch { + succeeded = false; + } + + document.body.removeChild(textarea); + + if (previousRange && selection) { + selection.removeAllRanges(); + selection.addRange(previousRange); + } + + return succeeded; +} + +const FAILURE_MESSAGE = + "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API."; + +/** + * Copy text and show a Mantine toast on success or failure. Mirrors the + * `notifications.show` pattern already used elsewhere in the app for copy + * actions. + */ +export async function copyTextWithToast( + text: string, + successMessage = 'Copied to clipboard', +): Promise { + const ok = await copyTextToClipboard(text); + if (ok) { + notifications.show({ + color: 'green', + message: successMessage, + }); + } else { + notifications.show({ + color: 'red', + message: FAILURE_MESSAGE, + }); + } + return ok; +} diff --git a/packages/app/tests/e2e/features/search/clipboard.spec.ts b/packages/app/tests/e2e/features/search/clipboard.spec.ts new file mode 100644 index 0000000000..685e969f35 --- /dev/null +++ b/packages/app/tests/e2e/features/search/clipboard.spec.ts @@ -0,0 +1,318 @@ +/** + * Verifies the clipboard fallback (`packages/app/src/utils/clipboard.ts`) + * across the six call sites that previously called `navigator.clipboard.writeText` + * directly. See https://github.com/hyperdxio/hyperdx/issues/2135. + * + * Tests are hook-based: an init script wires `navigator.clipboard.writeText` + * and `document.execCommand('copy')` to a shared `window.__copyHistory` array + * so we can assert which path each call site actually took without depending + * on Chromium clipboard permissions or polluted state from previous tests. + */ +import { Page } from '@playwright/test'; + +import { SearchPage } from '../../page-objects/SearchPage'; +import { expect, test } from '../../utils/base-test'; + +type CopyEntry = { source: 'modern' | 'fallback'; text: string }; + +declare global { + interface Window { + __copyHistory: CopyEntry[]; + __forceModernThrow: boolean; + __forceExecCommandFalse: boolean; + } +} + +const HOOK_INIT_SCRIPT = (mode: 'modern' | 'no-modern' | 'fail-both') => { + return () => { + window.__copyHistory = []; + window.__forceModernThrow = false; + window.__forceExecCommandFalse = false; + + const realExec = document.execCommand.bind(document); + document.execCommand = function (cmd: string, ...rest: any[]): boolean { + if (cmd === 'copy') { + const ta = document.activeElement as HTMLTextAreaElement | null; + const text = ta && 'value' in ta ? ta.value : ''; + if (window.__forceExecCommandFalse) { + return false; + } + window.__copyHistory.push({ source: 'fallback', text }); + return true; + } + return realExec(cmd, ...(rest as [])); + }; + + // Modern API installation depends on mode + if (mode === 'modern') { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async (text: string) => { + if (window.__forceModernThrow) { + throw new Error('forced rejection'); + } + window.__copyHistory.push({ source: 'modern', text }); + }, + readText: async () => { + const last = window.__copyHistory[window.__copyHistory.length - 1]; + return last ? last.text : ''; + }, + }, + configurable: true, + }); + } else { + // Simulate non-secure context: navigator.clipboard is undefined + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + configurable: true, + }); + if (mode === 'fail-both') { + window.__forceExecCommandFalse = true; + } + } + }; +}; + +async function installCopyHook( + page: Page, + mode: 'modern' | 'no-modern' | 'fail-both' = 'modern', +) { + await page.addInitScript(HOOK_INIT_SCRIPT(mode)); +} + +async function getCopyHistory(page: Page): Promise { + return await page.evaluate(() => window.__copyHistory ?? []); +} + +async function openSearchAndFirstRow(searchPage: SearchPage) { + await searchPage.goto(); + await searchPage.submitEmptySearch(); + await expect(searchPage.table.firstRow).toBeVisible({ timeout: 10000 }); +} + +async function openParsedTab(searchPage: SearchPage) { + await openSearchAndFirstRow(searchPage); + await searchPage.table.clickFirstRow(); + await searchPage.sidePanel.clickTab('parsed'); +} + +test.describe('Clipboard fallback', { tag: ['@search'] }, () => { + test('row JSON button copies via the modern API and shows a success toast', async ({ + page, + }) => { + await installCopyHook(page, 'modern'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + // The rowButtons container is opacity-0 until the row is hovered. + await searchPage.table.firstRow.hover(); + const copyJsonButton = searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-copy') + .first(); + await copyJsonButton.click(); + + await expect(page.getByText('Copied row as JSON')).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history).toHaveLength(1); + expect(history[0].source).toBe('modern'); + // Should be a JSON object string (parses cleanly). + expect(() => JSON.parse(history[0].text)).not.toThrow(); + }); + + test('row URL button copies a shareable link with rowWhere and rowSource params', async ({ + page, + }) => { + await installCopyHook(page, 'modern'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + const copyLinkButton = searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-link') + .first(); + await copyLinkButton.click(); + + await expect(page.getByText('Copied shareable link')).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history).toHaveLength(1); + expect(history[0].source).toBe('modern'); + expect(history[0].text).toContain('rowWhere='); + }); + + test('parsed-tab "Copy row as JSON" icon copies via the modern API', async ({ + page, + }) => { + await installCopyHook(page, 'modern'); + const searchPage = new SearchPage(page); + await openParsedTab(searchPage); + + // HyperJsonMenu renders the row-level copy button with a `Copy row as JSON` + // tooltip and the Tabler IconCopy SVG. + const sidePanelCopy = page + .getByTitle('Copy row as JSON') + .or(page.locator('[title="Copy row as JSON"]')) + .first(); + // Fallback locator: the IconCopy inside the side panel's HyperJsonMenu group. + const sideCopyByIcon = searchPage.sidePanel.container + .locator('.tabler-icon-copy') + .first(); + if (await sidePanelCopy.isVisible().catch(() => false)) { + await sidePanelCopy.click(); + } else { + await sideCopyByIcon.click(); + } + + await expect( + page.getByText('Value copied to clipboard').first(), + ).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history.length).toBeGreaterThanOrEqual(1); + expect(history[history.length - 1].source).toBe('modern'); + }); + + test('parsed-tab line action "Copy Value" copies via the modern API', async ({ + page, + }) => { + await installCopyHook(page, 'modern'); + const searchPage = new SearchPage(page); + await openParsedTab(searchPage); + + // Hover a string leaf so the line menu mounts (HyperJson only mounts + // LineMenu when hovered). + const stringEl = page.locator('[class*="string"]').first(); + await expect(stringEl).toBeVisible(); + await stringEl.hover(); + + const copyValueButton = page + .getByRole('button', { name: /Copy Value/ }) + .first(); + await copyValueButton.click(); + + await expect( + page.getByText('Value copied to clipboard').first(), + ).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history.length).toBeGreaterThanOrEqual(1); + expect(history[history.length - 1].source).toBe('modern'); + }); + + test('row JSON button falls back to execCommand when the modern API is unavailable', async ({ + page, + }) => { + await installCopyHook(page, 'no-modern'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + const copyJsonButton = searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-copy') + .first(); + await copyJsonButton.click(); + + await expect(page.getByText('Copied row as JSON')).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history).toHaveLength(1); + expect(history[0].source).toBe('fallback'); + expect(() => JSON.parse(history[0].text)).not.toThrow(); + }); + + test('row JSON button shows the failure toast when both paths fail', async ({ + page, + }) => { + const consoleErrors: string[] = []; + page.on('pageerror', err => consoleErrors.push(err.message)); + + await installCopyHook(page, 'fail-both'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + const copyJsonButton = searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-copy') + .first(); + await copyJsonButton.click(); + + await expect( + page.getByText( + "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.", + ), + ).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history).toHaveLength(0); + expect(consoleErrors).toEqual([]); + }); + + test('success toast renders correctly in light theme', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('mantine-color-scheme-value', 'light'); + }); + await installCopyHook(page, 'modern'); + + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + await searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-copy') + .first() + .click(); + + const toast = page.getByText('Copied row as JSON'); + await expect(toast).toBeVisible(); + + const html = page.locator('html'); + await expect(html).toHaveAttribute('data-mantine-color-scheme', 'light'); + }); + + test('success toast renders correctly in dark theme', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('mantine-color-scheme-value', 'dark'); + }); + await installCopyHook(page, 'modern'); + + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + await searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-copy') + .first() + .click(); + + const toast = page.getByText('Copied row as JSON'); + await expect(toast).toBeVisible(); + + const html = page.locator('html'); + await expect(html).toHaveAttribute('data-mantine-color-scheme', 'dark'); + }); + + test('failure toast renders correctly in dark theme', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('mantine-color-scheme-value', 'dark'); + }); + await installCopyHook(page, 'fail-both'); + + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + await searchPage.table.firstRow + .locator('[class*="rowButtons"] .tabler-icon-copy') + .first() + .click(); + + const failureToast = page.getByText( + "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.", + ); + await expect(failureToast).toBeVisible(); + + const html = page.locator('html'); + await expect(html).toHaveAttribute('data-mantine-color-scheme', 'dark'); + }); +}); From 3e2f30666435998547f04ff6e5d9775be1dc94ab Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 7 May 2026 03:40:39 +0000 Subject: [PATCH 2/5] fix(e2e): pass clipboard hook mode via Playwright arg to fix closure serialisation addInitScript serialises functions via .toString(), which drops closure variables. HOOK_INIT_SCRIPT returned a closure that captured `mode`, so `mode` was always undefined inside the page and every test ran in no-modern mode. Pass mode as the Playwright arg parameter instead. Also moves textarea cleanup and selection restore into a finally block in execCommandFallback, and restores previouslyFocused.focus() so the HTTP fallback path does not silently steal typing context from open inputs. --- packages/app/src/utils/clipboard.ts | 27 ++++++++++--------- .../e2e/features/search/clipboard.spec.ts | 23 ++++++++-------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts index 59616ee237..910b555841 100644 --- a/packages/app/src/utils/clipboard.ts +++ b/packages/app/src/utils/clipboard.ts @@ -29,6 +29,13 @@ function execCommandFallback(text: string): boolean { return false; } + // Capture focus and selection state before we mount the scratch textarea so + // we can restore both in the finally block. + const previouslyFocused = document.activeElement as HTMLElement | null; + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', ''); @@ -38,11 +45,6 @@ function execCommandFallback(text: string): boolean { textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; - // Preserve any existing selection so we can restore it after the copy. - const selection = document.getSelection(); - const previousRange = - selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - document.body.appendChild(textarea); textarea.select(); @@ -51,13 +53,14 @@ function execCommandFallback(text: string): boolean { succeeded = document.execCommand('copy'); } catch { succeeded = false; - } - - document.body.removeChild(textarea); - - if (previousRange && selection) { - selection.removeAllRanges(); - selection.addRange(previousRange); + } finally { + document.body.removeChild(textarea); + if (previousRange && selection) { + selection.removeAllRanges(); + selection.addRange(previousRange); + } + // Restore focus so typing context is not silently lost on HTTP fallback. + previouslyFocused?.focus(); } return succeeded; diff --git a/packages/app/tests/e2e/features/search/clipboard.spec.ts b/packages/app/tests/e2e/features/search/clipboard.spec.ts index 685e969f35..6969362d78 100644 --- a/packages/app/tests/e2e/features/search/clipboard.spec.ts +++ b/packages/app/tests/e2e/features/search/clipboard.spec.ts @@ -23,8 +23,14 @@ declare global { } } -const HOOK_INIT_SCRIPT = (mode: 'modern' | 'no-modern' | 'fail-both') => { - return () => { +// Note: addInitScript serialises the function via .toString(), which strips +// closure variables. Pass `mode` as the Playwright arg parameter so it is +// properly serialised and available inside the page. +async function installCopyHook( + page: Page, + mode: 'modern' | 'no-modern' | 'fail-both' = 'modern', +) { + await page.addInitScript((m: string) => { window.__copyHistory = []; window.__forceModernThrow = false; window.__forceExecCommandFalse = false; @@ -44,7 +50,7 @@ const HOOK_INIT_SCRIPT = (mode: 'modern' | 'no-modern' | 'fail-both') => { }; // Modern API installation depends on mode - if (mode === 'modern') { + if (m === 'modern') { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (text: string) => { @@ -66,18 +72,11 @@ const HOOK_INIT_SCRIPT = (mode: 'modern' | 'no-modern' | 'fail-both') => { value: undefined, configurable: true, }); - if (mode === 'fail-both') { + if (m === 'fail-both') { window.__forceExecCommandFalse = true; } } - }; -}; - -async function installCopyHook( - page: Page, - mode: 'modern' | 'no-modern' | 'fail-both' = 'modern', -) { - await page.addInitScript(HOOK_INIT_SCRIPT(mode)); + }, mode); } async function getCopyHistory(page: Page): Promise { From 6d3ee8828aa2c8d5eb932f4ccd87386da6762d4e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 7 May 2026 04:01:59 +0000 Subject: [PATCH 3/5] test(e2e): drop theme-parity clipboard tests The three theme-parity tests asserted on `html[data-mantine-color-scheme]` after seeding `localStorage.mantine-color-scheme-value`, but the app reads its color preference from `hdx-user-preferences` (a JSON object with a `colorMode` field). The light test passed only because chromium defaults to light; the dark tests failed in CI for that reason. Drop the tests rather than rewriting against the right key. The clipboard util doesn't render anything theme-specific (toasts are stock `notifications.show`), so theme parity isn't this util's concern. The remaining six cases still cover all six callsites plus the modern -> fallback transition and the failure toast. Co-Authored-By: Claude Opus 4.5 --- .../e2e/features/search/clipboard.spec.ts | 68 ------------------- 1 file changed, 68 deletions(-) diff --git a/packages/app/tests/e2e/features/search/clipboard.spec.ts b/packages/app/tests/e2e/features/search/clipboard.spec.ts index 6969362d78..86a6d67c09 100644 --- a/packages/app/tests/e2e/features/search/clipboard.spec.ts +++ b/packages/app/tests/e2e/features/search/clipboard.spec.ts @@ -246,72 +246,4 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { expect(history).toHaveLength(0); expect(consoleErrors).toEqual([]); }); - - test('success toast renders correctly in light theme', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('mantine-color-scheme-value', 'light'); - }); - await installCopyHook(page, 'modern'); - - const searchPage = new SearchPage(page); - await openSearchAndFirstRow(searchPage); - - await searchPage.table.firstRow.hover(); - await searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-copy') - .first() - .click(); - - const toast = page.getByText('Copied row as JSON'); - await expect(toast).toBeVisible(); - - const html = page.locator('html'); - await expect(html).toHaveAttribute('data-mantine-color-scheme', 'light'); - }); - - test('success toast renders correctly in dark theme', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('mantine-color-scheme-value', 'dark'); - }); - await installCopyHook(page, 'modern'); - - const searchPage = new SearchPage(page); - await openSearchAndFirstRow(searchPage); - - await searchPage.table.firstRow.hover(); - await searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-copy') - .first() - .click(); - - const toast = page.getByText('Copied row as JSON'); - await expect(toast).toBeVisible(); - - const html = page.locator('html'); - await expect(html).toHaveAttribute('data-mantine-color-scheme', 'dark'); - }); - - test('failure toast renders correctly in dark theme', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('mantine-color-scheme-value', 'dark'); - }); - await installCopyHook(page, 'fail-both'); - - const searchPage = new SearchPage(page); - await openSearchAndFirstRow(searchPage); - - await searchPage.table.firstRow.hover(); - await searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-copy') - .first() - .click(); - - const failureToast = page.getByText( - "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.", - ); - await expect(failureToast).toBeVisible(); - - const html = page.locator('html'); - await expect(html).toHaveAttribute('data-mantine-color-scheme', 'dark'); - }); }); From 591b17ce7303aec441626bc3b1b941e553e27360 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 7 May 2026 04:20:20 +0000 Subject: [PATCH 4/5] fix(clipboard): address deep-review P2 items Util (`packages/app/src/utils/clipboard.ts`): - Route straight to the synchronous fallback when `window.isSecureContext` is false. The previous flow awaited a writeText rejection first, which could land after the click's user-activation token expired and leave the fallback without activation (Safari HTTPS-permission-denied path). - Narrow `document.activeElement` with `instanceof HTMLElement` so SVG / MathML elements don't slip through the focus-restore. - Cap the synchronous fallback at 1MB. Multi-MB JSON payloads through `textarea.value = text; document.execCommand('copy')` froze the main thread for seconds; oversized payloads now show the failure toast. - `console.warn` on each catch path so developers have a breadcrumb when remoting into a customer browser. - Soften the failure-toast wording to cover permission-denied / sandboxed- iframe / missing-API cases without asserting the cause is HTTP. Callsites: - `void` the floating Promise calls in `DBRowJsonViewer` (3 sites) so a future invariant break surfaces as an unhandled rejection instead of silently dropping the success/fail signal. - Track post-await timeout ids in refs and gate post-await `setIsCopied` on `isMountedRef.current` in `DBRowTableFieldWithPopover` and `DBRowTableRowButtons`. Rows / popovers are virtualised; without this guard the timer leaks and setState-on-unmounted noise fires after the parent recycles. Tests: - 16 unit tests cover modern API, secure-context routing, modern-throws fallback, both-fail, textarea cleanup, selection restore, focus restore, payload cap (refused + still uses modern), empty string, and toast colours / messages. - 12 e2e tests cover all six callsites on the modern path, the modern -> fallback transition for row JSON and row URL, the failure toast on three callsites, the `if (ok) setIsCopied` regression for the row-JSON button, the `Copy Object` parsed-tab branch, and one test that bypasses the hook to verify the real OS clipboard receives the text via the fallback. - Stable `data-testid` on row JSON / link / field-popover / json-viewer copy buttons and on each HyperJson leaf type, so the e2e doesn't depend on `[class*="string"]` or hover-only Mantine tooltip text. - Existing `DBRowJsonViewer.test.tsx` copy tests now seed `window.isSecureContext = true` and `await waitFor(...)`, since the new util short-circuits to the fallback otherwise. Co-Authored-By: Claude Opus 4.5 --- .../src/components/DBRowJsonViewer.test.tsx | 38 ++- .../app/src/components/DBRowJsonViewer.tsx | 7 +- .../DBTable/DBRowTableFieldWithPopover.tsx | 28 +- .../DBTable/DBRowTableIconButton.tsx | 3 + .../DBTable/DBRowTableRowButtons.tsx | 38 ++- packages/app/src/components/HyperJson.tsx | 30 ++- .../app/src/utils/__tests__/clipboard.test.ts | 135 +++++++++- packages/app/src/utils/clipboard.ts | 74 +++++- .../e2e/features/search/clipboard.spec.ts | 246 ++++++++++++++---- 9 files changed, 496 insertions(+), 103 deletions(-) diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx index 161a5abfe6..b7fd2a65ca 100644 --- a/packages/app/src/components/DBRowJsonViewer.test.tsx +++ b/packages/app/src/components/DBRowJsonViewer.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, screen, within } from '@testing-library/react'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import { buildJSONExtractQuery, DBRowJsonViewer } from './DBRowJsonViewer'; import { RowSidePanelContext } from './DBRowSidePanel'; @@ -159,40 +159,56 @@ describe('DBRowJsonViewer', () => { const mockClipboard = jest.fn(); beforeEach(() => { + // copyTextToClipboard short-circuits to the execCommand fallback unless + // both `window.isSecureContext` is true AND `navigator.clipboard.writeText` + // exists, so seed both for these unit tests. + Object.defineProperty(window, 'isSecureContext', { + value: true, + writable: true, + configurable: true, + }); + mockClipboard.mockReset(); + mockClipboard.mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText: mockClipboard }, }); }); - it('copies array elements from expanded stringified JSON', () => { + it('copies array elements from expanded stringified JSON', async () => { const arrayObject = { status: 'True', type: 'PodReady' }; const data = { conditions: JSON.stringify([arrayObject]) }; renderComponent(data); expandAndClickButton('conditions', '0', 'Copy Object'); - expect(mockClipboard).toHaveBeenCalledWith( - JSON.stringify(arrayObject, null, 2), - ); + await waitFor(() => { + expect(mockClipboard).toHaveBeenCalledWith( + JSON.stringify(arrayObject, null, 2), + ); + }); }); - it('copies entire stringified value when not expanded', () => { + it('copies entire stringified value when not expanded', async () => { const arrayData = [{ type: 'Ready' }, { type: 'Init' }]; const data = { conditions: JSON.stringify(arrayData) }; renderComponent(data); clickLineButton('conditions', 'Copy Value'); - expect(mockClipboard).toHaveBeenCalledWith(JSON.stringify(arrayData)); + await waitFor(() => { + expect(mockClipboard).toHaveBeenCalledWith(JSON.stringify(arrayData)); + }); }); - it('copies regular nested objects', () => { + it('copies regular nested objects', async () => { renderComponent(logData); clickLineButton('nested', 'Copy Object'); - expect(mockClipboard).toHaveBeenCalledWith( - JSON.stringify({ field3: 'nested value' }, null, 2), - ); + await waitFor(() => { + expect(mockClipboard).toHaveBeenCalledWith( + JSON.stringify({ field3: 'nested value' }, null, 2), + ); + }); }); }); diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index e4e6ebe6f3..ba3552f7fd 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -220,8 +220,9 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { {rowData != null && ( { - copyTextWithToast( + void copyTextWithToast( typeof rowData === 'string' ? rowData : JSON.stringify(rowData, null, 2), @@ -557,7 +558,7 @@ export function DBRowJsonViewer({ copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath); } - copyTextWithToast( + void copyTextWithToast( JSON.stringify(copiedObj, null, 2), 'Copied object to clipboard', ); @@ -579,7 +580,7 @@ export function DBRowJsonViewer({ ), onClick: () => { - copyTextWithToast( + void copyTextWithToast( typeof value === 'string' ? value : JSON.stringify(value, null, 2), diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx index 43f6ead289..c17a6faff1 100644 --- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -34,16 +34,27 @@ const DBRowTableFieldWithPopover = ({ const [hoverDisabled, setHoverDisabled] = useState(false); const timeoutRef = useRef(undefined); const hoverDisableTimeoutRef = useRef(undefined); - - // Cleanup timeouts on unmount to prevent memory leaks + // Tracks the post-copy "isCopied -> isCopied=false" reset so unmount can + // cancel it (popover and its parent row are virtualised; either can recycle + // mid-await). + const copyResetTimeoutRef = useRef(undefined); + const isMountedRef = useRef(true); + + // Cleanup timeouts on unmount to prevent memory leaks and setState-after- + // unmount warnings. useEffect(() => { + isMountedRef.current = true; return () => { + isMountedRef.current = false; if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (hoverDisableTimeoutRef.current) { clearTimeout(hoverDisableTimeoutRef.current); } + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + } }; }, []); @@ -88,10 +99,16 @@ const DBRowTableFieldWithPopover = ({ const value = typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); const ok = await copyTextWithToast(value, 'Copied field value'); - if (ok) { - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); + if (!ok || !isMountedRef.current) return; + setIsCopied(true); + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); } + copyResetTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + setIsCopied(false); + } + }, 2000); }; const addFilter = () => { @@ -181,6 +198,7 @@ const DBRowTableFieldWithPopover = ({ variant="copy" isActive={isCopied} title={isCopied ? 'Copied!' : 'Copy field value'} + data-testid="field-copy-value-button" > diff --git a/packages/app/src/components/DBTable/DBRowTableIconButton.tsx b/packages/app/src/components/DBTable/DBRowTableIconButton.tsx index 0a36409c9a..f4c87e7c5d 100644 --- a/packages/app/src/components/DBTable/DBRowTableIconButton.tsx +++ b/packages/app/src/components/DBTable/DBRowTableIconButton.tsx @@ -14,6 +14,7 @@ interface DBRowTableIconButtonProps { variant?: 'copy' | 'default'; isActive?: boolean; iconSize?: number; + 'data-testid'?: string; } export const DBRowTableIconButton: React.FC = ({ @@ -25,6 +26,7 @@ export const DBRowTableIconButton: React.FC = ({ variant = 'default', isActive = false, iconSize = 14, + 'data-testid': dataTestId, }) => { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -55,6 +57,7 @@ export const DBRowTableIconButton: React.FC = ({ className={baseClasses} tabIndex={tabIndex} role="button" + data-testid={dataTestId} > {isActive ? : children} diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index ab364d3ca8..1d472e935f 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react'; import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere'; @@ -25,6 +25,20 @@ const DBRowTableRowButtons: React.FC = ({ }) => { const [isCopied, setIsCopied] = useState(false); const [isUrlCopied, setIsUrlCopied] = useState(false); + // Rows are virtualised and recycle on scroll; cancel pending state resets on + // unmount so we don't setState on a recycled row. + const copyResetRef = useRef(undefined); + const urlCopyResetRef = useRef(undefined); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + if (copyResetRef.current) clearTimeout(copyResetRef.current); + if (urlCopyResetRef.current) clearTimeout(urlCopyResetRef.current); + }; + }, []); const copyRowData = async () => { // Filter out internal metadata fields that start with __ or are generated IDs @@ -53,10 +67,12 @@ const DBRowTableRowButtons: React.FC = ({ const rowData = JSON.stringify(parsedRow, null, 2); const ok = await copyTextWithToast(rowData, 'Copied row as JSON'); - if (ok) { - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } + if (!ok || !isMountedRef.current) return; + setIsCopied(true); + if (copyResetRef.current) clearTimeout(copyResetRef.current); + copyResetRef.current = setTimeout(() => { + if (isMountedRef.current) setIsCopied(false); + }, 2000); }; const copyRowUrl = async () => { @@ -71,10 +87,12 @@ const DBRowTableRowButtons: React.FC = ({ currentUrl.toString(), 'Copied shareable link', ); - if (ok) { - setIsUrlCopied(true); - setTimeout(() => setIsUrlCopied(false), 2000); - } + if (!ok || !isMountedRef.current) return; + setIsUrlCopied(true); + if (urlCopyResetRef.current) clearTimeout(urlCopyResetRef.current); + urlCopyResetRef.current = setTimeout(() => { + if (isMountedRef.current) setIsUrlCopied(false); + }, 2000); }; return ( @@ -95,6 +113,7 @@ const DBRowTableRowButtons: React.FC = ({ title={ isCopied ? 'Copied entire row as JSON!' : 'Copy entire row as JSON' } + data-testid="row-copy-json-button" > @@ -107,6 +126,7 @@ const DBRowTableRowButtons: React.FC = ({ ? 'Copied shareable link!' : 'Copy shareable link to this specific row' } + data-testid="row-copy-link-button" > diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx index 4f8ee4c236..72d566f954 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -55,35 +55,55 @@ const ValueRenderer = React.memo( } if (isString(value)) { return ( - + {value} ); } if (isNumber(value)) { return ( - + {value} ); } if (isBoolean(value)) { return ( - + {value ? 'true' : 'false'} ); } if (isPlainObject(value)) { return ( - + {'{}'} {Object.keys(value).length} keys ); } if (isArray(value)) { return ( - + {'[]'} {value.length} items ); diff --git a/packages/app/src/utils/__tests__/clipboard.test.ts b/packages/app/src/utils/__tests__/clipboard.test.ts index 6a254596e4..517b5b6749 100644 --- a/packages/app/src/utils/__tests__/clipboard.test.ts +++ b/packages/app/src/utils/__tests__/clipboard.test.ts @@ -7,20 +7,37 @@ jest.mock('@mantine/notifications', () => ({ }, })); +const FAILURE_MESSAGE = + "Couldn't copy to clipboard. If you're on plain HTTP, switch to HTTPS or localhost."; + +function setSecureContext(secure: boolean) { + Object.defineProperty(window, 'isSecureContext', { + value: secure, + writable: true, + configurable: true, + }); +} + describe('copyTextToClipboard', () => { + let warnSpy: jest.SpyInstance; + beforeEach(() => { mantineShow.mockClear(); - // Reset clipboard between tests Object.defineProperty(navigator, 'clipboard', { value: undefined, writable: true, configurable: true, }); - // Default execCommand stub returns true + setSecureContext(true); document.execCommand = jest.fn().mockReturnValue(true); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); }); - it('uses navigator.clipboard when available', async () => { + it('uses navigator.clipboard when secure-context with the API', async () => { const writeText = jest.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { value: { writeText }, @@ -36,8 +53,29 @@ describe('copyTextToClipboard', () => { expect(execSpy).not.toHaveBeenCalled(); }); + it('routes straight to execCommand when isSecureContext is false (preserves user activation)', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + // Even though writeText exists, a non-secure-context page should not + // burn the click activation on an awaited modern attempt. + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + setSecureContext(false); + const execSpy = document.execCommand as jest.Mock; + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(writeText).not.toHaveBeenCalled(); + expect(execSpy).toHaveBeenCalledWith('copy'); + }); + it('falls back to execCommand when navigator.clipboard.writeText rejects', async () => { - const writeText = jest.fn().mockRejectedValue(new Error('denied')); + const writeText = jest + .fn() + .mockRejectedValue(new Error('permission denied')); Object.defineProperty(navigator, 'clipboard', { value: { writeText }, writable: true, @@ -50,10 +88,13 @@ describe('copyTextToClipboard', () => { expect(result).toBe(true); expect(writeText).toHaveBeenCalledWith('hello'); expect(execSpy).toHaveBeenCalledWith('copy'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('writeText failed'), + expect.any(Error), + ); }); it('falls back to execCommand when navigator.clipboard is undefined', async () => { - // clipboard is undefined per beforeEach const execSpy = document.execCommand as jest.Mock; const result = await copyTextToClipboard('hello'); @@ -92,10 +133,13 @@ describe('copyTextToClipboard', () => { expect(result).toBe(false); expect(document.querySelectorAll('textarea').length).toBe(0); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('execCommand fallback failed'), + expect.any(Error), + ); }); it('restores the existing selection after the fallback runs', async () => { - // Seed an existing range on a paragraph in the DOM const p = document.createElement('p'); p.textContent = 'preserved selection target'; document.body.appendChild(p); @@ -120,6 +164,63 @@ describe('copyTextToClipboard', () => { document.body.removeChild(p); }); + + it('restores the previously focused element after the fallback runs', async () => { + const input = document.createElement('input'); + input.type = 'text'; + document.body.appendChild(input); + input.focus(); + expect(document.activeElement).toBe(input); + + const result = await copyTextToClipboard('hello'); + + expect(result).toBe(true); + expect(document.activeElement).toBe(input); + + document.body.removeChild(input); + }); + + it('refuses to fall back for payloads larger than the size cap', async () => { + const big = 'x'.repeat(1_000_001); + const execSpy = document.execCommand as jest.Mock; + + const result = await copyTextToClipboard(big); + + expect(result).toBe(false); + expect(execSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('fallback refused'), + ); + }); + + it('still uses the modern API for large payloads when available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + const big = 'x'.repeat(1_000_001); + + const result = await copyTextToClipboard(big); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith(big); + }); + + it('handles empty-string input correctly', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + const result = await copyTextToClipboard(''); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith(''); + }); }); describe('copyTextWithToast', () => { @@ -130,7 +231,13 @@ describe('copyTextWithToast', () => { writable: true, configurable: true, }); + setSecureContext(true); document.execCommand = jest.fn().mockReturnValue(true); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('shows a green success toast on success', async () => { @@ -174,8 +281,20 @@ describe('copyTextWithToast', () => { expect(result).toBe(false); expect(mantineShow).toHaveBeenCalledWith({ color: 'red', - message: - "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.", + message: FAILURE_MESSAGE, + }); + }); + + it('shows a red failure toast for oversized payloads on non-secure contexts', async () => { + setSecureContext(false); + const big = 'x'.repeat(1_000_001); + + const result = await copyTextWithToast(big); + + expect(result).toBe(false); + expect(mantineShow).toHaveBeenCalledWith({ + color: 'red', + message: FAILURE_MESSAGE, }); }); }); diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts index 910b555841..285e3a9a04 100644 --- a/packages/app/src/utils/clipboard.ts +++ b/packages/app/src/utils/clipboard.ts @@ -1,37 +1,78 @@ import { notifications } from '@mantine/notifications'; +/** + * Refuse to fall back to `document.execCommand('copy')` for payloads larger + * than this. The fallback path is fully synchronous (textarea + select + + * execCommand) and freezes the main thread for multi-MB JSON copies. + */ +const FALLBACK_MAX_BYTES = 1_000_000; + /** * Copy text to the clipboard, with a fallback for non-secure contexts. * * `navigator.clipboard` is only defined when the page is served over HTTPS * or `localhost`. When HyperDX is reached over plain HTTP (Tailscale tunnel, * corporate VPN, non-localhost host), the modern API is undefined and any - * call to `navigator.clipboard.writeText` throws. This util tries the modern - * API first and falls back to a hidden-textarea + `document.execCommand('copy')` - * trick that works in non-secure contexts. + * call to `navigator.clipboard.writeText` throws. + * + * In non-secure contexts we route straight to the synchronous textarea + + * `document.execCommand('copy')` fallback. Doing the modern attempt first + * would burn the click's user-activation token on an awaited rejection, so + * the fallback that runs afterwards has lost activation and silently fails. * - * Returns `true` on success, `false` if both paths fail. + * Returns `true` on success, `false` if both paths fail or the payload is + * larger than the fallback limit and the modern path isn't available. */ export async function copyTextToClipboard(text: string): Promise { - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + if (isSecureContextWithClipboard()) { try { await navigator.clipboard.writeText(text); return true; - } catch { - // Permission denied or other failure. Fall through to the legacy fallback. + } catch (err) { + // Permission denied / sandboxed iframe / another race. Log and fall + // through to the legacy fallback so we still have a chance to copy. + + console.warn( + 'clipboard writeText failed; trying execCommand fallback', + err, + ); } } + + if (text.length > FALLBACK_MAX_BYTES) { + console.warn( + `clipboard fallback refused: payload ${text.length} > ${FALLBACK_MAX_BYTES} bytes`, + ); + return false; + } + return execCommandFallback(text); } +function isSecureContextWithClipboard(): boolean { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + // `isSecureContext` is true on HTTPS and on `http://localhost`. Browsers + // only expose `navigator.clipboard.writeText` in secure contexts; checking + // both guards against the rare browser that exposes the API but blocks + // the call. + return ( + window.isSecureContext === true && Boolean(navigator.clipboard?.writeText) + ); +} + function execCommandFallback(text: string): boolean { if (typeof document === 'undefined') { return false; } // Capture focus and selection state before we mount the scratch textarea so - // we can restore both in the finally block. - const previouslyFocused = document.activeElement as HTMLElement | null; + // we can restore both in the finally block. Use `instanceof HTMLElement` so + // SVG / MathML elements (which subclass `Element` but not `HTMLElement`) + // don't slip through. + const active = document.activeElement; + const previouslyFocused = active instanceof HTMLElement ? active : null; const selection = document.getSelection(); const previousRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; @@ -51,7 +92,8 @@ function execCommandFallback(text: string): boolean { let succeeded = false; try { succeeded = document.execCommand('copy'); - } catch { + } catch (err) { + console.warn('clipboard execCommand fallback failed', err); succeeded = false; } finally { document.body.removeChild(textarea); @@ -59,15 +101,23 @@ function execCommandFallback(text: string): boolean { selection.removeAllRanges(); selection.addRange(previousRange); } - // Restore focus so typing context is not silently lost on HTTP fallback. + // Restore focus so typing context is not silently lost on the HTTP + // fallback path. previouslyFocused?.focus(); } return succeeded; } +/** + * Failure message shown when the modern API is unavailable AND the fallback + * path also fails. The wording deliberately leaves room for permission-denied + * and sandboxed-iframe cases (it doesn't assert "you must switch to HTTPS"), + * since the secure-context check above means we only reach this message via + * an actual fallback failure, not via a never-attempted secure context. + */ const FAILURE_MESSAGE = - "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API."; + "Couldn't copy to clipboard. If you're on plain HTTP, switch to HTTPS or localhost."; /** * Copy text and show a Mantine toast on success or failure. Mirrors the diff --git a/packages/app/tests/e2e/features/search/clipboard.spec.ts b/packages/app/tests/e2e/features/search/clipboard.spec.ts index 86a6d67c09..5e4df6812d 100644 --- a/packages/app/tests/e2e/features/search/clipboard.spec.ts +++ b/packages/app/tests/e2e/features/search/clipboard.spec.ts @@ -7,6 +7,13 @@ * and `document.execCommand('copy')` to a shared `window.__copyHistory` array * so we can assert which path each call site actually took without depending * on Chromium clipboard permissions or polluted state from previous tests. + * + * One test (`row JSON button writes through to the real OS clipboard via the + * fallback`) does NOT install the hook so it exercises the real + * `document.execCommand('copy')` path end-to-end and reads the result via + * `navigator.clipboard.readText()`. That keeps us honest: the hook only + * confirms code-path routing, not that the OS clipboard actually receives + * the text. */ import { Page } from '@playwright/test'; @@ -49,8 +56,11 @@ async function installCopyHook( return realExec(cmd, ...(rest as [])); }; - // Modern API installation depends on mode if (m === 'modern') { + Object.defineProperty(window, 'isSecureContext', { + value: true, + configurable: true, + }); Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (text: string) => { @@ -67,7 +77,13 @@ async function installCopyHook( configurable: true, }); } else { - // Simulate non-secure context: navigator.clipboard is undefined + // Simulate non-secure context end-to-end: both `isSecureContext` and + // the clipboard API are absent so `copyTextToClipboard` routes + // straight to the synchronous execCommand fallback. + Object.defineProperty(window, 'isSecureContext', { + value: false, + configurable: true, + }); Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, @@ -83,6 +99,9 @@ async function getCopyHistory(page: Page): Promise { return await page.evaluate(() => window.__copyHistory ?? []); } +const FAILURE_TOAST = + "Couldn't copy to clipboard. If you're on plain HTTP, switch to HTTPS or localhost."; + async function openSearchAndFirstRow(searchPage: SearchPage) { await searchPage.goto(); await searchPage.submitEmptySearch(); @@ -95,6 +114,11 @@ async function openParsedTab(searchPage: SearchPage) { await searchPage.sidePanel.clickTab('parsed'); } +async function hoverFirstRowAndClickCopyJson(searchPage: SearchPage) { + await searchPage.table.firstRow.hover(); + await searchPage.table.firstRow.getByTestId('row-copy-json-button').click(); +} + test.describe('Clipboard fallback', { tag: ['@search'] }, () => { test('row JSON button copies via the modern API and shows a success toast', async ({ page, @@ -103,19 +127,13 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const searchPage = new SearchPage(page); await openSearchAndFirstRow(searchPage); - // The rowButtons container is opacity-0 until the row is hovered. - await searchPage.table.firstRow.hover(); - const copyJsonButton = searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-copy') - .first(); - await copyJsonButton.click(); + await hoverFirstRowAndClickCopyJson(searchPage); await expect(page.getByText('Copied row as JSON')).toBeVisible(); const history = await getCopyHistory(page); expect(history).toHaveLength(1); expect(history[0].source).toBe('modern'); - // Should be a JSON object string (parses cleanly). expect(() => JSON.parse(history[0].text)).not.toThrow(); }); @@ -127,10 +145,7 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { await openSearchAndFirstRow(searchPage); await searchPage.table.firstRow.hover(); - const copyLinkButton = searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-link') - .first(); - await copyLinkButton.click(); + await searchPage.table.firstRow.getByTestId('row-copy-link-button').click(); await expect(page.getByText('Copied shareable link')).toBeVisible(); @@ -140,6 +155,45 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { expect(history[0].text).toContain('rowWhere='); }); + test('row URL button falls back to execCommand when the modern API is unavailable', async ({ + page, + }) => { + await installCopyHook(page, 'no-modern'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await searchPage.table.firstRow.hover(); + await searchPage.table.firstRow.getByTestId('row-copy-link-button').click(); + + await expect(page.getByText('Copied shareable link')).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history).toHaveLength(1); + expect(history[0].source).toBe('fallback'); + expect(history[0].text).toContain('rowWhere='); + }); + + test('field-value popover copy button uses the modern API and shows a success toast', async ({ + page, + }) => { + await installCopyHook(page, 'modern'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + // Hover a cell to open the field popover; click the copy button by testid. + const firstCell = searchPage.table.firstRow.locator('td').nth(1); + await firstCell.hover(); + const copyFieldButton = page.getByTestId('field-copy-value-button'); + await expect(copyFieldButton).toBeVisible(); + await copyFieldButton.click(); + + await expect(page.getByText('Copied field value')).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history).toHaveLength(1); + expect(history[0].source).toBe('modern'); + }); + test('parsed-tab "Copy row as JSON" icon copies via the modern API', async ({ page, }) => { @@ -147,21 +201,7 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const searchPage = new SearchPage(page); await openParsedTab(searchPage); - // HyperJsonMenu renders the row-level copy button with a `Copy row as JSON` - // tooltip and the Tabler IconCopy SVG. - const sidePanelCopy = page - .getByTitle('Copy row as JSON') - .or(page.locator('[title="Copy row as JSON"]')) - .first(); - // Fallback locator: the IconCopy inside the side panel's HyperJsonMenu group. - const sideCopyByIcon = searchPage.sidePanel.container - .locator('.tabler-icon-copy') - .first(); - if (await sidePanelCopy.isVisible().catch(() => false)) { - await sidePanelCopy.click(); - } else { - await sideCopyByIcon.click(); - } + await page.getByTestId('json-viewer-copy-row').click(); await expect( page.getByText('Value copied to clipboard').first(), @@ -169,7 +209,10 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const history = await getCopyHistory(page); expect(history.length).toBeGreaterThanOrEqual(1); - expect(history[history.length - 1].source).toBe('modern'); + const last = history[history.length - 1]; + expect(last.source).toBe('modern'); + // Should be a JSON object string (parses cleanly). + expect(() => JSON.parse(last.text)).not.toThrow(); }); test('parsed-tab line action "Copy Value" copies via the modern API', async ({ @@ -179,16 +222,14 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const searchPage = new SearchPage(page); await openParsedTab(searchPage); - // Hover a string leaf so the line menu mounts (HyperJson only mounts - // LineMenu when hovered). - const stringEl = page.locator('[class*="string"]').first(); + const stringEl = page.getByTestId('hyperjson-value-string').first(); await expect(stringEl).toBeVisible(); await stringEl.hover(); - const copyValueButton = page + await page .getByRole('button', { name: /Copy Value/ }) - .first(); - await copyValueButton.click(); + .first() + .click(); await expect( page.getByText('Value copied to clipboard').first(), @@ -199,6 +240,36 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { expect(history[history.length - 1].source).toBe('modern'); }); + test('parsed-tab line action "Copy Object" copies a nested object as JSON', async ({ + page, + }) => { + await installCopyHook(page, 'modern'); + const searchPage = new SearchPage(page); + await openParsedTab(searchPage); + + const objectEl = page.getByTestId('hyperjson-value-object').first(); + await expect(objectEl).toBeVisible(); + await objectEl.hover(); + + await page + .getByRole('button', { name: /Copy Object/ }) + .first() + .click(); + + await expect( + page.getByText('Copied object to clipboard').first(), + ).toBeVisible(); + + const history = await getCopyHistory(page); + expect(history.length).toBeGreaterThanOrEqual(1); + const last = history[history.length - 1]; + expect(last.source).toBe('modern'); + // Copy Object always serialises a JSON object; assert it parses. + const parsed = JSON.parse(last.text); + expect(typeof parsed).toBe('object'); + expect(parsed).not.toBeNull(); + }); + test('row JSON button falls back to execCommand when the modern API is unavailable', async ({ page, }) => { @@ -206,11 +277,7 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const searchPage = new SearchPage(page); await openSearchAndFirstRow(searchPage); - await searchPage.table.firstRow.hover(); - const copyJsonButton = searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-copy') - .first(); - await copyJsonButton.click(); + await hoverFirstRowAndClickCopyJson(searchPage); await expect(page.getByText('Copied row as JSON')).toBeVisible(); @@ -220,7 +287,48 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { expect(() => JSON.parse(history[0].text)).not.toThrow(); }); - test('row JSON button shows the failure toast when both paths fail', async ({ + test('row JSON button writes through to the real OS clipboard via the fallback', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'Clipboard read permission flow tested only on chromium', + ); + + // Force the fallback path WITHOUT installing the hook. The browser's + // real document.execCommand('copy') should land the text on the OS + // clipboard, which we then read via navigator.clipboard.readText. + await page.addInitScript(() => { + Object.defineProperty(window, 'isSecureContext', { + value: false, + configurable: true, + }); + }); + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']); + + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + await hoverFirstRowAndClickCopyJson(searchPage); + + await expect(page.getByText('Copied row as JSON')).toBeVisible(); + + // Re-grant clipboard permissions and read what landed on the OS clipboard. + const onClipboard = await page.evaluate(async () => { + try { + return await navigator.clipboard.readText(); + } catch { + return ''; + } + }); + expect(onClipboard.length).toBeGreaterThan(0); + expect(() => JSON.parse(onClipboard)).not.toThrow(); + }); + + test('row JSON button shows the failure toast when both paths fail and keeps isCopied false', async ({ page, }) => { const consoleErrors: string[] = []; @@ -230,20 +338,58 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const searchPage = new SearchPage(page); await openSearchAndFirstRow(searchPage); - await searchPage.table.firstRow.hover(); - const copyJsonButton = searchPage.table.firstRow - .locator('[class*="rowButtons"] .tabler-icon-copy') - .first(); - await copyJsonButton.click(); - + await hoverFirstRowAndClickCopyJson(searchPage); + + await expect(page.getByText(FAILURE_TOAST)).toBeVisible(); + + // Regression test for the `if (ok) setIsCopied(true)` guard: the icon's + // tooltip must NOT switch to "Copied entire row as JSON!" on a failed + // copy. The Mantine Tooltip exposes the title via the wrapped div. + const copyJsonButton = searchPage.table.firstRow.getByTestId( + 'row-copy-json-button', + ); + await expect(copyJsonButton).toBeVisible(); + // The inner Tooltip target wraps the data-testid'd div. Inspect the + // sibling Tooltip-rendered label after hovering to surface the current + // tooltip text. + await copyJsonButton.hover(); await expect( - page.getByText( - "Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.", - ), + page.getByText('Copy entire row as JSON', { exact: true }), ).toBeVisible(); + await expect( + page.getByText('Copied entire row as JSON!', { exact: true }), + ).toBeHidden(); const history = await getCopyHistory(page); expect(history).toHaveLength(0); expect(consoleErrors).toEqual([]); }); + + test('field-value popover shows the failure toast when both paths fail', async ({ + page, + }) => { + await installCopyHook(page, 'fail-both'); + const searchPage = new SearchPage(page); + await openSearchAndFirstRow(searchPage); + + const firstCell = searchPage.table.firstRow.locator('td').nth(1); + await firstCell.hover(); + const copyFieldButton = page.getByTestId('field-copy-value-button'); + await expect(copyFieldButton).toBeVisible(); + await copyFieldButton.click(); + + await expect(page.getByText(FAILURE_TOAST)).toBeVisible(); + }); + + test('parsed-tab "Copy row as JSON" shows the failure toast when both paths fail', async ({ + page, + }) => { + await installCopyHook(page, 'fail-both'); + const searchPage = new SearchPage(page); + await openParsedTab(searchPage); + + await page.getByTestId('json-viewer-copy-row').click(); + + await expect(page.getByText(FAILURE_TOAST)).toBeVisible(); + }); }); From c319851301d41dde9974726e76bd8a4f5e54e574 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 12 May 2026 02:16:26 +0000 Subject: [PATCH 5/5] test(e2e): target field-popover trigger span directly in clipboard spec The "field-value popover copy button" test in clipboard.spec.ts hovered `searchPage.table.firstRow.locator('td').nth(1)` and waited for `field-copy-value-button` to appear. The popover trigger is an inner `` inside `DBRowTableFieldWithPopover`, and `mouseenter` does not bubble, so hovering the outer `td` did not always fire the span's `onMouseEnter`. CI Shard 2 hit this on all three retries (run 25529406984, job 74932164598). Add a stable `data-testid="field-popover-trigger"` to the `` span and hover that locator directly in the test. Raise the visibility timeout to 10000ms to match the rest of clipboard.spec.ts (line 108). Co-Authored-By: Claude Opus 4.5 --- .../components/DBTable/DBRowTableFieldWithPopover.tsx | 1 + packages/app/tests/e2e/features/search/clipboard.spec.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx index c17a6faff1..43382b3cce 100644 --- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -176,6 +176,7 @@ const DBRowTableFieldWithPopover = ({ tabIndex={-1} aria-hidden="true" style={{ cursor: 'pointer' }} + data-testid="field-popover-trigger" > {children} diff --git a/packages/app/tests/e2e/features/search/clipboard.spec.ts b/packages/app/tests/e2e/features/search/clipboard.spec.ts index 5e4df6812d..e234876159 100644 --- a/packages/app/tests/e2e/features/search/clipboard.spec.ts +++ b/packages/app/tests/e2e/features/search/clipboard.spec.ts @@ -180,11 +180,12 @@ test.describe('Clipboard fallback', { tag: ['@search'] }, () => { const searchPage = new SearchPage(page); await openSearchAndFirstRow(searchPage); - // Hover a cell to open the field popover; click the copy button by testid. - const firstCell = searchPage.table.firstRow.locator('td').nth(1); - await firstCell.hover(); + // Target the span that triggers DBRowTableFieldWithPopover directly. + // Hovering the outer td can miss the span's onMouseEnter (does not bubble). + const firstFieldTrigger = page.getByTestId('field-popover-trigger').first(); + await firstFieldTrigger.hover(); const copyFieldButton = page.getByTestId('field-copy-value-button'); - await expect(copyFieldButton).toBeVisible(); + await expect(copyFieldButton).toBeVisible({ timeout: 10000 }); await copyFieldButton.click(); await expect(page.getByText('Copied field value')).toBeVisible();