diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e78c9ad..059f4c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - parser - performance - responsive-design + - search-controls - stack-details - theme-consistency - visual-regression diff --git a/README.md b/README.md index 1d14830..d9a8ac0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ function App() { height={600} showHottestFrames={true} showControls={true} + showSearch={true} showStackDetails={true} /> ) diff --git a/src/cli-template.tsx b/src/cli-template.tsx index 81897c0..7548010 100644 --- a/src/cli-template.tsx +++ b/src/cli-template.tsx @@ -47,6 +47,7 @@ window.renderReactPprofFlameGraph = (containerId: string, options) => { textColor={TEXT_COLOR} showHottestFrames={true} showControls={true} + showSearch={true} showStackDetails={true} hottestFramesHeight={12} /> diff --git a/src/components/FlameGraph.md b/src/components/FlameGraph.md index 75481f6..4ba8ff9 100644 --- a/src/components/FlameGraph.md +++ b/src/components/FlameGraph.md @@ -27,6 +27,7 @@ The FlameGraph component renders performance profiling data from Go's pprof form | `scrollZoomSpeed` | `number` | `0.05` | Speed of scroll zoom | | `scrollZoomInverted` | `boolean` | `false` | Invert scroll zoom direction | | `selectedFrameId` | `string \| null` | - | ID of the currently selected frame | +| `highlightedFrameIds` | `string[] \| null` | - | IDs of frames to highlight (e.g. search matches); matches render at full opacity, other frames are dimmed. `null` clears the highlight | | `onFrameClick` | `function` | - | Callback when a frame is clicked | | `onZoomChange` | `function` | - | Callback when zoom level changes | | `onAnimationComplete` | `function` | - | Callback when animations complete | diff --git a/src/components/FlameGraph.tsx b/src/components/FlameGraph.tsx index a0e8386..1957a93 100644 --- a/src/components/FlameGraph.tsx +++ b/src/components/FlameGraph.tsx @@ -21,6 +21,7 @@ export interface FlameGraphProps { scrollZoomSpeed?: number scrollZoomInverted?: boolean selectedFrameId?: string | null + highlightedFrameIds?: string[] | null showAppCodeOnly?: boolean onFrameClick?: (frame: FrameData | null, stackTrace: FlameNode[], children: FlameNode[]) => void onZoomChange?: (zoomLevel: number) => void @@ -45,6 +46,7 @@ export const FlameGraph = forwardRef<{ rendererRef: React.RefObject { + if (rendererRef.current && highlightedFrameIds !== undefined) { + rendererRef.current.setHighlightedFrames(highlightedFrameIds) + } + }, [highlightedFrameIds]) + useEffect(() => { if (rendererRef.current && canvasRef.current && containerRef.current) { rendererRef.current.setFramePadding(framePadding) diff --git a/src/components/FullFlameGraph.tsx b/src/components/FullFlameGraph.tsx index c4c1b16..8ed6631 100644 --- a/src/components/FullFlameGraph.tsx +++ b/src/components/FullFlameGraph.tsx @@ -4,6 +4,7 @@ import { FrameData, FlameNode, detectProfileMetadata, FlameGraphRenderer } from import { HottestFramesBar, type FrameWithSelfTime } from './HottestFramesBar.js' import { HottestFramesControls } from './HottestFramesControls.js' import { FilterControls } from './FilterControls.js' +import { SearchControls } from './SearchControls.js' import { FrameDetails } from './FrameDetails.js' import { FlameGraph } from './FlameGraph.js' import { StackDetails } from './StackDetails.js' @@ -18,6 +19,7 @@ export interface FullFlameGraphProps { fontFamily?: string showHottestFrames?: boolean showControls?: boolean + showSearch?: boolean showFrameDetails?: boolean showStackDetails?: boolean hottestFramesHeight?: number @@ -34,6 +36,7 @@ export const FullFlameGraph: React.FC = ({ fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif', showHottestFrames = true, showControls = true, + showSearch = true, showFrameDetails = false, showStackDetails = true, hottestFramesHeight = 10, @@ -45,6 +48,7 @@ export const FullFlameGraph: React.FC = ({ const [stackTrace, setStackTrace] = useState([]) const [frameChildren, setFrameChildren] = useState([]) const [showAppCodeOnly, setShowAppCodeOnly] = useState(showAppCodeOnlyProp) + const [highlightedFrameIds, setHighlightedFrameIds] = useState(null) // Reference to FlameGraph's renderer const flameGraphRef = useRef<{ rendererRef: React.RefObject }>(null) @@ -195,6 +199,10 @@ export const FullFlameGraph: React.FC = ({ // Use the correct value index from profile metadata const valueIndex = profileMetadata?.sampleTypeIndex ?? 0 + // pprof-format wraps the string table in a StringTable object; fall + // back to treating it as a plain array for pre-decoded profiles + const stringTable: string[] = (profile?.stringTable as any)?.strings ?? (profile?.stringTable as any) ?? [] + // Process each sample in the profile if (profile && profile.sample) { profile.sample.forEach((sample: any) => { @@ -207,26 +215,33 @@ export const FullFlameGraph: React.FC = ({ root.sampleCount += 1 // Count each sample // Build the stack for this sample - const stack: string[] = [] + const stack: Array<{ name: string, fileName?: string, lineNumber?: number }> = [] sample.locationId.forEach((locationId: any) => { const location = profile.location?.find((loc: any) => loc.id === locationId) if (location && location.line && location.line.length > 0) { - const func = profile.function?.find((f: any) => f.id === location.line?.[0]?.functionId) + const line = location.line[0] + const func = profile.function?.find((f: any) => f.id === line?.functionId) if (func) { const nameIndex = Number(func.name) - const funcName = (profile.stringTable as any)?.[nameIndex] || `func_${func.id}` - stack.push(funcName) + const funcName = stringTable[nameIndex] || `func_${func.id}` + const fileIndex = Number(func.filename) + stack.push({ + name: funcName, + fileName: stringTable[fileIndex], + lineNumber: line?.line ? Number(line.line) : undefined + }) } } }) - + // Traverse/create the tree based on the stack - stack.reverse().forEach((funcName, depth) => { + stack.reverse().forEach((frame, depth) => { + const funcName = frame.name let child = currentNode.children.find(c => c.name === funcName) if (!child) { - const nodeId = currentNode.id === 'root' - ? funcName - : `${currentNode.id}/${funcName}` + // Match the renderer's node ID scheme (root/parent/child) so + // selections resolve against frameMap and the flame graph + const nodeId = `${currentNode.id}/${funcName}` child = { id: nodeId, name: funcName, @@ -238,7 +253,9 @@ export const FullFlameGraph: React.FC = ({ depth: depth + 1, x: 0, width: 0, - selfWidth: 0 + selfWidth: 0, + fileName: frame.fileName, + lineNumber: frame.lineNumber } currentNode.children.push(child) } @@ -327,7 +344,7 @@ export const FullFlameGraph: React.FC = ({ )} - {(showControls || showFrameDetails) && ( + {(showControls || showSearch || showFrameDetails) && (
= ({ minHeight: '40px', gap: '16px', }}> - {showControls && ( + {(showControls || showSearch) && (
- - + {showControls && ( + <> + + + + )} + {showSearch && ( + + )}
)} @@ -377,6 +407,7 @@ export const FullFlameGraph: React.FC = ({ textColor={textColor} fontFamily={fontFamily} selectedFrameId={selectedFrameId} + highlightedFrameIds={highlightedFrameIds} showAppCodeOnly={showAppCodeOnly} onFrameClick={handleFrameSelection} /> diff --git a/src/components/SearchControls.md b/src/components/SearchControls.md new file mode 100644 index 0000000..dbcbe9d --- /dev/null +++ b/src/components/SearchControls.md @@ -0,0 +1,64 @@ +# SearchControls + +A search box for finding frames in the flame graph by function name or file name, with previous/next navigation through the matches. + +## Purpose + +The SearchControls component lets users locate specific functions in large profiles. Typing a query shows how many frames match, and the navigation buttons (or Enter / Shift+Enter) cycle through the matches hottest-first, selecting each frame in the flame graph so it is highlighted and its stack details are shown. + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `frames` | `FlameNode[]` | **required** | All frames in the flame graph to search through | +| `selectedFrame` | `FrameData \| null` | `undefined` | The currently selected frame, used to track the match position | +| `onFrameSelect` | `function` | `undefined` | Callback when a matching frame is selected | +| `onMatchesChange` | `function` | `undefined` | Callback with the IDs of all matching frames whenever the query changes (`null` when the query is empty); used to highlight matches in the flame graph | +| `textColor` | `string` | `'#ffffff'` | Text color for the input, buttons, and match count | +| `fontSize` | `string` | `'14px'` | Font size for text | +| `fontFamily` | `string` | System font stack | Font family for text rendering | +| `placeholder` | `string` | `'Search frames'` | Placeholder text for the search input | + +## Behavior + +- Matching is a case-insensitive substring test against each frame's function name and file name. +- While a query is active, all matching frames are highlighted in the flame graph: matches render at full opacity and non-matching frames are dimmed. +- Matches are ordered by total value (hottest first), so the first match is the most significant frame. +- **Enter** selects the next match, **Shift+Enter** the previous one, wrapping around at either end. +- **Escape** clears the query. +- The match counter shows `N of M` when the current selection is one of the matches, or the total match count otherwise. + +## Usage Examples + +### Basic Usage + +```tsx +import React, { useState } from 'react' +import { SearchControls } from './SearchControls' + +function FlameGraphViewer({ allFrames }) { + const [selectedFrame, setSelectedFrame] = useState(null) + + return ( +
+ + +
+ ) +} +``` + +### Within FullFlameGraph + +SearchControls is rendered automatically by `FullFlameGraph` unless disabled: + +```tsx + +``` diff --git a/src/components/SearchControls.stories.tsx b/src/components/SearchControls.stories.tsx new file mode 100644 index 0000000..362e432 --- /dev/null +++ b/src/components/SearchControls.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { SearchControls } from './SearchControls.js' +import { FrameData, FlameNode } from '../renderer/index.js' + +const makeNode = (id: string, name: string, value: number, depth: number, fileName?: string): FlameNode => ({ + id, + name, + value, + selfValue: value / 2, + sampleCount: value, + selfSampleCount: value / 2, + children: [], + depth, + x: 0, + width: 0.5, + selfWidth: 0.25, + fileName, +}) + +const sampleFrames: FlameNode[] = [ + makeNode('root', 'root', 1000, 0), + makeNode('root/main', 'main', 1000, 1, 'main.go'), + makeNode('root/main/serveHTTP', 'serveHTTP', 600, 2, 'server.go'), + makeNode('root/main/serveHTTP/handleRequest', 'handleRequest', 450, 3, 'server.go'), + makeNode('root/main/serveHTTP/handleRequest/parseJSON', 'parseJSON', 200, 4, 'parser.go'), + makeNode('root/main/runWorker', 'runWorker', 400, 2, 'worker.go'), + makeNode('root/main/runWorker/processJob', 'processJob', 350, 3, 'worker.go'), +] + +const meta = { + title: 'SearchControls', + component: SearchControls, + parameters: { + layout: 'centered', + }, + argTypes: { + onFrameSelect: { control: false }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + frames: sampleFrames, + onFrameSelect: (frame) => console.log('Frame selected:', frame), + textColor: '#ffffff', + }, +} + +export const CustomColors: Story = { + args: { + frames: sampleFrames, + onFrameSelect: (frame) => console.log('Frame selected:', frame), + textColor: '#00ff00', + fontSize: '16px', + }, +} + +export const Interactive: Story = { + render: () => { + const [selectedFrame, setSelectedFrame] = useState(null) + + return ( +
+ +
+ Selected: {selectedFrame ? selectedFrame.name : 'none'} +
+
+ ) + }, +} diff --git a/src/components/SearchControls.tsx b/src/components/SearchControls.tsx new file mode 100644 index 0000000..fa0c770 --- /dev/null +++ b/src/components/SearchControls.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { FrameData, FlameNode } from '../renderer/index.js' + +export interface SearchControlsProps { + frames: FlameNode[] + selectedFrame?: FrameData | null + onFrameSelect?: (frame: FrameData | null) => void + onMatchesChange?: (matchedFrameIds: string[] | null) => void + textColor?: string + fontSize?: string + fontFamily?: string + placeholder?: string +} + +const toFrameData = (node: FlameNode): FrameData => ({ + id: node.id, + name: node.name, + value: node.value, + selfValue: node.selfValue, + depth: node.depth, + x: node.x, + width: node.width, + selfWidth: node.selfWidth, + functionName: node.name, + fileName: node.fileName, + lineNumber: node.lineNumber, + totalValue: node.value, + sampleCount: node.sampleCount, +}) + +export const SearchControls: React.FC = ({ + frames, + selectedFrame, + onFrameSelect, + onMatchesChange, + textColor = '#ffffff', + fontSize = '14px', + fontFamily = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif', + placeholder = 'Search frames', +}) => { + const [query, setQuery] = useState('') + + // Find frames matching the query (case-insensitive substring on + // function name or file name), hottest first + const matches = useMemo(() => { + const trimmed = query.trim().toLowerCase() + if (!trimmed) { + return [] + } + + return frames + .filter(node => { + if (node.id === 'root') { + return false + } + return ( + node.name.toLowerCase().includes(trimmed) || + (node.fileName ? node.fileName.toLowerCase().includes(trimmed) : false) + ) + }) + .sort((a, b) => b.value - a.value) + }, [frames, query]) + + // Report the current matches so the flame graph can highlight them; + // null means no active search + useEffect(() => { + if (onMatchesChange) { + onMatchesChange(query.trim() ? matches.map(node => node.id) : null) + } + }, [matches, query, onMatchesChange]) + + const totalMatches = matches.length + // Derive the position from the current selection so it stays in sync + // when the selection changes elsewhere (click, hottest-frames nav, ...) + const currentIndex = selectedFrame + ? matches.findIndex(node => node.id === selectedFrame.id) + : -1 + + const selectMatch = (index: number) => { + if (onFrameSelect && matches[index]) { + onFrameSelect(toFrameData(matches[index])) + } + } + + const handleNext = () => { + if (totalMatches === 0) {return} + selectMatch(currentIndex === -1 ? 0 : (currentIndex + 1) % totalMatches) + } + + const handlePrev = () => { + if (totalMatches === 0) {return} + selectMatch(currentIndex === -1 ? totalMatches - 1 : (currentIndex - 1 + totalMatches) % totalMatches) + } + + const handleQueryChange = (event: React.ChangeEvent) => { + setQuery(event.target.value) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + if (event.shiftKey) { + handlePrev() + } else { + handleNext() + } + } else if (event.key === 'Escape') { + setQuery('') + } + } + + const hasQuery = query.trim().length > 0 + const buttonsDisabled = totalMatches === 0 + + const buttonStyle = (disabled: boolean) => ({ + background: 'transparent', + border: `1px solid ${textColor}`, + color: textColor, + padding: '4px 8px', + borderRadius: '2px', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.5 : 1, + fontSize, + fontFamily, + }) + + return ( +
+ + + + + + + {hasQuery && ( + + {totalMatches === 0 + ? 'No matches' + : currentIndex === -1 + ? `${totalMatches} match${totalMatches === 1 ? '' : 'es'}` + : `${currentIndex + 1} of ${totalMatches}`} + + )} +
+ ) +} diff --git a/src/components/index.ts b/src/components/index.ts index f8d7556..065bafa 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,4 +3,5 @@ export { StackDetails, type StackDetailsProps } from './StackDetails.js' export { HottestFramesBar, type HottestFramesBarProps, type FrameWithSelfTime, useHottestFramesNavigation } from './HottestFramesBar.js' export { HottestFramesControls, type HottestFramesControlsProps } from './HottestFramesControls.js' export { FrameDetails, type FrameDetailsProps } from './FrameDetails.js' +export { SearchControls, type SearchControlsProps } from './SearchControls.js' export { FullFlameGraph, type FullFlameGraphProps } from './FullFlameGraph.js' diff --git a/src/index.ts b/src/index.ts index 3267334..653f31b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { FlameGraphTooltip, type FlameGraphTooltipProps } from './components/Fla export { HottestFramesBar, type HottestFramesBarProps } from './components/HottestFramesBar.js' export { HottestFramesControls, type HottestFramesControlsProps } from './components/HottestFramesControls.js' export { FrameDetails, type FrameDetailsProps } from './components/FrameDetails.js' +export { SearchControls, type SearchControlsProps } from './components/SearchControls.js' export { FullFlameGraph, type FullFlameGraphProps } from './components/FullFlameGraph.js' export { fetchProfile, type Profile } from './parser.js' diff --git a/src/renderer/FlameGraphRenderer.ts b/src/renderer/FlameGraphRenderer.ts index 08c7f43..3d92fb9 100644 --- a/src/renderer/FlameGraphRenderer.ts +++ b/src/renderer/FlameGraphRenderer.ts @@ -37,6 +37,7 @@ export class FlameGraphRenderer { // Frame states #selectedFrameId: string | null = null #hoveredFrameId: string | null = null + #highlightedFrameIds: Set | null = null // Animation #animationFrame: number | null = null @@ -204,6 +205,15 @@ export class FlameGraphRenderer { } } + /** + * Set the frames to highlight (e.g. search matches). + * Pass null to clear the highlight and restore normal opacities. + */ + setHighlightedFrames(frameIds: string[] | null): void { + this.#highlightedFrameIds = frameIds ? new Set(frameIds) : null + this.render() + } + /** * Set height mode for zoom behavior */ @@ -348,7 +358,8 @@ export class FlameGraphRenderer { this.#selectedOpacity, this.#hoverOpacity, this.#unselectedOpacity, - cameraState + cameraState, + this.#highlightedFrameIds ) // Borders are now handled as insets in frame rendering, no separate border pass needed diff --git a/src/renderer/FrameRenderer.ts b/src/renderer/FrameRenderer.ts index a5f18bf..9848e4f 100644 --- a/src/renderer/FrameRenderer.ts +++ b/src/renderer/FrameRenderer.ts @@ -33,7 +33,8 @@ export class FrameRenderer { selectedOpacity: number, hoverOpacity: number, unselectedOpacity: number, - camera: { x: number; y: number; scale: number } + camera: { x: number; y: number; scale: number }, + highlightedFrameIds: Set | null = null ): Map { const gl = this.#webgl.getContext() const frameProgram = this.#webgl.getProgram() @@ -103,6 +104,16 @@ export class FrameRenderer { opacity = hoverOpacity } + // When a highlight set is active (e.g. search matches), matching + // frames render at full opacity and everything else is dimmed + if (highlightedFrameIds) { + if (highlightedFrameIds.has(node.id) || node.id === selectedFrameId) { + opacity = selectedOpacity + } else { + opacity = unselectedOpacity * 0.3 + } + } + // Store for text rendering frameColors.set(node.id, color) frameOpacities.set(node.id, opacity) diff --git a/tests/search-controls.spec.ts b/tests/search-controls.spec.ts new file mode 100644 index 0000000..baf86c7 --- /dev/null +++ b/tests/search-controls.spec.ts @@ -0,0 +1,170 @@ +import { test, expect } from '@playwright/test' +import { FlameGraphTestUtils } from './test-utils' + +test.describe('SearchControls Component', () => { + test.describe('Rendering', () => { + test('renders search input in FullFlameGraph', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + const container = page.locator('[data-testid="full-flamegraph-container"]') + await expect(container).toBeVisible() + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await expect(searchInput).toBeVisible() + await expect(searchInput).toHaveAttribute('placeholder', 'Search frames') + }) + + test('navigation buttons start disabled with no query', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const prevButton = page.locator('button[title="Previous match"]') + const nextButton = page.locator('button[title="Next match"]') + await expect(prevButton).toBeDisabled() + await expect(nextButton).toBeDisabled() + }) + }) + + test.describe('Functionality', () => { + test('shows match count when typing a query', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('main') + await page.waitForTimeout(500) + + // The mock profile always contains a "main" frame + const matchCount = page.locator('text=/\\d+ match(es)?/') + await expect(matchCount).toBeVisible() + + const nextButton = page.locator('button[title="Next match"]') + await expect(nextButton).toBeEnabled() + }) + + test('shows no matches for unknown query', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('definitely-not-a-function-name') + await page.waitForTimeout(500) + + await expect(page.locator('text=No matches')).toBeVisible() + + const nextButton = page.locator('button[title="Next match"]') + await expect(nextButton).toBeDisabled() + }) + + test('matches by file name', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('main.go') + await page.waitForTimeout(500) + + const matchCount = page.locator('text=/\\d+ match(es)?/') + await expect(matchCount).toBeVisible() + }) + + test('Enter selects the first match and opens stack details', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('main') + await searchInput.press('Enter') + await page.waitForTimeout(500) + + // The counter switches to "N of M" once a match is selected + await expect(page.locator('text=/1 of \\d+/')).toBeVisible() + + // Selecting a frame opens the StackDetails overlay + await expect(page.locator('text=Stack Details')).toBeVisible() + }) + + test('Enter cycles through matches', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('main') + await searchInput.press('Enter') + await page.waitForTimeout(500) + await expect(page.locator('text=/1 of \\d+/')).toBeVisible() + + const countText = await page.locator('text=/1 of \\d+/').textContent() + const totalMatches = parseInt(countText!.match(/1 of (\d+)/)![1]) + + if (totalMatches > 1) { + await searchInput.press('Enter') + await page.waitForTimeout(500) + await expect(page.locator('text=/2 of \\d+/')).toBeVisible() + + // Shift+Enter navigates back + await searchInput.press('Shift+Enter') + await page.waitForTimeout(500) + await expect(page.locator('text=/1 of \\d+/')).toBeVisible() + } + }) + + test('searching highlights matching frames in the flame graph', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const canvas = page.locator('canvas').first() + const before = await canvas.screenshot() + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('main') + await page.waitForTimeout(1000) + + // Non-matching frames are dimmed, so the canvas must render differently + const highlighted = await canvas.screenshot() + expect(highlighted.equals(before)).toBe(false) + + // Clearing the query restores the original rendering + await searchInput.press('Escape') + await page.waitForTimeout(1000) + + const cleared = await canvas.screenshot() + expect(cleared.equals(highlighted)).toBe(false) + }) + + test('Escape clears the query', async ({ page }) => { + const utils = new FlameGraphTestUtils(page) + await utils.navigateToTest({ fullFlameGraph: true }) + + await page.waitForTimeout(1000) + + const searchInput = page.locator('input[type="search"]') + await searchInput.fill('main') + await page.waitForTimeout(500) + + await searchInput.press('Escape') + await page.waitForTimeout(500) + + await expect(searchInput).toHaveValue('') + const nextButton = page.locator('button[title="Next match"]') + await expect(nextButton).toBeDisabled() + }) + }) +})