Compare commits

...
Author SHA1 Message Date
Joe LiandClaude Sonnet 5 1d8ac94e14 test(dashboard): drop migration-history framing from url-key spec comment
The Cypress-suite-bug narrative was migration trivia with no lasting
value; state the durable facts directly (reuse contract, goto-vs-reload,
cache/worker determinism) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:51:35 -07:00
Joe LiandClaude Sonnet 5 fdc71e3e6a test(dashboard): trim restated-what filler from native-filter url-key spec comment
The header comment mixed genuinely non-obvious "why" notes (the inherited
Cypress bug this test intentionally does not reproduce, the goto-vs-reload
distinction, the SimpleCache/single-worker determinism requirement) with
provenance and a step-by-step restatement of what the test body already
shows. Keep only the three non-obvious points.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:45:40 -07:00
Joe LiandClaude Fable 5.1 166d395a1e test(dashboard): reuse shared builders and page-object helpers in native-filter url-key spec
Rework the Playwright native filter URL key spec to reuse existing
infrastructure instead of bespoke setup:

- Build the dashboard through createDashboardWithCharts, adding an
  optional buildJsonMetadata hook so specs can attach native filter
  metadata without hand-rolling dashboard creation.
- Add apiGetDashboardFilterState to the dashboard API helpers.
- Rewrite DashboardPage.waitForNativeFiltersKey on page.waitForURL to
  avoid driver-side URL lag; drop the unused getNativeFiltersKey.
- Intercept the filter_state POST with waitForPost and assert the
  minted key matches the URL key, the stored mask contains the filter
  id, and a fresh navigation reuses the same key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:33:49 -07:00
Joe LiandClaude Opus 4.8 e5bbb793a4 test(dashboard): use typed getDatasetByName helper in native-filter url-key spec
Replace local findDatasetIdByName(page: any) with the existing typed
getDatasetByName(page: Page) helper (retry-wrapped apiGet, no new any).
Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-04 14:40:00 -07:00
Joe LiandClaude Opus 4.8 e77d7eed89 test(dashboard): migrate native filter URL key E2E to Playwright
Migrate the "nativefilter url param key" suite from the deprecated Cypress
tests to the Playwright framework. When a dashboard with native filters
loads, the filter bar publishes its data mask to the server-side
filter_state key-value store and stamps the returned key into the URL as
native_filters_key.

The migration builds the dashboard hermetically (one native filter + one
chart on birth_names) and strengthens the original URL-sniffing into a real
round-trip assertion: a POST mints the key, the key resolves server-side via
GET /api/v1/dashboard/<id>/filter_state/<key> (200 with the stored data
mask), and a reload reuses the same resolvable key.

The original suite's second case ("different key when page reloads") was
non-functional — it compared native_filters_key against a variable that was
declared but never assigned, so it asserted against undefined and passed
vacuously. The real backend contract reuses the key for a given
(session, tab, dashboard) via a contextual cache, so this test asserts the
true reuse behaviour instead of the inherited bug.

