feat: add global async query playwright tests (#43004)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
149
.github/workflows/bashlib.sh
vendored
149
.github/workflows/bashlib.sh
vendored
@@ -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
|
||||
|
||||
169
.github/workflows/superset-e2e.yml
vendored
169
.github/workflows/superset-e2e.yml
vendored
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ''));
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
59
tests/integration_tests/superset_test_config_gaq.py
Normal file
59
tests/integration_tests/superset_test_config_gaq.py
Normal 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),
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user