Compare commits

...

4 Commits

Author SHA1 Message Date
Joe Li
5a198e8b96 test(dashboard): wait on the grid, not a chart, for chart-less dashboards
Hardening waitForChartsToLoad to require a chart grid component broke
theme.spec, whose dashboard is created from dashboard_title/published/
theme_id alone — apiPostDashboard passes the payload straight through, so
that dashboard has no position_json and its grid renders empty. No chart
component ever attaches and the wait times out. Previously the loader-only
check passed vacuously there, which is the same weakness the hardening
removed, so restoring the old call is not an option.

Add waitForGridToLoad for that case: the grid container renders whatever
the grid holds, so it gives the "got past the header" evidence a chart
component cannot. Waits for it attached rather than visible, since an empty
grid collapses to zero height. Extract the shared loader-settle step into a
private helper; waitForChartsToLoad keeps its behavior unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:52:07 -07:00
Joe Li
5822b94b98 test(dashboard): harden dashboard page-object waits
waitForChartsToLoad only checked that no loading indicator was visible.
Loader absence is also the state of a dashboard whose grid has not mounted
yet, so calling it straight after waitForLoad — which resolves on the
header container, rendered well before the grid — returned immediately and
let callers assert against an empty page. Wait for at least one chart grid
component to attach first, so the absence of loaders means "charts
finished" rather than "charts have not begun".

applyFiltersIfEnabled swallowed locator-resolution errors through
`.catch(() => false)`, collapsing a missing Apply button into the same
silent no-op as a disabled one. Wait for the button, then branch on
enabled-ness only.

Also switch waitForLoad, enterEditMode, openHeaderActionsMenu and
openNativeFiltersConfigModal from page.click/page.waitForSelector to the
locator idiom used everywhere else in the file, correct the
blurToDashboardTitle docstring (clicking the title focuses its input in
edit mode — a state change, not a no-op), note that dropTarget only
resolves while the grid is empty, and inline the createDashboard
passthrough in the edit-mode spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:19:21 -07:00
Joe Li
dc7593f978 test(dashboard): address review of edit-mode Playwright helpers
Follow-up to the edit-mode component migration, applying a review of the
new spec, page object and drag-and-drop helper.

Correctness:
- Resolve the dataset through getDatasetByName and create the chart through
  apiPostChart instead of calling page.request directly. Both wrap the request
  in the shared transient-retry helper, which exists because the dev server
  drops connections under concurrent load; the direct call had no such cover.
- The resize assertion was guarded by `if (handleBox && boxBefore)` while only
  handleBox was asserted non-null, so a null bounding box silently skipped the
  assertion and the test still passed. DashboardPage.resizeComponent now throws
  on a missing box and returns the before/after heights for the spec to assert.
- Replace the deprecated Locator.type() with pressSequentially().

Reuse over duplication:
- Drop the local dashboard-creation helper in favour of createTestDashboard,
  which already handles both response shapes and cleanup registration; it gains
  an optional `published` flag. Chart ids come from extractIdFromResponse.
- Generated names use the worker's parallel index, matching the convention in
  the sibling helpers, rather than a performance.now() suffix.

Page object and readability:
- Move the ace, resize-handle and title selectors out of the spec behind
  DashboardPage accessors. These stay locators rather than routing through the
  AceEditor component, whose setText/getText work through the ace API and so
  would bypass the keystroke path under test and lose web-first retries.
- Name the react-dnd settle delay and the markdown retry budgets.
- Make openBuilderTab private, drop the unused deleteChartHolder index, remove
  the unused GRID_CONTENT selector, correct the dropTarget docstring, type
  layout-element labels as a union, and prefix the locator getters with `get`
  to match the rest of the class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:55:18 -07:00
Joe Li
8435a9ca42 test(dashboard): migrate edit-mode component E2E tests to Playwright
Port the Cypress dashboard edit-mode 'Components' tests (add chart, remove
chart, add markdown) to Playwright as true E2E. The dashboard grid uses
react-dnd with the HTML5 backend, so drags are driven by synthetic native
drag events with a shared DataTransfer (new helpers/dnd.ts). Tests are
hermetic: charts/dashboards are created from birth_names via the API and
cleaned up by the testAssets fixture.

The 21 skipped 'Color consistency' Cypress tests are intentionally not
ported: they assert per-series colors via an .nv-legend-symbol SVG fill that
no longer exists (ECharts renders to canvas, not DOM-inspectable); that
color-precedence logic is covered by Jest/RTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:06:24 -07:00
5 changed files with 572 additions and 22 deletions