Adds DashboardPage.getNativeFiltersKey()/waitForNativeFiltersKey() helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-04 14:40:00 -07:00
4 changed files with 218 additions and 2 deletions
@@ -160,6 +160,29 @@ export async function apiGetDashboard(
return apiGet(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options);
}
/**
* GET request to resolve a native-filter `filter_state` key for a dashboard.
* The key is the `native_filters_key` URL param the filter bar publishes; a
* 200 response carries `{ value: string }`, the serialized data mask.
* @param page - Playwright page instance (provides authentication context)
* @param dashboardId - ID of the dashboard the key belongs to
* @param key - filter_state key to resolve
* @param options - Optional request options
* @returns API response with the stored filter state
*/
export async function apiGetDashboardFilterState(
page: Page,
dashboardId: number,
key: string,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(
page,
`${ENDPOINTS.DASHBOARD}${dashboardId}/filter_state/${key}`,
options,
);
}
/**
* DELETE request to remove a dashboard
* @param page - Playwright page instance (provides authentication context)
@@ -25,6 +25,15 @@ import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
/**
* URL query param carrying the server-side `filter_state` key the native
* filter bar publishes. Mirrors `URL_PARAMS.nativeFiltersKey.name` in
* `src/constants.ts`, which cannot be imported here: it pulls in
* `@superset-ui/core`, whose source graph is ESM-only under the Playwright
* runner.
*/
const NATIVE_FILTERS_KEY_PARAM = 'native_filters_key';
/** Tabs of the dashboard builder side pane, by their rendered label. */
type BuilderTab = 'Charts' | 'Layout elements';
@@ -235,6 +244,36 @@ export class DashboardPage {
.toBe(tabName);
}
/**
* Wait for a non-empty `native_filters_key` query param in the URL and return
* it. The filter bar stamps the key via `history.replace` once its data mask
* has been published to the backend `filter_state` store; Playwright treats
* that History API change as a navigation, so `waitForURL` resolves only
* after the driver-side frame URL has been updated.
*
* Only the URL is observed: on a page whose URL already carries the param
* (a permalink, or `page.reload()`) this resolves immediately without any
* publish having happened.
*/
async waitForNativeFiltersKey(options?: {
timeout?: number;
}): Promise<string> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
await this.page.waitForURL(
url => !!url.searchParams.get(NATIVE_FILTERS_KEY_PARAM),
{ timeout },
);
const key = new URL(this.page.url()).searchParams.get(
NATIVE_FILTERS_KEY_PARAM,
);
if (!key) {
throw new Error(
`${NATIVE_FILTERS_KEY_PARAM} not found in URL after publish`,
);
}
return key;
}
/**
* Open the dashboard header actions menu (three-dot menu)
*/
@@ -0,0 +1,140 @@
/**
* 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.
*/
/**
* A fresh navigation is expected to reuse the same filter_state key, not mint
* a new one: CreateFilterStateCommand caches by (session, tab, dashboard), so
* this test uses `page.goto` on the bare dashboard URL rather than
* `page.reload()` — a reload keeps `?native_filters_key=` in the address bar,
* which would take the PUT (update) branch instead of re-exercising create.
*
* Reuse also requires every request to hit the same cache: the test config
* uses a per-process SimpleCache behind a single non-recycling gunicorn
* worker, so this is only deterministic under that setup.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import {
apiGetDashboardFilterState,
ENDPOINTS,
} from '../../helpers/api/dashboard';
import { expectStatus } from '../../helpers/api/assertions';
import { waitForPost } from '../../helpers/api/intercepts';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import {
buildFilterJsonMetadata,
buildSelectFilter,
createDashboardWithCharts,
} from './dashboard-test-helpers';
const DATASET_NAME = 'birth_names';
const FILTER_COLUMN = 'gender';
testWithAssets(
'native filter bar mints a persisted, server-resolvable filter_state key and reuses it on a fresh navigation',
async ({ page, testAssets }, testInfo) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// The filter id is generated by the builder; capture it so the stored data
// mask can be checked for this dashboard's filter specifically.
let filterId = '';
const { dashboardId } = await createDashboardWithCharts(
page,
testAssets,
testInfo,
{
datasetName: DATASET_NAME,
chartNamePrefix: 'nf_key',
dashboardTitlePrefix: 'nf_key',
chartSpecs: [
{
viz_type: 'big_number_total',
params: { metric: 'count', adhoc_filters: [] },
},
],
buildJsonMetadata: ({ charts, datasetId }) => {
const chartsInScope = charts.map(chart => chart.id);
const filter = buildSelectFilter({
datasetId,
column: FILTER_COLUMN,
chartsInScope,
name: 'Gender',
});
filterId = filter.id;
return buildFilterJsonMetadata({
chartsInScope,
nativeFilters: [filter],
});
},
},
);
const dashboard = new DashboardPage(page);
const filterStateEndpoint = `${ENDPOINTS.DASHBOARD}${dashboardId}/filter_state`;
// Load the dashboard and return the URL key together with the key the
// filter_state POST minted, so the two can be compared.
const visitAndCaptureKeys = async () => {
const created = waitForPost(page, filterStateEndpoint, {
timeout: TIMEOUT.API_RESPONSE,
});
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
const urlKey = await dashboard.waitForNativeFiltersKey();
const { key: postedKey } = await expectStatus(await created, 201).json();
return { urlKey, postedKey };
};
// Confirm the key resolves to this dashboard's stored data mask via the
// backend filter_state GET endpoint — proving it is a real server-side
// entry, not a client token. A client-only token would not resolve.
const assertKeyResolves = async (key: string) => {
const stateResp = await apiGetDashboardFilterState(
page,
dashboardId,
key,
{ failOnStatusCode: false },
);
expectStatus(stateResp, 200);
const stateBody = await stateResp.json();
// The stored value is the serialized data mask, keyed by filter id.
expect(
JSON.parse(stateBody.value),
`filter_state key ${key} should carry the data mask for filter ${filterId}`,
).toHaveProperty(filterId);
};
const first = await visitAndCaptureKeys();
expect(
first.urlKey,
'the key in the URL should be the one minted by the filter_state POST',
).toBe(first.postedKey);
await assertKeyResolves(first.urlKey);
// Fresh navigation: the backend reuses the existing key for this
// (session, tab, dashboard), and the overwritten entry still resolves.
const second = await visitAndCaptureKeys();
expect(second.urlKey).toBe(second.postedKey);
expect(
second.urlKey,
'a fresh navigation should reuse the same filter_state key for the session/tab',
).toBe(first.urlKey);
await assertKeyResolves(second.urlKey);
},
);
@@ -151,8 +151,8 @@ interface SelectFilterOptions {
/**
* Builds one `filter_select` native filter for a dashboard's `json_metadata`.
* The filter id is generated here because no test needs to know it — filters are
* addressed through the filter bar UI, not by id.
* The filter id is generated here; most specs address filters through the filter
* bar UI, and a spec that needs the id reads it off the returned config.
*/
export function buildSelectFilter(
options: SelectFilterOptions,
@@ -241,6 +241,15 @@ interface CreateDashboardWithChartsOptions {
buildLayout?: (
charts: readonly DashboardLayoutChart[],
) => DashboardPositionJson;
/**
* Dashboard `json_metadata` (e.g. native filters via
* `buildFilterJsonMetadata`); omitted when not provided. Receives the created
* charts and the resolved dataset id so filters can target both.
*/
buildJsonMetadata?: (context: {
charts: readonly DashboardLayoutChart[];
datasetId: number;
}) => Record<string, unknown>;
}
/**
@@ -289,10 +298,15 @@ export async function createDashboardWithCharts(
const positionJson = options.buildLayout
? options.buildLayout(charts)
: buildSingleRowDashboardLayout(charts);
const jsonMetadata = options.buildJsonMetadata?.({
charts,
datasetId: dataset.id,
});
const dashResp = await apiPostDashboard(page, {
dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
published: true,
position_json: JSON.stringify(positionJson),
...(jsonMetadata && { json_metadata: JSON.stringify(jsonMetadata) }),
});
expect(dashResp.ok()).toBe(true);
const dashboardId = await extractIdFromResponse(dashResp);