From d2068d332cb29eae29fce645506261f277ff5091 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 16:31:16 +0000 Subject: [PATCH 1/2] test(e2e): cover heatmap drag-select persistence (HDX-4147) Adds Playwright regression coverage for the bug fixed in PR #2189: the dashed drag-select rectangle on the Event Deltas heatmap collapsed to a 2x2 px residue after mouseup unless u.select is mirrored from the URL state. Three scenarios in one spec: 1. Drag-select draws a persistent rectangle and writes URL state. Asserts URL gains xMin/xMax/yMin/yMax AND the .u-select element has width/height > 20 px, well above the 2x2 px collapse signature. 2. Reloading the page restores the rectangle from URL state. This is what specifically catches the ready-vs-onCreate timing issue: at onCreate u.scales.y.min/max aren't populated for mode-2 facet data, so the apply must run from uPlot's ready hook. The test reloads after a drag and asserts the rectangle returns with the same dimensions. 3. Clicking off the rectangle clears both URL state and the rectangle (collapses below 5 px on each axis). New SearchPage methods: openEventDeltasMode, getHeatmap, getHeatmapSelectionRect, dragHeatmapSelection. The drag helper spans both axes so the resulting selection has non-zero width AND height (uPlot treats single-axis drags as zero-size and skips the hook). --- .../search/event-deltas-heatmap.spec.ts | 117 ++++++++++++++++++ .../app/tests/e2e/page-objects/SearchPage.ts | 62 ++++++++++ 2 files changed, 179 insertions(+) create mode 100644 packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts diff --git a/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts new file mode 100644 index 0000000000..c90f7bbb03 --- /dev/null +++ b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts @@ -0,0 +1,117 @@ +import { SearchPage } from '../../page-objects/SearchPage'; +import { DEFAULT_TRACES_SOURCE_NAME } from '../../utils/constants'; +import { expect, test } from '../../utils/base-test'; + +/** + * Regression coverage for HDX-4147 (PR #2189): the dashed drag-select + * rectangle on the Event Deltas heatmap collapses to a 2x2 px residue + * after mouseup unless u.select is mirrored from the URL state. These + * tests assert the visible rectangle survives the round-trip through + * the URL (drag, reload, click-to-clear). + */ +test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { + let searchPage: SearchPage; + + test.beforeEach(async ({ page }) => { + searchPage = new SearchPage(page); + await searchPage.goto(); + await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME); + await searchPage.openEventDeltasMode(); + }); + + test('drag-select draws a persistent rectangle and writes URL state', async () => { + await searchPage.dragHeatmapSelection(); + + // URL gains the four selection params nuqs writes via setFields. + const url = searchPage.page.url(); + expect(url, 'URL should carry xMin').toContain('xMin='); + expect(url, 'URL should carry xMax').toContain('xMax='); + expect(url, 'URL should carry yMin').toContain('yMin='); + expect(url, 'URL should carry yMax').toContain('yMax='); + + // The dashed rectangle stays visible after mouseup. The bug shipped + // a 2x2 px collapse pinned to (0, 0); a healthy selection has both + // dimensions well above that residue. + const rect = await searchPage.getHeatmapSelectionRect().boundingBox(); + expect(rect, 'selection element should be in the DOM').not.toBeNull(); + expect(rect!.width, 'selection width should reflect the drag').toBeGreaterThan(20); + expect(rect!.height, 'selection height should reflect the drag').toBeGreaterThan(20); + }); + + test('reloading the page restores the rectangle from URL state', async () => { + await searchPage.dragHeatmapSelection(); + + // Capture the rectangle right after the drag for cross-check. + const beforeReload = await searchPage + .getHeatmapSelectionRect() + .boundingBox(); + expect(beforeReload).not.toBeNull(); + expect(beforeReload!.width).toBeGreaterThan(20); + + // Round-trip through the URL: a fresh page load goes through the + // uPlot ready hook path, which is what the on-create path got wrong + // before the fix (scales aren't populated for mode-2 facet data + // until the first draw). + await searchPage.page.reload(); + await searchPage.getHeatmap().waitFor({ state: 'visible' }); + + const afterReload = await searchPage + .getHeatmapSelectionRect() + .boundingBox(); + expect(afterReload, 'selection element should be in the DOM after reload').not.toBeNull(); + expect( + afterReload!.width, + 'rectangle width should be restored from URL on reload', + ).toBeGreaterThan(20); + expect( + afterReload!.height, + 'rectangle height should be restored from URL on reload', + ).toBeGreaterThan(20); + + // Coordinates round-trip with sub-pixel accuracy (small rounding + // from log-space conversion is acceptable). + expect(afterReload!.width).toBeCloseTo(beforeReload!.width, 0); + expect(afterReload!.height).toBeCloseTo(beforeReload!.height, 0); + }); + + test('clicking off the rectangle clears both URL state and the rectangle', async () => { + await searchPage.dragHeatmapSelection(); + + // Sanity: the drag set the URL state. + expect(searchPage.page.url()).toContain('xMin='); + + // Click somewhere on the chart canvas that isn't inside the + // selection. Top edge of the canvas is far enough from the mid-band + // selection drawn by dragHeatmapSelection() defaults. + const heatmapBox = await searchPage.getHeatmap().boundingBox(); + if (!heatmapBox) { + throw new Error('Heatmap not found'); + } + await searchPage.page.mouse.click( + heatmapBox.x + heatmapBox.width * 0.9, + heatmapBox.y + heatmapBox.height * 0.05, + ); + + // URL params drop out (nuqs serializes a null value to no key). + await expect + .poll(() => searchPage.page.url(), { + message: 'xMin should be cleared from URL', + }) + .not.toContain('xMin='); + + const afterClear = await searchPage + .getHeatmapSelectionRect() + .boundingBox(); + expect(afterClear, 'selection element should still be in the DOM').not.toBeNull(); + // uPlot resets u.select to width=0/height=0; the element collapses + // to its 1 px border on each side (2x2 with the border included). + expect( + afterClear!.width, + 'rectangle width should collapse on clear', + ).toBeLessThan(5); + expect( + afterClear!.height, + 'rectangle height should collapse on clear', + ).toBeLessThan(5); + }); +}); diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts index 60c4d93a00..e979442f4f 100644 --- a/packages/app/tests/e2e/page-objects/SearchPage.ts +++ b/packages/app/tests/e2e/page-objects/SearchPage.ts @@ -295,6 +295,68 @@ export class SearchPage { await this.page.mouse.up(); } + /** + * Switch the search page into Event Deltas mode and wait for the + * heatmap chart to render. Event Deltas is the analysis-mode tab + * that puts the latency-vs-time heatmap on screen. + */ + async openEventDeltasMode() { + const tab = this.page.getByRole('tab', { name: 'Event Deltas' }); + await tab.click(); + await this.getHeatmap().waitFor({ + state: 'visible', + timeout: this.defaultTimeout * 2, + }); + } + + /** + * Get the uPlot heatmap canvas wrapper. The Event Deltas page renders + * a single uPlot chart; first match disambiguates from any other charts + * that might appear on the page. + */ + getHeatmap() { + return this.page.locator('.uplot').first(); + } + + /** + * Get the dashed selection rectangle inside the heatmap. uPlot writes + * `left`/`top`/`width`/`height` into its inline style; tests assert + * against the bounding box. + */ + getHeatmapSelectionRect() { + return this.getHeatmap().locator('.u-select'); + } + + /** + * Drag a region on the heatmap canvas. Coordinates are percentages of + * the canvas (0-1). The drag spans both axes so the resulting selection + * has non-zero width AND height (zero on either axis is treated as a + * single-click by uPlot and produces no selection). + */ + async dragHeatmapSelection( + startXPercent: number = 0.25, + startYPercent: number = 0.3, + endXPercent: number = 0.7, + endYPercent: number = 0.7, + ) { + const heatmap = this.getHeatmap(); + const box = await heatmap.boundingBox(); + + if (!box) { + throw new Error('Heatmap not found'); + } + + const startX = box.x + box.width * startXPercent; + const startY = box.y + box.height * startYPercent; + const endX = box.x + box.width * endXPercent; + const endY = box.y + box.height * endYPercent; + + await this.page.mouse.move(startX, startY); + await this.page.mouse.down(); + await this.page.mouse.move(endX, endY, { steps: 10 }); + await this.page.mouse.up(); + } + // Getters for assertions in spec files get form() { From d821b03d18c48080f046551a481a9292245ded48 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 16:56:12 +0000 Subject: [PATCH 2/2] test(e2e): poll URL and bounding-box reads in heatmap drag-select spec nuqs setFields and uPlot's ready hook both flush asynchronously after mouseup and after page reload. The synchronous reads at line 27 (URL params) and line 35/64 (post-reload boundingBox) raced with the flush and failed in CI shard 2 across all 3 retries: the URL never carried xMin= within the test window. Wrap each URL read in expect.poll(...) per param and gate the rect dimension reads on a polled width > 20 (or < 5 for the post-clear collapse). Capture rect once after polling settles, then assert the remaining dimensions and the cross-check round-trip. Also fixes lint: simple-import-sort/imports, prettier multi-line arg formatting on assertion messages. --- .../search/event-deltas-heatmap.spec.ts | 83 ++++++++++++++----- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts index c90f7bbb03..eb91a16af6 100644 --- a/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts +++ b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts @@ -1,6 +1,6 @@ import { SearchPage } from '../../page-objects/SearchPage'; -import { DEFAULT_TRACES_SOURCE_NAME } from '../../utils/constants'; import { expect, test } from '../../utils/base-test'; +import { DEFAULT_TRACES_SOURCE_NAME } from '../../utils/constants'; /** * Regression coverage for HDX-4147 (PR #2189): the dashed drag-select @@ -8,6 +8,10 @@ import { expect, test } from '../../utils/base-test'; * after mouseup unless u.select is mirrored from the URL state. These * tests assert the visible rectangle survives the round-trip through * the URL (drag, reload, click-to-clear). + * + * nuqs setFields and uPlot's ready hook both flush asynchronously, so + * URL and bounding-box reads use expect.poll rather than one-shot + * reads. */ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { let searchPage: SearchPage; @@ -23,30 +27,48 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { await searchPage.dragHeatmapSelection(); // URL gains the four selection params nuqs writes via setFields. - const url = searchPage.page.url(); - expect(url, 'URL should carry xMin').toContain('xMin='); - expect(url, 'URL should carry xMax').toContain('xMax='); - expect(url, 'URL should carry yMin').toContain('yMin='); - expect(url, 'URL should carry yMax').toContain('yMax='); + for (const param of ['xMin=', 'xMax=', 'yMin=', 'yMax=']) { + await expect + .poll(() => searchPage.page.url(), { + message: `URL should carry ${param.slice(0, -1)}`, + }) + .toContain(param); + } // The dashed rectangle stays visible after mouseup. The bug shipped // a 2x2 px collapse pinned to (0, 0); a healthy selection has both // dimensions well above that residue. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + { message: 'selection width should reflect the drag' }, + ) + .toBeGreaterThan(20); const rect = await searchPage.getHeatmapSelectionRect().boundingBox(); expect(rect, 'selection element should be in the DOM').not.toBeNull(); - expect(rect!.width, 'selection width should reflect the drag').toBeGreaterThan(20); - expect(rect!.height, 'selection height should reflect the drag').toBeGreaterThan(20); + expect( + rect!.height, + 'selection height should reflect the drag', + ).toBeGreaterThan(20); }); test('reloading the page restores the rectangle from URL state', async () => { await searchPage.dragHeatmapSelection(); - // Capture the rectangle right after the drag for cross-check. + // Wait for the rectangle to settle, then capture it for cross-check. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + ) + .toBeGreaterThan(20); const beforeReload = await searchPage .getHeatmapSelectionRect() .boundingBox(); expect(beforeReload).not.toBeNull(); - expect(beforeReload!.width).toBeGreaterThan(20); // Round-trip through the URL: a fresh page load goes through the // uPlot ready hook path, which is what the on-create path got wrong @@ -55,14 +77,23 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { await searchPage.page.reload(); await searchPage.getHeatmap().waitFor({ state: 'visible' }); + // The ready hook fires after the first draw, which is async relative + // to the canvas being visible; poll until the rectangle re-renders. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + { message: 'rectangle width should be restored from URL on reload' }, + ) + .toBeGreaterThan(20); const afterReload = await searchPage .getHeatmapSelectionRect() .boundingBox(); - expect(afterReload, 'selection element should be in the DOM after reload').not.toBeNull(); expect( - afterReload!.width, - 'rectangle width should be restored from URL on reload', - ).toBeGreaterThan(20); + afterReload, + 'selection element should be in the DOM after reload', + ).not.toBeNull(); expect( afterReload!.height, 'rectangle height should be restored from URL on reload', @@ -77,8 +108,8 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { test('clicking off the rectangle clears both URL state and the rectangle', async () => { await searchPage.dragHeatmapSelection(); - // Sanity: the drag set the URL state. - expect(searchPage.page.url()).toContain('xMin='); + // Sanity: the drag set the URL state. nuqs flushes async, so poll. + await expect.poll(() => searchPage.page.url()).toContain('xMin='); // Click somewhere on the chart canvas that isn't inside the // selection. Top edge of the canvas is far enough from the mid-band @@ -99,16 +130,22 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { }) .not.toContain('xMin='); - const afterClear = await searchPage - .getHeatmapSelectionRect() - .boundingBox(); - expect(afterClear, 'selection element should still be in the DOM').not.toBeNull(); // uPlot resets u.select to width=0/height=0; the element collapses // to its 1 px border on each side (2x2 with the border included). + // The collapse goes through React state, so poll. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + { message: 'rectangle width should collapse on clear' }, + ) + .toBeLessThan(5); + const afterClear = await searchPage.getHeatmapSelectionRect().boundingBox(); expect( - afterClear!.width, - 'rectangle width should collapse on clear', - ).toBeLessThan(5); + afterClear, + 'selection element should still be in the DOM', + ).not.toBeNull(); expect( afterClear!.height, 'rectangle height should collapse on clear',