You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/06/25 19:45:42 UTC

(superset) branch dashboard-pw-key-value created (now 547dd6986bf)

This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a change to branch dashboard-pw-key-value
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 547dd6986bf test(dashboard): migrate native filter URL key E2E to Playwright

This branch includes the following new commits:

     new 547dd6986bf test(dashboard): migrate native filter URL key E2E to Playwright

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: test(dashboard): migrate native filter URL key E2E to Playwright

Posted by jl...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch dashboard-pw-key-value
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 547dd6986bfabf61fd70059d1eb434cf2007f78d
Author: Joe Li <jo...@preset.io>
AuthorDate: Thu Jun 25 12:45:27 2026 -0700

    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) <no...@anthropic.com>
---
 .../playwright/pages/DashboardPage.ts              |  28 +++
 .../dashboard-native-filter-url-key.spec.ts        | 228 +++++++++++++++++++++
 2 files changed, 256 insertions(+)

diff --git a/superset-frontend/playwright/pages/DashboardPage.ts b/superset-frontend/playwright/pages/DashboardPage.ts
index a61ff3415c1..72ed9960c24 100644
--- a/superset-frontend/playwright/pages/DashboardPage.ts
+++ b/superset-frontend/playwright/pages/DashboardPage.ts
@@ -94,6 +94,34 @@ export class DashboardPage {
     );
   }
 
+  /**
+   * Read the `native_filters_key` query param from the current dashboard URL,
+   * or null if absent. This key references the server-side filter_state entry
+   * the native filter bar creates when it publishes its data mask.
+   */
+  getNativeFiltersKey(): string | null {
+    return new URL(this.page.url()).searchParams.get('native_filters_key');
+  }
+
+  /**
+   * Wait until the native filter bar has published its state to the backend and
+   * the resulting `native_filters_key` appears in the URL, then return it.
+   */
+  async waitForNativeFiltersKey(options?: { timeout?: number }): Promise<string> {
+    const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
+    await this.page.waitForFunction(
+      () =>
+        new URLSearchParams(window.location.search).has('native_filters_key'),
+      undefined,
+      { timeout },
+    );
+    const key = this.getNativeFiltersKey();
+    if (!key) {
+      throw new Error('native_filters_key not found in URL after publish');
+    }
+    return key;
+  }
+
   /**
    * Open the dashboard header actions menu (three-dot menu)
    */
