feat: add global async query playwright tests (#43004)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
drivaspreset
2026-10-06 13:03:54 -06:00
committed by GitHub
parent 457007b2f8
commit a5ffac58f4
12 changed files with 2049 additions and 33 deletions

View File

@@ -174,7 +174,13 @@ playwright-install() {
playwright-run() {
local APP_ROOT=$1
local TEST_PATH=$2
shift || true
# Remaining arguments are test paths, relative to playwright/tests. Passing
# more than one lets a caller run a suite that spans directories. With none,
# selection comes from PLAYWRIGHT_EXTRA_ARGS instead -- that is how
# playwright-run-gaq picks its suite, via --project.
local TEST_PATHS=("$@")
local TEST_PATH=${TEST_PATHS[0]:-}
# Start the Superset backend via gunicorn (not `flask run`). The Flask
# development server is single-threaded and has no crash-recovery, so
@@ -259,18 +265,29 @@ playwright-run() {
say "::group::Run Playwright tests"
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
if [ -n "$TEST_PATH" ]; then
# Check if there are any test files in the specified path
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
echo "No test files found in ${TEST_PATH} - skipping test run"
# Check if there are any test files in the specified paths
local found=0
local candidate
for candidate in "${TEST_PATHS[@]}"; do
if find "playwright/tests/${candidate}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
found=1
elif [ -f "playwright/tests/${candidate}" ]; then
found=1
fi
done
if [ "$found" -eq 0 ]; then
echo "No test files found in ${TEST_PATHS[*]} - skipping test run"
say "::endgroup::"
return 0
fi
echo "Running tests: ${TEST_PATH}"
npx playwright test "${TEST_PATH}" --output=playwright-results
echo "Running tests: ${TEST_PATHS[*]}"
# shellcheck disable=SC2086
npx playwright test "${TEST_PATHS[@]}" --output=playwright-results ${PLAYWRIGHT_EXTRA_ARGS:-}
local status=$?
else
echo "Running all default-project tests"
npx playwright test --output=playwright-results
echo "Running with no explicit paths (project selection comes from PLAYWRIGHT_EXTRA_ARGS, if set)"
# shellcheck disable=SC2086
npx playwright test --output=playwright-results ${PLAYWRIGHT_EXTRA_ARGS:-}
local status=$?
fi
say "::endgroup::"
@@ -278,6 +295,122 @@ playwright-run() {
return $status
}
playwright-run-gaq() {
# Global Async Queries needs more than a feature flag: submissions are handed
# to Celery, so without a worker consuming the queue the API returns 202 and
# no job ever runs -- the specs would time out rather than fail usefully.
# `playwright-run` boots gunicorn with this step's environment, so the flag
# set on the step reaches both the web server and the worker started here.
local APP_ROOT=$1
cd "$GITHUB_WORKSPACE"
if [ "${SUPERSET_FEATURE_GLOBAL_ASYNC_QUERIES:-}" != "true" ]; then
echo "::error::SUPERSET_FEATURE_GLOBAL_ASYNC_QUERIES must be \"true\" for this step; the specs would skip themselves and report nothing."
return 1
fi
local workerlog="${HOME}/superset-gaq-worker.log"
say "::group::Start Celery worker for GAQ"
# Mirrors docker/docker-bootstrap.sh's worker invocation.
nohup celery --app=superset.tasks.celery_app:app worker \
-O fair \
--loglevel=INFO \
--concurrency=2 \
>"$workerlog" 2>&1 </dev/null &
local workerPid=$!
# Fail fast on a worker that never comes up: a dead worker is
# indistinguishable from a slow one once the specs start timing out.
local timeout=60
while [ $timeout -gt 0 ]; do
if ! kill -0 "$workerPid" 2>/dev/null; then
echo "::error::Celery worker exited during startup"
cat "$workerlog" || true
say "::endgroup::"
return 1
fi
if grep -q "celery@.*ready" "$workerlog" 2>/dev/null; then
say "Celery worker is ready"
break
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "::error::Celery worker failed to become ready within 60 seconds"
cat "$workerlog" || true
kill "$workerPid" 2>/dev/null || true
say "::endgroup::"
return 1
fi
say "::endgroup::"
# The GAQ specs are excluded from every other project, so this is what makes
# them loadable at all -- see the chromium-gaq project in playwright.config.ts.
export INCLUDE_GAQ=true
local report="${GITHUB_WORKSPACE}/superset-frontend/playwright-gaq-report.json"
rm -f "$report"
export PLAYWRIGHT_JSON_OUTPUT_NAME="$report"
# --workers=1: the fixtures all create charts as the same admin user, and
# Superset's tag listener races on the shared `editor:<id>` tag (see the
# chromium-gaq project in playwright.config.ts). That project's
# `fullyParallel: false` only orders tests within one file -- Playwright
# still runs separate files concurrently -- and this suite spans three, so
# one worker is what actually serializes it.
#
# --project=chromium-gaq rather than a list of spec paths: that project's
# testMatch (`**/global-async-query*.spec.ts`) is the same glob the default
# and sqllab projects use to *exclude* these specs, so selecting by project
# keeps both sides reading from one definition. A new spec matching the glob
# is picked up here automatically; with a hand-maintained path list it would
# be excluded from the required run and never added here, so it would run
# nowhere and the zero-executed check below could not notice.
export PLAYWRIGHT_EXTRA_ARGS="--reporter=list,json --workers=1 --project=chromium-gaq"
# `set -e` is on: without the guard a failing run would exit before the
# worker log is emitted and before the did-it-actually-run check below.
local status=0
playwright-run "$APP_ROOT" || status=$?
unset PLAYWRIGHT_EXTRA_ARGS PLAYWRIGHT_JSON_OUTPUT_NAME INCLUDE_GAQ
say "::group::Celery worker log"
cat "$workerlog" || true
say "::endgroup::"
kill "$workerPid" 2>/dev/null || true
# A suite that skips itself still exits 0. That is the failure mode this step
# exists to prevent, so assert that tests actually ran.
if [ ! -f "$report" ]; then
echo "::error::No Playwright JSON report produced; cannot confirm the GAQ specs ran."
return 1
fi
# "Did the suite run?" must count every outcome that proves a test executed,
# not just first-attempt passes. A run where every GAQ test genuinely fails
# (say the worker loses Redis mid-run) reports expected=0 too, and blaming
# that on an inactive GLOBAL_ASYNC_QUERIES would point whoever is debugging
# at the wrong layer -- the job is already red from the real failure.
local expected unexpected flaky skipped executed
expected=$(jq '.stats.expected // 0' "$report")
unexpected=$(jq '.stats.unexpected // 0' "$report")
flaky=$(jq '.stats.flaky // 0' "$report")
skipped=$(jq '.stats.skipped // 0' "$report")
executed=$((expected + unexpected + flaky))
say "GAQ suite: ${expected} passed, ${unexpected} failed, ${flaky} flaky, ${skipped} skipped"
if [ "$executed" -eq 0 ]; then
echo "::error::The GAQ specs reported zero executed tests -- they skipped themselves, which means GLOBAL_ASYNC_QUERIES was not active on the server under test."
return 1
fi
if [ "$skipped" -ne 0 ]; then
echo "::error::${skipped} GAQ test(s) skipped; this step must run the whole suite."
return 1
fi
return $status
}
eyes-storybook-dependencies() {
say "::group::install eyes-storyook dependencies"
sudo apt-get update -y && sudo apt-get -y install gconf-service ca-certificates libxshmfence-dev fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libglib2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libnspr4 libnss3 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils libappindicator1

View File

@@ -259,6 +259,175 @@ jobs:
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
# GAQ runs in its own job rather than as a step in playwright-tests above.
# A step with no explicit `if:` implicitly inherits `if: success()`, so
# when GAQ was a step after the other suites in that job, a failure in
# either of those unrelated suites skipped GAQ entirely rather than failing it
# -- silently leaving that commit with zero GAQ coverage instead of a visible
# red check. A separate job can't share that fate: it either runs and reports
# for itself, or it doesn't start (e.g. the environment itself never came up),
# which is the only case where "no GAQ result" is actually the right outcome.
playwright-tests-gaq:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-26.04
timeout-minutes: 30
continue-on-error: true
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config_gaq
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: ghcr.io/apache/superset/ci/postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
# -------------------------------------------------------
# Conditional checkout based on context
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Setup Python
uses: $/.github/actions/setup-backend/
- name: Setup postgres
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: setup-postgres
- name: Import test data
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: playwright_testdata
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: "./superset-frontend/.nvmrc"
cache: "npm"
cache-dependency-path: "superset-frontend/package-lock.json"
- name: Install npm dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: npm-install
- name: Build javascript packages
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: build-instrumented-assets
- name: Install Playwright
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: playwright-install
- name: Run Playwright (Global Async Queries Tests)
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scoped to this step for the same reason as the Soft-delete, Embedded
# and Mobile steps in playwright-tests above: GLOBAL_ASYNC_QUERIES
# changes chart-data behaviour for every spec sharing a server.
#
# This suite has its own job, and is not required, because unlike the
# other flag-gated suites it needs a Celery worker: submissions are
# handed to Celery, so without one the API returns 202 and no job ever
# executes. Putting a background worker and its startup window on the
# required path would let a GAQ flake block PRs that have nothing to
# do with async queries.
#
# `playwright-run-gaq` starts the worker and sets INCLUDE_GAQ, which
# is what makes the chromium-gaq project exist at all.
SUPERSET_FEATURE_GLOBAL_ASYNC_QUERIES: "true"
with:
# No spec list: playwright-run-gaq selects --project=chromium-gaq, whose
# testMatch is the single definition of what the GAQ suite contains.
run: playwright-run-gaq "${{ matrix.app_root }}"
- name: Set safe app root
if: failure()
id: set-safe-app-root
env:
APP_ROOT: ${{ matrix.app_root }}
run: |
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: failure()
with:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-gaq-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
# Stable required-status-check anchor. playwright-tests is a matrix job
# gated on change detection (python || frontend). On a PR that touches
# neither — e.g. a docs-only PR — it is skipped at the job level, which

View File

@@ -52,9 +52,16 @@ PATTERNS = {
r"^requirements/.+\.txt",
r"^pyproject\.toml",
r"^.pylintrc",
# See the note in "frontend": setup-backend/change-detector are this
# group's own setup.
r"^\.github/actions/",
],
"frontend": [
r"^\.github/workflows/.*(bashlib|frontend|e2e)",
# Composite actions are shared setup, matched by nothing else. Listed
# under "python" too -- setup-backend is a backend action, so gating it
# on "frontend" alone would skip the Python jobs whose setup changed.
r"^\.github/actions/",
r"^superset-frontend/",
],
"docker": [

View File

@@ -93,6 +93,11 @@ export default defineConfig({
'**/tests/sqllab/**/*.spec.ts',
'**/tests/embedded/**/*.spec.ts',
'**/tests/mobile/**/*.spec.ts',
// Global Async Queries needs the GLOBAL_ASYNC_QUERIES flag, Redis and a
// Celery worker, which the required run does not provide. They live in
// the chromium-gaq project below, which only exists when the workflow's
// GAQ step opts in.
'**/global-async-query*.spec.ts',
],
use: {
browserName: 'chromium',
@@ -110,6 +115,8 @@ export default defineConfig({
// via API with unique names — no shared mutable state between tests.
name: 'chromium-sqllab',
testMatch: '**/tests/sqllab/**/*.spec.ts',
// See the chromium-gaq project below.
testIgnore: '**/global-async-query*.spec.ts',
fullyParallel: false,
use: {
browserName: 'chromium',
@@ -152,6 +159,36 @@ export default defineConfig({
},
]
: []),
// Global Async Queries tests need the GLOBAL_ASYNC_QUERIES feature flag
// enabled in the Flask backend, plus Redis and a running Celery worker --
// without a worker, submissions return 202 and no job ever executes. The
// workflow's GAQ step provisions all three and sets INCLUDE_GAQ, so these
// specs never load in the required run, where the pipeline is inert. Same
// strict 'true' check as INCLUDE_EMBEDDED.
...(process.env.INCLUDE_GAQ?.toLowerCase() === 'true'
? [
{
name: 'chromium-gaq',
testMatch: '**/global-async-query*.spec.ts',
// Every dashboard fixture here creates charts as the same admin
// user, and Superset's tag listener (superset/tags/models.py,
// get_tag) resolves the shared `editor:<id>` tag with an
// unguarded SELECT-then-INSERT against tag.name's unique index.
// Concurrent fixtures lose that race and the chart POST comes
// back 422 "Chart could not be created" (tag_name_key), which
// retries then paper over. Serializing the suite keeps that
// upstream bug out of this suite's signal; note this only orders
// tests *within* a file -- `--workers=1` in playwright-run-gaq
// is what also stops the three GAQ spec files racing each other.
fullyParallel: false,
use: {
browserName: 'chromium' as const,
testIdAttribute: 'data-test',
storageState: 'playwright/.auth/user.json',
},
},
]
: []),
// Mobile consumption-mode tests need the MOBILE_CONSUMPTION_MODE feature
// flag enabled in the Flask backend (the workflow's mobile step sets
// SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE), so they only run when the

View File

@@ -85,15 +85,31 @@ export class Select {
await this.waitForDropdownClose();
}
/**
* The currently open dropdown. Excludes hidden ones, and uses .last() since
* the most recently opened is the one in play when several exist in the DOM.
*/
private get visibleDropdown(): Locator {
return this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last();
}
/**
* Options rendered in the currently open dropdown (assumes {@link open} was
* called first).
*/
get options(): Locator {
return this.visibleDropdown.locator(SELECT_SELECTORS.OPTION);
}
/**
* Waits for dropdown to close after selection
* This prevents strict mode violations when multiple selects are used sequentially
*/
private async waitForDropdownClose(): Promise<void> {
// Wait for dropdown to actually close (become hidden)
await this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last()
await this.visibleDropdown
.waitFor({ state: 'hidden', timeout: TIMEOUT.UI_TRANSITION })
.catch(error => {
// Only ignore TimeoutError (dropdown may already be closed); re-throw others
@@ -117,11 +133,7 @@ export class Select {
* @param optionText - The text of the option to click (partial match for filtered results)
*/
async clickOption(optionText: string): Promise<void> {
// Target visible dropdown (excludes hidden ones via :not(.ant-select-dropdown-hidden))
// Use .last() in case multiple dropdowns exist - the most recent one is what we want
const dropdown = this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last();
const dropdown = this.visibleDropdown;
await dropdown.waitFor({ state: 'visible' });
// Find option by text content - use partial match since filtered results may have prefixes

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import type { Page, TestInfo } from '@playwright/test';
import type { Locator, Page, TestInfo } from '@playwright/test';
import { expect, type TestAssets } from '../../helpers/fixtures';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
import {
@@ -26,8 +26,15 @@ import {
type DashboardLayoutChart,
type DashboardPositionJson,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import {
apiPostVirtualDataset,
getDatasetByName,
} from '../../helpers/api/dataset';
import { getDatabaseByName } from '../../helpers/api/database';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import { GAQ } from '../../utils/constants';
import { DashboardFilterBar } from '../../components/dashboard/DashboardFilterBar';
/**
* Extracts the chart id that a `/api/v1/chart/data` request was issued for.
@@ -247,14 +254,61 @@ export interface DashboardChartSpec {
params: Record<string, unknown>;
}
/**
* "A chart that renders a number" -- all most GAQ tests need from their fixture.
*
* `count` here is the *saved* metric the physical example datasets ship with.
* A dataset created through the API carries no saved metrics at all
* (`fetch_metadata` only discovers columns), so on one of those this spec's
* query fails with "Metric 'count' does not exist" -- use
* {@link BIG_NUMBER_ADHOC_COUNT_SPEC} for those.
*/
export const BIG_NUMBER_COUNT_SPEC: DashboardChartSpec = {
viz_type: 'big_number_total',
params: { metric: 'count' },
};
/**
* `COUNT(name)` as an ad-hoc metric: resolvable on any dataset over
* `birth_names`, whether or not it has saved metrics.
*/
export const ADHOC_COUNT_NAME_METRIC = {
expressionType: 'SIMPLE',
column: { column_name: 'name' },
aggregate: 'COUNT',
label: 'COUNT(name)',
} as const;
/** {@link BIG_NUMBER_COUNT_SPEC} for datasets without saved metrics. */
export const BIG_NUMBER_ADHOC_COUNT_SPEC: DashboardChartSpec = {
viz_type: 'big_number_total',
params: { metric: ADHOC_COUNT_NAME_METRIC },
};
interface CreateDashboardWithChartsOptions {
/** Example dataset the charts query (e.g. 'birth_names'). */
datasetName: string;
/** Example dataset the charts query, by name (e.g. 'birth_names'). */
datasetName?: string;
/** Dataset id, for datasets without a stable name (e.g. a per-run virtual one). */
datasetId?: number;
/** Chart slice-name prefix: `${chartNamePrefix}_${viz_type}_${suffix}`. */
chartNamePrefix: string;
/** Dashboard title prefix: `${dashboardTitlePrefix}_${suffix}`. */
dashboardTitlePrefix: string;
/** Dashboard title prefix (default: `chartNamePrefix`). */
dashboardTitlePrefix?: string;
chartSpecs: DashboardChartSpec[];
/**
* Grid width per chart, passed through to `buildSingleRowDashboardLayout`.
* Defaults to `GRID_DEFAULT_CHART_WIDTH` (4) -- lower this when `chartSpecs`
* has enough entries that the default width would exceed the 12-column
* single-row grid.
*/
chartWidth?: number;
/**
* When set, the dashboard gets one single-select native filter on this
* column, scoped to every chart. Single-select is load-bearing for the tests
* that use it: each selection replaces the previous one rather than
* accumulating, so a rapid swap is unambiguously a swap.
*/
selectFilter?: { column: string; name?: string };
/** Custom dashboard layout; defaults to placing every chart in one row. */
buildLayout?: (
charts: readonly DashboardLayoutChart[],
@@ -282,11 +336,18 @@ export async function createDashboardWithCharts(
testInfo: TestInfo,
options: CreateDashboardWithChartsOptions,
): Promise<{ dashboardId: number; charts: DashboardLayoutChart[] }> {
const dataset = await getDatasetByName(page, options.datasetName);
if (!dataset) {
throw new Error(`Dataset ${options.datasetName} not found`);
let { datasetId } = options;
if (datasetId === undefined) {
if (!options.datasetName) {
throw new Error('Provide either datasetName or datasetId');
}
const dataset = await getDatasetByName(page, options.datasetName);
if (!dataset) {
throw new Error(`Dataset ${options.datasetName} not found`);
}
datasetId = dataset.id;
}
const datasource = `${dataset.id}__table`;
const datasource = `${datasetId}__table`;
// Parallel-safe suffix so chart/dashboard names never collide across workers.
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
@@ -297,7 +358,7 @@ export async function createDashboardWithCharts(
const resp = await apiPostChart(page, {
slice_name: sliceName,
viz_type: spec.viz_type,
datasource_id: dataset.id,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify({
// Caller params first so the helper-owned datasource/viz_type always win
@@ -310,18 +371,33 @@ export async function createDashboardWithCharts(
expect(resp.ok()).toBe(true);
const chartId = await extractIdFromResponse(resp);
testAssets.trackChart(chartId);
charts.push({ id: chartId, sliceName });
charts.push({ id: chartId, sliceName, width: options.chartWidth });
}
const positionJson = options.buildLayout
? options.buildLayout(charts)
: buildSingleRowDashboardLayout(charts);
const jsonMetadata = options.buildJsonMetadata?.({
charts,
datasetId: dataset.id,
});
const chartIds = charts.map(chart => chart.id);
// Two ways to supply dashboard metadata: `buildJsonMetadata` is the general
// escape hatch, `selectFilter` the shorthand for the common single-select
// case. If a caller passes both, the explicit callback wins.
const jsonMetadata =
options.buildJsonMetadata?.({ charts, datasetId }) ??
(options.selectFilter
? buildFilterJsonMetadata({
chartsInScope: chartIds,
nativeFilters: [
buildSelectFilter({
datasetId,
column: options.selectFilter.column,
chartsInScope: chartIds,
name: options.selectFilter.name,
}),
],
})
: undefined);
const dashResp = await apiPostDashboard(page, {
dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
dashboard_title: `${options.dashboardTitlePrefix ?? options.chartNamePrefix}_${uniqueSuffix}`,
published: true,
position_json: JSON.stringify(positionJson),
...(jsonMetadata && { json_metadata: JSON.stringify(jsonMetadata) }),
@@ -337,3 +413,357 @@ export async function createDashboardWithCharts(
return { dashboardId, charts };
}
/** The rendered value of a big-number chart. */
export function bigNumberValueLocator(
dashboard: DashboardPage,
chartId: number,
): Locator {
return dashboard
.getChart(chartId)
.locator('.superset-legacy-chart-big-number .header-line');
}
interface SetupDashboardWithChartsResult {
dashboardId: number;
charts: DashboardLayoutChart[];
dashboard: DashboardPage;
/** Big-number value locator per chart, in the same order as `charts`. */
valueLocators: Locator[];
}
/**
* {@link createDashboardWithCharts} plus navigating to the result and waiting
* for it to load. Callers still assert on `valueLocators` themselves -- a
* happy-path test wants them visible, a broken-chart test wants an error alert.
*/
export async function setupDashboardWithBigNumberCharts(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options: CreateDashboardWithChartsOptions,
navigateOptions?: { timeout?: number },
): Promise<SetupDashboardWithChartsResult> {
const { dashboardId, charts } = await createDashboardWithCharts(
page,
testAssets,
testInfo,
options,
);
const dashboard = new DashboardPage(page);
const valueLocators = charts.map(chart =>
bigNumberValueLocator(dashboard, chart.id),
);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad(navigateOptions);
return { dashboardId, charts, dashboard, valueLocators };
}
export interface GaqSignals {
/**
* Every chart-data response status seen for a slice, in order. Under the
* Global Task Framework an async chart-data request produces *two* responses
* for the same URL: the 202 that hands the work to GTF, then the 200 the
* client gets when it re-issues the request and is served from the cache the
* tasks populated. A single value per slice would hide one of them.
*
* A native filter's value fetch hits the same endpoint without a `slice_id`,
* so it is keyed under `undefined` (see {@link sliceIdFromChartDataUrl}).
*/
submitStatusesFor(sliceId?: number): readonly number[];
/** First status seen for a slice; `undefined` if it has not responded yet. */
submitStatusFor(sliceId?: number): number | undefined;
/**
* Statuses for a slice whose *request payload* satisfies `matches`, in order.
*
* {@link submitStatusesFor} keys only on slice id, so when two requests for
* one slice are in flight it reports whichever responded first -- which on a
* loaded runner need not be the one under test. Correlating on the payload
* (see {@link nativeFilterValuesIn}) identifies a specific request instead of
* relying on response ordering.
*/
submitStatusesWhere(
sliceId: number | undefined,
matches: (requestBody: string) => boolean,
): readonly number[];
/** Poll/fetch events are counted, not flagged: on a busy dashboard they arrive per chart. */
readonly taskStatusPollCount: number;
/** Chart-data re-requests that were served synchronously (200) after a 202. */
readonly cachedRereadCount: number;
readonly sawTaskStatusPoll: boolean;
/** True once some slice went 202 -> 200: a full async round trip completed. */
readonly sawAsyncRoundTrip: boolean;
}
/**
* The values a chart-data payload filters `column` on.
*
* A dashboard merges its native filters into the request as
* `{ col, op: 'IN', val: [...] }` clauses (see `getSelectExtraFormData` in
* src/filters/utils.ts). Reading them back is what lets a test say "this
* response belongs to the 'girl' request" rather than trusting arrival order.
*
* Returns an empty array for an unparseable body, so a caller's predicate
* simply does not match rather than throwing inside a `response` listener.
*/
export function nativeFilterValuesIn(
requestBody: string,
column: string,
): string[] {
const values: string[] = [];
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(visit);
return;
}
if (node === null || typeof node !== 'object') {
return;
}
const clause = node as { col?: unknown; val?: unknown };
if (clause.col === column && clause.val !== undefined) {
const vals = Array.isArray(clause.val) ? clause.val : [clause.val];
vals.forEach(val => values.push(String(val)));
}
Object.values(node as Record<string, unknown>).forEach(visit);
};
try {
visit(JSON.parse(requestBody));
} catch {
return [];
}
return values;
}
/**
* Records the GAQ lifecycle signals seen from now on.
*
* Under GTF the cycle is: `POST /api/v1/chart/data` with `async_mode` returns
* **202**; the client polls `GET /api/v1/task/status_changes`, then re-issues
* the same POST and gets **200** from the cache those tasks warmed.
*
* Attach only once the traffic you care about is the *next* thing to happen: an
* initial dashboard load fires the same signals. Reads are live getters, so
* callers can poll them inside `expect(...).toPass()`.
*/
export function trackGaqSignals(page: Page): GaqSignals {
const submitStatuses = new Map<number | undefined, number[]>();
/** Every chart-data response, with the payload that produced it. */
const submissions: {
sliceId: number | undefined;
status: number;
requestBody: string;
}[] = [];
let taskStatusPollCount = 0;
let cachedRereadCount = 0;
page.on('response', response => {
const request = response.request();
const url = response.url();
if (request.method() === 'POST' && url.includes('/api/v1/chart/data')) {
const sliceId = sliceIdFromChartDataUrl(url);
const seen = submitStatuses.get(sliceId) ?? [];
// A 200 following a 202 for the same slice is the re-request being served
// from the warmed cache -- the completion half of the round trip.
if (response.status() === 200 && seen.includes(202)) {
cachedRereadCount += 1;
}
submitStatuses.set(sliceId, [...seen, response.status()]);
submissions.push({
sliceId,
status: response.status(),
requestBody: request.postData() ?? '',
});
return;
}
if (
request.method() === 'GET' &&
url.includes(GAQ.TASK_STATUS_CHANGES_PATH)
) {
taskStatusPollCount += 1;
}
});
return {
submitStatusesFor: sliceId => submitStatuses.get(sliceId) ?? [],
submitStatusFor: sliceId => submitStatuses.get(sliceId)?.[0],
submitStatusesWhere: (sliceId, matches) =>
submissions
.filter(
entry => entry.sliceId === sliceId && matches(entry.requestBody),
)
.map(entry => entry.status),
get taskStatusPollCount() {
return taskStatusPollCount;
},
get cachedRereadCount() {
return cachedRereadCount;
},
get sawTaskStatusPoll() {
return taskStatusPollCount > 0;
},
get sawAsyncRoundTrip() {
return cachedRereadCount > 0;
},
};
}
interface SetupFilteredDashboardOptions {
/** Dataset backing both the chart and the filter's value lookup -- see {@link CreateDashboardWithChartsOptions}. */
datasetName?: string;
datasetId?: number;
/** Prefix for the generated chart and dashboard names. */
namePrefix: string;
/** Column the native filter targets. */
filterColumn: string;
/** Label shown in the filter bar (default: the column name). */
filterName?: string;
/**
* The dashboard's single chart (default: {@link BIG_NUMBER_COUNT_SPEC}).
* Callers passing an API-created dataset must supply one whose metric does
* not depend on saved metrics -- see {@link BIG_NUMBER_ADHOC_COUNT_SPEC}.
*/
chartSpec?: DashboardChartSpec;
}
interface SetupFilteredDashboardResult {
dashboardId: number;
chartId: number;
dashboard: DashboardPage;
filterBar: DashboardFilterBar;
/** Big-number value locator for the dashboard's single chart. */
value: Locator;
}
/**
* Builds a dashboard with one big-number chart plus a single-select native
* filter scoped to it. Does NOT navigate: some callers must attach network
* listeners before the first load (the filter's value fetch fires during the
* filter panel's own initialization).
*/
export async function setupDashboardWithSelectFilter(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options: SetupFilteredDashboardOptions,
): Promise<SetupFilteredDashboardResult> {
const { dashboardId, charts } = await createDashboardWithCharts(
page,
testAssets,
testInfo,
{
datasetName: options.datasetName,
datasetId: options.datasetId,
chartNamePrefix: options.namePrefix,
chartSpecs: [options.chartSpec ?? BIG_NUMBER_COUNT_SPEC],
chartWidth: 6,
selectFilter: { column: options.filterColumn, name: options.filterName },
},
);
const [chart] = charts;
const dashboard = new DashboardPage(page);
return {
dashboardId,
chartId: chart.id,
dashboard,
filterBar: new DashboardFilterBar(page),
value: bigNumberValueLocator(dashboard, chart.id),
};
}
/**
* A virtual dataset whose query text is unique to this run.
*
* Chart and filter-value results are cached by query text, so a dataset over a
* shared physical table is cache-cold the first time it runs and a cache hit
* every time after -- and a test that means to exercise the async pipeline
* quietly stops doing so. The per-run SQL comment keeps the cache key unique,
* which is what lets a spec assert the async cycle on a first, unforced load.
*
* The dataset is registered for fixture cleanup.
*
* @param options.namePrefix - Prefix for the dataset name; the run suffix is appended.
* @param options.select - SELECT to wrap (default: `SELECT name FROM birth_names`).
* @returns The new dataset's id, and the suffix, for callers that name other
* per-run objects consistently with it.
*/
export async function createCacheColdVirtualDataset(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options: { namePrefix: string; select?: string },
): Promise<{ datasetId: number; uniqueSuffix: string }> {
const examplesDb = await getDatabaseByName(page, 'examples');
if (!examplesDb) {
throw new Error('examples database not found');
}
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const select = options.select ?? 'SELECT name FROM birth_names';
const datasetResp = await apiPostVirtualDataset(page, {
database: examplesDb.id,
schema: '',
table_name: `${options.namePrefix}_${uniqueSuffix}`,
sql: `${select} /* run:${uniqueSuffix} */`,
editors: [],
});
expect(datasetResp.ok()).toBe(true);
const datasetId = await extractIdFromResponse(datasetResp);
testAssets.trackDataset(datasetId);
return { datasetId, uniqueSuffix };
}
/** Column {@link createQueryClockDataset} stamps every query with. */
export const QUERIED_AT_COLUMN = 'queried_at_ms';
/**
* A chart showing the latest {@link QUERIED_AT_COLUMN} stamp.
*
* `,d` rather than the default SMART_NUMBER, which rounds two stamps seconds
* apart to the same "1.76T".
*/
export const BIG_NUMBER_QUERY_CLOCK_SPEC: DashboardChartSpec = {
viz_type: 'big_number_total',
params: {
metric: {
expressionType: 'SIMPLE',
column: { column_name: QUERIED_AT_COLUMN },
aggregate: 'MAX',
label: `MAX(${QUERIED_AT_COLUMN})`,
},
y_axis_format: ',d',
},
};
/**
* {@link createCacheColdVirtualDataset} over `birth_names`, with every query
* also stamped with the server clock.
*
* The example data is static, so a correct re-execution reproduces the same
* numbers -- which means "the value is what it was" cannot tell a genuine
* re-render from a DOM that never updated. Pair this with
* {@link BIG_NUMBER_QUERY_CLOCK_SPEC} and {@link readQueryClock} for a value
* that *must* move whenever the query really ran again.
*/
export async function createQueryClockDataset(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options: { namePrefix: string },
): Promise<{ datasetId: number; uniqueSuffix: string }> {
return createCacheColdVirtualDataset(page, testAssets, testInfo, {
namePrefix: options.namePrefix,
select:
`SELECT name, CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT) ` +
`AS ${QUERIED_AT_COLUMN} FROM birth_names`,
});
}
/** The number rendered by a {@link BIG_NUMBER_QUERY_CLOCK_SPEC} chart. */
export async function readQueryClock(locator: Locator): Promise<number> {
return Number((await locator.textContent())?.replace(/,/g, ''));
}

View File

@@ -0,0 +1,600 @@
/**
* 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.
*/
/**
* Global Async Queries (GAQ) under stress: a query that fails, a superseded
* query racing a newer one -- cancelled both before and after the server
* accepts it -- a programmatic request that must stay synchronous, and a page
* torn down mid-flight.
*
* GAQ's happy path is visually identical to a synchronous load, so these are
* the cases where its machinery actually becomes observable -- or where it
* must stay invisible. The happy paths live in global-async-query.spec.ts.
*
* Requires the `GLOBAL_ASYNC_QUERIES` feature flag, Redis, and a running
* Celery worker.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiGetChart, apiPutChart } from '../../helpers/api/chart';
import { GAQ, TIMEOUT } from '../../utils/constants';
import { apiPost } from '../../helpers/api/requests';
import {
BIG_NUMBER_ADHOC_COUNT_SPEC,
BIG_NUMBER_COUNT_SPEC,
createCacheColdVirtualDataset,
nativeFilterValuesIn,
setupDashboardWithBigNumberCharts,
setupDashboardWithSelectFilter,
sliceIdFromChartDataUrl,
trackGaqSignals,
} from './dashboard-test-helpers';
import { isFeatureEnabled } from '../../helpers/featureFlags';
testWithAssets.beforeEach(async ({ page }) => {
await page.goto('chart/list/');
testWithAssets.skip(
!(await isFeatureEnabled(page, 'GLOBAL_ASYNC_QUERIES')),
'GLOBAL_ASYNC_QUERIES is not enabled on this instance',
);
});
testWithAssets(
'broken chart surfaces a clean error under GAQ instead of hanging, and recovers once fixed',
async ({ page, testAssets }) => {
// Two forced refreshes plus an API round-trip between them exceed the
// default timeout on a loaded runner.
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const BAD_COLUMN = 'this_column_does_not_exist_gaq_test';
// A custom SQL metric on a nonexistent column fails in Postgres, not in
// client-side validation -- so the job really is queued and run, and this
// exercises the async error path rather than a request that never ships.
const { dashboardId, dashboard, charts, valueLocators } =
await setupDashboardWithBigNumberCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
chartNamePrefix: 'gaq_tc3_broken_chart',
chartSpecs: [
{
viz_type: 'big_number_total',
params: {
metric: {
expressionType: 'SQL',
sqlExpression: `SUM(${BAD_COLUMN})`,
label: 'broken_metric',
hasCustomLabel: true,
},
},
},
],
},
);
const [chart] = charts;
const [value] = valueLocators;
const errorAlert = dashboard.getChart(chart.id).locator('.ant-alert-error');
// Let the initial (also broken) load settle before tracking.
await expect(errorAlert).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
const signals = trackGaqSignals(page);
// The alert is already on screen from that initial load and the refresh
// re-runs the identical broken query, so its text proves nothing about the
// refresh. Require the failure to come back over the wire instead.
const responseBodies: string[] = [];
page.on('response', async response => {
if (!response.url().includes(GAQ.CHART_DATA_PATH)) {
return;
}
try {
responseBodies.push(await response.text());
} catch {
// A superseded request's body is no longer retrievable; nothing to record.
}
});
await dashboard.forceRefresh();
await expect(() => {
expect(
signals.submitStatusFor(chart.id),
'forced chart-data submission for the broken chart should still be accepted (202) onto the async path',
).toBe(202);
expect(
signals.sawTaskStatusPoll,
'the client should have polled /api/v1/task/status_changes while the broken query ran',
).toBe(true);
expect(
responseBodies.filter(body => body.includes(BAD_COLUMN)),
"the refresh's own chart-data response should carry the failure, rather than the alert being left over from the initial load",
).not.toHaveLength(0);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
await expect(errorAlert).toBeVisible();
await expect(errorAlert).toContainText('Data error');
await expect(errorAlert).toContainText(BAD_COLUMN);
// Fix the config and confirm the chart recovers, rather than staying stuck.
const chartResp = await apiGetChart(page, chart.id);
expect(chartResp.ok()).toBe(true);
const { result } = await chartResp.json();
const fixedParams = { ...JSON.parse(result.params), metric: 'count' };
const updateResp = await apiPutChart(page, chart.id, {
params: JSON.stringify(fixedParams),
});
expect(updateResp.ok()).toBe(true);
// Refreshing re-submits the form_data already loaded client-side, so it
// would not pick up an out-of-band config change. Re-navigating refetches
// the chart's metadata.
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
await expect(value).toHaveText(/\d/);
await expect(errorAlert).not.toBeAttached();
},
);
testWithAssets(
'rapidly switching a filter value never lets the superseded selection clobber the screen',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Generous enough that girl's own select/apply cycle plus network latency
// cannot consume the window while boy sits parked -- the delay starts when
// boy is applied, i.e. before that cycle even begins.
const RACE_DELAY_MS = 8000;
const FILTER_COLUMN = 'gender';
const { chartId, dashboardId, dashboard, filterBar, value } =
await setupDashboardWithSelectFilter(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
namePrefix: 'gaq_tc4_filter_race',
filterColumn: 'gender',
filterName: 'Gender',
},
);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad({ timeout: TIMEOUT.SLOW_TEST });
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
// Learn both counts up front. The chart keeps showing its previous value
// while a query is in flight, so a bare /\d/ would capture whatever was
// already on screen -- wait for the round trip at the network level, and
// identify it by the filter that produced it.
const baselineSignals = trackGaqSignals(page);
const applyAndRead = async (option: string): Promise<string> => {
await filterBar.selectOption(option);
await filterBar.apply();
await expect(() => {
const statuses = baselineSignals.submitStatusesWhere(chartId, body =>
nativeFilterValuesIn(body, FILTER_COLUMN).includes(option),
);
expect(
statuses,
`"${option}"'s chart-data request should have been answered`,
).not.toHaveLength(0);
expect(
statuses.every(status => status === 200 || status === 202),
`"${option}"'s chart-data statuses should all be 200/202, got ${statuses.join(', ')}`,
).toBe(true);
expect(
statuses[statuses.length - 1],
`"${option}"'s chart-data round trip should end with a 200`,
).toBe(200);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
await expect(value).toHaveText(/\d/, { timeout: TIMEOUT.CHART_RENDER });
return (await value.textContent()) ?? '';
};
const boyText = await applyAndRead('boy');
const girlText = await applyAndRead('girl');
// Without this the test could not fail: the screen shows "girl" throughout
// the race, so a clobber is only detectable as a change *to boy's number*.
expect(
girlText,
'"boy" and "girl" should render different counts, or a clobber would be invisible',
).not.toBe(boyText);
// Example queries settle in ~1-2s, too fast to race for real. Delaying only
// the next request makes "boy" reliably still in flight when "girl" fires.
// Both stamped at interception -- when the delay actually starts, and when
// girl's request actually leaves. Measuring either from the surrounding
// test flow would fold in the UI cycle and the response wait.
let boyParkedAt = 0;
let girlRequestedAt = 0;
let boyCancelled = false;
const raceRoute = (url: URL) =>
sliceIdFromChartDataUrl(url.toString()) === chartId;
await page.route(raceRoute, async route => {
// By payload rather than arrival order: both requests are for this slice.
const filterValues = nativeFilterValuesIn(
route.request().postData() ?? '',
FILTER_COLUMN,
);
const isBoy = filterValues.includes('boy');
if (isBoy && boyParkedAt === 0) {
boyParkedAt = Date.now();
await new Promise(resolve => {
setTimeout(resolve, RACE_DELAY_MS);
});
} else if (filterValues.includes('girl') && girlRequestedAt === 0) {
girlRequestedAt = Date.now();
}
// Continuing a request the app already aborted rejects; that rejection is
// the expected outcome here, not a test failure.
await route.continue().catch(() => {
boyCancelled = boyCancelled || isBoy;
});
});
page.on('requestfailed', request => {
if (
sliceIdFromChartDataUrl(request.url()) === chartId &&
nativeFilterValuesIn(request.postData() ?? '', FILTER_COLUMN).includes(
'boy',
)
) {
boyCancelled = true;
}
});
// The chart keeps rendering its previous value while a query is in flight,
// so asserting the expected text alone would pass against what is already
// on screen. These signals give a network-level proof instead.
const signals = trackGaqSignals(page);
await filterBar.selectOption('boy');
await filterBar.apply();
// Deliberately no wait -- "boy" is still in flight as "girl" is applied.
await filterBar.selectOption('girl');
await filterBar.apply();
// Identify a response by the filter that produced it: both requests are for
// this same slice, so "first status seen for the slice" could be either.
const statusesFor = (value: string) =>
signals.submitStatusesWhere(chartId, body =>
nativeFilterValuesIn(body, FILTER_COLUMN).includes(value),
);
// "girl" ran once already, so this repeat may be a 200 cache hit rather
// than a fresh 202 -- but it has to be one of the two. Accepting any
// recorded status would accept a 4xx/5xx, and the text assertion below
// would then pass on the stale value still on screen.
await expect(() => {
const girlStatuses = statusesFor('girl');
expect(
girlStatuses,
'"girl"\'s fast chart-data request should have been answered',
).not.toHaveLength(0);
expect(
[200, 202],
'"girl"\'s fast chart-data submission should have succeeded (200 cache-hit or 202 async-accepted)',
).toContain(girlStatuses[0]);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
// The race only exists if "girl" started while "boy" was still parked.
// Compared stamp-to-stamp: measuring against `Date.now()` here would fold
// in however long "girl"'s response took, so a healthy race with a slow
// response would read as "boy was already released".
expect(
boyParkedAt,
'"boy"\'s request should have been intercepted and parked',
).toBeGreaterThan(0);
expect(
girlRequestedAt,
'"girl"\'s request should have been intercepted',
).toBeGreaterThan(0);
expect(
girlRequestedAt - boyParkedAt,
'"girl" should have started while "boy" was still parked',
).toBeLessThan(RACE_DELAY_MS);
await expect(value).toHaveText(girlText, {
timeout: TIMEOUT.UI_TRANSITION,
});
// What actually protects the screen: the app cancels the superseded request
// (`chartAction.ts` aborts the previous controller), so "boy"'s result never
// arrives. Wait out the rest of its park -- the window a regressed
// cancellation would let a stale result land in -- then check the mechanism
// and the screen separately.
await page.waitForTimeout(
Math.max(RACE_DELAY_MS - (Date.now() - boyParkedAt), 0) + 2000,
);
expect(
boyCancelled,
'"boy" should have been cancelled client-side when "girl" superseded it',
).toBe(true);
expect(
statusesFor('boy'),
'"boy" was superseded, so its result should never have reached the client',
).toHaveLength(0);
// Still "girl", and specifically not "boy": the two differ, so this fails
// if the superseded result ever reached the screen.
await expect(value).toHaveText(girlText);
await page.unroute(raceRoute);
},
);
testWithAssets(
'superseding an accepted query cancels its tasks instead of leaving them running',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const FILTER_COLUMN = 'gender';
// Long enough that "boy"'s waiter cannot settle before "girl" supersedes it.
const POLL_DELAY_MS = 10000;
// The test above parks "boy" before its POST reaches the server, so it only
// covers cancelling the *submission*. Once a submission is accepted the
// client holds a waiter for its task ids, and the abort has to reach that
// waiter too (`chartAction.ts` forwards its signal into `waitForAsyncData`,
// which calls `cancelUnwaitedTasks`). Drop that forwarding and superseded
// jobs keep running server-side with nobody waiting -- invisible to the
// test above, because "boy" never reaches 202 there.
//
// A per-run dataset keeps the cache cold, so "boy" is guaranteed to be
// accepted (202) rather than served synchronously from a warm cache.
const { datasetId } = await createCacheColdVirtualDataset(
page,
testAssets,
testWithAssets.info(),
{
namePrefix: 'gaq_tc9_supersede_cancel',
select: `SELECT name, ${FILTER_COLUMN} FROM birth_names`,
},
);
const { dashboardId, dashboard, filterBar } =
await setupDashboardWithSelectFilter(
page,
testAssets,
testWithAssets.info(),
{
datasetId,
namePrefix: 'gaq_tc9_supersede_cancel',
filterColumn: FILTER_COLUMN,
filterName: 'Gender',
// The per-run dataset carries no saved metrics.
chartSpec: BIG_NUMBER_ADHOC_COUNT_SPEC,
},
);
// Stall the completion transport rather than the submission: "boy" is
// accepted normally, but the client cannot observe its tasks finishing, so
// its waiter is still registered when "girl" aborts it. Without this the
// tasks could complete first and leave nothing to cancel.
const pollRoute = (url: URL) =>
url.toString().includes(GAQ.TASK_STATUS_CHANGES_PATH);
await page.route(pollRoute, async route => {
await new Promise(resolve => {
setTimeout(resolve, POLL_DELAY_MS);
});
await route.continue().catch(() => {});
});
let boyTaskIds: string[] = [];
page.on('response', async response => {
if (
response.request().method() !== 'POST' ||
!response.url().includes(GAQ.CHART_DATA_PATH) ||
response.status() !== 202 ||
!nativeFilterValuesIn(
response.request().postData() ?? '',
FILTER_COLUMN,
).includes('boy')
) {
return;
}
const body = await response.json().catch(() => null);
boyTaskIds = body?.task_ids ?? [];
});
const cancelledTaskIds: string[] = [];
page.on('request', request => {
const match = request.url().match(/\/api\/v1\/task\/([^/]+)\/cancel$/);
if (match && request.method() === 'POST') {
cancelledTaskIds.push(match[1]);
}
});
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad({ timeout: TIMEOUT.SLOW_TEST });
await filterBar.selectOption('boy');
await filterBar.apply();
await expect
.poll(() => boyTaskIds.length, {
message: '"boy" should have been accepted (202) onto the async path',
timeout: TIMEOUT.CHART_RENDER,
})
.toBeGreaterThan(0);
// Supersede it while its tasks are still outstanding.
await filterBar.selectOption('girl');
await filterBar.apply();
await expect
.poll(() => cancelledTaskIds, {
message:
'superseding an accepted query should cancel its tasks, so they do not keep running with nobody waiting',
timeout: TIMEOUT.CHART_RENDER,
})
.toEqual(expect.arrayContaining(boyTaskIds));
await page.unroute(pollRoute);
},
);
testWithAssets(
'a programmatic chart-data request stays synchronous unless it opts into async_mode',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const { dashboard, charts, valueLocators } =
await setupDashboardWithBigNumberCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
chartNamePrefix: 'gaq_tc6_async_mode_optin',
chartSpecs: [BIG_NUMBER_COUNT_SPEC],
},
);
const [chart] = charts;
const [value] = valueLocators;
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
// Under GTF, enabling the feature flag only makes async *available*: each
// request decides via an `async_mode` flag, which the endpoint defaults to
// false. So an API client that does not opt in keeps the synchronous 200
// flow even on a deployment where the browser is running everything async.
// Replaying the app's own payload keeps this honest -- a hand-built body
// could fail validation and 400 for reasons unrelated to async gating.
const submissionPromise = page.waitForRequest(
req =>
req.method() === 'POST' &&
req.url().includes('/api/v1/chart/data') &&
sliceIdFromChartDataUrl(req.url()) === chart.id,
{ timeout: TIMEOUT.CHART_RENDER },
);
await dashboard.forceRefresh();
const submission = await submissionPromise;
const body = submission.postDataJSON();
expect(
body,
'the app should have submitted a chart-data payload',
).toBeTruthy();
expect(
body.async_mode,
'the browser is expected to opt into async on a GAQ-enabled deployment',
).toBe(true);
// Same payload, async_mode dropped and the cache bypassed, so a 200 here
// means the query really ran inline rather than being served from a warm
// cache entry that would mask the difference. `apiPost` carries the browser
// session and the CSRF token the mutation needs.
const { async_mode: _optedIn, ...syncBody } = body;
const response = await apiPost(
page,
submission.url(),
{ ...syncBody, force: true },
// The status is the assertion here, so it must not throw on a non-2xx.
{ failOnStatusCode: false },
);
expect(
response.status(),
'without async_mode the request should be answered synchronously (200), never queued (202)',
).toBe(200);
},
);
// Pre-existing noise in this dev stack (HMR socket, antd deprecations, a React
// key warning in MetadataBar) -- present on any page load, not caused by
// navigating away mid-load.
const BENIGN_CONSOLE_NOISE = [
/WebSocket connection to 'ws:\/\/.*\/ws' failed/,
/\[webpack-dev-server\]/,
/Warning: \[antd:/,
/Warning: Each child in a list should have a unique "key" prop/,
];
testWithAssets(
'navigating away mid-load and back causes no console errors and re-renders cleanly',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('console', message => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
page.on('pageerror', error => {
pageErrors.push(error.message);
});
const { dashboardId, dashboard, valueLocators } =
await setupDashboardWithBigNumberCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
chartNamePrefix: 'gaq_tc7_navigate_away',
chartSpecs: [BIG_NUMBER_COUNT_SPEC],
},
);
const [value] = valueLocators;
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
await expect(value).toHaveText(/\d/);
const valueBeforeLeaving = await value.textContent();
// forceRefresh() only awaits the menu click, so wait for the request to
// actually ship -- otherwise navigation wins the race and nothing was ever
// in flight to tear down. Superset fully reloads between top-level
// sections, so leaving for Welcome really does destroy the query's context.
const refreshRequestPromise = page.waitForRequest(
request =>
request.method() === 'POST' &&
request.url().includes('/api/v1/chart/data'),
);
await dashboard.forceRefresh();
await refreshRequestPromise;
await page.goto('/superset/welcome/');
await expect(page.getByRole('button', { name: /Recents/i })).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
// The abandoned job keeps running server-side; coming back should be a
// clean re-fetch, not a stuck spinner or stale data.
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
// The value it had before, not merely "a digit": returning must re-fetch
// the same result rather than leave a stuck spinner or a different number.
await expect(value).toHaveText(valueBeforeLeaving ?? '');
const unexpectedConsoleErrors = consoleErrors.filter(
text => !BENIGN_CONSOLE_NOISE.some(pattern => pattern.test(text)),
);
expect(
unexpectedConsoleErrors,
'navigating away mid-load and back should not log any new browser console errors',
).toEqual([]);
expect(
pageErrors,
'navigating away mid-load and back should not throw any uncaught page errors',
).toEqual([]);
},
);

View File

@@ -0,0 +1,462 @@
/**
* 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.
*/
/**
* Global Async Queries (GAQ): the pipeline works for each of its consumers --
* a cold first load, a forced refresh, the cache-hit shortcut that bypasses the
* cycle entirely, many charts at once, and native filter value lookups.
*
* Failure and edge-case behavior lives in global-async-query-resilience.spec.ts.
* SQL Lab's smoke check lives in tests/sqllab/ but runs under the same
* `chromium-gaq` project as these specs -- `chromium-sqllab` deliberately
* excludes it via `testIgnore`, since it needs the flag only the GAQ job sets.
*
* Requires the `GLOBAL_ASYNC_QUERIES` feature flag, Redis, and a running
* Celery worker -- without a worker, submissions still return 202 but no job
* ever executes and these tests time out. The cache-hit test is the exception:
* it is served synchronously and needs only the flag.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { TIMEOUT } from '../../utils/constants';
import {
ADHOC_COUNT_NAME_METRIC,
BIG_NUMBER_ADHOC_COUNT_SPEC,
BIG_NUMBER_COUNT_SPEC,
BIG_NUMBER_QUERY_CLOCK_SPEC,
bigNumberValueLocator,
createCacheColdVirtualDataset,
createDashboardWithCharts,
createQueryClockDataset,
readQueryClock,
setupDashboardWithBigNumberCharts,
setupDashboardWithSelectFilter,
trackGaqSignals,
} from './dashboard-test-helpers';
import { DashboardPage } from '../../pages/DashboardPage';
import { isFeatureEnabled } from '../../helpers/featureFlags';
testWithAssets.beforeEach(async ({ page }) => {
await page.goto('chart/list/');
testWithAssets.skip(
!(await isFeatureEnabled(page, 'GLOBAL_ASYNC_QUERIES')),
'GLOBAL_ASYNC_QUERIES is not enabled on this instance',
);
});
testWithAssets(
'forced dashboard refresh goes through the GAQ 202 -> poll -> done cycle',
async ({ page, testAssets }) => {
// The chart renders the server clock rather than a count: example data is
// static, so "still shows a number" after a refresh holds whether the DOM
// re-rendered or never changed at all. A stamp that must advance does not.
const { datasetId } = await createQueryClockDataset(
page,
testAssets,
testWithAssets.info(),
{ namePrefix: 'gaq_tc1_cold_cache' },
);
const { dashboard, charts, valueLocators } =
await setupDashboardWithBigNumberCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetId,
chartNamePrefix: 'gaq_tc1_cold_cache',
chartSpecs: [BIG_NUMBER_QUERY_CLOCK_SPEC],
},
);
const [chart] = charts;
const [value] = valueLocators;
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
const clockBefore = await readQueryClock(value);
expect(clockBefore, 'the chart should render a stamp').toBeGreaterThan(0);
// Track only after the initial load settles, so these signals describe the
// forced refresh rather than the load that preceded it.
const signals = trackGaqSignals(page);
// A fresh chart's query can still collide with an identical one another
// suite already cached, so force the refresh: forced requests take the
// async path regardless of cache state.
await dashboard.forceRefresh();
await expect(() => {
expect(
signals.submitStatusFor(chart.id),
'forced chart-data submission should be accepted (202) onto the async path',
).toBe(202);
expect(
signals.sawTaskStatusPoll,
'the client should have polled /api/v1/task/status_changes while the tasks ran',
).toBe(true);
expect(
signals.submitStatusesFor(chart.id),
'the client should re-issue chart-data once the tasks finish and be served 200 from the warmed cache',
).toEqual([202, 200]);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
// Render-level proof, which the signals above cannot give: the refresh
// re-executed the query, so the stamp on screen has to be newer.
await expect
.poll(() => readQueryClock(value), {
message:
'the forced refresh should repaint the chart with its new result',
timeout: TIMEOUT.CHART_RENDER,
})
.toBeGreaterThan(clockBefore);
},
);
testWithAssets(
'a cold first load resolves through the GAQ cycle with no manual refresh',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// The test above forces a refresh, because a fresh chart over a shared
// physical table can collide with a query another suite already cached and
// a forced request takes the async path regardless of cache state. That
// leaves the unforced first load -- the one a real user gets -- unasserted.
//
// A cache-cold dataset removes the need to force anything, so the async
// cycle can be asserted on the initial render itself.
const { datasetId } = await createCacheColdVirtualDataset(
page,
testAssets,
testWithAssets.info(),
{ namePrefix: 'gaq_cold_first_load' },
);
const { dashboardId, charts } = await createDashboardWithCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetId,
chartNamePrefix: 'gaq_cold_first_load',
// An ad-hoc metric, not the saved `count` the physical example datasets
// ship with: a dataset created through the API carries no metrics at
// all, so a saved-metric reference would not resolve.
chartSpecs: [BIG_NUMBER_ADHOC_COUNT_SPEC],
},
);
const [chart] = charts;
const dashboard = new DashboardPage(page);
const value = bigNumberValueLocator(dashboard, chart.id);
// Track before navigating: the request under test is the one the first page
// load fires, so there is no later point at which to start listening.
const signals = trackGaqSignals(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad({ timeout: TIMEOUT.SLOW_TEST });
// The regression this guards: the chart resolves on the first load. An async
// handoff that never completes leaves a spinner here and the user has to
// refresh to get a number -- which the forced-refresh test cannot catch,
// because refreshing is the very thing it does.
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
await expect(value).toHaveText(/\d/);
await expect(() => {
expect(
signals.submitStatusFor(chart.id),
'a cold first load should be accepted (202) onto the async path',
).toBe(202);
expect(
signals.sawTaskStatusPoll,
'the client should have polled /api/v1/task/status_changes while the task ran',
).toBe(true);
expect(
signals.submitStatusesFor(chart.id),
'the first load alone should complete the round trip -- 202, then 200 from the warmed cache -- without any user-initiated refresh',
).toEqual([202, 200]);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
},
);
testWithAssets(
'reloading an already-cached dashboard serves the chart synchronously, without the async cycle',
async ({ page, testAssets }) => {
// This first load is what warms the cache -- not the request under test.
const { dashboard, charts, valueLocators } =
await setupDashboardWithBigNumberCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
chartNamePrefix: 'gaq_tc2_cache_hit',
chartSpecs: [BIG_NUMBER_COUNT_SPEC],
},
);
const [chart] = charts;
const [value] = valueLocators;
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
await expect(value).toHaveText(/\d/);
const cachedText = await value.textContent();
const signals = trackGaqSignals(page);
// A plain reload -- same filters, no forced refresh -- is what should take
// the cache-hit shortcut instead of re-entering the async cycle.
await page.reload();
await dashboard.waitForLoad();
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
// The exact value, not just "a digit": a cache hit must reproduce the
// cached result, and the reload rebuilds the DOM so this cannot pass on
// what was already on screen.
await expect(value).toHaveText(cachedText ?? '');
await expect(() => {
expect(
signals.submitStatusesFor(chart.id),
'a cache-hit reload should resolve chart-data synchronously (200) on the first request, never queueing onto the async path (202) or needing a re-request',
).toEqual([200]);
expect(
signals.sawTaskStatusPoll,
'a cache hit should never need to poll /api/v1/task/status_changes',
).toBe(false);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
},
);
testWithAssets(
'refreshing a dashboard with many charts resolves every chart independently and correctly',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Distinct names, so a misrouted event (one chart rendering another's
// result) is actually detectable -- identical queries would hide it.
const NAMES = [
'John',
'Mary',
'James',
'Linda',
'Robert',
'Patricia',
'Michael',
'Barbara',
];
// Static example data means a correct refresh reproduces every count
// exactly, so "same number as before" cannot tell a genuine re-render from
// a DOM that never updated. One extra chart shows the query clock, which
// must move on a correct refresh.
const { datasetId } = await createQueryClockDataset(
page,
testAssets,
testWithAssets.info(),
{ namePrefix: 'gaq_tc5_busy_dashboard' },
);
const { dashboard, charts, valueLocators } =
await setupDashboardWithBigNumberCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetId,
chartNamePrefix: 'gaq_tc5_busy_dashboard',
chartSpecs: [
...NAMES.map(name => ({
viz_type: 'big_number_total',
params: {
metric: ADHOC_COUNT_NAME_METRIC,
adhoc_filters: [
{
clause: 'WHERE',
expressionType: 'SIMPLE',
subject: 'name',
operator: '==',
comparator: name,
},
],
},
})),
BIG_NUMBER_QUERY_CLOCK_SPEC,
],
// 9 charts at the default width (4) would exceed the 12-column grid.
chartWidth: 1,
},
{ timeout: TIMEOUT.SLOW_TEST },
);
const nameCharts = charts.slice(0, NAMES.length);
const nameValues = valueLocators.slice(0, NAMES.length);
const clockValue = valueLocators[NAMES.length];
const readClock = () => readQueryClock(clockValue);
await Promise.all(
valueLocators.map(locator =>
expect(locator).toBeVisible({ timeout: TIMEOUT.CHART_RENDER }),
),
);
// Each chart's own filter is baked into its query, so its pre-refresh count
// is per-chart ground truth. "Values aren't all identical" would not catch
// two charts swapping results; "chart N still shows chart N's count" does.
const expectedValues = await Promise.all(
nameValues.map(locator => locator.textContent()),
);
const clockBefore = await readClock();
expect(
clockBefore,
'the clock chart should render a timestamp',
).toBeGreaterThan(0);
const signals = trackGaqSignals(page);
await dashboard.forceRefresh();
await expect(() => {
for (const chart of charts) {
expect(
signals.submitStatusesFor(chart.id),
`chart ${chart.id} (${chart.sliceName}) should have gone 202 onto the async path, then 200 on the re-request`,
).toEqual([202, 200]);
}
expect(
signals.taskStatusPollCount,
'the client should have polled /api/v1/task/status_changes while the concurrent tasks ran',
).toBeGreaterThan(0);
expect(
signals.cachedRereadCount,
'every chart should have completed its own 202 -> 200 round trip',
).toBeGreaterThanOrEqual(charts.length);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
// The round trips above are network-level proof. This is the render-level
// proof: a forced refresh re-executes the query, so the clock chart must
// show a later stamp than before -- something a DOM that never repainted
// could not do.
await expect
.poll(readClock, {
message:
'the clock chart should repaint with the timestamp of the re-executed query',
timeout: TIMEOUT.CHART_RENDER,
})
.toBeGreaterThan(clockBefore);
// If these names didn't produce distinct counts, the per-chart assertion
// below would pass no matter how badly results were shuffled.
expect(
new Set(expectedValues).size,
'each chart filters on a different name, so their pre-refresh counts should not all collapse to the same number',
).toBeGreaterThan(1);
// With the repaint established above, "same number as before" is a real
// correctness check rather than a tautology: static data means each chart
// must reproduce its own count, and only its own.
const displayedValues = await Promise.all(
nameValues.map(locator => locator.textContent()),
);
for (const [index, chart] of nameCharts.entries()) {
expect(
displayedValues[index],
`chart ${chart.id} (${chart.sliceName}) should show its own count (${expectedValues[index]}) after the refresh, not another chart's result`,
).toBe(expectedValues[index]);
}
},
);
testWithAssets(
"opening a native filter's value dropdown populates via the same async pipeline as chart data",
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// A filter's value query depends only on dataset/column, not on anything
// per-run, so a physical table would be cache-cold once and a cache hit on
// every later run -- and this test would stop exercising the pipeline.
const { datasetId } = await createCacheColdVirtualDataset(
page,
testAssets,
testWithAssets.info(),
{ namePrefix: 'gaq_tc8_filter_dropdown' },
);
const { dashboardId, dashboard, filterBar, chartId, value } =
await setupDashboardWithSelectFilter(
page,
testAssets,
testWithAssets.info(),
{
datasetId,
namePrefix: 'gaq_tc8_filter_dropdown',
// Thousands of distinct values, so populating the dropdown needs a
// real query rather than a handful of values the UI could inline.
filterColumn: 'name',
filterName: 'Name',
// The per-run dataset has no saved metrics, so the default spec's
// saved `count` would not resolve and the chart would error out.
chartSpec: BIG_NUMBER_ADHOC_COUNT_SPEC,
},
);
// Track before navigating: the fetch under test fires during the filter
// panel's own initialization, not in response to anything we do later.
const signals = trackGaqSignals(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad({ timeout: TIMEOUT.SLOW_TEST });
await dashboard.waitForChartsToLoad();
// The chart is not what this test is about, but a broken one must not slip
// through just because every assertion below looks at the filter: it has to
// render a number, and on this cache-cold dataset its own data has to come
// through the async cycle.
await expect(value).toHaveText(/\d/, { timeout: TIMEOUT.CHART_RENDER });
await expect(
dashboard.getChart(chartId).locator('.ant-alert-error'),
).not.toBeAttached();
await expect(() => {
expect(
signals.submitStatusesFor(chartId),
'the chart itself should have completed its 202 -> 200 round trip',
).toEqual([202, 200]);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
// Filter-value requests hit the same endpoint as chart data but carry no
// slice_id, which is exactly how they're told apart here.
//
// Asserted before the dropdown is touched at all: the options are fetched
// during panel init and then served from client state, so opening the
// dropdown does not trigger this cycle and must not appear to.
await expect(() => {
expect(
signals.submitStatusFor(),
'the filter-value fetch should be accepted (202) onto the async path, same as a chart-data request',
).toBe(202);
expect(
signals.sawTaskStatusPoll,
'the client should have polled /api/v1/task/status_changes while the filter-value tasks ran',
).toBe(true);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
// Separately: those already-fetched options actually render.
const filterSelect = filterBar.getFilterSelect();
await filterSelect.open();
await expect(filterSelect.options.first()).toBeVisible({
timeout: TIMEOUT.CHART_RENDER,
});
await expect(async () => {
expect(await filterSelect.options.count()).toBeGreaterThanOrEqual(5);
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
},
);

View File

@@ -0,0 +1,78 @@
/**
* 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.
*/
/**
* Global Async Queries (GAQ): SQL Lab keeps working with the flag turned on.
*
* SQL Lab's own async execution (`ASynchronousSqlJsonExecutor`) shares no code
* with GAQ, so this is one shallow smoke check that the flag does not leak into
* it -- not a SQL Lab suite (see sqllab.spec.ts). Needs only the feature flag.
*
* Runs under `chromium-gaq`, not `chromium-sqllab`: that project's `testIgnore`
* excludes it, because only the GAQ job turns the flag on. Without the
* exclusion it would sit in the ordinary SQL Lab run and skip itself every
* time, reporting coverage it never had.
*/
import { test, expect } from '../../helpers/fixtures/testAssets';
import { SqlLabPage } from '../../pages/SqlLabPage';
import { expectStatus } from '../../helpers/api/assertions';
import { GAQ, TIMEOUT } from '../../utils/constants';
import { isFeatureEnabled } from '../../helpers/featureFlags';
let sqlLabPage: SqlLabPage;
let sawTaskStatusPoll = false;
test.beforeEach(async ({ page }) => {
test.setTimeout(TIMEOUT.SLOW_TEST);
sqlLabPage = new SqlLabPage(page);
// Attached before the first navigation, not inside the test body: loading the
// SQL Lab bundle is itself part of what must not reach GAQ's pipeline. A
// listener registered after `gotoAndReady()` cannot see that traffic, so the
// never-polls assertion below would hold no matter what the page load did.
sawTaskStatusPoll = false;
page.on('response', response => {
if (
response.request().method() === 'GET' &&
response.url().includes(GAQ.TASK_STATUS_CHANGES_PATH)
) {
sawTaskStatusPoll = true;
}
});
await sqlLabPage.gotoAndReady();
test.skip(
!(await isFeatureEnabled(page, 'GLOBAL_ASYNC_QUERIES')),
'GLOBAL_ASYNC_QUERIES is not enabled on this instance',
);
});
test('runs a simple SELECT normally with GLOBAL_ASYNC_QUERIES enabled, never touching the GAQ task-status endpoint', async () => {
const response = await sqlLabPage.executeQuery('SELECT 1 AS test_col');
expectStatus(response, 200);
await sqlLabPage.waitForQueryResults('test_col');
const headers = await sqlLabPage.resultsGrid.getHeaderTexts();
expect(headers.some(h => h.includes('test_col'))).toBe(true);
expect(
sawTaskStatusPoll,
"SQL Lab execution should never touch GAQ's task-status polling endpoint -- it has its own, separate async mechanism",
).toBe(false);
});

View File

@@ -93,6 +93,24 @@ export const TIMEOUT = {
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
} as const;
/**
* Global Async Queries endpoints, as the browser calls them.
*
* Kept here rather than inline so the dashboard helpers and the SQL Lab spec
* assert against one definition: the SQL Lab case asserts this endpoint is
* *never* touched, which would silently start passing for the wrong reason if
* the two copies of the path ever drifted apart.
*/
export const GAQ = {
/**
* Task-status poll the client uses to observe completion. Replaced
* `/api/v1/async_event/` when GAQ moved onto the Global Task Framework.
*/
TASK_STATUS_CHANGES_PATH: '/api/v1/task/status_changes',
/** Chart-data endpoint both chart and native-filter-value queries go to. */
CHART_DATA_PATH: '/api/v1/chart/data',
} as const;
/**
* Embedded dashboard test app configuration.
* The test app is served by a Node.js http server started in the test fixture.

View File

@@ -0,0 +1,59 @@
# 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.
# flake8: noqa
# type: ignore
"""Config for the Global Async Queries Playwright job only.
This deliberately does NOT live in ``superset_test_config``. That module is
imported by ``tests/conftest.py`` on every test run, and
``CacheManager._init_distributed_coordination`` only *sets*
``_distributed_coordination`` when a config is present -- it never clears it.
Putting ``DISTRIBUTED_COORDINATION_CONFIG`` in the shared config therefore
leaves the process-wide ``cache_manager`` singleton pointing at a real Redis
for unit tests too, so any test reaching ``DistributedLock`` /
``CoordinationService`` without mocking would silently take the Redis path
instead of the documented no-op default, and fail outright wherever
localhost:6379 is not available.
"""
import os
from datetime import timedelta
from .superset_test_config import * # noqa: F403
from .superset_test_config import REDIS_HOST, REDIS_PORT
# Async chart data runs on the Global Task Framework, which reaches Redis
# through the coordination service rather than a GAQ-specific cache backend
# (`GLOBAL_ASYNC_QUERIES_CACHE_BACKEND` was removed with that migration). The
# config default is `None`, so without this there is no coordinator: task
# completion is never signalled, submissions return 202 and the client waits
# forever. Built from the same environment variables as `CACHE_CONFIG`, with
# its own DB index so coordination streams stay out of the query cache.
#
# Note the discrete host/port/db keys rather than a CACHE_REDIS_URL: the Redis
# backends read CACHE_REDIS_HOST/PORT/DB and ignore a URL entirely, so supplying
# one silently leaves the connection on its localhost:6379 defaults.
COORDINATION_REDIS_DB = os.environ.get("COORDINATION_REDIS_DB", 5)
DISTRIBUTED_COORDINATION_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_DEFAULT_TIMEOUT": int(timedelta(minutes=10).total_seconds()),
"CACHE_KEY_PREFIX": "superset_coordination",
"CACHE_REDIS_HOST": REDIS_HOST,
"CACHE_REDIS_PORT": int(REDIS_PORT),
"CACHE_REDIS_DB": int(COORDINATION_REDIS_DB),
}

View File

@@ -263,3 +263,14 @@ def test_main_languages_respects_the_99_file_cap(tmp_path, monkeypatch) -> None:
output = output_file.read_text()
assert 'languages=["javascript", "python"]' in output
@pytest.mark.parametrize("group", ["frontend", "python"])
def test_composite_action_changes_trigger_tests(group: str) -> None:
"""Composite actions are shared setup matched by nothing else, so a change
to one can break a job while touching neither superset-frontend/ nor
superset/. setup-backend is a backend action, so "python" needs it too."""
assert change_detector.detect_changes(
[".github/actions/setup-backend/action.yml"],
change_detector.PATTERNS[group],
)