View File

@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Unconditional pause between drag events, letting react-dnd's HTML5 backend
* commit its monitor state before the next event fires. Deliberately not in
* `TIMEOUT`: that object holds wait *ceilings* (a wait may finish sooner),
* whereas this is a fixed sleep that always costs what it says.
*/
const REACT_DND_SETTLE_MS = 50;
/**
* Drives an HTML5 drag-and-drop using synthetic native drag events.
*
* The dashboard grid uses react-dnd with the HTML5 backend
* (`react-dnd-html5-backend`), which listens for native `dragstart` /
* `dragenter` / `dragover` / `drop` events rather than the mouse events that
* Playwright's built-in `locator.dragTo()` produces. To trigger it we dispatch
* the native drag sequence ourselves, threading a single shared `DataTransfer`
* object through every event so react-dnd's monitor sees a consistent payload.
*
* Mirrors the synthetic-event sequence used by the deprecated Cypress `drag`
* helper (cypress-base/cypress/utils/index.ts).
*
* @param page - Playwright page (used to mint the shared DataTransfer)
* @param source - The draggable element (or a descendant; drag events bubble)
* @param target - The drop target element
*/
export async function html5DragAndDrop(
page: Page,
source: Locator,
target: Locator,
): Promise<void> {
// Note: we intentionally do not scrollIntoView the source. The chart card list
// is virtualized, so a separate scroll action can detach the element between
// resolution and use; dispatchEvent only requires the node to be attached.
// A single DataTransfer shared across every event in the sequence: react-dnd's
// HTML5 backend reads/writes drag state through it, so reusing one handle is
// what makes the monitor treat this as one coherent drag.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await source.dispatchEvent('dragstart', { dataTransfer });
// react-dnd's HTML5 backend commits monitor state (the active drag source) on a
// microtask after dragstart; a short settle avoids a race where dragover/drop
// fire before the backend considers a drag to be in progress.
await page.waitForTimeout(REACT_DND_SETTLE_MS);
// dragenter must precede dragover for react-dnd to register the hover target.
await target.dispatchEvent('dragenter', { dataTransfer });
await target.dispatchEvent('dragover', { dataTransfer });
await page.waitForTimeout(REACT_DND_SETTLE_MS);
await target.dispatchEvent('drop', { dataTransfer });
await source.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose();
}

View File