diff --git a/superset-frontend/playwright/tests/dashboard/dashboard-native-filter-url-key.spec.ts b/superset-frontend/playwright/tests/dashboard/dashboard-native-filter-url-key.spec.ts
new file mode 100644
index 00000000000..22d759a9519
--- /dev/null
+++ b/superset-frontend/playwright/tests/dashboard/dashboard-native-filter-url-key.spec.ts
@@ -0,0 +1,228 @@
+/**
+ * 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.
+ */
+
+/**
+ * E2E migration of the Cypress "nativefilter url param key" suite
+ * (dashboard/key_value.test.ts).
+ *
+ * When a dashboard with native filters loads, the filter bar publishes its data
+ * mask to the backend `filter_state` key-value store and stamps the returned
+ * key into the URL as `native_filters_key`. The original suite only sniffed the
+ * URL (the key is a string; it differs across visits). That is genuinely a
+ * full-stack behaviour — the key is minted by a real server round-trip and
+ * persisted server-side — so it is migrated here, but strengthened to assert the
+ * round-trip rather than just the URL shape:
+ *
+ *   1. A POST /api/v1/dashboard/<id>/filter_state mints the key, and that key is
+ *      what lands in the URL.
+ *   2. The key resolves server-side: GET /api/v1/dashboard/<id>/filter_state/<key>
+ *      returns the stored data mask (200). A client-only token would not resolve.
+ *   3. Reloading reuses the same resolvable key for the session/tab.
+ *
+ * The original suite's second case ("should have different key when page
+ * reloads") was non-functional: it compared `native_filters_key` against an
+ * `initialFilterKey` variable that was declared but never assigned, so it
+ * asserted against `undefined` and passed vacuously. The real backend contract
+ * is the opposite — CreateFilterStateCommand reuses the existing key for a given
+ * (session, tab, dashboard) via a contextual cache — so this migration asserts
+ * the true behaviour (reuse) instead of the bug it inherited.
+ *
+ * The dashboard is built hermetically (one native filter + one chart on
+ * birth_names), replacing the original's dependency on the seeded world_health
+ * dashboard (whose example charts are flaky under load).
+ *
+ * CI green => the filter bar minted a persisted, server-resolvable key and
+ *             reloading reused that same resolvable key.
+ * CI red   => no key was published, or the key did not resolve server-side.
+ */
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import { apiPost, apiPut } from '../../helpers/api/requests';
+import { apiPostDashboard } from '../../helpers/api/dashboard';
+import { TIMEOUT } from '../../utils/constants';
+import { DashboardPage } from '../../pages/DashboardPage';
+
+const DATASET_NAME = 'birth_names';
+const FILTER_COLUMN = 'gender';
+
+async function findDatasetIdByName(page: any, name: string): Promise<number> {
+  const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
+  const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
+  const body = await resp.json();
+  if (!body.result?.length) {
+    throw new Error(`Dataset ${name} not found`);
+  }
+  return body.result[0].id;
+}
+
+testWithAssets(
+  'native filter bar mints a persisted, server-resolvable filter_state key and reuses it on reload',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const datasetId = await findDatasetIdByName(page, DATASET_NAME);
+
+    // A single chart for the native filter to target.
+    const chartParams = {
+      datasource: `${datasetId}__table`,
+      viz_type: 'big_number_total',
+      metric: 'count',
+      adhoc_filters: [],
+    };
+    const chartResp = await apiPost(page, 'api/v1/chart/', {
+      slice_name: `nf_key_${Date.now()}`,
+      viz_type: 'big_number_total',
+      datasource_id: datasetId,
+      datasource_type: 'table',
+      params: JSON.stringify(chartParams),
+    });
+    expect(chartResp.ok()).toBe(true);
+    const chart = await chartResp.json();
+    const chartId: number = chart.id ?? chart.result?.id;
+    testAssets.trackChart(chartId);
+
+    const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
+    const chartLayoutKey = `CHART-${chartId}`;
+    const positionJson = {
+      DASHBOARD_VERSION_KEY: 'v2',
+      ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
+      GRID_ID: {
+        type: 'GRID',
+        id: 'GRID_ID',
+        children: ['ROW-1'],
+        parents: ['ROOT_ID'],
+      },
+      'ROW-1': {
+        type: 'ROW',
+        id: 'ROW-1',
+        children: [chartLayoutKey],
+        parents: ['ROOT_ID', 'GRID_ID'],
+        meta: { background: 'BACKGROUND_TRANSPARENT' },
+      },
+      [chartLayoutKey]: {
+        type: 'CHART',
+        id: chartLayoutKey,
+        children: [],
+        parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
+        meta: { chartId, width: 6, height: 50, sliceName: 'nf_key' },
+      },
+    };
+
+    const jsonMetadata = {
+      native_filter_configuration: [
+        {
+          id: filterId,
+          name: 'Gender',
+          filterType: 'filter_select',
+          type: 'NATIVE_FILTER',
+          targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
+          controlValues: {
+            multiSelect: false,
+            enableEmptyFilter: false,
+            defaultToFirstItem: false,
+            inverseSelection: false,
+            searchAllOptions: false,
+          },
+          defaultDataMask: { filterState: {}, extraFormData: {} },
+          cascadeParentIds: [],
+          scope: { rootPath: ['ROOT_ID'], excluded: [] },
+          chartsInScope: [chartId],
+        },
+      ],
+      chart_configuration: {},
+      cross_filters_enabled: false,
+      global_chart_configuration: {
+        scope: { rootPath: ['ROOT_ID'], excluded: [] },
+        chartsInScope: [chartId],
+      },
+    };
+
+    const dashResp = await apiPostDashboard(page, {
+      dashboard_title: `nf_key_${Date.now()}`,
+      published: true,
+      position_json: JSON.stringify(positionJson),
+      json_metadata: JSON.stringify(jsonMetadata),
+    });
+    expect(dashResp.ok()).toBe(true);
+    const dashBody = await dashResp.json();
+    const dashboardId: number = dashBody.result?.id ?? dashBody.id;
+    testAssets.trackDashboard(dashboardId);
+
+    const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
+      dashboards: [dashboardId],
+    });
+    expect(linkResp.ok()).toBe(true);
+
+    const dashboard = new DashboardPage(page);
+
+    // Confirm the key resolves to a 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 page.request.get(
+        `api/v1/dashboard/${dashboardId}/filter_state/${key}`,
+      );
+      expect(
+        stateResp.status(),
+        `filter_state key ${key} should resolve server-side`,
+      ).toBe(200);
+      const stateBody = await stateResp.json();
+      // The stored value is the serialized data mask (valid JSON).
+      expect(
+        () => JSON.parse(stateBody.value),
+        `filter_state key ${key} should carry a stored data mask`,
+      ).not.toThrow();
+    };
+
+    // The filter bar mints the key via a POST to filter_state on load.
+    let createPosted = false;
+    page.on('response', response => {
+      const req = response.request();
+      if (
+        req.method() === 'POST' &&
+        /\/api\/v1\/dashboard\/\d+\/filter_state(\?|$)/.test(response.url())
+      ) {
+        createPosted = true;
+      }
+    });
+
+    await dashboard.gotoById(dashboardId);
+    await dashboard.waitForLoad();
+    const firstKey = await dashboard.waitForNativeFiltersKey();
+
+    expect(firstKey).toEqual(expect.any(String));
+    expect(firstKey.length).toBeGreaterThan(0);
+    // The key was minted by a real create round-trip, not invented client-side.
+    expect(
+      createPosted,
+      'a POST to filter_state should mint the key on load',
+    ).toBe(true);
+    await assertKeyResolves(firstKey);
+
+    // Reload: the backend reuses the existing key for this (session, tab,
+    // dashboard), and it still resolves server-side.
+    await dashboard.gotoById(dashboardId);
+    await dashboard.waitForLoad();
+    const reloadKey = await dashboard.waitForNativeFiltersKey();
+    expect(
+      reloadKey,
+      'reloading should reuse the same filter_state key for the session/tab',
+    ).toEqual(firstKey);
+    await assertKeyResolves(reloadKey);
+  },
+);