@@ -18,11 +18,23 @@
*/
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { Button, Input, Menu, Tabs } from '../components/core';
import { NativeFiltersConfigModal } from '../components/modals';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
/** Tabs of the dashboard builder side pane, by their rendered label. */
type BuilderTab = 'Charts' | 'Layout elements';
/**
* Built-in draggable layout elements, by their rendered label (see
* `src/dashboard/components/gridComponents/new/`). Extension-provided elements
* carry dynamic names and are not covered here.
*/
type LayoutElementLabel =
'Tabs' | 'Row' | 'Column' | 'Header' | 'Text / Markdown' | 'Divider';
/**
* Dashboard Page object for interacting with dashboards.
*/
@@ -31,12 +43,28 @@ export class DashboardPage {
private static readonly SELECTORS = {
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
CHART_GRID_COMPONENT: '[data-test="chart-grid-component"]',
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
// The header-actions-menu is the data-test for the dropdown menu content
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
FILTER_BAR_SETTINGS: '[data-test="filterbar-orientation-icon"]',
APPLY_FILTERS_BUTTON:
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
EDIT_BUTTON: '[data-test="edit-dashboard-button"]',
BUILDER_PANE: '[data-test="dashboard-builder-sidepane"]',
CHARTS_SEARCH: '[data-test="dashboard-charts-filter-search-input"]',
CHART_CARD: '[data-test="chart-card"]',
EMPTY_DROPTARGET: '[data-test="grid-content"] .empty-droptarget',
NEW_COMPONENT: '[data-test="new-component"]',
CHART_HOLDER: '[data-test="dashboard-component-chart-holder"]',
GRID_CONTENT: '[data-test="grid-content"]',
DELETE_COMPONENT: '[data-test="dashboard-delete-component-button"]',
MARKDOWN_EDITOR: '[data-test="dashboard-markdown-editor"]',
EDITABLE_TITLE: '[data-test="editable-title-input"]',
// Ace exposes no data-test hooks; these are its own stable DOM classes.
ACE_CONTENT: '.ace_content',
ACE_TEXT_INPUT: '.ace_text-input',
RESIZE_HANDLE_BOTTOM: '.resizable-container-handle--bottom',
} as const;
constructor(page: Page) {
@@ -61,12 +89,16 @@ export class DashboardPage {
/**
* Wait for the dashboard header to be visible.
*
* The header container renders well before the grid does, so this only
* establishes that the dashboard route mounted — pair it with
* {@link waitForChartsToLoad} before asserting on chart content.
*/
async waitForLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
await this.page.waitForSelector(DashboardPage.SELECTORS.DASHBOARD_HEADER, {
timeout,
});
await this.page
.locator(DashboardPage.SELECTORS.DASHBOARD_HEADER)
.waitFor({ state: 'visible', timeout });
}
/**
@@ -74,21 +106,71 @@ export class DashboardPage {
*/
getChart(chartId: number): Locator {
return this.page.locator(
`[data-test="chart-grid-component"][data-test-chart-id="${chartId}"]`,
`${DashboardPage.SELECTORS.CHART_GRID_COMPONENT}[data-test-chart-id="${chartId}"]`,
);
}
/**
* Wait for all charts on the dashboard to finish loading.
* Waits until no loading indicators are visible on the page.
* Wait for the dashboard's charts to mount and finish loading.
*
* Waiting only for loading indicators to clear is not enough: the grid mounts
* its spinners after the header renders, so a "no visible loader" check
* called straight after {@link waitForLoad} passes instantly against a
* dashboard that has not started rendering anything. Waiting for at least one
* chart grid component first makes the absence of loaders mean "charts
* finished" rather than "charts have not begun".
*
* Only for dashboards that have charts — on an empty one this waits out
* `timeout` rather than returning. Use {@link waitForGridToLoad} there.
*/
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
// Use browser-context evaluation to check visibility directly.
// Loading indicators ([aria-label="Loading"]) may persist in the DOM as hidden
// elements after charts finish loading. This checks that none are currently visible,
// returning immediately when charts are already loaded (no timeout penalty).
await this.page
.locator(DashboardPage.SELECTORS.CHART_GRID_COMPONENT)
.first()
.waitFor({ state: 'attached', timeout });
await this.waitForLoadersToSettle(timeout);
}
/**
* Wait for the dashboard grid to mount and any loading indicators to clear.
*
* The counterpart to {@link waitForChartsToLoad} for a dashboard with no
* charts on it: the grid container renders whatever the grid holds, so it
* gives the "the page got past the header" evidence that a chart component
* cannot. Prefer {@link waitForChartsToLoad} whenever charts are expected —
* this cannot tell a grid that rendered empty from one whose charts have not
* begun rendering.
*/
async waitForGridToLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
// Attached rather than visible: an empty grid collapses to zero height,
// which Playwright counts as not visible.
await this.page
.locator(DashboardPage.SELECTORS.GRID_CONTENT)
.first()
.waitFor({ state: 'attached', timeout });
await this.waitForLoadersToSettle(timeout);
}
/**
* Resolve once no loading indicator is visible.
*
* Uses browser-context evaluation to check visibility directly. Loading
* indicators ([aria-label="Loading"]) may persist in the DOM as hidden
* elements after charts finish loading, so this checks that none are
* currently visible, returning immediately when they are already settled (no
* timeout penalty).
*
* Loader absence is also the state of a dashboard that has not started
* rendering, which is why every caller pairs this with a wait for the content
* it expects.
*/
private async waitForLoadersToSettle(timeout: number): Promise<void> {
await this.page.waitForFunction(
() => {
const loaders = document.querySelectorAll('[aria-label="Loading"]');
@@ -129,7 +211,9 @@ export class DashboardPage {
* Opens the native filters and Display Controls configuration modal.
*/
async openNativeFiltersConfigModal(): Promise<NativeFiltersConfigModal> {
await this.page.click(DashboardPage.SELECTORS.FILTER_BAR_SETTINGS);
await this.page
.locator(DashboardPage.SELECTORS.FILTER_BAR_SETTINGS)
.click();
await this.page
.getByText('Add or edit filters and controls', { exact: true })
.click();
@@ -141,12 +225,17 @@ export class DashboardPage {
/**
* Applies pending native filter changes when the Apply button is enabled.
*
* A disabled button means there is nothing pending, which is a valid state to
* skip. A *missing* button is not — the button is waited for rather than
* having its absence collapse into the same no-op as "nothing to apply".
*/
async applyFiltersIfEnabled(): Promise<void> {
const applyButton = this.page
.locator(DashboardPage.SELECTORS.APPLY_FILTERS_BUTTON)
.first();
if (!(await applyButton.isEnabled().catch(() => false))) {
await applyButton.waitFor({ state: 'attached' });
if (!(await applyButton.isEnabled())) {
return;
}
@@ -157,14 +246,13 @@ export class DashboardPage {
* Open the dashboard header actions menu (three-dot menu)
*/
async openHeaderActionsMenu(): Promise<void> {
await this.page.click(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER);
await this.page
.locator(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER)
.click();
// Wait for the dropdown menu to appear
await this.page.waitForSelector(
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
{
state: 'visible',
},
);
await this.page
.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU)
.waitFor({ state: 'visible' });
}
/**
@@ -199,4 +287,192 @@ export class DashboardPage {
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
/**
* Enter dashboard edit mode and wait for the builder side pane to appear.
*/
async enterEditMode(): Promise<void> {
const editButton = new Button(
this.page,
DashboardPage.SELECTORS.EDIT_BUTTON,
);
await editButton.click();
await this.page
.locator(DashboardPage.SELECTORS.BUILDER_PANE)
.waitFor({ state: 'visible' });
}
/**
* The builder side pane's tab bar (Charts / Layout elements).
*/
private builderTabs(): Tabs {
return new Tabs(
this.page,
this.page
.locator(`${DashboardPage.SELECTORS.BUILDER_PANE} .ant-tabs`)
.first(),
);
}
/**
* Switch the builder side pane to one of its tabs.
* @param tab - 'Charts' (existing slices) or 'Layout elements' (new components)
*/
private async openBuilderTab(tab: BuilderTab): Promise<void> {
await this.builderTabs().clickTab(tab);
}
/**
* Locator for chart-holder components currently placed on the grid.
* Markdown components are chart holders too — use
* {@link getMarkdownEditors} when the assertion must exclude them.
*/
getChartHolders(): Locator {
return this.page.locator(DashboardPage.SELECTORS.CHART_HOLDER);
}
/**
* Drag an existing chart from the Charts pane onto the dashboard grid.
* Requires edit mode to be active.
* @param sliceName - The slice name to search for and drag
*/
async addChartByName(sliceName: string): Promise<void> {
await this.openBuilderTab('Charts');
const search = new Input(this.page, DashboardPage.SELECTORS.CHARTS_SEARCH);
await search.fill(sliceName);
const card = this.page
.locator(DashboardPage.SELECTORS.CHART_CARD)
.filter({ hasText: sliceName })
.first();
await card.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, card, this.dropTarget());
}
/**
* Drag a new Layout element (by its label) onto the dashboard grid.
* Requires edit mode to be active.
* @param label - The new-component label, e.g. 'Text / Markdown'
*/
async addLayoutElement(label: LayoutElementLabel): Promise<void> {
await this.openBuilderTab('Layout elements');
const source = this.page
.locator(DashboardPage.SELECTORS.NEW_COMPONENT)
.filter({ hasText: label })
.first();
await source.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, source, this.dropTarget());
}
/**
* The grid's empty drop target, which the grid renders while in edit mode.
*
* Only resolves while the grid is still empty. Dropping a second component
* needs a target relative to the already-placed one, not this.
*/
private dropTarget(): Locator {
return this.page.locator(DashboardPage.SELECTORS.EMPTY_DROPTARGET).first();
}
/**
* Hover the first placed chart-holder and click its delete button (edit mode).
*/
async deleteChartHolder(): Promise<void> {
const holder = this.getChartHolders().first();
await holder.hover();
const deleteButton = new Button(
this.page,
holder.locator(DashboardPage.SELECTORS.DELETE_COMPONENT),
);
await deleteButton.click();
}
/**
* Locator for markdown editor components on the grid.
*/
getMarkdownEditors(): Locator {
return this.page.locator(DashboardPage.SELECTORS.MARKDOWN_EDITOR);
}
/**
* The rendered ace document inside a markdown component. Present only once
* the component has entered its editing state.
*
* Exposed as a locator rather than routed through the `AceEditor` component:
* that component reads and writes through `ace.edit(...)` in page context,
* which both bypasses the real keystroke path under test and gives up
* web-first retries on assertions.
*
* @param markdownEditor - A locator from {@link getMarkdownEditors}
*/
getMarkdownAceContent(markdownEditor: Locator): Locator {
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_CONTENT);
}
/**
* Ace's hidden textarea inside a markdown component — the element that
* receives keystrokes.
*
* @param markdownEditor - A locator from {@link getMarkdownEditors}
*/
getMarkdownAceInput(markdownEditor: Locator): Locator {
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_TEXT_INPUT);
}
/**
* Click the dashboard title, moving focus off whichever grid component holds
* it. Committing a markdown edit needs a click on some other element, and the
* title is the one that is always present regardless of what is on the grid.
*
* In edit mode the click focuses the title's input. That is a state change,
* not a no-op — but it edits nothing on its own, so it leaves the component
* under test untouched.
*/
async blurToDashboardTitle(): Promise<void> {
await this.page
.locator(DashboardPage.SELECTORS.EDITABLE_TITLE)
.first()
.click();
}
/**
* Drag a grid component's bottom resize handle down by `deltaY` pixels.
* Requires edit mode. Uses the mouse because the resize handle is driven by
* `react-resizable`, which tracks real pointer movement.
*
* @param component - The grid component to resize
* @param deltaY - Pixels to drag downwards (positive grows the component)
* @returns The component's height before and after the drag
*/
async resizeComponent(
component: Locator,
deltaY: number,
): Promise<{ heightBefore: number; heightAfter: number }> {
const boxBefore = await component.boundingBox();
if (!boxBefore) {
throw new Error('Cannot resize a component that is not visible');
}
const handle = component
.locator(DashboardPage.SELECTORS.RESIZE_HANDLE_BOTTOM)
.last();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error('Resize handle is not visible');
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await this.page.mouse.move(startX, startY);
await this.page.mouse.down();
// Multiple steps so react-resizable sees a drag rather than a teleport.
await this.page.mouse.move(startX, startY + deltaY, { steps: 10 });
await this.page.mouse.up();
const boxAfter = await component.boundingBox();
if (!boxAfter) {
throw new Error('Component disappeared during resize');
}
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
}
}

View File

@@ -62,6 +62,8 @@ interface TestDashboardResult {
interface CreateTestDashboardOptions {
/** Prefix for generated name (default: 'test_dashboard') */
prefix?: string;
/** Publish the dashboard on creation (default: false, the API default) */
published?: boolean;
}
/**
@@ -86,6 +88,7 @@ export async function createTestDashboard(
const response = await apiPostDashboard(page, {
dashboard_title: name,
...(options?.published !== undefined && { published: options.published }),
});
if (!response.ok()) {

View File

@@ -0,0 +1,195 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Dashboard edit-mode component tests — migrated from the deprecated Cypress
* suite (cypress-base/cypress/e2e/dashboard/editmode.test.ts, "Components"
* block). These cover the chart/markdown drag-and-drop workflows that the
* upstream Cypress notes flagged as the one part of edit mode that genuinely
* requires E2E coverage ("Chart drag/drop functionality requires true E2E
* testing"). The grid uses react-dnd with the HTML5 backend, so drags are
* driven by synthetic native drag events (see helpers/dnd.ts).
*
* The 21 skipped "Color consistency" tests from the same Cypress file are NOT
* migrated here: they assert per-series colors by reading an `.nv-legend-symbol`
* SVG `fill` attribute that no longer exists (ECharts renders to <canvas>, which
* is not DOM-inspectable — the upstream FIXME skipped them for exactly this
* reason). The underlying color-precedence logic is covered by Jest/RTL.
*/
import {
testWithAssets,
expect,
type TestAssets,
} from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import { createTestDashboard } from './dashboard-test-helpers';
import type { Page, TestInfo } from '@playwright/test';
const DATASET_NAME = 'birth_names';
/**
* How long one click on the markdown component gets to bring up the ace editor
* before the retry loop tries again, and how long the whole loop gets. The
* per-attempt budget is deliberately short: the failure mode is a swallowed
* click, and retrying is cheaper than waiting out the full budget once.
*/
const MARKDOWN_EDIT_ATTEMPT_TIMEOUT = 2000;
const MARKDOWN_EDIT_TOTAL_TIMEOUT = 20000;
/** Downward drag distance for the resize assertion — several grid rows. */
const RESIZE_DELTA_PX = 150;
/** Create a hermetic chart from birth_names, NOT placed on any dashboard. */
async function createChart(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
): Promise<string> {
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const sliceName = `edit_mode_chart_${Date.now()}_${testInfo.parallelIndex}`;
const resp = await apiPostChart(page, {
slice_name: sliceName,
viz_type: 'big_number_total',
datasource_id: dataset.id,
datasource_type: 'table',
params: JSON.stringify({
datasource: `${dataset.id}__table`,
viz_type: 'big_number_total',
metric: 'count',
}),
});
expect(resp.ok()).toBe(true);
testAssets.trackChart(await extractIdFromResponse(resp));
return sliceName;
}
/** Options for the empty published dashboard every test in this file starts from. */
const DASHBOARD_OPTIONS = { prefix: 'edit_mode', published: true } as const;
testWithAssets(
'edit mode: add a chart to the dashboard via drag-and-drop',
async ({ page, testAssets }, testInfo) => {
const sliceName = await createChart(page, testAssets, testInfo);
const { id: dashboardId } = await createTestDashboard(
page,
testAssets,
testInfo,
DASHBOARD_OPTIONS,
);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await expect(dashboard.getChartHolders()).toHaveCount(0);
await dashboard.addChartByName(sliceName);
await expect(dashboard.getChartHolders()).toHaveCount(1);
},
);
testWithAssets(
'edit mode: remove an added chart from the dashboard',
async ({ page, testAssets }, testInfo) => {
const sliceName = await createChart(page, testAssets, testInfo);
const { id: dashboardId } = await createTestDashboard(
page,
testAssets,
testInfo,
DASHBOARD_OPTIONS,
);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await dashboard.addChartByName(sliceName);
await expect(dashboard.getChartHolders()).toHaveCount(1);
await dashboard.deleteChartHolder();
await expect(dashboard.getChartHolders()).toHaveCount(0);
},
);
testWithAssets(
'edit mode: add a markdown component via drag-and-drop',
async ({ page, testAssets }, testInfo) => {
// Heaviest edit-mode flow (drag + ace edit + commit + mouse resize); give it
// extra headroom so it stays reliable when the suite runs in parallel.
testWithAssets.slow();
const { id: dashboardId } = await createTestDashboard(
page,
testAssets,
testInfo,
DASHBOARD_OPTIONS,
);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await dashboard.addLayoutElement('Text / Markdown');
const editor = dashboard.getMarkdownEditors().first();
await expect(editor).toBeVisible();
// Enter edit mode by focusing the component. The markdown enters edit on a
// document-level focus handler attached after mount, so a single early click
// can be missed under load; retry until the ace editor appears. Click the
// rendered "Header 1" heading element specifically (never the trailing
// hyperlink in the default content), so a stray click can't navigate away.
const aceContent = dashboard.getMarkdownAceContent(editor);
const heading = editor.locator('h1', { hasText: 'Header 1' });
await expect(async () => {
if (await aceContent.isVisible()) return;
await heading.click();
await expect(aceContent).toBeVisible({
timeout: MARKDOWN_EDIT_ATTEMPT_TIMEOUT,
});
}).toPass({ timeout: MARKDOWN_EDIT_TOTAL_TIMEOUT });
await expect(aceContent).toContainText('Header 1');
await expect(aceContent).toContainText('markdown formatting');
// Replace the content and confirm the edit is reflected.
const aceInput = dashboard.getMarkdownAceInput(editor);
await aceInput.press('ControlOrMeta+a');
await aceInput.press('Delete');
await aceInput.pressSequentially('Test resize');
await expect(aceContent).toContainText('Test resize');
// Commit by clicking outside the component; the preview keeps the text.
await dashboard.blurToDashboardTitle();
await expect(editor).toContainText('Test resize');
// Resize via the bottom handle and confirm the component grew taller.
const { heightBefore, heightAfter } = await dashboard.resizeComponent(
editor,
RESIZE_DELTA_PX,
);
expect(heightAfter).toBeGreaterThan(heightBefore);
},
);

View File

@@ -152,9 +152,11 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
const dashboardPage = new DashboardPage(page);
await dashboardPage.gotoById(dashboardId!);
// 7. Assert dashboard fully loads (not stuck on infinite spinner)
// 7. Assert dashboard fully loads (not stuck on infinite spinner).
// The dashboard is created with no position_json, so its grid renders
// empty — there is no chart to wait for, only the grid itself.
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
await dashboardPage.waitForChartsToLoad();
await dashboardPage.waitForGridToLoad();
// 8. Assert no /api/v1/theme/ requests were made (theme data comes from dashboard response)
expect(themeApiRequests).toHaveLength(0);