mirror of
https://github.com/apache/superset.git
synced 2026-06-11 18:49:15 +00:00
Compare commits
202 Commits
aminghader
...
url-param-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376b5c9491 | ||
|
|
8eda626466 | ||
|
|
fe9818226d | ||
|
|
1e8438a478 | ||
|
|
8fdabc44f5 | ||
|
|
e9e9245112 | ||
|
|
580be2cf32 | ||
|
|
911bb9dcda | ||
|
|
507cf93687 | ||
|
|
ba6e9cc90f | ||
|
|
228ac0d568 | ||
|
|
c6ecaf9642 | ||
|
|
534d2191ff | ||
|
|
709fd52b0b | ||
|
|
c5d795c1f1 | ||
|
|
983f2818b0 | ||
|
|
b4eda37fbf | ||
|
|
a5fe47ee71 | ||
|
|
dc423b22b3 | ||
|
|
7c7ab88a60 | ||
|
|
21189ae130 | ||
|
|
06f95f5362 | ||
|
|
5da63d716b | ||
|
|
9bb700ff0d | ||
|
|
c0a12f4cfb | ||
|
|
138e405cb6 | ||
|
|
849f297e9d | ||
|
|
9da4536354 | ||
|
|
2463eb65b1 | ||
|
|
d3f07a7ba5 | ||
|
|
6348aa1917 | ||
|
|
ef7379c47e | ||
|
|
84aaaaa6b0 | ||
|
|
b85a2cdab1 | ||
|
|
381b99ae84 | ||
|
|
6b0d747939 | ||
|
|
151df43d9d | ||
|
|
3d7021fdf9 | ||
|
|
2babb48081 | ||
|
|
4715cfd372 | ||
|
|
5a6306983e | ||
|
|
7f452e4096 | ||
|
|
7eaaffde89 | ||
|
|
0984839788 | ||
|
|
863e93539a | ||
|
|
81bc3088e2 | ||
|
|
19d01521bf | ||
|
|
1623ceda73 | ||
|
|
e956f82224 | ||
|
|
2aca35cb68 | ||
|
|
44777cc110 | ||
|
|
20024ce3af | ||
|
|
b069b6caf6 | ||
|
|
70ee6e21eb | ||
|
|
550c80f640 | ||
|
|
108e40cbb6 | ||
|
|
8119204857 | ||
|
|
645aa3b1df | ||
|
|
55bb75efe6 | ||
|
|
601f9c2b8c | ||
|
|
fa42b13eb8 | ||
|
|
aa4092ba68 | ||
|
|
45a616439b | ||
|
|
98c096df05 | ||
|
|
42367afb25 | ||
|
|
875673f670 | ||
|
|
79c74af2e9 | ||
|
|
7406098708 | ||
|
|
ccce0cab18 | ||
|
|
94c1a1b1f2 | ||
|
|
04939c94cc | ||
|
|
937eff6d52 | ||
|
|
f5f4a41598 | ||
|
|
639866625d | ||
|
|
7d323dc0ae | ||
|
|
0d1b702ce8 | ||
|
|
ddeec68c88 | ||
|
|
0ad09d5cd0 | ||
|
|
6662529306 | ||
|
|
09cd2c26cd | ||
|
|
cbd731e661 | ||
|
|
3f94c9db2d | ||
|
|
80a3df3550 | ||
|
|
6f97d9817e | ||
|
|
7d69f76127 | ||
|
|
9a31362fa5 | ||
|
|
cd5bdf11ac | ||
|
|
75d94ff466 | ||
|
|
c505c70c52 | ||
|
|
23d18743bd | ||
|
|
ddb09f468d | ||
|
|
8dcc7e7eec | ||
|
|
ff5e43c8a0 | ||
|
|
bdb081329f | ||
|
|
aa547da960 | ||
|
|
966c243db6 | ||
|
|
696705794b | ||
|
|
41572dbf9d | ||
|
|
5ba60d51fd | ||
|
|
cf5307d0c6 | ||
|
|
9d1bc6b2cc | ||
|
|
6a125bf774 | ||
|
|
43fde2fb07 | ||
|
|
2be2246a00 | ||
|
|
80a5f6b787 | ||
|
|
c373da1bb9 | ||
|
|
80ea36c852 | ||
|
|
6ea4e22785 | ||
|
|
fcb1e299ac | ||
|
|
f4dfb7f026 | ||
|
|
001834470b | ||
|
|
e5c7200551 | ||
|
|
cb2a56d16e | ||
|
|
e5ff6de790 | ||
|
|
accc94da51 | ||
|
|
c914df5a67 | ||
|
|
e3ba85b1a5 | ||
|
|
b8a2f925ee | ||
|
|
77c2bed5f7 | ||
|
|
56fd991efd | ||
|
|
61b32d1b7d | ||
|
|
3191b0fdcd | ||
|
|
cf08a5ebf7 | ||
|
|
f7f50a7977 | ||
|
|
725f5ed2a9 | ||
|
|
faa76f6741 | ||
|
|
8e4a460cc7 | ||
|
|
b9dc9d722e | ||
|
|
fa41769a08 | ||
|
|
df21fe6571 | ||
|
|
12bef03f4a | ||
|
|
0b9764aed5 | ||
|
|
ac522ded1c | ||
|
|
c54990c861 | ||
|
|
3bbb35e8a3 | ||
|
|
a2a369cb5c | ||
|
|
9af6746dbe | ||
|
|
6abee0289b | ||
|
|
8c62f533d7 | ||
|
|
17d1a45bc9 | ||
|
|
6eaee211aa | ||
|
|
3e589436fa | ||
|
|
a9df2c7e5e | ||
|
|
8508af3201 | ||
|
|
49f3dbba73 | ||
|
|
616c243278 | ||
|
|
00dd31494d | ||
|
|
b97d3ef520 | ||
|
|
4d2b10d916 | ||
|
|
86fa5bb46f | ||
|
|
19c2b67d09 | ||
|
|
d2d46169bf | ||
|
|
1b8099811b | ||
|
|
242c27a974 | ||
|
|
24422c8311 | ||
|
|
1632b235ae | ||
|
|
093b43c7a5 | ||
|
|
4996d7c277 | ||
|
|
d26a7aac3d | ||
|
|
699e741c69 | ||
|
|
fc0245bdb0 | ||
|
|
7275116f4c | ||
|
|
88abd41c8b | ||
|
|
ddb647cd3a | ||
|
|
aba6ea536c | ||
|
|
ca8855dc03 | ||
|
|
052e567f77 | ||
|
|
e2ed989639 | ||
|
|
2abbb64e6b | ||
|
|
c6faa50338 | ||
|
|
817a35f445 | ||
|
|
a6d2c95480 | ||
|
|
c29591b3b1 | ||
|
|
365914f1c7 | ||
|
|
41da35e9db | ||
|
|
861e668f74 | ||
|
|
41059c68bb | ||
|
|
94092d2f72 | ||
|
|
986148d924 | ||
|
|
f04221a06c | ||
|
|
70aa96458a | ||
|
|
8beea84952 | ||
|
|
3f0fbbaac9 | ||
|
|
ce602fc5a8 | ||
|
|
8731974e5c | ||
|
|
a06eb8fc78 | ||
|
|
aa8b474c58 | ||
|
|
efdfefeea2 | ||
|
|
f77fa3ae39 | ||
|
|
bffc3fc58f | ||
|
|
2b8e31bf68 | ||
|
|
74d1c83ec5 | ||
|
|
1523d797ca | ||
|
|
97be689b5c | ||
|
|
dab628c13a | ||
|
|
e1bc8e7ae8 | ||
|
|
5312d0adf8 | ||
|
|
041ecbc248 | ||
|
|
a33fcb0edd | ||
|
|
316dd3db22 | ||
|
|
d52e28c564 | ||
|
|
a2eda11a81 |
14
.asf.yaml
14
.asf.yaml
@@ -77,23 +77,17 @@ github:
|
||||
# combination here.
|
||||
contexts:
|
||||
- lint-check
|
||||
- cypress-matrix (0, chrome)
|
||||
- cypress-matrix (1, chrome)
|
||||
- cypress-matrix (2, chrome)
|
||||
- cypress-matrix (3, chrome)
|
||||
- cypress-matrix (4, chrome)
|
||||
- cypress-matrix (5, chrome)
|
||||
- cypress-matrix-required
|
||||
- dependency-review
|
||||
- frontend-build
|
||||
- playwright-tests (chromium)
|
||||
- playwright-tests-required
|
||||
- pre-commit (current)
|
||||
- pre-commit (previous)
|
||||
- test-mysql
|
||||
- test-postgres (current)
|
||||
- test-postgres-required
|
||||
- test-postgres-hive
|
||||
- test-postgres-presto
|
||||
- test-sqlite
|
||||
- unit-tests (current)
|
||||
- unit-tests-required
|
||||
|
||||
required_pull_request_reviews:
|
||||
dismiss_stale_reviews: false
|
||||
|
||||
20
.github/workflows/bashlib.sh
vendored
20
.github/workflows/bashlib.sh
vendored
@@ -20,10 +20,6 @@ set -e
|
||||
GITHUB_WORKSPACE=${GITHUB_WORKSPACE:-.}
|
||||
ASSETS_MANIFEST="$GITHUB_WORKSPACE/superset/static/assets/manifest.json"
|
||||
|
||||
# Rounded job start time, used to create a unique Cypress build id for
|
||||
# parallelization so we can manually rerun a job after 20 minutes
|
||||
NONCE=$(echo "$(date "+%Y%m%d%H%M") - ($(date +%M)%20)" | bc)
|
||||
|
||||
# Echo only when not in parallel mode
|
||||
say() {
|
||||
if [[ $(echo "$INPUT_PARALLEL" | tr '[:lower:]' '[:upper:]') != 'TRUE' ]]; then
|
||||
@@ -59,6 +55,15 @@ build-assets() {
|
||||
say "::endgroup::"
|
||||
}
|
||||
|
||||
build-embedded-sdk() {
|
||||
cd "$GITHUB_WORKSPACE/superset-embedded-sdk"
|
||||
|
||||
say "::group::Build embedded SDK bundle for E2E tests"
|
||||
npm ci
|
||||
npm run build
|
||||
say "::endgroup::"
|
||||
}
|
||||
|
||||
build-instrumented-assets() {
|
||||
cd "$GITHUB_WORKSPACE/superset-frontend"
|
||||
|
||||
@@ -276,7 +281,12 @@ playwright-run() {
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
local serverlog="${HOME}/superset-playwright.log"
|
||||
local port=8081
|
||||
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
|
||||
# Use 127.0.0.1 explicitly: `flask run` binds IPv4 only, and Node's DNS
|
||||
# resolution for `localhost` can return `::1` first (IPv6), which then
|
||||
# refuses against the IPv4 listener and surfaces as
|
||||
# `connect ECONNREFUSED ::1:<port>` in API helpers driven from Node
|
||||
# (e.g., the embedded test app's exposed token fetcher).
|
||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:${port}"
|
||||
if [ -n "$APP_ROOT" ]; then
|
||||
export SUPERSET_APP_ROOT=$APP_ROOT
|
||||
PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}${APP_ROOT}/
|
||||
|
||||
13
.github/workflows/check-python-deps.yml
vendored
13
.github/workflows/check-python-deps.yml
vendored
@@ -38,6 +38,19 @@ jobs:
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
|
||||
# Authenticate the Docker daemon so the python:slim pull in
|
||||
# uv-pip-compile.sh uses our (much higher) authenticated rate limit
|
||||
# instead of the shared-runner anonymous one. Best-effort: on fork PRs the
|
||||
# secrets are unavailable, so this no-ops and the pull falls back to
|
||||
# anonymous (covered by the retry loop in the script).
|
||||
- name: Login to Docker Hub
|
||||
if: steps.check.outputs.python
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Run uv
|
||||
if: steps.check.outputs.python
|
||||
run: ./scripts/uv-pip-compile.sh
|
||||
|
||||
35
.github/workflows/codeql-analysis.yml
vendored
35
.github/workflows/codeql-analysis.yml
vendored
@@ -15,9 +15,35 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
analyze:
|
||||
name: Analyze
|
||||
needs: changes
|
||||
# Skip on PRs that touch neither code group (e.g. docs-only) so the
|
||||
# analysis runners don't spin up. push/schedule runs always proceed:
|
||||
# the change-detector returns "all changed" for non-PR events.
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -31,16 +57,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
@@ -54,7 +74,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
54
.github/workflows/docker.yml
vendored
54
.github/workflows/docker.yml
vendored
@@ -19,8 +19,30 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
docker: ${{ steps.check.outputs.docker }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
setup_matrix:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matrix_config: ${{ steps.set_matrix.outputs.matrix_config }}
|
||||
steps:
|
||||
@@ -32,8 +54,13 @@ jobs:
|
||||
|
||||
docker-build:
|
||||
name: docker-build
|
||||
needs: setup_matrix
|
||||
needs: [setup_matrix, changes]
|
||||
if: >-
|
||||
needs.changes.outputs.python == 'true' ||
|
||||
needs.changes.outputs.frontend == 'true' ||
|
||||
needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
build_preset: ${{fromJson(needs.setup_matrix.outputs.matrix_config)}}
|
||||
@@ -50,14 +77,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Docker Environment
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
@@ -65,11 +85,9 @@ jobs:
|
||||
build: "true"
|
||||
|
||||
- name: Setup supersetbot
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Build Docker Image
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -95,7 +113,7 @@ jobs:
|
||||
|
||||
# in the context of push (using multi-platform build), we need to pull the image locally
|
||||
- name: Docker pull
|
||||
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
for i in 1 2 3; do
|
||||
docker pull $IMAGE_TAG && break
|
||||
@@ -103,7 +121,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Print docker stats
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
run: |
|
||||
echo "SHA: ${{ github.sha }}"
|
||||
echo "IMAGE: $IMAGE_TAG"
|
||||
@@ -111,7 +128,7 @@ jobs:
|
||||
docker history $IMAGE_TAG
|
||||
|
||||
- name: docker-compose sanity check
|
||||
if: (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'dev'
|
||||
if: matrix.build_preset == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_PRESET: ${{ matrix.build_preset }}
|
||||
@@ -124,20 +141,16 @@ jobs:
|
||||
docker-compose-image-tag:
|
||||
# Run this job only on pushes to master (not for PRs)
|
||||
# goal is to check that building the latest image works, not required for all PR pushes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
needs: changes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Docker Environment
|
||||
if: steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
@@ -145,7 +158,6 @@ jobs:
|
||||
build: "false"
|
||||
install-docker-compose: "true"
|
||||
- name: docker-compose sanity check
|
||||
if: steps.check.outputs.docker
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init
|
||||
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
validate-all-ghas:
|
||||
|
||||
5
.github/workflows/labeler.yml
vendored
5
.github/workflows/labeler.yml
vendored
@@ -2,6 +2,11 @@ name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
|
||||
5
.github/workflows/pr-lint.yml
vendored
5
.github/workflows/pr-lint.yml
vendored
@@ -8,6 +8,11 @@ on:
|
||||
# Possible values: https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-check:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
8
.github/workflows/pre-commit.yml
vendored
8
.github/workflows/pre-commit.yml
vendored
@@ -19,9 +19,13 @@ concurrency:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
# Run the full version spread on push (master/release) and nightly,
|
||||
# but only the current version on PRs — lint/format/type results
|
||||
# rarely differ across patch versions, so 3x per PR is wasteful.
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -45,6 +49,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: |
|
||||
|
||||
129
.github/workflows/superset-e2e.yml
vendored
129
.github/workflows/superset-e2e.yml
vendored
@@ -27,9 +27,32 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
cypress-matrix:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -40,9 +63,14 @@ jobs:
|
||||
# https://github.com/cypress-io/github-action/issues/48
|
||||
fail-fast: false
|
||||
matrix:
|
||||
parallel_id: [0, 1, 2, 3, 4, 5]
|
||||
parallel_id: [0, 1]
|
||||
browser: ["chrome"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
# The /app/prefix variant (push events only) is smoke-tested on a single
|
||||
# shard rather than the full matrix, so exclude it from the other shards.
|
||||
exclude:
|
||||
- parallel_id: 1
|
||||
app_root: "/app/prefix"
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -89,51 +117,40 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Install cypress
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: cypress-install
|
||||
- name: Run Cypress
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ matrix.browser }}
|
||||
PARALLEL_ID: ${{ matrix.parallel_id }}
|
||||
PARALLELISM: 6
|
||||
PARALLELISM: 2
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
@@ -154,7 +171,10 @@ jobs:
|
||||
name: cypress-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}-${{ matrix.parallel_id }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
playwright-tests:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -207,46 +227,39 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Build embedded SDK
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-embedded-sdk
|
||||
- name: Install Playwright
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright (Required Tests)
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
@@ -268,3 +281,63 @@ jobs:
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ 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 }}
|
||||
|
||||
# Stable required-status-check anchors. cypress-matrix and playwright-tests
|
||||
# are matrix jobs gated on change detection (python || frontend). On a PR
|
||||
# that touches neither — e.g. a docs-only PR — they are skipped at the job
|
||||
# level, which happens before matrix expansion, so the per-combination
|
||||
# contexts (`cypress-matrix (0, chrome)`, `playwright-tests (chromium)`) are
|
||||
# never produced and branch protection waits on them forever. These
|
||||
# always-running jobs report a single stable context that passes when the
|
||||
# underlying matrix job succeeded or was skipped, and fails only on a real
|
||||
# failure. Require these in .asf.yaml instead of the matrix-expanded names.
|
||||
#
|
||||
# A matrix job reads as "skipped" in two distinct cases, and only the first
|
||||
# is a legitimate pass: (a) change detection succeeded and gated the job off
|
||||
# (docs-only PR); (b) the `changes` job itself failed or was cancelled, in
|
||||
# which case GHA skips its dependents too. Accepting (b) would let a broken
|
||||
# change-detector report a false green, so each anchor first requires
|
||||
# `changes` to have succeeded before honouring a skip.
|
||||
cypress-matrix-required:
|
||||
needs: [changes, cypress-matrix]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Check cypress-matrix result
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
RESULT: ${{ needs.cypress-matrix.result }}
|
||||
run: |
|
||||
if [ "$CHANGES" != "success" ]; then
|
||||
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "cypress-matrix did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "cypress-matrix result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
playwright-tests-required:
|
||||
needs: [changes, playwright-tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Check playwright-tests result
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
RESULT: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
if [ "$CHANGES" != "success" ]; then
|
||||
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "playwright-tests did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "playwright-tests result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
@@ -20,9 +20,12 @@ concurrency:
|
||||
jobs:
|
||||
test-superset-extensions-cli-package:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: superset-extensions-cli
|
||||
|
||||
6
.github/workflows/superset-frontend.yml
vendored
6
.github/workflows/superset-frontend.yml
vendored
@@ -22,6 +22,7 @@ permissions:
|
||||
jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
should-run: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
@@ -74,6 +75,7 @@ jobs:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -103,6 +105,7 @@ jobs:
|
||||
needs: [sharded-jest-tests]
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
@@ -144,6 +147,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -168,6 +172,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -187,6 +192,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
||||
56
.github/workflows/superset-playwright.yml
vendored
56
.github/workflows/superset-playwright.yml
vendored
@@ -23,10 +23,33 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests)
|
||||
# This workflow contains only experimental tests that run in shadow mode
|
||||
playwright-tests-experimental:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -80,51 +103,58 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Build embedded SDK
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-embedded-sdk
|
||||
- name: Install Playwright
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright (Experimental Tests)
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" experimental/
|
||||
- name: Run Playwright (Embedded Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scope embedded-only env vars to this step. Setting them at the job
|
||||
# level enabled the EMBEDDED_SUPERSET feature flag inside Flask for
|
||||
# the preceding "Required Tests" and "Experimental Tests" steps too,
|
||||
# which loads extra handlers and destabilizes the werkzeug dev
|
||||
# server under the 2-worker Playwright load. Required Tests should
|
||||
# match master's Flask configuration.
|
||||
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||
INCLUDE_EMBEDDED: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" embedded
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
|
||||
@@ -14,8 +14,30 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-mysql:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test-mysql:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -27,6 +49,8 @@ jobs:
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
|
||||
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
@@ -47,26 +71,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
- name: Setup MySQL
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-mysql
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (MySQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
@@ -77,7 +92,6 @@ jobs:
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
- name: Generate database diagnostics for docs
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: |
|
||||
@@ -100,19 +114,23 @@ jobs:
|
||||
print(f'Generated diagnostics for {len(docs)} databases')
|
||||
"
|
||||
- name: Upload database diagnostics artifact
|
||||
if: steps.check.outputs.python
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: database-diagnostics
|
||||
path: databases-diagnostics.json
|
||||
retention-days: 7
|
||||
test-postgres:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -138,29 +156,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Postgres
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: |
|
||||
setup-postgres
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (PostgreSQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
@@ -172,7 +181,10 @@ jobs:
|
||||
slug: apache/superset
|
||||
|
||||
test-sqlite:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -194,28 +206,19 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: |
|
||||
# sqlite needs this working directory
|
||||
mkdir ${{ github.workspace }}/.temp
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (SQLite)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
@@ -225,3 +228,25 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
# Stable required-status-check anchor for the matrix-based test-postgres job.
|
||||
# It is gated on change detection, so on non-Python PRs it is skipped and
|
||||
# never produces its `test-postgres (current)` context (a job-level skip
|
||||
# happens before matrix expansion). This always-running job reports a single
|
||||
# context branch protection can require: it passes when test-postgres
|
||||
# succeeded or was skipped, and fails only on a real failure.
|
||||
test-postgres-required:
|
||||
needs: [changes, test-postgres]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check test-postgres result
|
||||
env:
|
||||
RESULT: ${{ needs.test-postgres.result }}
|
||||
run: |
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "test-postgres did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "test-postgres result: $RESULT"
|
||||
|
||||
@@ -15,8 +15,30 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-postgres-presto:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test-postgres-presto:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -54,28 +76,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python == 'true'
|
||||
- name: Setup Postgres
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: |
|
||||
echo "${{ steps.check.outputs.python }}"
|
||||
setup-postgres
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python unit tests (PostgreSQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
@@ -87,7 +98,10 @@ jobs:
|
||||
slug: apache/superset
|
||||
|
||||
test-postgres-hive:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -117,35 +131,23 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create csv upload directory
|
||||
if: steps.check.outputs.python
|
||||
run: sudo mkdir -p /tmp/.superset/uploads
|
||||
- name: Give write access to the csv upload directory
|
||||
if: steps.check.outputs.python
|
||||
run: sudo chown -R $USER:$USER /tmp/.superset
|
||||
- name: Start hadoop and hive
|
||||
if: steps.check.outputs.python
|
||||
run: docker compose -f scripts/databases/hive/docker-compose.yml up -d
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
- name: Setup Postgres
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python unit tests (PostgreSQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
|
||||
58
.github/workflows/superset-python-unittest.yml
vendored
58
.github/workflows/superset-python-unittest.yml
vendored
@@ -15,13 +15,37 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
unit-tests:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
@@ -30,25 +54,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Python unit tests
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50
|
||||
- name: Python 100% coverage unit tests
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
@@ -62,3 +78,25 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
|
||||
# change detection, so on non-Python PRs it is skipped and never produces its
|
||||
# `unit-tests (current)` context (a job-level skip happens before matrix
|
||||
# expansion). This always-running job reports a single context that branch
|
||||
# protection can require: it passes when unit-tests succeeded or was skipped,
|
||||
# and fails only on a real failure.
|
||||
unit-tests-required:
|
||||
needs: [changes, unit-tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check unit-tests result
|
||||
env:
|
||||
RESULT: ${{ needs.unit-tests.result }}
|
||||
run: |
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "unit-tests did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "unit-tests result: $RESULT"
|
||||
|
||||
12
.github/workflows/superset-translations.yml
vendored
12
.github/workflows/superset-translations.yml
vendored
@@ -41,6 +41,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
@@ -113,13 +115,9 @@ jobs:
|
||||
--translations-dir /tmp/base-worktree/superset/translations \
|
||||
> /tmp/before.json
|
||||
|
||||
# Reset the PR worktree's translations to the pristine BASE state so
|
||||
# both babel_update runs start from the same .po files. The only
|
||||
# difference between the runs is the source code.
|
||||
- name: Reset PR worktree translations to pristine BASE
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: git checkout FETCH_HEAD -- superset/translations/
|
||||
|
||||
# Run babel_update against the PR source and PR translations. This keeps
|
||||
# committed .po fixes in play while the base babel_update above still
|
||||
# cancels out translation drift already present on the base branch.
|
||||
- name: Run babel_update against PR source
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: ./scripts/translations/babel_update.sh
|
||||
|
||||
1
.github/workflows/superset-websocket.yml
vendored
1
.github/workflows/superset-websocket.yml
vendored
@@ -22,6 +22,7 @@ concurrency:
|
||||
jobs:
|
||||
app-checks:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
@@ -189,6 +189,11 @@ Try out Superset's [quickstart](https://superset.apache.org/docs/quickstart/) gu
|
||||
- [Join our community's Slack](http://bit.ly/join-superset-slack)
|
||||
and please read our [Slack Community Guidelines](https://github.com/apache/superset/blob/master/CODE_OF_CONDUCT.md#slack-community-guidelines)
|
||||
- [Join our dev@superset.apache.org Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org). To join, simply send an email to [dev-subscribe@superset.apache.org](mailto:dev-subscribe@superset.apache.org)
|
||||
- Follow us on social media:
|
||||
[X](https://x.com/apachesuperset) |
|
||||
[LinkedIn](https://www.linkedin.com/company/apache-superset) |
|
||||
[Bluesky](https://bsky.app/profile/apachesuperset.bsky.social) |
|
||||
[Reddit](https://reddit.com/r/apache-superset)
|
||||
- If you want to help troubleshoot GitHub Issues involving the numerous database drivers that Superset supports, please consider adding your name and the databases you have access to on the [Superset Database Familiarity Rolodex](https://docs.google.com/spreadsheets/d/1U1qxiLvOX0kBTUGME1AHHi6Ywel6ECF8xk_Qy-V9R8c/edit#gid=0)
|
||||
- Join Superset's Town Hall and [Operational Model](https://preset.io/blog/the-superset-operational-model-wants-you/) recurring meetings. Meeting info is available on the [Superset Community Calendar](https://superset.apache.org/community)
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ If yes, it is in scope. If no, it is out of scope. The lists below apply that te
|
||||
- Any action an Admin role can perform through documented configuration, API, or UI. The Admin role is a trusted operational principal by policy. Per MITRE CNA Operational Rules 4.1, a qualifying vulnerability must violate a security policy; behavior within a documented trust boundary does not.
|
||||
- Deployment or operator decisions: the values of secrets and tokens, whether internal networks are reachable from the server, which database connectors or cache backends are enabled, which feature flags are set, where notifications are delivered, and which third-party plugins are loaded.
|
||||
- Compromise, modification, or malicious control of trusted backend infrastructure. Apache Superset assumes the integrity of its metastore, cache backends (for example Redis or Memcached), message brokers, secret stores, and other operator-managed infrastructure. Findings that require an attacker to read from, write to, or otherwise tamper with these systems, including injecting malicious state, serialized objects, cache entries, task metadata, configuration, or database records, are post-compromise scenarios and do not constitute vulnerabilities in Apache Superset itself. A finding remains in scope only if an unprivileged user can cause such modification through a vulnerability in Apache Superset.
|
||||
- Code paths whose intended purpose is example data, demos, fixtures, local development, or documentation, rather than the production runtime.
|
||||
- The continued presence of expired key-value or metastore-cache entries that have not yet been deleted from the metadata database. Such entries are excluded from reads once expired, are purged opportunistically on write, and are removed in bulk by the scheduled `prune_key_value` maintenance task; their lingering until purged is an eventual-cleanup property, not a security boundary, and does not constitute a vulnerability.
|
||||
- How a downstream application (spreadsheet program, email client, browser handling user-downloaded files) interprets output Apache Superset produced for it.
|
||||
- Findings without a reproducible proof of concept against a supported release. The burden of demonstrating exploitability rests with the reporter; findings closed for lack of a proof of concept may be refiled if one is later produced.
|
||||
- Brute force, rate limiting, denial of service, or resource exhaustion that does not bypass a documented control.
|
||||
|
||||
39
UPDATING.md
39
UPDATING.md
@@ -24,6 +24,45 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Duration formatter precision
|
||||
|
||||
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
|
||||
|
||||
To preserve sub-second precision in custom duration formatters, enable `formatSubMilliseconds`.
|
||||
|
||||
### Cache warmup authenticates via SUPERSET_CACHE_WARMUP_USER
|
||||
|
||||
The `cache-warmup` Celery task now drives a real WebDriver session for reliable authentication and reads the user to authenticate as from the new `SUPERSET_CACHE_WARMUP_USER` config option. It no longer consults `CACHE_WARMUP_EXECUTORS` for the warmup path. `SUPERSET_CACHE_WARMUP_USER` defaults to `None`, so the task fails fast with a clear message until you set it. Operators who previously relied on `CACHE_WARMUP_EXECUTORS` for cache warmup must set `SUPERSET_CACHE_WARMUP_USER` to a dedicated least-privilege user with access to the dashboards they want warmed up before the next warmup run.
|
||||
|
||||
### YDB now uses a native sqlglot dialect
|
||||
|
||||
YDB SQL parsing now relies on the dedicated [`ydb-sqlglot-plugin`](https://pypi.org/project/ydb-sqlglot-plugin/) dialect, which registers itself with sqlglot automatically. YDB users must install this plugin (e.g., via `pip install "apache-superset[ydb]"`) to avoid a `ValueError` when Superset parses YDB queries.
|
||||
|
||||
### Embedded dashboards enforce configured Allowed Domains for postMessage
|
||||
|
||||
The embedded dashboard page now validates the origin of incoming `postMessage` events against the dashboard's configured **Allowed Domains**. The server-rendered embedded page exposes the configured domains in its bootstrap payload, and the frontend rejects message events whose origin is not in that list.
|
||||
|
||||
Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
|
||||
If you relied on importing datasets with a non-default catalog, enable "Allow changing catalogs" on the target connection, or set the dataset's catalog to the connection's default before importing.
|
||||
|
||||
### Extension supply-chain controls (denylist + version policy)
|
||||
|
||||
Two opt-in static gates control which extensions are allowed to load:
|
||||
|
||||
- `EXTENSION_DENYLIST` refuses extensions matching an id (every version) or `id@version` (a single version), e.g. `["compromised-extension", "other-ext@1.2.3"]`.
|
||||
- `EXTENSION_VERSION_POLICY` enforces a minimum version per extension id, e.g. `{"acme.widget": "1.2.0"}` (PEP 440 comparison); a release below the minimum is refused.
|
||||
|
||||
Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENSIONS` and `EXTENSIONS_PATH` load paths.
|
||||
|
||||
### Dynamic Group By respects the sort toggle for display values
|
||||
|
||||
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (A–Z), descending (Z–A), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of A–Z; open the customization and enable the toggle to restore alphabetical ordering.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
@@ -61,6 +61,31 @@ services:
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/templates:/etc/nginx/templates:ro
|
||||
# Wait for the webpack dev server's manifest.json to be served before
|
||||
# starting nginx. This prevents 404s on static assets at startup. The
|
||||
# probe targets host.docker.internal so it works regardless of whether
|
||||
# the dev server runs in the superset-node container
|
||||
# (BUILD_SUPERSET_FRONTEND_IN_DOCKER=true, the default) or directly on
|
||||
# the host (BUILD_SUPERSET_FRONTEND_IN_DOCKER=false).
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
url="http://host.docker.internal:9000/static/assets/manifest.json"
|
||||
max_attempts=150 # ~5 minutes at 2s intervals
|
||||
echo "Waiting for webpack dev server at $url..."
|
||||
attempt=0
|
||||
until curl -sf --max-time 5 -o /dev/null "$url"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "ERROR: webpack dev server did not serve $url after $max_attempts attempts (~5 minutes)." >&2
|
||||
echo "Is the dev server running? With BUILD_SUPERSET_FRONTEND_IN_DOCKER=false you must start it on the host (e.g. 'npm run dev' in superset-frontend)." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Webpack dev server is ready; starting nginx."
|
||||
exec nginx -g 'daemon off;'
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
|
||||
@@ -80,7 +80,23 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
|
||||
# Environment-based debugger control for security
|
||||
# Only enable Werkzeug interactive debugger when explicitly requested
|
||||
# Modern Werkzeug (3.0+) includes PIN protection, but defense-in-depth approach
|
||||
# Override FLASK_DEBUG so the effective state matches SUPERSET_DEBUG_ENABLED even
|
||||
# when FLASK_DEBUG=true is inherited from docker/.env or .flaskenv
|
||||
if [[ "${SUPERSET_DEBUG_ENABLED:-}" == "true" ]]; then
|
||||
export FLASK_DEBUG=1
|
||||
DEBUGGER_FLAG="--debugger"
|
||||
echo " ⚠️ Werkzeug debugger enabled (requires PIN for /console access)"
|
||||
else
|
||||
export FLASK_DEBUG=0
|
||||
DEBUGGER_FLAG="--no-debugger"
|
||||
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true to enable)"
|
||||
fi
|
||||
|
||||
flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
# Configuration for docker-compose-light.yml - disables Redis and uses minimal services
|
||||
|
||||
# Import all settings from the main config first
|
||||
import os
|
||||
|
||||
from flask_caching.backends.filesystemcache import FileSystemCache
|
||||
|
||||
from superset_config import * # noqa: F403
|
||||
@@ -36,3 +38,32 @@ THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG
|
||||
|
||||
# Disable Celery entirely for lightweight mode
|
||||
CELERY_CONFIG = None # type: ignore[assignment,misc]
|
||||
|
||||
# Honor SUPERSET_FEATURE_<NAME> env vars on top of any flags inherited from
|
||||
# superset_config. Lets local dev/e2e enable features (e.g. EMBEDDED_SUPERSET)
|
||||
# without editing shipped config files. Only the literal string "true"
|
||||
# (case-insensitive) is treated as enabled — "1"/"yes"/"on" are not, matching
|
||||
# the strict-string convention used elsewhere in Superset's env parsing.
|
||||
FEATURE_FLAGS = {
|
||||
**FEATURE_FLAGS, # noqa: F405
|
||||
**{
|
||||
name[len("SUPERSET_FEATURE_") :]: value.strip().lower() == "true"
|
||||
for name, value in os.environ.items()
|
||||
if name.startswith("SUPERSET_FEATURE_")
|
||||
},
|
||||
}
|
||||
|
||||
if os.environ.get("SUPERSET_FEATURE_EMBEDDED_SUPERSET", "").strip().lower() == "true":
|
||||
# Disable Talisman so /embedded/<uuid> doesn't return X-Frame-Options:SAMEORIGIN.
|
||||
# Without this, browsers refuse to render Superset inside an iframe from a
|
||||
# different origin (i.e. the embedded SDK use case). Production/CI configures
|
||||
# Talisman with explicit `frame-ancestors`; for the lightweight local stack we
|
||||
# just turn it off.
|
||||
TALISMAN_ENABLED = False
|
||||
|
||||
# Guest tokens (used by the embedded SDK) inherit the "Public" role's perms.
|
||||
# Out of the box Public has zero perms, so embedded dashboards immediately fail
|
||||
# their first call (`/api/v1/me/roles/`) with 403. Mirror Public to Gamma —
|
||||
# the standard read-only viewer role — so the embedded flow can authenticate
|
||||
# and load dashboard data in local dev.
|
||||
PUBLIC_ROLE_LIKE = "Gamma"
|
||||
|
||||
@@ -86,6 +86,39 @@ instead requires a cachelib object.
|
||||
|
||||
See [Async Queries via Celery](/admin-docs/configuration/async-queries-celery) for details.
|
||||
|
||||
## Celery beat
|
||||
|
||||
Superset has a Celery task that will periodically warm up the cache based on different strategies.
|
||||
To use it, add the following to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
from celery.schedules import crontab
|
||||
from superset.config import CeleryConfig
|
||||
|
||||
# User that will be used to authenticate and render dashboards for cache warmup
|
||||
SUPERSET_CACHE_WARMUP_USER = "user_with_permission_to_dashboards"
|
||||
|
||||
# Extend the default CeleryConfig to add cache warmup schedule
|
||||
class CustomCeleryConfig(CeleryConfig):
|
||||
beat_schedule = {
|
||||
**CeleryConfig.beat_schedule,
|
||||
'cache-warmup-hourly': {
|
||||
'task': 'cache-warmup',
|
||||
'schedule': crontab(minute=0, hour='*'), # hourly
|
||||
'kwargs': {
|
||||
'strategy_name': 'top_n_dashboards',
|
||||
'top_n': 5,
|
||||
'since': '7 days ago',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_CONFIG = CustomCeleryConfig
|
||||
```
|
||||
|
||||
This will cache the top 5 most popular dashboards every hour. For other
|
||||
strategies, check the `superset/tasks/cache.py` file.
|
||||
|
||||
## Caching Thumbnails
|
||||
|
||||
This is an optional feature that can be turned on by activating its [feature flag](/admin-docs/configuration/configuring-superset#feature-flags) on config:
|
||||
|
||||
@@ -157,8 +157,15 @@ superset load_examples
|
||||
superset init
|
||||
|
||||
# To start a development web server on port 8088, use -p to bind to another port
|
||||
superset run -p 8088 --with-threads --reload --debugger
|
||||
superset run -p 8088 --with-threads --reload
|
||||
|
||||
# For debugging with interactive console (⚠️ localhost only)
|
||||
# superset run -p 8088 --with-threads --reload --debugger
|
||||
```
|
||||
|
||||
:::warning Security Note
|
||||
The `--debugger` flag enables Werkzeug's interactive console at `/console`. Only use this for local development and never bind to `0.0.0.0` or expose the server to networks when debugging is enabled.
|
||||
:::
|
||||
|
||||
If everything worked, you should be able to navigate to `hostname:port` in your browser (e.g.
|
||||
locally by default at `localhost:8088`) and login using the username and password you created.
|
||||
|
||||
@@ -157,8 +157,15 @@ superset load_examples
|
||||
superset init
|
||||
|
||||
# To start a development web server on port 8088, use -p to bind to another port
|
||||
superset run -p 8088 --with-threads --reload --debugger
|
||||
superset run -p 8088 --with-threads --reload
|
||||
|
||||
# For debugging with interactive console (⚠️ localhost only)
|
||||
# superset run -p 8088 --with-threads --reload --debugger
|
||||
```
|
||||
|
||||
:::warning Security Note
|
||||
The `--debugger` flag enables Werkzeug's interactive console at `/console`. Only use this for local development and never bind to `0.0.0.0` or expose the server to networks when debugging is enabled.
|
||||
:::
|
||||
|
||||
If everything worked, you should be able to navigate to `hostname:port` in your browser (e.g.
|
||||
locally by default at `localhost:8088`) and login using the username and password you created.
|
||||
|
||||
@@ -102,6 +102,8 @@ Affecting the Docker build process:
|
||||
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`
|
||||
- **SUPERSET_LOG_LEVEL (default=info)**: Can be set to debug, info, warning, error, critical
|
||||
for more verbose logging
|
||||
- **SUPERSET_DEBUG_ENABLED (default=false)**: Enable Werkzeug debugger with interactive console.
|
||||
Set to `true` for debugging: `SUPERSET_DEBUG_ENABLED=true docker compose up`
|
||||
|
||||
For more env vars that affect your configuration, see this
|
||||
[superset_config.py](https://github.com/apache/superset/blob/master/docker/pythonpath_dev/superset_config.py)
|
||||
|
||||
@@ -917,6 +917,23 @@ const config: Config = {
|
||||
footer: {
|
||||
links: [],
|
||||
copyright: `
|
||||
<div class="footer__social-links">
|
||||
<a href="https://bit.ly/join-superset-slack" target="_blank" rel="noopener noreferrer" title="Join us on Slack" aria-label="Slack">
|
||||
<img src="/img/community/slack-symbol.svg" alt="Slack" />
|
||||
</a>
|
||||
<a href="https://x.com/apachesuperset" target="_blank" rel="noopener noreferrer" title="Follow us on X" aria-label="X">
|
||||
<img src="/img/community/x-symbol.svg" alt="X" />
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/company/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on LinkedIn" aria-label="LinkedIn">
|
||||
<img src="/img/community/linkedin-symbol.svg" alt="LinkedIn" />
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/apachesuperset.bsky.social" target="_blank" rel="noopener noreferrer" title="Follow us on Bluesky" aria-label="Bluesky">
|
||||
<img src="/img/community/bluesky-symbol.svg" alt="Bluesky" />
|
||||
</a>
|
||||
<a href="https://reddit.com/r/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on Reddit" aria-label="Reddit">
|
||||
<img src="/img/community/reddit-symbol.svg" alt="Reddit" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__ci-services">
|
||||
<span>CI powered by</span>
|
||||
<a href="https://www.netlify.com/" target="_blank" rel="nofollow noopener noreferrer"><img src="/img/netlify.png" alt="Netlify" title="Netlify - Deploy Previews" /></a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"copyright": {
|
||||
"message": "\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a> | \n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a> | \n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a> | \n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a> | \n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a> | \n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
|
||||
"message": "\n <div class=\"footer__social-links\">\n <a href=\"https://bit.ly/join-superset-slack\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Join us on Slack\" aria-label=\"Slack\">\n <img src=\"/img/community/slack-symbol.svg\" alt=\"Slack\" />\n </a>\n <a href=\"https://x.com/apachesuperset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on X\" aria-label=\"X\">\n <img src=\"/img/community/x-symbol.svg\" alt=\"X\" />\n </a>\n <a href=\"https://www.linkedin.com/company/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on LinkedIn\" aria-label=\"LinkedIn\">\n <img src=\"/img/community/linkedin-symbol.svg\" alt=\"LinkedIn\" />\n </a>\n <a href=\"https://bsky.app/profile/apachesuperset.bsky.social\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Bluesky\" aria-label=\"Bluesky\">\n <img src=\"/img/community/bluesky-symbol.svg\" alt=\"Bluesky\" />\n </a>\n <a href=\"https://reddit.com/r/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Reddit\" aria-label=\"Reddit\">\n <img src=\"/img/community/reddit-symbol.svg\" alt=\"Reddit\" />\n </a>\n </div>\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/admin-docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a> | \n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a> | \n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a> | \n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a> | \n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a> | \n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
|
||||
"description": "The footer copyright"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,17 @@
|
||||
command = "yarn install && yarn build"
|
||||
# Output directory (relative to base)
|
||||
publish = "build"
|
||||
# Skip builds when no docs changes (exit 0 = skip, exit 1 = build)
|
||||
# Checks for changes in docs/ and README.md (which gets pulled into docs)
|
||||
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../README.md"
|
||||
# Skip builds when no docs changes (exit 0 = skip, non-zero = build).
|
||||
# Checks for changes in docs/ and README.md (which gets pulled into docs).
|
||||
#
|
||||
# $CACHED_COMMIT_REF is the last *deployed* commit. On a PR's first build it
|
||||
# is empty, so the original `git diff` errored and Netlify fell back to
|
||||
# building -- which is why every PR built a docs preview once even with no
|
||||
# docs changes. When it is empty we instead diff the whole branch against its
|
||||
# merge-base with master, so non-docs PRs are skipped from the very first
|
||||
# build. Subsequent builds (and the master production build) keep the cheaper
|
||||
# incremental $CACHED_COMMIT_REF diff. Any failure exits non-zero -> build.
|
||||
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" "$COMMIT_REF" -- . ../README.md; else git fetch origin master --depth=100 >/dev/null 2>&1; git diff --quiet "$(git merge-base origin/master "$COMMIT_REF" 2>/dev/null || echo origin/master)" "$COMMIT_REF" -- . ../README.md; fi'
|
||||
|
||||
[build.environment]
|
||||
# Node version matching docs/.nvmrc
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"version:remove:components": "node scripts/manage-versions.mjs remove components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@docusaurus/core": "^3.10.1",
|
||||
"@docusaurus/faster": "^3.10.1",
|
||||
"@docusaurus/plugin-client-redirects": "^3.10.1",
|
||||
@@ -72,11 +72,11 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.40",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -101,16 +101,16 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.1"
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"webpack": "^5.107.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -260,10 +260,45 @@ a > span > svg {
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
padding-top: 90px;
|
||||
padding-top: 130px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.footer__social-links {
|
||||
background-color: #173036;
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer__social-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.footer__social-links a:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.footer__social-links img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
/* The brand SVGs ship in their native colors (e.g. Slack's dark aubergine,
|
||||
X's near-black), which disappear on the dark footer. Render them all as
|
||||
uniform white silhouettes. The icons are single-path glyphs whose
|
||||
counters (the LinkedIn "in", Slack gaps, Reddit face) are transparent
|
||||
cut-outs, so they stay legible against the footer background. */
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
background-color: #0d3e49;
|
||||
color: #e1e1e1;
|
||||
@@ -309,6 +344,21 @@ a > span > svg {
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 996px) {
|
||||
.footer {
|
||||
padding-top: 120px;
|
||||
}
|
||||
|
||||
.footer__social-links {
|
||||
top: 44px;
|
||||
gap: 20px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.footer__social-links img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
|
||||
21
docs/static/img/community/reddit-symbol.svg
vendored
Normal file
21
docs/static/img/community/reddit-symbol.svg
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#FF4500">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12c-.688 0-1.25.561-1.25 1.25 0 .687.562 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
21
docs/static/img/community/slack-symbol.svg
vendored
Normal file
21
docs/static/img/community/slack-symbol.svg
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#4A154B">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.166 0a2.528 2.528 0 0 1 2.521 2.522v6.312zm-2.521 10.124a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.166 24a2.528 2.528 0 0 1-2.521-2.52v-2.522h2.521zm0-1.271a2.528 2.528 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.312A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.52 2.521h-6.313z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
235
docs/yarn.lock
235
docs/yarn.lock
@@ -212,14 +212,14 @@
|
||||
resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz"
|
||||
integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==
|
||||
|
||||
"@ant-design/icons@^6.2.3":
|
||||
version "6.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.3.tgz#66e1c7fdea009b9c3fab6964062bedc76f308ad8"
|
||||
integrity sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==
|
||||
"@ant-design/icons@^6.2.3", "@ant-design/icons@^6.2.5":
|
||||
version "6.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.5.tgz#31c142aa6ce5eaf99598aaead222f4c459693512"
|
||||
integrity sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/icons-svg" "^4.4.2"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@ant-design/react-slick@~2.0.0":
|
||||
@@ -3021,10 +3021,10 @@
|
||||
os-homedir "^1.0.1"
|
||||
regexpu-core "^4.5.4"
|
||||
|
||||
"@pkgr/core@^0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz"
|
||||
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
|
||||
"@pkgr/core@^0.3.6":
|
||||
version "0.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.3.6.tgz#3569708bd4be4d8870ba32bf1c456dac81600d97"
|
||||
integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==
|
||||
|
||||
"@pnpm/config.env-replace@^1.1.0":
|
||||
version "1.1.0"
|
||||
@@ -4812,100 +4812,110 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
|
||||
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
|
||||
"@typescript-eslint/eslint-plugin@8.60.0", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz#8fc1e0a950c43270eaf0212dc060f7edaa42f9cf"
|
||||
integrity sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/type-utils" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/type-utils" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
|
||||
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
|
||||
"@typescript-eslint/parser@8.60.0", "@typescript-eslint/parser@^8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.0.tgz#38d611b8e658cb10850d4975e8a175a222fbcd6a"
|
||||
integrity sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
|
||||
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
|
||||
"@typescript-eslint/project-service@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.0.tgz#b82ab12e64d005d0c7163d1240c432381f1bde0f"
|
||||
integrity sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.4"
|
||||
"@typescript-eslint/types" "^8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.60.0"
|
||||
"@typescript-eslint/types" "^8.60.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
|
||||
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
|
||||
"@typescript-eslint/scope-manager@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz#7617a4617c043fe235dcf066f9a40f106cfd2fd5"
|
||||
integrity sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
|
||||
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
|
||||
"@typescript-eslint/tsconfig-utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz#3af78c48956227a407dea9626b8db8ca53f130d2"
|
||||
integrity sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
|
||||
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
|
||||
"@typescript-eslint/tsconfig-utils@^8.60.0":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
|
||||
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz#6971a61bc4f3a1b2df45dcc14e26a43a88a4cb6a"
|
||||
integrity sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
|
||||
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
|
||||
"@typescript-eslint/types@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.0.tgz#e77ad768e933263b1960b2fe79de75fe1cc6e7db"
|
||||
integrity sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
|
||||
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
|
||||
"@typescript-eslint/types@^8.60.0":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
|
||||
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz#c102196a44414481190041c99eea1d854e66001b"
|
||||
integrity sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/project-service" "8.60.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
|
||||
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
|
||||
"@typescript-eslint/utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.0.tgz#6110cddaef87606ae4ca6f8bf81bb5949fc8e098"
|
||||
integrity sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
|
||||
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
|
||||
"@typescript-eslint/visitor-keys@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz#f2c41eedd3d7b03b808369fb2e3fb40a93783ec2"
|
||||
integrity sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5568,10 +5578,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.32"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
|
||||
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
|
||||
baseline-browser-mapping@^2.10.33, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.33"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz#27c299b096404978831958d429f48390424c4f9b"
|
||||
integrity sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7248,10 +7258,10 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.21.4:
|
||||
version "5.21.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
|
||||
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
|
||||
enhanced-resolve@^5.22.0:
|
||||
version "5.22.1"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz#c34bc3f414298496fc244b21bbe316440782da17"
|
||||
integrity sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.3"
|
||||
@@ -7512,13 +7522,13 @@ eslint-config-prettier@^10.1.8:
|
||||
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz"
|
||||
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
|
||||
|
||||
eslint-plugin-prettier@^5.5.5:
|
||||
version "5.5.5"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz"
|
||||
integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
|
||||
eslint-plugin-prettier@^5.5.6:
|
||||
version "5.5.6"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
|
||||
integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.12"
|
||||
synckit "^0.11.13"
|
||||
|
||||
eslint-plugin-react@^7.37.5:
|
||||
version "7.37.5"
|
||||
@@ -9331,7 +9341,7 @@ js-yaml@4.1.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@=4.1.1, js-yaml@^4.1.0, js-yaml@^4.1.1:
|
||||
js-yaml@=4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
||||
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
|
||||
@@ -9346,6 +9356,13 @@ js-yaml@^3.13.1:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524"
|
||||
integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsdoc-type-pratt-parser@^4.0.0:
|
||||
version "4.8.0"
|
||||
resolved "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz"
|
||||
@@ -14086,12 +14103,12 @@ swc-loader@^0.2.6, swc-loader@^0.2.7:
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
|
||||
synckit@^0.11.12:
|
||||
version "0.11.12"
|
||||
resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz"
|
||||
integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
|
||||
synckit@^0.11.13:
|
||||
version "0.11.13"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
|
||||
integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
"@pkgr/core" "^0.3.6"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
@@ -14372,15 +14389,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.4:
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
|
||||
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
|
||||
typescript-eslint@^8.60.0:
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.0.tgz#6686fecb1f4f367c0bf0075828e93b7ecacbc62b"
|
||||
integrity sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.4"
|
||||
"@typescript-eslint/parser" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/eslint-plugin" "8.60.0"
|
||||
"@typescript-eslint/parser" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
@@ -14930,20 +14947,20 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
|
||||
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
|
||||
webpack-sources@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.0.tgz#87bf7f5801a4e985b1f1c92b64b9620a02f76d08"
|
||||
integrity sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
|
||||
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
|
||||
webpack@^5.107.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.2.tgz#dea14dcb177b46b29de15f952f7303691ee2b596"
|
||||
integrity sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
@@ -14954,7 +14971,7 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.21.4"
|
||||
enhanced-resolve "^5.22.0"
|
||||
es-module-lexer "^2.1.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
@@ -14967,7 +14984,7 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
tapable "^2.3.0"
|
||||
terser-webpack-plugin "^5.5.0"
|
||||
watchpack "^2.5.1"
|
||||
webpack-sources "^3.4.1"
|
||||
webpack-sources "^3.5.0"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
@@ -109,7 +109,7 @@ dependencies = [
|
||||
"watchdog>=6.0.0",
|
||||
"wtforms>=2.3.3, <4",
|
||||
"wtforms-json",
|
||||
"xlsxwriter>=3.0.7, <3.3",
|
||||
"xlsxwriter>=3.2.9, <3.3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -154,7 +154,7 @@ fastmcp = [
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
gevent = ["gevent>=26.4.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
|
||||
hive = [
|
||||
@@ -165,7 +165,7 @@ hive = [
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
kusto = ["sqlalchemy-kusto>=3.0.0, <4"]
|
||||
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
|
||||
kylin = ["kylinpy>=2.8.1, <2.9"]
|
||||
mssql = ["pymssql>=2.2.8, <3"]
|
||||
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
|
||||
@@ -180,7 +180,7 @@ ocient = [
|
||||
oracle = ["cx-Oracle>8.0.0, <8.4"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.37.0, <2"]
|
||||
playwright = ["playwright>=1.60.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.328.0"]
|
||||
@@ -199,16 +199,16 @@ spark = [
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.7.21",
|
||||
"taos-ws-py>=0.3.8"
|
||||
"taos-ws-py>=0.6.9"
|
||||
]
|
||||
teradata = ["teradatasql>=16.20.0.23"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.5.9, < 0.7"]
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>=11.0.2"]
|
||||
starrocks = ["starrocks>=1.0.0"]
|
||||
doris = ["pydoris>=1.0.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.2"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.2", "ydb-sqlglot-plugin>=0.2.5"]
|
||||
development = [
|
||||
# no bounds for apache-superset-extensions-cli until a stable version
|
||||
"apache-superset-extensions-cli",
|
||||
@@ -225,13 +225,13 @@ development = [
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
"pyfakefs",
|
||||
"pyinstrument>=4.0.2,<6",
|
||||
"pyinstrument>=5.1.2,<6",
|
||||
"pylint",
|
||||
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
|
||||
"pytest-asyncio",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"python-ldap>=3.4.4",
|
||||
"python-ldap>=3.4.7",
|
||||
"ruff",
|
||||
"sqloxide",
|
||||
"statsd",
|
||||
@@ -456,6 +456,7 @@ authorized_licenses = [
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
"mit",
|
||||
"mit and psf-2.0",
|
||||
"mit-cmu",
|
||||
"mozilla public license 2.0 (mpl 2.0)",
|
||||
"osi approved",
|
||||
|
||||
@@ -161,7 +161,7 @@ geopy==2.4.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
google-auth==2.43.0
|
||||
# via shillelagh
|
||||
greenlet==3.1.1
|
||||
greenlet==3.5.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
@@ -490,7 +490,7 @@ wtforms-json==0.3.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
xlrd==2.0.1
|
||||
# via pandas
|
||||
xlsxwriter==3.0.9
|
||||
xlsxwriter==3.2.9
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# pandas
|
||||
|
||||
@@ -331,7 +331,7 @@ geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
gevent==24.2.1
|
||||
gevent==26.4.0
|
||||
# via apache-superset
|
||||
google-api-core==2.23.0
|
||||
# via
|
||||
@@ -373,7 +373,7 @@ googleapis-common-protos==1.66.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
greenlet==3.1.1
|
||||
greenlet==3.5.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -768,7 +768,7 @@ pygments==2.20.0
|
||||
# rich
|
||||
pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==4.4.0
|
||||
pyinstrument==5.1.2
|
||||
# via apache-superset
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
@@ -838,7 +838,7 @@ python-dotenv==1.2.2
|
||||
# apache-superset
|
||||
# fastmcp
|
||||
# pydantic-settings
|
||||
python-ldap==3.4.5
|
||||
python-ldap==3.4.7
|
||||
# via apache-superset
|
||||
python-multipart==0.0.29
|
||||
# via mcp
|
||||
@@ -1140,7 +1140,7 @@ xlrd==2.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pandas
|
||||
xlsxwriter==3.0.9
|
||||
xlsxwriter==3.2.9
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -69,6 +69,24 @@ DEFAULT_INDEX = TRANSLATIONS_DIR / "translation_index.json"
|
||||
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||
DEFAULT_BATCH_SIZE = 50
|
||||
|
||||
_ASF_LICENSE_HEADER = """\
|
||||
# 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.
|
||||
"""
|
||||
|
||||
# Language names for the prompt, keyed by ISO code
|
||||
LANGUAGE_NAMES: dict[str, str] = {
|
||||
"ar": "Arabic",
|
||||
@@ -95,6 +113,19 @@ LANGUAGE_NAMES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _ensure_license_header(po_path: Path, *, dry_run: bool = False) -> None:
|
||||
"""Prepend the ASF license header to the .po file if it is missing."""
|
||||
content = po_path.read_text(encoding="utf-8")
|
||||
if "Licensed to the Apache Software Foundation" not in content:
|
||||
if dry_run:
|
||||
print(
|
||||
f"[dry-run] Would add ASF license header to {po_path}", file=sys.stderr
|
||||
)
|
||||
else:
|
||||
po_path.write_text(_ASF_LICENSE_HEADER + content, encoding="utf-8")
|
||||
print(f"Added ASF license header to {po_path}", file=sys.stderr)
|
||||
|
||||
|
||||
def _lang_name(code: str) -> str:
|
||||
"""Return a human-readable language name for an ISO language code."""
|
||||
return LANGUAGE_NAMES.get(code, code)
|
||||
@@ -510,6 +541,8 @@ def backfill(
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
index: dict[str, Any] = json.load(f)
|
||||
|
||||
_ensure_license_header(po_path, dry_run=dry_run)
|
||||
|
||||
print(f"Loading {po_path} …", file=sys.stderr)
|
||||
cat = polib.pofile(str(po_path))
|
||||
|
||||
|
||||
@@ -18,14 +18,31 @@
|
||||
"""
|
||||
Check that source-code changes don't cause translation regressions.
|
||||
|
||||
What counts as a regression
|
||||
---------------------------
|
||||
A regression is an *existing translation that a source change invalidated* —
|
||||
i.e. a string was renamed/reworded so its committed translation no longer
|
||||
applies. ``babel_update.sh`` (``pybabel update --ignore-obsolete``) surfaces
|
||||
exactly these as **newly fuzzy** entries: the old translation is fuzzy-matched
|
||||
onto the new ``msgid`` and flagged ``#, fuzzy``.
|
||||
|
||||
Crucially, *deleting* a translatable string is **not** a regression. With
|
||||
``--ignore-obsolete`` a removed string is dropped from the catalogs entirely;
|
||||
no fuzzy entry is created. So a PR that intentionally removes a string (e.g. a
|
||||
security fix that stops rendering a value) legitimately lowers the translated
|
||||
count without introducing any fuzzies, and must not be flagged. We therefore
|
||||
key the check on the **increase in fuzzy entries**, not on a drop in the
|
||||
translated count (a drop happens identically for a benign deletion and a real
|
||||
rename, so it cannot distinguish the two).
|
||||
|
||||
Usage
|
||||
-----
|
||||
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
|
||||
Count translated + fuzzy entries in all .po files and write JSON to stdout:
|
||||
|
||||
python check_translation_regression.py --count
|
||||
|
||||
Compare the current .po state against a previously-recorded baseline and fail
|
||||
if any language lost translations:
|
||||
if a source change invalidated existing translations (new fuzzies):
|
||||
|
||||
python check_translation_regression.py --compare /path/to/before.json
|
||||
|
||||
@@ -44,13 +61,14 @@ Typical CI workflow
|
||||
1. Create a base-branch worktree alongside the PR worktree
|
||||
2. Run babel_update.sh in the base worktree (extract from BASE source)
|
||||
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
|
||||
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
|
||||
from the same pristine BASE translations
|
||||
4. Run babel_update.sh in the PR worktree (extract from PR source and keep
|
||||
any committed PR .po updates)
|
||||
5. Compare: python ... --compare before.json [--report report.md]
|
||||
|
||||
Comparing two babel_update outputs that started from the same BASE .po files
|
||||
isolates regressions caused by the PR's source diff from any pre-existing
|
||||
drift on the base branch.
|
||||
Running babel_update on the base branch first isolates regressions caused by
|
||||
the PR's source diff from any pre-existing drift on the base branch, while the
|
||||
PR worktree run still allows committed .po updates to resolve the fuzzies (and
|
||||
thus clear the regression) before merging.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -70,8 +88,13 @@ DEFAULT_TRANSLATIONS_DIR = (
|
||||
SKIP_LANGS = {"en"}
|
||||
|
||||
|
||||
def count_translated(po_file: Path) -> int:
|
||||
"""Return the number of non-fuzzy translated messages in a .po file.
|
||||
def count_stats(po_file: Path) -> dict[str, int]:
|
||||
"""Return ``{"translated": int, "fuzzy": int}`` for a .po file.
|
||||
|
||||
``translated`` is the number of non-fuzzy translated messages; ``fuzzy`` is
|
||||
the number of fuzzy translations. The fuzzy count is what the regression
|
||||
check keys on — a source rename invalidates an existing translation by
|
||||
making it fuzzy, whereas a deletion simply drops it (``--ignore-obsolete``).
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
|
||||
@@ -89,29 +112,50 @@ def count_translated(po_file: Path) -> int:
|
||||
check=True,
|
||||
)
|
||||
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
|
||||
match = re.search(r"(\d+) translated message", result.stderr)
|
||||
if not match:
|
||||
# The fuzzy and untranslated clauses are omitted by msgfmt when they are 0.
|
||||
translated_match = re.search(r"(\d+) translated message", result.stderr)
|
||||
if not translated_match:
|
||||
raise RuntimeError(
|
||||
f"Could not parse msgfmt --statistics output for {po_file}: "
|
||||
f"{result.stderr!r}"
|
||||
)
|
||||
return int(match.group(1))
|
||||
fuzzy_match = re.search(r"(\d+) fuzzy translation", result.stderr)
|
||||
return {
|
||||
"translated": int(translated_match.group(1)),
|
||||
"fuzzy": int(fuzzy_match.group(1)) if fuzzy_match else 0,
|
||||
}
|
||||
|
||||
|
||||
def get_counts(translations_dir: Path) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
def get_counts(
|
||||
translations_dir: Path,
|
||||
failures: Optional[set[str]] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Count translated/fuzzy entries for every ``.po`` file in a directory.
|
||||
|
||||
If ``failures`` is provided, the name of each language whose ``.po`` file
|
||||
is present on disk but could not be counted (msgfmt non-zero exit, or
|
||||
unparseable output) is added to it. Such a language is deliberately absent
|
||||
from the returned mapping — but, unlike a language whose catalog was simply
|
||||
deleted, it must not be mistaken for an intentional removal: a caller that
|
||||
cares about the distinction (see :func:`cmd_compare`) can inspect
|
||||
``failures`` and treat it as a hard error.
|
||||
"""
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
|
||||
lang = po_file.parent.parent.name
|
||||
if lang in SKIP_LANGS:
|
||||
continue
|
||||
try:
|
||||
counts[lang] = count_translated(po_file)
|
||||
counts[lang] = count_stats(po_file)
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
# A malformed .po file (msgfmt non-zero exit, or stderr we
|
||||
# can't parse) is a real problem worth seeing, but it shouldn't
|
||||
# take the whole regression check down with it — that would
|
||||
# hide every other language's status. Skip and warn instead;
|
||||
# the missing lang will not appear in the comparison output.
|
||||
# hide every other language's status. Skip and warn here; the
|
||||
# caller is told which langs failed via ``failures`` so it can
|
||||
# decide whether a present-but-uncountable catalog is fatal.
|
||||
if failures is not None:
|
||||
failures.add(lang)
|
||||
print(
|
||||
f"WARNING: skipping {lang} — {po_file} could not be counted: {exc}",
|
||||
file=sys.stderr,
|
||||
@@ -119,18 +163,42 @@ def get_counts(translations_dir: Path) -> dict[str, int]:
|
||||
return counts
|
||||
|
||||
|
||||
def _normalize(entry: object) -> dict[str, int]:
|
||||
"""Coerce a baseline entry into ``{"translated", "fuzzy"}``.
|
||||
|
||||
Tolerates the legacy baseline format where each language mapped directly to
|
||||
an integer translated count (no fuzzy data); such entries contribute a
|
||||
fuzzy baseline of 0.
|
||||
"""
|
||||
if isinstance(entry, dict):
|
||||
return {
|
||||
"translated": int(entry.get("translated", 0)),
|
||||
"fuzzy": int(entry.get("fuzzy", 0)),
|
||||
}
|
||||
if isinstance(entry, int):
|
||||
return {"translated": entry, "fuzzy": 0}
|
||||
raise TypeError(f"Unsupported baseline entry: {entry!r}")
|
||||
|
||||
|
||||
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
|
||||
"""Build a markdown report for posting as a PR comment."""
|
||||
"""Build a markdown report for posting as a PR comment.
|
||||
|
||||
Each regression tuple is ``(lang, before_fuzzy, after_fuzzy)``.
|
||||
"""
|
||||
rows = "\n".join(
|
||||
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
|
||||
f"| `{lang}` | {b} | {a} | +{a - b} |" for lang, b, a in regressions
|
||||
)
|
||||
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
|
||||
return (
|
||||
"## ⚠️ Translation Regression Detected\n\n"
|
||||
f"This PR causes existing translations to become fuzzy or be removed "
|
||||
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
|
||||
"| Language | Before | After | Lost |\n"
|
||||
"|----------|-------:|------:|-----:|\n"
|
||||
f"A source change in this PR renamed or reworded strings, invalidating "
|
||||
f"existing translations (they are now `#, fuzzy`) in {affected}. Please "
|
||||
f"resolve the affected `.po` files before merging.\n\n"
|
||||
"_Note: intentionally **deleting** a translatable string is not a "
|
||||
"regression and is not flagged here — only translations invalidated by "
|
||||
"a renamed/reworded source string are._\n\n"
|
||||
"| Language | Fuzzy before | Fuzzy after | New |\n"
|
||||
"|----------|-------------:|------------:|----:|\n"
|
||||
f"{rows}\n\n"
|
||||
"### How to fix\n\n"
|
||||
"**1. Install dependencies** (if not already set up):\n\n"
|
||||
@@ -168,26 +236,49 @@ def cmd_compare(
|
||||
report_path: Optional[str] = None,
|
||||
) -> None:
|
||||
with open(before_path) as f:
|
||||
before: dict[str, int] = json.load(f)
|
||||
before_raw: dict[str, object] = json.load(f)
|
||||
before = {lang: _normalize(entry) for lang, entry in before_raw.items()}
|
||||
|
||||
after = get_counts(translations_dir)
|
||||
failures: set[str] = set()
|
||||
after = get_counts(translations_dir, failures=failures)
|
||||
|
||||
# A baseline language whose catalog is *missing* from `after` is fine —
|
||||
# that's an intentional catalog deletion (handled below like any other
|
||||
# deletion). But a language whose .po file is still present yet could not
|
||||
# be counted (msgfmt failed / output unparseable) is a hard error: leaving
|
||||
# it out silently would let a corrupt catalog pass as "no regression".
|
||||
broken = sorted(lang for lang in failures if lang in before)
|
||||
if broken:
|
||||
print("Translation check failed!\n")
|
||||
for lang in broken:
|
||||
print(f" {lang}: catalog present but could not be counted (msgfmt error)")
|
||||
print(
|
||||
"\nFix the malformed .po file(s) above before merging — a catalog "
|
||||
"that cannot be parsed must not be silently dropped."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# A regression is an *increase* in fuzzy entries: the PR's source diff
|
||||
# renamed/reworded strings, leaving their committed translations stranded.
|
||||
# A plain drop in the translated count is NOT used — deleting a string
|
||||
# lowers it identically to a rename but is a legitimate change, and with
|
||||
# `pybabel update --ignore-obsolete` a deletion creates no fuzzy entry.
|
||||
regressions: list[tuple[str, int, int]] = []
|
||||
for lang, before_count in sorted(before.items()):
|
||||
after_count = after.get(lang, 0)
|
||||
if after_count < before_count:
|
||||
regressions.append((lang, before_count, after_count))
|
||||
for lang, before_stats in sorted(before.items()):
|
||||
after_stats = after.get(lang, {"translated": 0, "fuzzy": 0})
|
||||
if after_stats["fuzzy"] > before_stats["fuzzy"]:
|
||||
regressions.append((lang, before_stats["fuzzy"], after_stats["fuzzy"]))
|
||||
|
||||
if regressions:
|
||||
print("Translation regression detected!\n")
|
||||
for lang, b, a in regressions:
|
||||
lost = b - a
|
||||
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
|
||||
print(
|
||||
f" {lang}: {a - b} translation(s) invalidated "
|
||||
f"(fuzzy {b} -> {a}) by a renamed/reworded source string"
|
||||
)
|
||||
print(
|
||||
"\nStrings renamed or deleted by this PR invalidated existing translations."
|
||||
)
|
||||
print(
|
||||
"Update the affected .po files to restore the lost entries before merging."
|
||||
"\nResolve the newly-fuzzy entries in the affected .po files "
|
||||
"before merging."
|
||||
)
|
||||
if report_path:
|
||||
Path(report_path).write_text(
|
||||
@@ -198,15 +289,15 @@ def cmd_compare(
|
||||
# All good — print a summary so it's easy to read in CI logs.
|
||||
print("No translation regressions.\n")
|
||||
for lang in sorted(after):
|
||||
b = before.get(lang, 0)
|
||||
a = after[lang]
|
||||
if a > b:
|
||||
delta = f"+{a - b}"
|
||||
elif a == b:
|
||||
delta = "no change"
|
||||
else:
|
||||
delta = f"-{b - a}"
|
||||
print(f" {lang}: {b} -> {a} ({delta})")
|
||||
before_stats = before.get(lang, {"translated": 0, "fuzzy": 0})
|
||||
after_stats = after[lang]
|
||||
t_delta = after_stats["translated"] - before_stats["translated"]
|
||||
f_delta = after_stats["fuzzy"] - before_stats["fuzzy"]
|
||||
print(
|
||||
f" {lang}: translated {before_stats['translated']} -> "
|
||||
f"{after_stats['translated']} ({t_delta:+d}), fuzzy "
|
||||
f"{before_stats['fuzzy']} -> {after_stats['fuzzy']} ({f_delta:+d})"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -31,11 +31,32 @@ if [ -z "$RUNNING_IN_DOCKER" ]; then
|
||||
|
||||
echo "Running in Docker (Python ${PYTHON_VERSION} on Linux)..."
|
||||
|
||||
IMAGE="python:${PYTHON_VERSION}-slim"
|
||||
|
||||
# Pre-pull the image with a few retries to absorb transient Docker Hub
|
||||
# registry failures ("context deadline exceeded" / anonymous rate-limit blips
|
||||
# on shared CI runners). Without this a flaky pull fails the whole
|
||||
# check-python-deps job on an infrastructure hiccup rather than a real
|
||||
# dependency drift. The pull is in the `until` condition so `set -e` does not
|
||||
# abort on an individual failed attempt.
|
||||
attempt=1
|
||||
max_attempts=4
|
||||
until docker pull "$IMAGE"; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "docker pull $IMAGE failed after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
delay=$((attempt * 10))
|
||||
echo "docker pull $IMAGE failed (attempt ${attempt}/${max_attempts}); retrying in ${delay}s..." >&2
|
||||
sleep "$delay"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd)":/app \
|
||||
-w /app \
|
||||
-e RUNNING_IN_DOCKER=1 \
|
||||
python:${PYTHON_VERSION}-slim \
|
||||
"$IMAGE" \
|
||||
bash -c "pip install uv && ./scripts/uv-pip-compile.sh $*"
|
||||
|
||||
exit $?
|
||||
|
||||
@@ -29,8 +29,8 @@ Embedding is done by inserting an iframe, containing a Superset page, into the h
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Activate the feature flag `EMBEDDED_SUPERSET`
|
||||
* Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
|
||||
- Activate the feature flag `EMBEDDED_SUPERSET`
|
||||
- Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
|
||||
|
||||
## Embedding a Dashboard
|
||||
|
||||
@@ -41,32 +41,37 @@ npm install --save @superset-ui/embedded-sdk
|
||||
```
|
||||
|
||||
```js
|
||||
import { embedDashboard } from "@superset-ui/embedded-sdk";
|
||||
import { embedDashboard } from '@superset-ui/embedded-sdk';
|
||||
|
||||
embedDashboard({
|
||||
id: "abc123", // given by the Superset embedding UI
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"), // any html element that can contain an iframe
|
||||
id: 'abc123', // given by the Superset embedding UI
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'), // any html element that can contain an iframe
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: { // dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
hideTitle: true,
|
||||
filters: {
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
// ...
|
||||
}
|
||||
dashboardUiConfig: {
|
||||
// dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
hideTitle: true,
|
||||
filters: {
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
// themeMode: 'dark', // set the initial theme: 'dark' | 'system' | 'default' (default: 'default')
|
||||
// ...
|
||||
},
|
||||
},
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'],
|
||||
iframeSandboxExtras: [
|
||||
'allow-top-navigation',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
],
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'],
|
||||
// optional config to enforce a particular referrerPolicy
|
||||
referrerPolicy: "same-origin",
|
||||
referrerPolicy: 'same-origin',
|
||||
// optional callback to customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -97,7 +102,7 @@ Guest tokens can have Row Level Security rules which filter data for the user ca
|
||||
|
||||
The agent making the `POST` request must be authenticated with the `can_grant_guest_token` permission.
|
||||
|
||||
Within your app, using the Guest Token will then allow authentication to your Superset instance via creating an Anonymous user object. This guest anonymous user will default to the public role as per this setting `GUEST_ROLE_NAME = "Public"`.
|
||||
Within your app, using the Guest Token will then allow authentication to your Superset instance via creating an Anonymous user object. This guest anonymous user will default to the public role as per this setting `GUEST_ROLE_NAME = "Public"`.
|
||||
|
||||
The user parameters in the example below are optional and are provided as a means of passing user attributes that may be accessed in jinja templates inside your charts.
|
||||
|
||||
@@ -110,13 +115,13 @@ Example `POST /security/guest_token` payload:
|
||||
"first_name": "Stan",
|
||||
"last_name": "Lee"
|
||||
},
|
||||
"resources": [{
|
||||
"type": "dashboard",
|
||||
"id": "abc123"
|
||||
}],
|
||||
"rls": [
|
||||
{ "clause": "publisher = 'Nintendo'" }
|
||||
]
|
||||
"resources": [
|
||||
{
|
||||
"type": "dashboard",
|
||||
"id": "abc123"
|
||||
}
|
||||
],
|
||||
"rls": [{ "clause": "publisher = 'Nintendo'" }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -152,15 +157,43 @@ In this example, the configuration file includes the following setting:
|
||||
GUEST_TOKEN_JWT_AUDIENCE="superset"
|
||||
```
|
||||
|
||||
### Setting the Initial Theme Mode
|
||||
|
||||
Use the `themeMode` URL parameter to control the embedded dashboard's initial colour scheme:
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
themeMode: 'dark', // 'dark' | 'system' | 'default' (default: 'default')
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The supported values are:
|
||||
|
||||
| Value | Behaviour |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| `default` | Light theme (Superset default) |
|
||||
| `dark` | Dark theme |
|
||||
| `system` | Follows the user's OS preference (`prefers-color-scheme`) |
|
||||
|
||||
The theme can also be changed at runtime via `embeddedDashboard.setThemeMode(mode)`.
|
||||
|
||||
### Sandbox iframe
|
||||
|
||||
The Embedded SDK creates an iframe with [sandbox](https://developer.mozilla.org/es/docs/Web/HTML/Element/iframe#sandbox) mode by default
|
||||
which applies certain restrictions to the iframe's content.
|
||||
To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
|
||||
```js
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox']
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'];
|
||||
```
|
||||
|
||||
### Permissions Policy
|
||||
@@ -168,11 +201,12 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
To enable specific browser features within the embedded iframe, use `iframeAllowExtras` to set the iframe's [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) (the `allow` attribute):
|
||||
|
||||
```js
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen']
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'];
|
||||
```
|
||||
|
||||
Common permissions you might need:
|
||||
|
||||
- `clipboard-write` - Required for "Copy permalink to clipboard" functionality
|
||||
- `fullscreen` - Required for fullscreen chart viewing
|
||||
- `camera`, `microphone` - If your dashboards include media capture features
|
||||
@@ -191,16 +225,16 @@ When users click share buttons inside an embedded dashboard, Superset generates
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
|
||||
// Customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => {
|
||||
// key: the permalink key (e.g., "xyz789")
|
||||
return `https://my-app.com/analytics/share/${key}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -211,15 +245,15 @@ To restore the dashboard state from a permalink in your app:
|
||||
const permalinkKey = routeParams.key;
|
||||
|
||||
embedDashboard({
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
|
||||
}
|
||||
}
|
||||
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -226,7 +226,7 @@ def copy_frontend_dist(cwd: Path) -> str:
|
||||
def copy_backend_files(cwd: Path) -> None:
|
||||
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
|
||||
dist_dir = cwd / "dist"
|
||||
backend_dir = cwd / "backend"
|
||||
backend_dir = (cwd / "backend").resolve()
|
||||
|
||||
# Read build config from pyproject.toml
|
||||
pyproject = read_toml(backend_dir / "pyproject.toml")
|
||||
@@ -239,11 +239,31 @@ def copy_backend_files(cwd: Path) -> None:
|
||||
|
||||
# Process include patterns
|
||||
for pattern in include_patterns:
|
||||
# Include patterns are only meant to select files within the backend
|
||||
# directory. Reject absolute patterns or ones that walk outside it via
|
||||
# parent ("..") components before handing them to glob().
|
||||
pattern_parts = Path(pattern).parts
|
||||
if Path(pattern).is_absolute() or ".." in pattern_parts:
|
||||
raise click.ClickException(
|
||||
f"Invalid include pattern {pattern!r}: patterns must be "
|
||||
"relative to the backend directory and may not contain '..'."
|
||||
)
|
||||
for f in backend_dir.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Check exclude patterns
|
||||
# Defense in depth: confirm the matched file resolves to a location
|
||||
# inside the backend directory before copying it into the bundle.
|
||||
resolved = f.resolve()
|
||||
if not resolved.is_relative_to(backend_dir):
|
||||
raise click.ClickException(
|
||||
f"Refusing to copy {f}: resolved path is outside the "
|
||||
f"backend directory {backend_dir}."
|
||||
)
|
||||
|
||||
# Use the matched path (not the resolved target) for the bundle
|
||||
# layout and exclude evaluation so symlinked files are staged at
|
||||
# their configured path rather than their symlink target.
|
||||
relative_path = f.relative_to(backend_dir)
|
||||
should_exclude = any(
|
||||
relative_path.match(excl_pattern) for excl_pattern in exclude_patterns
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from superset_extensions_cli.cli import (
|
||||
app,
|
||||
@@ -625,6 +626,155 @@ exclude = []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_supports_legitimate_nested_patterns(isolated_filesystem):
|
||||
"""Test copy_backend_files copies deeply nested files via recursive globs."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
nested = backend_dir / "src" / "test_org" / "test_ext" / "deep" / "deeper"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "module.py").write_text("# nested module")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert_file_exists(
|
||||
dist_dir
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ "test_org"
|
||||
/ "test_ext"
|
||||
/ "deep"
|
||||
/ "deeper"
|
||||
/ "module.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"bad_pattern",
|
||||
[
|
||||
"../../.ssh/*",
|
||||
"../config",
|
||||
"src/../../secret.txt",
|
||||
"/etc/passwd",
|
||||
],
|
||||
)
|
||||
def test_copy_backend_files_rejects_patterns_escaping_backend_dir(
|
||||
isolated_filesystem, bad_pattern
|
||||
):
|
||||
"""Test copy_backend_files refuses include patterns that escape backend_dir."""
|
||||
# Create a sensitive file outside the backend directory.
|
||||
(isolated_filesystem / "secret.txt").write_text("SECRET")
|
||||
(isolated_filesystem / "config").write_text("SECRET")
|
||||
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
backend_src = backend_dir / "src" / "test_org" / "test_ext"
|
||||
backend_src.mkdir(parents=True)
|
||||
(backend_src / "__init__.py").write_text("# init")
|
||||
|
||||
pyproject_content = f"""[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"{bad_pattern}",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
|
||||
with pytest.raises(click.ClickException):
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
# Nothing outside the backend directory should have been staged into dist,
|
||||
# including paths reachable via ".." from inside dist/backend.
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert not (dist_dir / "secret.txt").exists()
|
||||
assert not (dist_dir / "config").exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_stages_symlink_at_matched_path(isolated_filesystem):
|
||||
"""Symlinked files inside backend are staged at the matched path, not the target."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
target_dir = backend_dir / "src" / "common"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "module.py").write_text("# shared module")
|
||||
|
||||
link_dir = backend_dir / "src" / "test_org" / "test_ext" / "common"
|
||||
link_dir.mkdir(parents=True)
|
||||
link = link_dir / "module.py"
|
||||
link.symlink_to(target_dir / "module.py")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
# Staged at the configured (symlink) path, not the resolved target path.
|
||||
assert_file_exists(
|
||||
dist_dir / "backend" / "src" / "test_org" / "test_ext" / "common" / "module.py"
|
||||
)
|
||||
assert not (dist_dir / "backend" / "src" / "common" / "module.py").exists()
|
||||
|
||||
|
||||
# Removed obsolete tests:
|
||||
# - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists
|
||||
# - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called
|
||||
|
||||
@@ -80,7 +80,7 @@ const restrictedImportsRules = {
|
||||
'no-jest-mock-console': {
|
||||
name: 'jest-mock-console',
|
||||
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { interceptFav, interceptUnfav } from './utils';
|
||||
|
||||
describe('Dashboard actions', () => {
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
cy.visit(SAMPLE_DASHBOARD_1);
|
||||
});
|
||||
it('should allow to favorite/unfavorite dashboard', () => {
|
||||
interceptFav();
|
||||
interceptUnfav();
|
||||
|
||||
// Find and click StarOutlined (adds to favorites)
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlined')
|
||||
.should('exist')
|
||||
.click();
|
||||
|
||||
cy.wait('@select');
|
||||
|
||||
// After clicking, StarFilled should appear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='starred']")
|
||||
.as('starIconFilled')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the filled star (gold)
|
||||
cy.get('@starIconFilled')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgb(252, 199, 0)');
|
||||
|
||||
// Click on StarFilled (removes from favorites)
|
||||
cy.get('@starIconFilled').click();
|
||||
|
||||
cy.wait('@unselect');
|
||||
|
||||
// After clicking, StarOutlined should reappear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlinedAfter')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the outlined star (gray)
|
||||
cy.get('@starIconOutlinedAfter')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgba(0, 0, 0, 0.45)');
|
||||
});
|
||||
});
|
||||
@@ -160,18 +160,6 @@ export function interceptLog() {
|
||||
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
|
||||
}
|
||||
|
||||
export function interceptFav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'select',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptUnfav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'unselect',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptDataset() {
|
||||
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
|
||||
}
|
||||
|
||||
498
superset-frontend/cypress-base/package-lock.json
generated
498
superset-frontend/cypress-base/package-lock.json
generated
@@ -1717,9 +1717,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/code-coverage/node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -1739,9 +1740,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/request": {
|
||||
"version": "2.88.12",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz",
|
||||
"integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==",
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz",
|
||||
"integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.8.0",
|
||||
@@ -1749,16 +1751,16 @@
|
||||
"combined-stream": "~1.0.6",
|
||||
"extend": "~3.0.2",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "~2.3.2",
|
||||
"http-signature": "~1.3.6",
|
||||
"form-data": "~4.0.4",
|
||||
"http-signature": "~1.4.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.10.3",
|
||||
"qs": "~6.14.1",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "^4.1.3",
|
||||
"tough-cookie": "^5.0.0",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
@@ -1766,14 +1768,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/request/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/webpack-preprocessor": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/webpack-preprocessor/-/webpack-preprocessor-5.17.0.tgz",
|
||||
@@ -2956,6 +2950,7 @@
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
@@ -2964,6 +2959,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
@@ -3128,6 +3124,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
@@ -3235,18 +3232,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -3260,6 +3245,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -3554,7 +3555,8 @@
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
|
||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "6.0.0",
|
||||
@@ -3804,6 +3806,7 @@
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0"
|
||||
},
|
||||
@@ -3948,6 +3951,7 @@
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
|
||||
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsbn": "~0.1.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
@@ -4493,7 +4497,8 @@
|
||||
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
]
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
@@ -4681,42 +4686,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
|
||||
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.12"
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fromentries": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
|
||||
@@ -4858,6 +4842,7 @@
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
|
||||
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0"
|
||||
}
|
||||
@@ -5163,13 +5148,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-signature": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
|
||||
"integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
|
||||
"integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"jsprim": "^2.0.2",
|
||||
"sshpk": "^1.14.1"
|
||||
"sshpk": "^1.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
@@ -5620,7 +5606,8 @@
|
||||
"node_modules/jsbn": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
|
||||
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="
|
||||
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
@@ -5642,7 +5629,8 @@
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
@@ -5698,6 +5686,7 @@
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assert-plus": "1.0.0",
|
||||
"extsprintf": "1.3.0",
|
||||
@@ -6688,9 +6677,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
|
||||
"integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -7016,11 +7009,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
@@ -7034,16 +7022,19 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz",
|
||||
"integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.4"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -7346,11 +7337,6 @@
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
|
||||
@@ -7468,7 +7454,8 @@
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.19.1",
|
||||
@@ -7570,13 +7557,72 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.0",
|
||||
"get-intrinsic": "^1.0.2",
|
||||
"object-inspect": "^1.9.0"
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -7713,9 +7759,10 @@
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
|
||||
"integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1": "~0.2.3",
|
||||
"assert-plus": "^1.0.0",
|
||||
@@ -8019,6 +8066,24 @@
|
||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
@@ -8039,17 +8104,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
@@ -8118,7 +8181,8 @@
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -8302,14 +8366,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/untildify": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
|
||||
@@ -8357,21 +8413,17 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uvu": {
|
||||
@@ -8405,6 +8457,7 @@
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
@@ -9848,7 +9901,7 @@
|
||||
"execa": "4.1.0",
|
||||
"globby": "11.0.4",
|
||||
"istanbul-lib-coverage": "3.0.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"nyc": "15.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -9872,9 +9925,9 @@
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"requires": {
|
||||
"argparse": "^2.0.1"
|
||||
}
|
||||
@@ -9890,9 +9943,9 @@
|
||||
}
|
||||
},
|
||||
"@cypress/request": {
|
||||
"version": "2.88.12",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz",
|
||||
"integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==",
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz",
|
||||
"integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==",
|
||||
"requires": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.8.0",
|
||||
@@ -9900,25 +9953,18 @@
|
||||
"combined-stream": "~1.0.6",
|
||||
"extend": "~3.0.2",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "^2.3.4",
|
||||
"http-signature": "~1.3.6",
|
||||
"form-data": "~4.0.4",
|
||||
"http-signature": "~1.4.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.10.3",
|
||||
"qs": "^6.14.2",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "^4.1.3",
|
||||
"tough-cookie": "^5.0.0",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
||||
}
|
||||
"uuid": "^11.1.1"
|
||||
}
|
||||
},
|
||||
"@cypress/webpack-preprocessor": {
|
||||
@@ -11167,15 +11213,6 @@
|
||||
"write-file-atomic": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -11185,6 +11222,15 @@
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"requires": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -11400,7 +11446,7 @@
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
|
||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="
|
||||
},
|
||||
"cosmiconfig": {
|
||||
"version": "6.0.0",
|
||||
@@ -11440,7 +11486,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-11.2.0.tgz",
|
||||
"integrity": "sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA==",
|
||||
"requires": {
|
||||
"@cypress/request": "^2.88.10",
|
||||
"@cypress/request": "^3.0.0",
|
||||
"@cypress/xvfb": "^1.2.4",
|
||||
"@types/node": "^14.14.31",
|
||||
"@types/sinonjs__fake-timers": "8.1.1",
|
||||
@@ -12254,23 +12300,15 @@
|
||||
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="
|
||||
},
|
||||
"form-data": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
|
||||
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
}
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"fromentries": {
|
||||
@@ -12596,13 +12634,13 @@
|
||||
"integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A=="
|
||||
},
|
||||
"http-signature": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
|
||||
"integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
|
||||
"integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==",
|
||||
"requires": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"jsprim": "^2.0.2",
|
||||
"sshpk": "^1.14.1"
|
||||
"sshpk": "^1.18.0"
|
||||
}
|
||||
},
|
||||
"human-signals": {
|
||||
@@ -12809,7 +12847,7 @@
|
||||
"make-dir": "^3.0.0",
|
||||
"p-map": "^3.0.0",
|
||||
"rimraf": "^3.0.0",
|
||||
"uuid": "^3.3.3"
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"p-map": {
|
||||
@@ -13640,9 +13678,9 @@
|
||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
|
||||
},
|
||||
"object-inspect": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
|
||||
"integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g=="
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
|
||||
},
|
||||
"once": {
|
||||
"version": "1.4.0",
|
||||
@@ -13873,11 +13911,6 @@
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.2.0.tgz",
|
||||
"integrity": "sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg=="
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
|
||||
},
|
||||
"pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
@@ -13890,14 +13923,16 @@
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz",
|
||||
"integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"requires": {
|
||||
"side-channel": "^1.0.4"
|
||||
"side-channel": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"querystringify": {
|
||||
@@ -14121,11 +14156,6 @@
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||
},
|
||||
"requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
},
|
||||
"reselect": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
|
||||
@@ -14291,13 +14321,47 @@
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
|
||||
},
|
||||
"side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"requires": {
|
||||
"call-bind": "^1.0.0",
|
||||
"get-intrinsic": "^1.0.2",
|
||||
"object-inspect": "^1.9.0"
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"requires": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
}
|
||||
},
|
||||
"side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"requires": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
}
|
||||
},
|
||||
"side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"requires": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"signal-exit": {
|
||||
@@ -14402,9 +14466,9 @@
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
},
|
||||
"sshpk": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
|
||||
"integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
|
||||
"requires": {
|
||||
"asn1": "~0.2.3",
|
||||
"assert-plus": "^1.0.0",
|
||||
@@ -14600,6 +14664,19 @@
|
||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||
"peer": true
|
||||
},
|
||||
"tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"requires": {
|
||||
"tldts-core": "^6.1.86"
|
||||
}
|
||||
},
|
||||
"tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
@@ -14614,14 +14691,11 @@
|
||||
}
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"requires": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
"tldts": "^6.1.32"
|
||||
}
|
||||
},
|
||||
"trim-lines": {
|
||||
@@ -14794,11 +14868,6 @@
|
||||
"unist-util-is": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="
|
||||
},
|
||||
"untildify": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
|
||||
@@ -14823,19 +14892,10 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="
|
||||
},
|
||||
"uvu": {
|
||||
"version": "0.5.6",
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"overrides": {
|
||||
"cypress": {
|
||||
"form-data": "^2.3.4"
|
||||
},
|
||||
"qs": "^6.14.2",
|
||||
"uuid": "^11.1.1",
|
||||
"@cypress/request": "^3.0.0",
|
||||
"@cypress/code-coverage": {
|
||||
"js-yaml": "4.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ module.exports = {
|
||||
],
|
||||
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
|
||||
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
1949
superset-frontend/package-lock.json
generated
1949
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@
|
||||
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
|
||||
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
|
||||
"test-storybook": "test-storybook",
|
||||
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
|
||||
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
|
||||
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
|
||||
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
|
||||
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
|
||||
@@ -98,6 +98,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@apache-superset/core": "file:packages/superset-core",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@deck.gl/aggregation-layers": "~9.2.5",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~9.2.5",
|
||||
@@ -168,10 +169,10 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
@@ -200,11 +201,10 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.7.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -243,24 +243,25 @@
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/node": "^7.29.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
|
||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/register": "^7.29.3",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime-corejs3": "^7.29.2",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||
"@babel/plugin-transform-runtime": "^7.29.7",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"@babel/register": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@babel/runtime-corejs3": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -278,7 +279,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -305,16 +306,16 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"concurrently": "^10.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
@@ -330,9 +331,9 @@
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -352,7 +353,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -366,15 +367,15 @@
|
||||
"storybook": "8.6.18",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
@@ -394,6 +395,7 @@
|
||||
"npm": "^10.8.1"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "$uuid",
|
||||
"core-js": "^3.38.1",
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
"author": "Apache Software Foundation",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"typescript": "^5.0.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
|
||||
@@ -115,6 +115,21 @@ export const GlobalStyles = () => {
|
||||
display: flex;
|
||||
margin-top: ${theme.marginXS}px;
|
||||
}
|
||||
|
||||
.superset-explore-popover.ant-popover
|
||||
.ant-popover-inner:has(.ant-popover-title) {
|
||||
padding-top: 0;
|
||||
}
|
||||
.superset-explore-popover.ant-popover .ant-popover-title {
|
||||
padding-top: ${theme.paddingXS}px;
|
||||
margin-bottom: ${theme.paddingSM}px;
|
||||
line-height: 1;
|
||||
}
|
||||
.superset-explore-popover.ant-popover
|
||||
.ant-popover-inner:has(.ant-popover-title)
|
||||
.ant-tabs-tab {
|
||||
padding-top: 0;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { PostProcessingFactory } from './types';
|
||||
|
||||
const PERCENTILE_REGEX = /(\d+)\/(\d+) percentiles/;
|
||||
const PERCENTILE_REGEX = /(\d{1,3})\/(\d{1,3}) percentiles/;
|
||||
|
||||
export const boxplotOperator: PostProcessingFactory<PostProcessingBoxplot> = (
|
||||
formData,
|
||||
|
||||
@@ -57,7 +57,7 @@ export const D3_FORMAT_OPTIONS: [string, string][] = [
|
||||
...d3Formatted,
|
||||
['DURATION', t('Duration in ms (66000 => 1m 6s)')],
|
||||
['DURATION_SUB', t('Duration in ms (1.40008 => 1ms 400µs 80ns)')],
|
||||
['DURATION_COL', t('Duration in ms (10500 => 0:10.5)')],
|
||||
['DURATION_COL', t('Duration in ms (10500 => 0:00:10.5)')],
|
||||
['MEMORY_DECIMAL', t('Memory in bytes - decimal (1024B => 1.024kB)')],
|
||||
['MEMORY_BINARY', t('Memory in bytes - binary (1024B => 1KiB)')],
|
||||
[
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
@@ -41,17 +42,17 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.4.5",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"math-expression-evaluator": "^2.0.7",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
|
||||
@@ -72,6 +72,15 @@ export const DropdownContainer = forwardRef(
|
||||
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
|
||||
// When the item set changes, the overflow index is briefly reset while the
|
||||
// new widths are measured (see the layout effect below). During that window
|
||||
// the dropdown content momentarily becomes empty, which would hide and then
|
||||
// re-show the trigger, causing a flicker. We track whether a recalculation
|
||||
// is pending so the trigger can stay mounted across the transient (when it
|
||||
// was showing content just before) without lingering in the steady state
|
||||
// when nothing actually overflows.
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
|
||||
// callback to update item widths so that the useLayoutEffect runs whenever
|
||||
// width of any of the child changes
|
||||
const recalculateItemWidths = useCallback(() => {
|
||||
@@ -171,6 +180,7 @@ export const DropdownContainer = forwardRef(
|
||||
);
|
||||
} else {
|
||||
setOverflowingIndex(-1);
|
||||
setRecalculating(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -211,6 +221,7 @@ export const DropdownContainer = forwardRef(
|
||||
}
|
||||
|
||||
setOverflowingIndex(newOverflowingIndex);
|
||||
setRecalculating(false);
|
||||
}
|
||||
}, [
|
||||
current,
|
||||
@@ -261,6 +272,15 @@ export const DropdownContainer = forwardRef(
|
||||
],
|
||||
);
|
||||
|
||||
// The trigger had content in the previous render if popoverContent was
|
||||
// truthy then. During the brief mid-recalculation render where
|
||||
// popoverContent flips to null, this still reflects the prior (non-empty)
|
||||
// value, letting us keep the trigger mounted across the transient.
|
||||
const hadPopoverContent = usePrevious(!!popoverContent, false);
|
||||
|
||||
const showDropdownButton =
|
||||
!!popoverContent || (recalculating && hadPopoverContent);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
// Measures scroll height after rendering the elements
|
||||
@@ -314,7 +334,7 @@ export const DropdownContainer = forwardRef(
|
||||
>
|
||||
{notOverflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
{popoverContent && (
|
||||
{showDropdownButton && (
|
||||
<>
|
||||
<Global
|
||||
styles={css`
|
||||
@@ -348,8 +368,13 @@ export const DropdownContainer = forwardRef(
|
||||
}}
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={visible => setPopoverVisible(visible)}
|
||||
open={popoverVisible && !!popoverContent}
|
||||
onOpenChange={visible => {
|
||||
// While a recalculation keeps the trigger mounted but there is
|
||||
// no content yet, ignore open attempts so it stays visible
|
||||
// without opening an empty popover.
|
||||
if (popoverContent) setPopoverVisible(visible);
|
||||
}}
|
||||
placement="bottom"
|
||||
forceRender={forceRender}
|
||||
fresh // This prop prevents caching and stale data for filter scoping.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { FC } from 'react';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import { Skeleton } from '../Skeleton';
|
||||
@@ -140,7 +141,7 @@ const ThinSkeleton = styled(Skeleton)`
|
||||
const paragraphConfig = { rows: 1, width: 150 };
|
||||
|
||||
const AnchorLink: FC<LinkProps> = ({ to, children }) => (
|
||||
<a href={to}>{children}</a>
|
||||
<a href={to !== undefined ? sanitizeUrl(to) : undefined}>{children}</a>
|
||||
);
|
||||
|
||||
function ListViewCard({
|
||||
|
||||
@@ -16,21 +16,35 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { isValidElement, cloneElement, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react';
|
||||
import { isNil } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { Modal as AntdModal, ModalProps as AntdModalProps } from 'antd';
|
||||
import { Resizable } from 're-resizable';
|
||||
import Draggable, {
|
||||
import RawDraggable, {
|
||||
DraggableBounds,
|
||||
DraggableData,
|
||||
DraggableEvent,
|
||||
DraggableProps,
|
||||
} from 'react-draggable';
|
||||
import { Icons } from '../Icons';
|
||||
import { Button } from '../Button';
|
||||
import type { ModalProps, StyledModalProps } from './types';
|
||||
|
||||
// react-draggable 4.6.0 ships generated types that mark every Draggable prop as
|
||||
// required (its LibraryManagedAttributes no longer honors defaultProps), even
|
||||
// though the component accepts a Partial<DraggableProps> at runtime. Re-type the
|
||||
// component so optional props stay optional, preserving the prior behavior.
|
||||
const Draggable = RawDraggable as ComponentType<Partial<DraggableProps>>;
|
||||
|
||||
const MODAL_HEADER_HEIGHT = 55;
|
||||
const MODAL_MIN_CONTENT_HEIGHT = 54;
|
||||
const MODAL_FOOTER_HEIGHT = 65;
|
||||
@@ -246,7 +260,7 @@ const CustomModal = ({
|
||||
[bodyStyle, stylesProp],
|
||||
);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [bounds, setBounds] = useState<DraggableBounds>();
|
||||
const [bounds, setBounds] = useState<DraggableBounds>({});
|
||||
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -355,7 +369,7 @@ const CustomModal = ({
|
||||
resizable || draggable ? (
|
||||
<Draggable
|
||||
disabled={!draggable || dragDisabled}
|
||||
bounds={bounds}
|
||||
bounds={bounds ?? false}
|
||||
onStart={(event, uiData) => onDragStart(event, uiData)}
|
||||
{...draggableConfig}
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ModalProps {
|
||||
resizable?: boolean;
|
||||
resizableConfig?: ResizableProps;
|
||||
draggable?: boolean;
|
||||
draggableConfig?: DraggableProps;
|
||||
draggableConfig?: Partial<DraggableProps>;
|
||||
destroyOnHidden?: boolean;
|
||||
maskClosable?: boolean;
|
||||
zIndex?: number;
|
||||
|
||||
@@ -31,6 +31,53 @@ interface SafeMarkdownProps {
|
||||
htmlSchemaOverrides?: typeof defaultSchema;
|
||||
}
|
||||
|
||||
// Link protocols that can execute script when used as an href.
|
||||
const DANGEROUS_LINK_PROTOCOLS = ['javascript', 'vbscript', 'data'];
|
||||
|
||||
/**
|
||||
* Sanitize link hrefs without using react-markdown's default protocol
|
||||
* allowlist, which would strip the custom link schemes that Superset markdown
|
||||
* is expected to support (see #26211). Instead of allowlisting known-safe
|
||||
* protocols, this blocks the protocols that enable script execution and leaves
|
||||
* everything else (http(s), mailto, relative URLs, anchors and custom schemes)
|
||||
* untouched. Applied regardless of the EscapeMarkdownHtml feature flag.
|
||||
*/
|
||||
export function transformLinkUri(uri: string): string {
|
||||
// Per the WHATWG URL parser, browsers strip leading C0 control
|
||||
// characters (\x00-\x1f) and space before resolving the scheme, so e.g.
|
||||
// "\x01javascript:alert(1)" executes on click. Strip them here too,
|
||||
// otherwise the blocklist check below could be bypassed with a leading
|
||||
// control character. The pattern is anchored at the start so it runs in
|
||||
// linear time; trailing whitespace does not affect the scheme and is
|
||||
// left for the renderer to handle.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const url = (uri || '').replace(/^[\u0000-\u0020]+/, '');
|
||||
const first = url.charAt(0);
|
||||
// Anchors and absolute/relative paths have no protocol.
|
||||
if (first === '#' || first === '/') {
|
||||
return url;
|
||||
}
|
||||
const colon = url.indexOf(':');
|
||||
if (colon === -1) {
|
||||
return url;
|
||||
}
|
||||
// A ':' after a '?' or '#' belongs to the query/fragment, not a scheme.
|
||||
const queryIndex = url.indexOf('?');
|
||||
if (queryIndex !== -1 && colon > queryIndex) {
|
||||
return url;
|
||||
}
|
||||
const hashIndex = url.indexOf('#');
|
||||
if (hashIndex !== -1 && colon > hashIndex) {
|
||||
return url;
|
||||
}
|
||||
// Whitespace and C0 control characters inside the scheme (e.g.
|
||||
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
|
||||
// them before comparing against the blocklist.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
@@ -82,7 +129,7 @@ export function SafeMarkdown({
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml={false}
|
||||
transformLinkUri={null}
|
||||
transformLinkUri={transformLinkUri}
|
||||
>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -295,6 +295,7 @@ export function Table<RecordType extends object>(
|
||||
onRow,
|
||||
allowHTML = false,
|
||||
childrenColumnName,
|
||||
expandable: expandableProp,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -427,6 +428,7 @@ export function Table<RecordType extends object>(
|
||||
bordered,
|
||||
expandable: {
|
||||
childrenColumnName,
|
||||
...expandableProp,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -214,6 +214,12 @@ test('Bulk selection should work with pagination', () => {
|
||||
// Check that selection checkboxes are rendered
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
|
||||
// Guard: the select-all column header carries `data-test="header-toggle-all"`,
|
||||
// which the `header.cell` slot keys on antd's internal `ant-table-selection-column`
|
||||
// class. If antd renames that class, this assertion fails fast at the unit level
|
||||
// instead of leaking into Playwright as a flake.
|
||||
expect(screen.getByTestId('header-toggle-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call setSortBy when clicking sortable column header', () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { Table, TableSize } from '@superset-ui/core/components/Table';
|
||||
import { TableRowSelection, SorterResult } from 'antd/es/table/interface';
|
||||
import { mapColumns, mapRows } from './utils';
|
||||
|
||||
interface TableCollectionProps<T extends object> {
|
||||
export interface TableCollectionProps<T extends object> {
|
||||
getTableProps: TablePropGetter<T>;
|
||||
getTableBodyProps: TableBodyPropGetter<T>;
|
||||
prepareRow: (row: Row<T>) => void;
|
||||
@@ -53,6 +53,7 @@ interface TableCollectionProps<T extends object> {
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
isPaginationSticky?: boolean;
|
||||
showRowCount?: boolean;
|
||||
expandable?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const StyledTable = styled(Table)<{
|
||||
@@ -177,6 +178,7 @@ function TableCollection<T extends object>({
|
||||
onPageChange,
|
||||
isPaginationSticky = false,
|
||||
showRowCount = true,
|
||||
expandable,
|
||||
}: TableCollectionProps<T>) {
|
||||
const mappedColumns = useMemo(
|
||||
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
|
||||
@@ -196,6 +198,14 @@ function TableCollection<T extends object>({
|
||||
const rowSelection: TableRowSelection | undefined = useMemo(() => {
|
||||
if (!bulkSelectEnabled) return undefined;
|
||||
|
||||
// antd Table's `rowSelection` API renders its own checkbox column.
|
||||
// The select-all `data-test` lives on the `<th>` via `header.cell`
|
||||
// below (keyed on antd's `ant-table-selection-column` className), NOT
|
||||
// via `columnTitle` — rc-table's MeasureCell renders the column
|
||||
// `title` verbatim inside `<tbody>`, so a `columnTitle` wrapper leaks
|
||||
// any `data-test` attr into the measure row and breaks Playwright
|
||||
// strict-mode selectors. `renderCell` only renders in real body rows,
|
||||
// so wrapping per-row checkboxes there is safe.
|
||||
return {
|
||||
selectedRowKeys,
|
||||
onSelect: (record, selected) => {
|
||||
@@ -204,6 +214,9 @@ function TableCollection<T extends object>({
|
||||
onSelectAll: (selected: boolean) => {
|
||||
toggleAllRowsSelected?.(selected);
|
||||
},
|
||||
renderCell: (_value, _record, _index, originNode) => (
|
||||
<span data-test="row-select-checkbox">{originNode}</span>
|
||||
),
|
||||
};
|
||||
}, [
|
||||
bulkSelectEnabled,
|
||||
@@ -304,11 +317,21 @@ function TableCollection<T extends object>({
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
rowClassName={getRowClassName}
|
||||
expandable={expandable}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th {...props} data-test="sort-header" />
|
||||
),
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
const isSelectionColumn =
|
||||
props.className?.includes('ant-table-selection-column') ?? false;
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
data-test={
|
||||
isSelectionColumn ? 'header-toggle-all' : 'sort-header'
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
body: {
|
||||
row: (props: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import callApiAndParseWithTimeout from './callApi/callApiAndParseWithTimeout';
|
||||
import {
|
||||
ClientConfig,
|
||||
@@ -123,7 +124,7 @@ export default class SupersetClientClass {
|
||||
if (endpoint) {
|
||||
await this.ensureAuth();
|
||||
const hiddenForm = document.createElement('form');
|
||||
hiddenForm.action = this.getUrl({ endpoint });
|
||||
hiddenForm.action = sanitizeUrl(this.getUrl({ endpoint }));
|
||||
hiddenForm.method = 'POST';
|
||||
hiddenForm.target = target;
|
||||
const payloadWithToken: Record<string, any> = {
|
||||
|
||||
@@ -64,15 +64,15 @@ NumberFormats.PERCENT; // ,.2%
|
||||
NumberFormats.PERCENT_3_POINT; // ,.3%
|
||||
```
|
||||
|
||||
There is also a formatter based on [pretty-ms](https://www.npmjs.com/package/pretty-ms) that can be
|
||||
There is also a formatter based on [Intl.DurationFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat) that can be
|
||||
used to format time durations:
|
||||
|
||||
```js
|
||||
import { createDurationFormatter, formatNumber, getNumberFormatterRegistry } from '@superset-ui-number-format';
|
||||
|
||||
getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ colonNotation: true });
|
||||
getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ style: 'digital' }));
|
||||
console.log(formatNumber('my_duration_format', 95500))
|
||||
// prints '1:35.5'
|
||||
// prints '0:01:35'
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import prettyMilliseconds, { Options } from 'pretty-ms';
|
||||
import NumberFormatter from '../NumberFormatter';
|
||||
import { getIntlDurationFormatter } from '../utils/getIntlDurationFormatter';
|
||||
import { parseMilliseconds } from '../utils/parseMilliseconds';
|
||||
|
||||
export default function createDurationFormatter(
|
||||
config: {
|
||||
@@ -26,14 +27,48 @@ export default function createDurationFormatter(
|
||||
id?: string;
|
||||
label?: string;
|
||||
multiplier?: number;
|
||||
} & Options = {},
|
||||
locale?: string;
|
||||
formatSubMilliseconds?: boolean;
|
||||
} & Intl.DurationFormatOptions = {},
|
||||
) {
|
||||
const { description, id, label, multiplier = 1, ...prettyMsOptions } = config;
|
||||
|
||||
const {
|
||||
description,
|
||||
id,
|
||||
label,
|
||||
multiplier = 1,
|
||||
locale,
|
||||
formatSubMilliseconds = false,
|
||||
...intlOptions
|
||||
} = config;
|
||||
const durationFormatter = getIntlDurationFormatter(locale, {
|
||||
secondsDisplay: 'auto',
|
||||
style: 'narrow',
|
||||
...intlOptions,
|
||||
});
|
||||
const zeroDurationFormatter = getIntlDurationFormatter(locale, {
|
||||
secondsDisplay: 'always',
|
||||
style: 'narrow',
|
||||
...intlOptions,
|
||||
});
|
||||
return new NumberFormatter({
|
||||
description,
|
||||
formatFunc: value =>
|
||||
prettyMilliseconds(value * multiplier, prettyMsOptions),
|
||||
formatFunc: value => {
|
||||
const durObject = parseMilliseconds(value * multiplier);
|
||||
|
||||
if (!formatSubMilliseconds) {
|
||||
durObject.milliseconds = 0;
|
||||
durObject.microseconds = 0;
|
||||
durObject.nanoseconds = 0;
|
||||
}
|
||||
|
||||
const isAllUnitsZero = Object.values(durObject).every(
|
||||
value => value === 0,
|
||||
);
|
||||
|
||||
return (
|
||||
isAllUnitsZero ? zeroDurationFormatter : durationFormatter
|
||||
).format(durObject);
|
||||
},
|
||||
id: id ?? 'duration_format',
|
||||
label: label ?? `Duration formatter`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export function getIntlDurationFormatter(
|
||||
locale?: string,
|
||||
options?: Intl.DurationFormatOptions,
|
||||
): Intl.DurationFormat {
|
||||
const normalizedLocale = locale?.replace(/_/g, '-');
|
||||
try {
|
||||
return new Intl.DurationFormat(normalizedLocale, options);
|
||||
} catch {
|
||||
return new Intl.DurationFormat('en', options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import parseMs from 'parse-ms';
|
||||
|
||||
interface Duration {
|
||||
years: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
milliseconds: number;
|
||||
microseconds: number;
|
||||
nanoseconds: number;
|
||||
}
|
||||
|
||||
const DAYS_IN_YEAR = 365;
|
||||
|
||||
/**
|
||||
* Parses milliseconds into a duration object.
|
||||
|
||||
* @param ms - The number of milliseconds to parse
|
||||
* @returns A duration object containing years, days, hours, minutes, seconds,
|
||||
* milliseconds, microseconds, and nanoseconds (1 year = 365 days)
|
||||
* @example
|
||||
* // Parse a complex duration
|
||||
* parseMilliseconds(90061000);
|
||||
* // { years: 0, days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 0, ... }
|
||||
*/
|
||||
export function parseMilliseconds(ms: number): Duration {
|
||||
const parsed = parseMs(ms);
|
||||
const totalDays = parsed.days;
|
||||
const years = Math.trunc(totalDays / DAYS_IN_YEAR);
|
||||
const remainingDays = totalDays % DAYS_IN_YEAR;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
years,
|
||||
days: remainingDays,
|
||||
};
|
||||
}
|
||||
@@ -102,7 +102,6 @@ export type ChartCustomization = {
|
||||
defaultDataMask: DataMask;
|
||||
controlValues: {
|
||||
sortAscending?: boolean;
|
||||
sortMetric?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
description?: string;
|
||||
|
||||
@@ -33,6 +33,35 @@ describe('sanitizeHtml', () => {
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).not.toContain('script');
|
||||
});
|
||||
|
||||
test('should preserve allowed presentational CSS properties', () => {
|
||||
const htmlString =
|
||||
'<div style="color: red; background-color: blue; font-size: 12px; text-align: center">x</div>';
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).toContain('color:red');
|
||||
expect(sanitizedString).toContain('background-color:blue');
|
||||
expect(sanitizedString).toContain('font-size:12px');
|
||||
expect(sanitizedString).toContain('text-align:center');
|
||||
});
|
||||
|
||||
test('should strip layout and positioning CSS properties', () => {
|
||||
const htmlString =
|
||||
'<div style="color: red; position: fixed; z-index: 9999; width: 100%; height: 100%">x</div>';
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).toContain('color:red');
|
||||
expect(sanitizedString).not.toContain('position');
|
||||
expect(sanitizedString).not.toContain('z-index');
|
||||
expect(sanitizedString).not.toContain('width');
|
||||
expect(sanitizedString).not.toContain('height');
|
||||
});
|
||||
|
||||
test('should strip unsafe CSS property values', () => {
|
||||
const htmlString =
|
||||
'<div style="background-color: url(javascript:alert(1)); color: blue">x</div>';
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).not.toContain('javascript');
|
||||
expect(sanitizedString).not.toContain('url(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProbablyHTML', () => {
|
||||
|
||||
@@ -19,6 +19,50 @@
|
||||
import { FilterXSS, getDefaultWhiteList } from 'xss';
|
||||
import { DataRecordValue } from '../types';
|
||||
|
||||
// Restrict inline `style` attributes to a small set of presentational CSS
|
||||
// properties. Overlay/positioning properties (e.g. position, z-index, top,
|
||||
// left, transform) and sizing properties that could cover the page (e.g.
|
||||
// width, height) are intentionally excluded so that sanitized markup cannot
|
||||
// escape its container to overlay or obscure the surrounding page. The
|
||||
// allowlisted spacing/border properties (margin, padding, border) can still
|
||||
// affect layout within the container, which is acceptable. The `xss` library
|
||||
// also validates property values against this allowlist, stripping unsupported
|
||||
// constructs such as url()/expression().
|
||||
const allowedCssProperties = {
|
||||
color: true,
|
||||
'background-color': true,
|
||||
'text-align': true,
|
||||
'text-decoration': true,
|
||||
'font-family': true,
|
||||
'font-size': true,
|
||||
'font-style': true,
|
||||
'font-weight': true,
|
||||
'line-height': true,
|
||||
'letter-spacing': true,
|
||||
'white-space': true,
|
||||
padding: true,
|
||||
'padding-top': true,
|
||||
'padding-right': true,
|
||||
'padding-bottom': true,
|
||||
'padding-left': true,
|
||||
margin: true,
|
||||
'margin-top': true,
|
||||
'margin-right': true,
|
||||
'margin-bottom': true,
|
||||
'margin-left': true,
|
||||
border: true,
|
||||
'border-color': true,
|
||||
'border-style': true,
|
||||
'border-width': true,
|
||||
'border-radius': true,
|
||||
'vertical-align': true,
|
||||
// Needed by ECharts tooltips for row transparency and text truncation.
|
||||
opacity: true,
|
||||
'max-width': true,
|
||||
overflow: true,
|
||||
'text-overflow': true,
|
||||
};
|
||||
|
||||
const xssFilter = new FilterXSS({
|
||||
whiteList: {
|
||||
...getDefaultWhiteList(),
|
||||
@@ -45,7 +89,7 @@ const xssFilter = new FilterXSS({
|
||||
tfoot: ['align', 'valign', 'style'],
|
||||
},
|
||||
stripIgnoreTag: true,
|
||||
css: false,
|
||||
css: { whiteList: allowedCssProperties },
|
||||
});
|
||||
|
||||
export function sanitizeHtml(htmlString: string) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { render } from '@testing-library/react';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
transformLinkUri,
|
||||
} from '../../src/components/SafeMarkdown/SafeMarkdown';
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,63 @@ describe('getOverrideHtmlSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformLinkUri', () => {
|
||||
// Build script-executing protocols via concatenation so the literal URLs
|
||||
// don't trip the no-script-url lint rule.
|
||||
const js = `java${'script'}`;
|
||||
const vbs = `vb${'script'}`;
|
||||
|
||||
// Cases are [label, uri] pairs: the raw URIs contain C0 control characters
|
||||
// (\x00, \x01, \x1F) that are invalid in XML, so they must not be
|
||||
// interpolated into the test name (the HTML/JUnit reporters serialize names
|
||||
// to XML and would crash). The label keeps the reported name printable while
|
||||
// the uri is exercised in the body.
|
||||
test.each([
|
||||
['javascript', `${js}:alert(1)`],
|
||||
['mixed-case JavaScript', `Java${'Script'}:alert(1)`],
|
||||
['leading whitespace', ` ${js}:alert(document.cookie)`],
|
||||
['tab inside scheme', `java\t${'script'}:alert(1)`],
|
||||
// Leading C0 control characters are stripped by the WHATWG URL parser
|
||||
// before the scheme is resolved, so they must not bypass the blocklist.
|
||||
['leading 0x01 control', `\x01${js}:alert(1)`],
|
||||
['leading NUL (0x00)', `\x00${js}:alert(1)`],
|
||||
['leading 0x1F control', `\x1F${js}:alert(1)`],
|
||||
// C0 control characters inside the scheme are ignored by browsers too.
|
||||
['0x01 control inside scheme', `java\x01${'script'}:alert(1)`],
|
||||
['vbscript', `${vbs}:msgbox(1)`],
|
||||
['data: text/html', 'data:text/html,<script>alert(1)</script>'],
|
||||
])(
|
||||
'blocks the script-executing protocol (%s)',
|
||||
(_label: string, uri: string) => {
|
||||
expect(transformLinkUri(uri)).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
'https://superset.apache.org',
|
||||
'http://example.com/path?q=1',
|
||||
'mailto:someone@example.com',
|
||||
'/relative/path',
|
||||
'#section',
|
||||
])('keeps the safe URL %p unchanged', uri => {
|
||||
expect(transformLinkUri(uri)).toBe(uri);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'custom-scheme://open/thing',
|
||||
'slack://channel?id=1',
|
||||
`foo:bar?${js}:alert(1)`,
|
||||
])('preserves custom link scheme %p (see #26211)', uri => {
|
||||
expect(transformLinkUri(uri)).toBe(uri);
|
||||
});
|
||||
|
||||
test('handles empty and nullish input', () => {
|
||||
expect(transformLinkUri('')).toBe('');
|
||||
// @ts-expect-error -- guarding runtime nullish input
|
||||
expect(transformLinkUri(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SafeMarkdown', () => {
|
||||
describe('remark-gfm compatibility tests', () => {
|
||||
/**
|
||||
|
||||
@@ -26,34 +26,31 @@ test('creates an instance of NumberFormatter', () => {
|
||||
test('format milliseconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter();
|
||||
expect(formatter(-1000)).toBe('-1s');
|
||||
expect(formatter(0)).toBe('0ms');
|
||||
expect(formatter(0)).toBe('0s');
|
||||
expect(formatter(1000)).toBe('1s');
|
||||
expect(formatter(1337)).toBe('1.3s');
|
||||
expect(formatter(10500)).toBe('10.5s');
|
||||
expect(formatter(1337)).toBe('1s');
|
||||
expect(formatter(10500)).toBe('10s');
|
||||
expect(formatter(60 * 1000)).toBe('1m');
|
||||
expect(formatter(90 * 1000)).toBe('1m 30s');
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('-500ms');
|
||||
expect(formatter(0.5)).toBe('500ms');
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
expect(formatter(60)).toBe('1m');
|
||||
expect(formatter(90)).toBe('1m 30s');
|
||||
});
|
||||
test('format milliseconds in human readable format with additional pretty-ms options', () => {
|
||||
test('format milliseconds in human readable format with additional options', () => {
|
||||
const colonNotationFormatter = createDurationFormatter({
|
||||
colonNotation: true,
|
||||
style: 'digital',
|
||||
formatSubMilliseconds: true,
|
||||
fractionalDigits: 1,
|
||||
});
|
||||
expect(colonNotationFormatter(-10500)).toBe('-0:10.5');
|
||||
expect(colonNotationFormatter(10500)).toBe('0:10.5');
|
||||
const zeroDecimalFormatter = createDurationFormatter({
|
||||
secondsDecimalDigits: 0,
|
||||
});
|
||||
expect(zeroDecimalFormatter(10500)).toBe('10s');
|
||||
expect(colonNotationFormatter(10500)).toBe('0:00:10.5');
|
||||
const subMillisecondFormatter = createDurationFormatter({
|
||||
formatSubMilliseconds: true,
|
||||
});
|
||||
expect(subMillisecondFormatter(100.40008)).toBe('100ms 400µs 80ns');
|
||||
expect(subMillisecondFormatter(100.40008)).toBe('100ms 400μs 80ns');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getIntlDurationFormatter } from '@superset-ui/core/number-format/utils/getIntlDurationFormatter';
|
||||
|
||||
test('getIntlDurationFormatter creates formatter with fallback locale when passed locale is invalid', () => {
|
||||
const formatter = getIntlDurationFormatter('invalid-locale-xyz');
|
||||
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
|
||||
expect(formatter.format({ seconds: 60 })).toBe('60 sec');
|
||||
});
|
||||
|
||||
test('getIntlDurationFormatter creates formatter with custom options', () => {
|
||||
const formatter = getIntlDurationFormatter('en', { style: 'digital' });
|
||||
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
|
||||
expect(formatter.format({ minutes: 5, seconds: 30 })).toContain(':');
|
||||
});
|
||||
|
||||
test('getIntlDurationFormatter normalizes locale underscores', () => {
|
||||
const formatter = getIntlDurationFormatter('zh_Hans_CN');
|
||||
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
|
||||
expect(formatter.resolvedOptions().locale).toMatch(/^zh/);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { parseMilliseconds } from '@superset-ui/core/number-format/utils/parseMilliseconds';
|
||||
|
||||
test('parseMilliseconds should parse basic time units correctly', () => {
|
||||
expect(parseMilliseconds(500)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 500,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(5000)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 5,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(120000)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 2,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(7200000)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 2,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(172800000)).toEqual({
|
||||
years: 0,
|
||||
days: 2,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(31536000000)).toEqual({
|
||||
years: 1,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle complex duration', () => {
|
||||
expect(parseMilliseconds(90061234)).toEqual({
|
||||
years: 0,
|
||||
days: 1,
|
||||
hours: 1,
|
||||
minutes: 1,
|
||||
seconds: 1,
|
||||
milliseconds: 234,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle fractional milliseconds', () => {
|
||||
expect(parseMilliseconds(1.001001)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 1,
|
||||
microseconds: 1,
|
||||
nanoseconds: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle zero', () => {
|
||||
expect(parseMilliseconds(0)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle negative duration', () => {
|
||||
expect(parseMilliseconds(-1000)).toEqual({
|
||||
years: -0,
|
||||
days: -0,
|
||||
hours: -0,
|
||||
minutes: -0,
|
||||
seconds: -1,
|
||||
milliseconds: -0,
|
||||
microseconds: -0,
|
||||
nanoseconds: -0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle negative days without overflowing into years', () => {
|
||||
expect(parseMilliseconds(-31449600000)).toEqual({
|
||||
years: -0,
|
||||
days: -364,
|
||||
hours: -0,
|
||||
minutes: -0,
|
||||
seconds: -0,
|
||||
milliseconds: -0,
|
||||
microseconds: -0,
|
||||
nanoseconds: -0,
|
||||
});
|
||||
});
|
||||
@@ -161,10 +161,13 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
</table>
|
||||
`;
|
||||
|
||||
// The `xss` CSS filter normalizes declarations, dropping the space after
|
||||
// each colon (e.g. `opacity: 0.8;` becomes `opacity:0.8;`) while preserving
|
||||
// the property/value pairs themselves.
|
||||
const sanitized = sanitizeHtml(tableWithStyles);
|
||||
expect(sanitized).toContain('style="opacity: 0.8;"');
|
||||
expect(sanitized).toContain('style="text-align: left; padding-left: 0px;"');
|
||||
expect(sanitized).toContain('style="text-align: right; padding-left: 16px;"');
|
||||
expect(sanitized).toContain('style="opacity:0.8;"');
|
||||
expect(sanitized).toContain('style="text-align:left; padding-left:0px;"');
|
||||
expect(sanitized).toContain('style="text-align:right; padding-left:16px;"');
|
||||
|
||||
const data = [
|
||||
['Metric', 'Value'],
|
||||
@@ -172,10 +175,10 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
];
|
||||
const html = tooltipHtml(data, 'Test Tooltip');
|
||||
|
||||
expect(html).toContain('style="opacity: 0.8;"');
|
||||
expect(html).toContain('text-align: left');
|
||||
expect(html).toContain('text-align: right');
|
||||
expect(html).toContain('padding-left: 0px');
|
||||
expect(html).toContain('padding-left: 16px');
|
||||
expect(html).toContain('max-width: 300px');
|
||||
expect(html).toContain('style="opacity:0.8;"');
|
||||
expect(html).toContain('text-align:left');
|
||||
expect(html).toContain('text-align:right');
|
||||
expect(html).toContain('padding-left:0px');
|
||||
expect(html).toContain('padding-left:16px');
|
||||
expect(html).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
73
superset-frontend/packages/superset-ui-core/types/intl.d.ts
vendored
Normal file
73
superset-frontend/packages/superset-ui-core/types/intl.d.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
declare namespace Intl {
|
||||
class DurationFormat {
|
||||
constructor(locale?: string | string[], options?: DurationFormatOptions);
|
||||
format(duration: DurationObject): string;
|
||||
formatToParts(
|
||||
duration: DurationObject,
|
||||
): { type: string; value: string; unit?: string }[];
|
||||
resolvedOptions(): ResolvedDurationFormatOptions;
|
||||
}
|
||||
|
||||
interface DurationObject {
|
||||
years?: number;
|
||||
months?: number;
|
||||
weeks?: number;
|
||||
days?: number;
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
seconds?: number;
|
||||
milliseconds?: number;
|
||||
microseconds?: number;
|
||||
nanoseconds?: number;
|
||||
}
|
||||
|
||||
interface DurationFormatOptions {
|
||||
localeMatcher?: 'lookup' | 'best fit';
|
||||
numberingSystem?: string;
|
||||
style?: 'long' | 'short' | 'narrow' | 'digital';
|
||||
years?: 'long' | 'short' | 'narrow';
|
||||
yearsDisplay?: 'always' | 'auto';
|
||||
months?: 'long' | 'short' | 'narrow';
|
||||
monthsDisplay?: 'always' | 'auto';
|
||||
weeks?: 'long' | 'short' | 'narrow';
|
||||
weeksDisplay?: 'always' | 'auto';
|
||||
days?: 'long' | 'short' | 'narrow';
|
||||
daysDisplay?: 'always' | 'auto';
|
||||
hours?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
|
||||
hoursDisplay?: 'always' | 'auto';
|
||||
minutes?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
|
||||
minutesDisplay?: 'always' | 'auto';
|
||||
seconds?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
|
||||
secondsDisplay?: 'always' | 'auto';
|
||||
milliseconds?: 'long' | 'short' | 'narrow' | 'numeric';
|
||||
millisecondsDisplay?: 'always' | 'auto';
|
||||
microseconds?: 'long' | 'short' | 'narrow' | 'numeric';
|
||||
microsecondsDisplay?: 'always' | 'auto';
|
||||
nanoseconds?: 'long' | 'short' | 'narrow' | 'numeric';
|
||||
nanosecondsDisplay?: 'always' | 'auto';
|
||||
fractionalDigits?: number;
|
||||
}
|
||||
|
||||
interface ResolvedDurationFormatOptions extends DurationFormatOptions {
|
||||
locale: string;
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export default defineConfig({
|
||||
testIgnore: [
|
||||
'**/tests/auth/**/*.spec.ts',
|
||||
'**/tests/sqllab/**/*.spec.ts',
|
||||
'**/tests/embedded/**/*.spec.ts',
|
||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||
],
|
||||
use: {
|
||||
@@ -132,6 +133,29 @@ export default defineConfig({
|
||||
// No storageState = clean browser with no cached cookies
|
||||
},
|
||||
},
|
||||
// Strict 'true' check: non-empty strings like 'false' or '0' would
|
||||
// otherwise enable the embedded project, matching the env-parsing
|
||||
// convention used in docker/pythonpath_dev/superset_config_docker_light.py.
|
||||
...(process.env.INCLUDE_EMBEDDED?.toLowerCase() === 'true'
|
||||
? [
|
||||
{
|
||||
// Embedded dashboard tests - validates the full embedding flow:
|
||||
// external app -> SDK -> iframe -> guest token -> dashboard render.
|
||||
// Each spec file mutates per-dashboard embedding state (UUID,
|
||||
// allowed_domains) on a single shared Superset, so files must not
|
||||
// run in parallel even if more are added later.
|
||||
name: 'chromium-embedded',
|
||||
testMatch: '**/tests/embedded/**/*.spec.ts',
|
||||
fullyParallel: false,
|
||||
use: {
|
||||
browserName: 'chromium' as const,
|
||||
testIdAttribute: 'data-test',
|
||||
// Uses admin auth for API calls to configure embedding and get guest tokens
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||
|
||||
@@ -17,14 +17,24 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
import { Button, Checkbox, Table } from '../core';
|
||||
|
||||
const BULK_SELECT_SELECTORS = {
|
||||
CONTROLS: '[data-test="bulk-select-controls"]',
|
||||
ACTION: '[data-test="bulk-select-action"]',
|
||||
HEADER_TOGGLE: '[data-test="header-toggle-all"]',
|
||||
ROW_CHECKBOX: '[data-test="row-select-checkbox"]',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stable keys for ListView bulk actions, matching `action.key` in the
|
||||
* `bulkActions` prop passed to `ListView` (see `src/pages/*List`). Using
|
||||
* the key — not the localized button text — keeps selectors valid across
|
||||
* locales.
|
||||
*/
|
||||
export type BulkSelectActionKey = 'delete' | 'export';
|
||||
|
||||
/**
|
||||
* BulkSelect component for Superset ListView bulk operations.
|
||||
* Provides a reusable interface for bulk selection and actions across list pages.
|
||||
@@ -34,7 +44,7 @@ const BULK_SELECT_SELECTORS = {
|
||||
* await bulkSelect.enable();
|
||||
* await bulkSelect.selectRow('my-dataset');
|
||||
* await bulkSelect.selectRow('another-dataset');
|
||||
* await bulkSelect.clickAction('Delete');
|
||||
* await bulkSelect.clickAction('delete');
|
||||
*/
|
||||
export class BulkSelect {
|
||||
private readonly page: Page;
|
||||
@@ -56,35 +66,67 @@ export class BulkSelect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables bulk selection mode by clicking the toggle button
|
||||
* Enables bulk selection mode by clicking the toggle button.
|
||||
*
|
||||
* Waits for the bulk-select column header to render so the next row
|
||||
* interaction does not race the table re-render that adds the checkbox
|
||||
* column. The `data-test="header-toggle-all"` attribute is on the
|
||||
* select-all `<th>` itself (see `TableCollection`'s `components.header.cell`
|
||||
* slot, which keys on antd's `ant-table-selection-column` className).
|
||||
* It deliberately is NOT injected via `rowSelection.columnTitle` because
|
||||
* rc-table's measure row in `<tbody>` clones `columnTitle` and any
|
||||
* `data-test` would duplicate, breaking Playwright strict mode.
|
||||
*/
|
||||
async enable(): Promise<void> {
|
||||
await this.getToggleButton().click();
|
||||
await this.page.locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE).waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the checkbox for a row by name
|
||||
* Gets the bulk-select checkbox for a row by name.
|
||||
*
|
||||
* The `data-test="row-select-checkbox"` attribute is on the `<span>`
|
||||
* wrapper that `TableCollection`'s `rowSelection.renderCell` puts around
|
||||
* antd's checkbox originNode (the attribute can't be moved directly
|
||||
* onto antd's `<input>` from `renderCell` because the originNode is
|
||||
* opaque). We drill into `input[type="checkbox"]` so Playwright's
|
||||
* `.check()` operates on the real input — `.check()` on the wrapper
|
||||
* `<span>` throws "Not a checkbox or radio button".
|
||||
*
|
||||
* @param rowName - The name/text identifying the row
|
||||
*/
|
||||
getRowCheckbox(rowName: string): Checkbox {
|
||||
const row = this.table.getRow(rowName);
|
||||
return new Checkbox(this.page, row.getByRole('checkbox'));
|
||||
return new Checkbox(
|
||||
this.page,
|
||||
row.locator(
|
||||
`${BULK_SELECT_SELECTORS.ROW_CHECKBOX} input[type="checkbox"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a row's checkbox in bulk select mode
|
||||
* Selects a row's checkbox in bulk select mode.
|
||||
* Asserts the checkbox is checked afterwards so any state-update race
|
||||
* surfaces here rather than as a missing bulk-action button later.
|
||||
* @param rowName - The name/text identifying the row to select
|
||||
*/
|
||||
async selectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).check();
|
||||
const checkbox = this.getRowCheckbox(rowName);
|
||||
await checkbox.check();
|
||||
await expect(checkbox.element).toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselects a row's checkbox in bulk select mode
|
||||
* Deselects a row's checkbox in bulk select mode.
|
||||
* Mirrors selectRow: asserts the unchecked state so any lingering selection
|
||||
* surfaces here rather than as a stale bulk-action count later.
|
||||
* @param rowName - The name/text identifying the row to deselect
|
||||
*/
|
||||
async deselectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).uncheck();
|
||||
const checkbox = this.getRowCheckbox(rowName);
|
||||
await checkbox.uncheck();
|
||||
await expect(checkbox.element).not.toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,22 +137,30 @@ export class BulkSelect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a bulk action button by name
|
||||
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
|
||||
* Gets a bulk action button by its stable action key.
|
||||
*
|
||||
* Scoping by `data-test-action-key` (rendered from `action.key`) instead
|
||||
* of visible text keeps this selector valid across locales — the
|
||||
* button's label is localized via i18n, but the action key is not.
|
||||
*
|
||||
* @param actionKey - The stable key of the bulk action (e.g., "delete", "export")
|
||||
*/
|
||||
getActionButton(actionName: string): Button {
|
||||
getActionButton(actionKey: BulkSelectActionKey): Button {
|
||||
const controls = this.getControls();
|
||||
return new Button(
|
||||
this.page,
|
||||
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
|
||||
controls.locator(
|
||||
`${BULK_SELECT_SELECTORS.ACTION}[data-test-action-key="${actionKey}"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key.
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickAction(actionName: string): Promise<void> {
|
||||
await this.getActionButton(actionName).click();
|
||||
async clickAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
const button = this.getActionButton(actionKey);
|
||||
await button.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@
|
||||
|
||||
// ListView-specific Playwright Components for Superset
|
||||
export { BulkSelect } from './BulkSelect';
|
||||
export type { BulkSelectActionKey } from './BulkSelect';
|
||||
|
||||
@@ -32,9 +32,25 @@ import { Tabs } from './Tabs';
|
||||
export class EditableTabs extends Tabs {
|
||||
/**
|
||||
* Clicks the add-tab button rendered by antd in editable-card mode.
|
||||
*
|
||||
* When the tab strip overflows, antd renders two `Add tab` buttons:
|
||||
* one hidden inside `.ant-tabs-nav-list` (visibility: hidden) and one
|
||||
* visible inside `.ant-tabs-nav-operations`. Scope the click to the
|
||||
* visible operations container so we never match the hidden inline copy.
|
||||
*/
|
||||
async addTab(): Promise<void> {
|
||||
await this.element.getByRole('button', { name: 'Add tab' }).click();
|
||||
const operationsButton = this.element
|
||||
.locator('.ant-tabs-nav-operations')
|
||||
.getByRole('button', { name: 'Add tab' });
|
||||
if ((await operationsButton.count()) > 0) {
|
||||
await operationsButton.click();
|
||||
return;
|
||||
}
|
||||
// No overflow yet — the inline nav-list button is the only one rendered.
|
||||
await this.element
|
||||
.locator('.ant-tabs-nav-list')
|
||||
.getByRole('button', { name: 'Add tab' })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { Modal, Input } from '../core';
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,8 @@ import { Modal, Input } from '../core';
|
||||
*/
|
||||
export class DeleteConfirmationModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
CONFIRMATION_INPUT: 'input[type="text"]',
|
||||
CONFIRMATION_INPUT: '[data-test="delete-modal-input"]',
|
||||
CONFIRM_BUTTON: '[data-test="modal-confirm-button"]',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,12 +38,16 @@ export class DeleteConfirmationModal extends Modal {
|
||||
private get confirmationInput(): Input {
|
||||
return new Input(
|
||||
this.page,
|
||||
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
|
||||
this.element.locator(
|
||||
DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the confirmation input with the specified text.
|
||||
* Waits for the input to be visible before filling so callers don't race
|
||||
* with the modal's open animation / focus effect.
|
||||
*
|
||||
* @param confirmationText - The text to type
|
||||
* @param options - Optional fill options (timeout, force)
|
||||
@@ -57,11 +63,25 @@ export class DeleteConfirmationModal extends Modal {
|
||||
confirmationText: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.confirmationInput.element.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.confirmationInput.fill(confirmationText, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Delete button in the footer
|
||||
* Clicks the Delete button in the footer.
|
||||
*
|
||||
* Targets the confirm button by data-test rather than going through
|
||||
* Modal.clickFooterButton, which finds buttons by their visible text. The
|
||||
* button label is i18n'd ("Delete" / "Supprimer" / …) so name-based lookups
|
||||
* break in non-English locales.
|
||||
*
|
||||
* Also waits for the button to become enabled before clicking: it is
|
||||
* disabled until the confirmation text matches "DELETE", and React's state
|
||||
* update from fillConfirmationInput is asynchronous, so an immediate click
|
||||
* can race the disabled→enabled transition.
|
||||
*
|
||||
* @param options - Optional click options (timeout, force, delay)
|
||||
*/
|
||||
@@ -70,6 +90,10 @@ export class DeleteConfirmationModal extends Modal {
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
}): Promise<void> {
|
||||
await this.clickFooterButton('Delete', options);
|
||||
const confirmButton = this.element.locator(
|
||||
DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON,
|
||||
);
|
||||
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
|
||||
await confirmButton.click(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export class EditDatasetModal extends Modal {
|
||||
UNLOCK_ICON: '[data-test="unlock"]',
|
||||
};
|
||||
|
||||
// FAST_DEBOUNCE in @superset-ui/core is 250 ms; pad slightly so the
|
||||
// debounced onChange has reliably flushed before we click Save.
|
||||
private static readonly TEXT_CONTROL_DEBOUNCE_FLUSH_MS = 350;
|
||||
|
||||
private readonly tabs: Tabs;
|
||||
private readonly specificLocator: Locator;
|
||||
|
||||
@@ -94,6 +98,7 @@ export class EditDatasetModal extends Modal {
|
||||
*/
|
||||
async fillName(name: string): Promise<void> {
|
||||
await this.nameInput.fill(name);
|
||||
await this.waitForTextControlDebounce();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,5 +193,17 @@ export class EditDatasetModal extends Modal {
|
||||
await dateFormatInput.element.waitFor({ state: 'visible' });
|
||||
await dateFormatInput.clear();
|
||||
await dateFormatInput.fill(format);
|
||||
await this.waitForTextControlDebounce();
|
||||
}
|
||||
|
||||
/**
|
||||
* TextControl debounces its onChange by FAST_DEBOUNCE (250 ms) before
|
||||
* propagating the value to the parent form. Wait past that window so a
|
||||
* subsequent Save click captures the new value rather than the stale state.
|
||||
*/
|
||||
private async waitForTextControlDebounce(): Promise<void> {
|
||||
await this.page.waitForTimeout(
|
||||
EditDatasetModal.TEXT_CONTROL_DEBOUNCE_FLUSH_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
95
superset-frontend/playwright/embedded-app/index.html
Normal file
95
superset-frontend/playwright/embedded-app/index.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embedded Dashboard Test App</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
#superset-container { width: 100%; height: 100vh; }
|
||||
#superset-container iframe { width: 100%; height: 100%; border: none; }
|
||||
#error { color: red; padding: 20px; display: none; }
|
||||
#status { padding: 10px; font-family: monospace; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status">Initializing embedded dashboard...</div>
|
||||
<div id="error"></div>
|
||||
<div id="superset-container" data-test="embedded-container"></div>
|
||||
|
||||
<script src="/sdk/index.js"></script>
|
||||
<script>
|
||||
(async function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const uuid = params.get('uuid');
|
||||
const supersetDomain = params.get('supersetDomain');
|
||||
|
||||
if (!uuid || !supersetDomain) {
|
||||
document.getElementById('error').style.display = 'block';
|
||||
document.getElementById('error').textContent =
|
||||
'Missing required query params: uuid, supersetDomain';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
// fetchGuestToken is injected by Playwright via page.exposeFunction()
|
||||
async function fetchGuestToken() {
|
||||
if (typeof window.__fetchGuestToken !== 'function') {
|
||||
throw new Error('No guest token source available');
|
||||
}
|
||||
statusEl.textContent = 'Fetching guest token...';
|
||||
const token = await window.__fetchGuestToken();
|
||||
statusEl.textContent = 'Guest token received, loading dashboard...';
|
||||
return token;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse optional UI config from query params
|
||||
const uiConfig = {};
|
||||
if (params.get('hideTitle') === 'true') uiConfig.hideTitle = true;
|
||||
if (params.get('hideTab') === 'true') uiConfig.hideTab = true;
|
||||
if (params.get('hideChartControls') === 'true') uiConfig.hideChartControls = true;
|
||||
|
||||
const dashboard = await supersetEmbeddedSdk.embedDashboard({
|
||||
id: uuid,
|
||||
supersetDomain: supersetDomain,
|
||||
mountPoint: document.getElementById('superset-container'),
|
||||
fetchGuestToken: fetchGuestToken,
|
||||
dashboardUiConfig: Object.keys(uiConfig).length > 0 ? uiConfig : undefined,
|
||||
debug: params.get('debug') === 'true',
|
||||
});
|
||||
|
||||
statusEl.textContent = 'Dashboard embedded successfully';
|
||||
// Expose dashboard API on window for Playwright assertions
|
||||
window.__embeddedDashboard = dashboard;
|
||||
} catch (err) {
|
||||
// Browser exceptions can be strings, DOMExceptions, or arbitrary
|
||||
// thrown values; only Error instances reliably expose `.message`.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
document.getElementById('error').style.display = 'block';
|
||||
document.getElementById('error').textContent = 'Embed failed: ' + message;
|
||||
statusEl.textContent = 'Error';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -132,26 +132,14 @@ export interface DashboardResult {
|
||||
published?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a dashboard by its title
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param title - The dashboard_title to search for
|
||||
* @returns Dashboard object if found, null if not found
|
||||
*/
|
||||
export async function getDashboardByName(
|
||||
async function getDashboardByFilter(
|
||||
page: Page,
|
||||
title: string,
|
||||
col: 'dashboard_title' | 'slug',
|
||||
value: string,
|
||||
): Promise<DashboardResult | null> {
|
||||
const filter = {
|
||||
filters: [
|
||||
{
|
||||
col: 'dashboard_title',
|
||||
opr: 'eq',
|
||||
value: title,
|
||||
},
|
||||
],
|
||||
};
|
||||
const queryParam = rison.encode(filter);
|
||||
const queryParam = rison.encode({
|
||||
filters: [{ col, opr: 'eq', value }],
|
||||
});
|
||||
const response = await apiGet(
|
||||
page,
|
||||
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
|
||||
@@ -169,3 +157,29 @@ export async function getDashboardByName(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a dashboard by its title
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param title - The dashboard_title to search for
|
||||
* @returns Dashboard object if found, null if not found
|
||||
*/
|
||||
export async function getDashboardByName(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<DashboardResult | null> {
|
||||
return getDashboardByFilter(page, 'dashboard_title', title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a dashboard by its slug
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param slug - The slug to search for
|
||||
* @returns Dashboard object if found, null if not found
|
||||
*/
|
||||
export async function getDashboardBySlug(
|
||||
page: Page,
|
||||
slug: string,
|
||||
): Promise<DashboardResult | null> {
|
||||
return getDashboardByFilter(page, 'slug', slug);
|
||||
}
|
||||
|
||||
133
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
133
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { apiPost, apiPut } from './requests';
|
||||
import { ENDPOINTS as DASHBOARD_ENDPOINTS } from './dashboard';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
SECURITY_LOGIN: 'api/v1/security/login',
|
||||
GUEST_TOKEN: 'api/v1/security/guest_token/',
|
||||
} as const;
|
||||
|
||||
export interface EmbeddedConfig {
|
||||
uuid: string;
|
||||
allowed_domains: string[];
|
||||
dashboard_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable embedding on a dashboard and return the embedded UUID.
|
||||
* Uses PUT (upsert) to preserve UUID across repeated calls.
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param dashboardIdOrSlug - Numeric dashboard id or slug
|
||||
* @param allowedDomains - Domains allowed to embed; empty array allows all
|
||||
* @returns Embedded config with UUID, allowed_domains, and dashboard_id
|
||||
*/
|
||||
export async function apiEnableEmbedding(
|
||||
page: Page,
|
||||
dashboardIdOrSlug: number | string,
|
||||
allowedDomains: string[] = [],
|
||||
): Promise<EmbeddedConfig> {
|
||||
const response = await apiPut(
|
||||
page,
|
||||
`${DASHBOARD_ENDPOINTS.DASHBOARD}${dashboardIdOrSlug}/embedded`,
|
||||
{ allowed_domains: allowedDomains },
|
||||
);
|
||||
const body = await response.json();
|
||||
return body.result as EmbeddedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login as admin and return the JWT access token used by the guest_token
|
||||
* endpoint. Cache the result at suite level (`beforeAll`) and pass it into
|
||||
* `getGuestToken` to avoid a fresh login on every test.
|
||||
*
|
||||
* Defaults match `playwright/global-setup.ts` so credentials come from one
|
||||
* source (env vars). Overrides via `options` win.
|
||||
*/
|
||||
export async function getAccessToken(
|
||||
page: Page,
|
||||
options?: { username?: string; password?: string },
|
||||
): Promise<string> {
|
||||
const username =
|
||||
options?.username ?? process.env.PLAYWRIGHT_ADMIN_USERNAME ?? 'admin';
|
||||
const password =
|
||||
options?.password ?? process.env.PLAYWRIGHT_ADMIN_PASSWORD ?? 'general';
|
||||
const loginResponse = await apiPost(
|
||||
page,
|
||||
ENDPOINTS.SECURITY_LOGIN,
|
||||
{
|
||||
username,
|
||||
password,
|
||||
provider: 'db',
|
||||
refresh: true,
|
||||
},
|
||||
{ allowMissingCsrf: true },
|
||||
);
|
||||
const loginBody = await loginResponse.json();
|
||||
return loginBody.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a guest token for an embedded dashboard.
|
||||
* If `accessToken` is provided, the login round-trip is skipped — preferred
|
||||
* for tests that fetch many tokens from a single suite.
|
||||
* @returns Signed guest token string
|
||||
*/
|
||||
export async function getGuestToken(
|
||||
page: Page,
|
||||
dashboardId: number | string,
|
||||
options?: {
|
||||
accessToken?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rls?: Array<{ dataset: number; clause: string }>;
|
||||
},
|
||||
): Promise<string> {
|
||||
const accessToken =
|
||||
options?.accessToken ??
|
||||
(await getAccessToken(page, {
|
||||
username: options?.username,
|
||||
password: options?.password,
|
||||
}));
|
||||
const rls = options?.rls ?? [];
|
||||
|
||||
// The guest_token endpoint authenticates via JWT Bearer, but `page.request`
|
||||
// inherits the session cookie from storageState, so Flask-WTF still requires
|
||||
// a matching X-CSRFToken (plus a same-origin Referer). Route through
|
||||
// `apiPost` so CSRF + Referer headers are built consistently with every
|
||||
// other mutation helper; only the Authorization header is added here.
|
||||
const guestResponse = await apiPost(
|
||||
page,
|
||||
ENDPOINTS.GUEST_TOKEN,
|
||||
{
|
||||
user: {
|
||||
username: 'embedded_test_user',
|
||||
first_name: 'Embedded',
|
||||
last_name: 'TestUser',
|
||||
},
|
||||
resources: [{ type: 'dashboard', id: String(dashboardId) }],
|
||||
rls,
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
const guestBody = await guestResponse.json();
|
||||
return guestBody.token;
|
||||
}
|
||||
@@ -26,6 +26,40 @@ export interface ApiRequestOptions {
|
||||
allowMissingCsrf?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkzeug (Flask's dev server, used in CI) periodically drops connections
|
||||
* mid-request under concurrent load — surfacing as `socket hang up`,
|
||||
* `ECONNRESET`, or `ERR_EMPTY_RESPONSE`. These are transport-layer
|
||||
* failures, not application errors, so retrying is safe.
|
||||
*
|
||||
* The matcher is intentionally narrow: only retry on signatures that
|
||||
* indicate the server never produced a response. Application errors
|
||||
* (4xx/5xx, HTTP-level CSRF rejection) bubble up unchanged.
|
||||
*/
|
||||
const TRANSIENT_NETWORK_ERROR =
|
||||
/socket hang up|ECONNRESET|ERR_EMPTY_RESPONSE|ECONNREFUSED|EPIPE/i;
|
||||
const TRANSIENT_RETRY_ATTEMPTS = 3;
|
||||
const TRANSIENT_RETRY_BACKOFF_MS = 250;
|
||||
|
||||
async function withTransientRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < TRANSIENT_RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!TRANSIENT_NETWORK_ERROR.test(String(error))) {
|
||||
throw error;
|
||||
}
|
||||
// Linear backoff — werkzeug recovers in 100–300 ms after a drop.
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, TRANSIENT_RETRY_BACKOFF_MS * (attempt + 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL for Referer header
|
||||
* Reads from environment variable configured in playwright.config.ts
|
||||
@@ -39,7 +73,7 @@ function getBaseUrl(): string {
|
||||
return url.endsWith('/') ? url : `${url}/`;
|
||||
}
|
||||
|
||||
interface CsrfResult {
|
||||
export interface CsrfResult {
|
||||
token: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -49,11 +83,13 @@ interface CsrfResult {
|
||||
* Superset provides a CSRF token via api/v1/security/csrf_token/
|
||||
* The session cookie is automatically included by page.request
|
||||
*/
|
||||
async function getCsrfToken(page: Page): Promise<CsrfResult> {
|
||||
export async function getCsrfToken(page: Page): Promise<CsrfResult> {
|
||||
try {
|
||||
const response = await page.request.get('api/v1/security/csrf_token/', {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
const response = await withTransientRetry(() =>
|
||||
page.request.get('api/v1/security/csrf_token/', {
|
||||
failOnStatusCode: false,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!response.ok()) {
|
||||
return {
|
||||
@@ -107,11 +143,13 @@ export async function apiGet(
|
||||
url: string,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return page.request.get(url, {
|
||||
headers: options?.headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
return withTransientRetry(() =>
|
||||
page.request.get(url, {
|
||||
headers: options?.headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,12 +164,14 @@ export async function apiPost(
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.post(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
return withTransientRetry(() =>
|
||||
page.request.post(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,12 +186,14 @@ export async function apiPut(
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.put(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
return withTransientRetry(() =>
|
||||
page.request.put(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,12 +208,14 @@ export async function apiPatch(
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.patch(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
return withTransientRetry(() =>
|
||||
page.request.patch(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,9 +229,11 @@ export async function apiDelete(
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.delete(url, {
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
return withTransientRetry(() =>
|
||||
page.request.delete(url, {
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
57
superset-frontend/playwright/helpers/navigation.ts
Normal file
57
superset-frontend/playwright/helpers/navigation.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Response } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Werkzeug (Flask's dev server, used in CI) periodically drops connections
|
||||
* during page navigation under concurrent load — surfacing as
|
||||
* `ERR_EMPTY_RESPONSE`, `ERR_CONNECTION_RESET`, or a socket hang up. These
|
||||
* are transport-layer failures, not application errors, so retrying the
|
||||
* navigation is safe: the next request hits a fresh werkzeug worker thread.
|
||||
*
|
||||
* Application errors (4xx/5xx, JS exceptions during load) bubble up
|
||||
* unchanged — the matcher is narrow on purpose.
|
||||
*/
|
||||
const TRANSIENT_NAV_ERROR =
|
||||
/ERR_EMPTY_RESPONSE|ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED|socket hang up|ECONNRESET/i;
|
||||
const NAV_RETRY_ATTEMPTS = 3;
|
||||
const NAV_RETRY_BACKOFF_MS = 400;
|
||||
|
||||
export async function gotoWithRetry(
|
||||
page: Page,
|
||||
url: string,
|
||||
options?: Parameters<Page['goto']>[1],
|
||||
): Promise<Response | null> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < NAV_RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await page.goto(url, options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!TRANSIENT_NAV_ERROR.test(String(error))) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, NAV_RETRY_BACKOFF_MS * (attempt + 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
import { Button, Select } from '../components/core';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
|
||||
/**
|
||||
* Chart Creation Page object for the "Create a new chart" wizard.
|
||||
@@ -74,7 +75,7 @@ export class ChartCreationPage {
|
||||
* Navigate to the chart creation page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('chart/add');
|
||||
await gotoWithRetry(this.page, 'chart/add');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
/**
|
||||
@@ -31,13 +32,12 @@ export class ChartListPage {
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Verified: ChartList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
* Stable data-test keys for the row action buttons in ChartList.
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'chart-row-delete',
|
||||
EDIT: 'chart-row-edit',
|
||||
EXPORT: 'chart-row-export',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -52,14 +52,14 @@ export class ChartListPage {
|
||||
* (ListviewsDefaultCardView feature flag may enable card view).
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
|
||||
await gotoWithRetry(this.page, `${URL.CHART_LIST}?viewMode=table`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the chart list page in card view.
|
||||
*/
|
||||
async gotoCardView(): Promise<void> {
|
||||
await this.page.goto(`${URL.CHART_LIST}?viewMode=card`);
|
||||
await gotoWithRetry(this.page, `${URL.CHART_LIST}?viewMode=card`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,9 +97,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickDeleteAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,9 +106,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickEditAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +115,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickExportAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,11 +134,11 @@ export class ChartListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
// --- Card view methods ---
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { Button, Select } from '../components/core';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
|
||||
/**
|
||||
* Create Dataset Page object for the dataset creation wizard.
|
||||
@@ -75,7 +76,7 @@ export class CreateDatasetPage {
|
||||
* Navigate to the create dataset page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('dataset/add/');
|
||||
await gotoWithRetry(this.page, 'dataset/add/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Button, Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
/**
|
||||
@@ -31,13 +32,12 @@ export class DashboardListPage {
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* DashboardList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
* Stable data-test keys for the row action buttons in DashboardList.
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'dashboard-row-delete',
|
||||
EDIT: 'dashboard-row-edit',
|
||||
EXPORT: 'dashboard-row-export',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -52,7 +52,7 @@ export class DashboardListPage {
|
||||
* (ListviewsDefaultCardView feature flag may enable card view).
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(`${URL.DASHBOARD_LIST}?viewMode=table`);
|
||||
await gotoWithRetry(this.page, `${URL.DASHBOARD_LIST}?viewMode=table`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +80,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickDeleteAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,9 +89,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickEditAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,9 +98,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickExportAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,11 +117,11 @@ export class DashboardListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Page, Download } from '@playwright/test';
|
||||
import { Menu } from '../components/core';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
/**
|
||||
@@ -43,7 +44,7 @@ export class DashboardPage {
|
||||
* @param slug - The dashboard slug (e.g., 'world_health')
|
||||
*/
|
||||
async gotoBySlug(slug: string): Promise<void> {
|
||||
await this.page.goto(`superset/dashboard/${slug}/`);
|
||||
await gotoWithRetry(this.page, `superset/dashboard/${slug}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +52,7 @@ export class DashboardPage {
|
||||
* @param id - The dashboard ID
|
||||
*/
|
||||
async gotoById(id: number): Promise<void> {
|
||||
await this.page.goto(`superset/dashboard/${id}/`);
|
||||
await gotoWithRetry(this.page, `superset/dashboard/${id}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Button, Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
/**
|
||||
@@ -35,13 +36,14 @@ export class DatasetListPage {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Stable data-test keys for the row action buttons in DatasetList
|
||||
* (shared with the semantic-view rendering since only one renders per row).
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload', // Export button uses upload icon
|
||||
DUPLICATE: 'copy',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'dataset-row-delete',
|
||||
EDIT: 'dataset-row-edit',
|
||||
EXPORT: 'dataset-row-export',
|
||||
DUPLICATE: 'dataset-row-duplicate',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -54,7 +56,7 @@ export class DatasetListPage {
|
||||
* Navigate to the dataset list page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(URL.DATASET_LIST);
|
||||
await gotoWithRetry(this.page, URL.DATASET_LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,9 +98,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickDeleteAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,9 +107,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickEditAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,9 +116,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickExportAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,9 +125,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickDuplicateAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DUPLICATE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,11 +144,11 @@ export class DatasetListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
172
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
172
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, FrameLocator, Locator, expect } from '@playwright/test';
|
||||
import { EMBEDDED } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* Page object for the embedded dashboard test app.
|
||||
*
|
||||
* The test app runs on a separate origin (its origin is assigned per-suite
|
||||
* via an OS-allocated port) and uses the @superset-ui/embedded-sdk to render
|
||||
* a Superset dashboard in an iframe. Playwright's page.exposeFunction()
|
||||
* bridges the guest token from Node.js into the browser page.
|
||||
*/
|
||||
export class EmbeddedPage {
|
||||
private readonly page: Page;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
CONTAINER: '[data-test="embedded-container"]',
|
||||
IFRAME: 'iframe[title="Embedded Dashboard"]',
|
||||
STATUS: '#status',
|
||||
ERROR: '#error',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the guest token bridge before navigating.
|
||||
* Must be called BEFORE goto() since embedDashboard() calls fetchGuestToken
|
||||
* immediately on page load.
|
||||
*/
|
||||
async exposeTokenFetcher(tokenFn: () => Promise<string>): Promise<void> {
|
||||
await this.page.exposeFunction('__fetchGuestToken', tokenFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the embedded test app with the given parameters.
|
||||
* `appUrl` is the origin of the static test app (assigned dynamically by
|
||||
* the spec's beforeAll fixture so workers don't collide on a fixed port).
|
||||
*/
|
||||
async goto(params: {
|
||||
appUrl: string;
|
||||
uuid: string;
|
||||
supersetDomain: string;
|
||||
hideTitle?: boolean;
|
||||
hideTab?: boolean;
|
||||
hideChartControls?: boolean;
|
||||
debug?: boolean;
|
||||
}): Promise<void> {
|
||||
const searchParams = new URLSearchParams({
|
||||
uuid: params.uuid,
|
||||
supersetDomain: params.supersetDomain,
|
||||
});
|
||||
if (params.hideTitle) searchParams.set('hideTitle', 'true');
|
||||
if (params.hideTab) searchParams.set('hideTab', 'true');
|
||||
if (params.hideChartControls) searchParams.set('hideChartControls', 'true');
|
||||
if (params.debug) searchParams.set('debug', 'true');
|
||||
|
||||
await this.page.goto(`${params.appUrl}/?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* FrameLocator for the embedded dashboard iframe.
|
||||
*/
|
||||
get iframe(): FrameLocator {
|
||||
return this.page.frameLocator(EmbeddedPage.SELECTORS.IFRAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the iframe to appear in the DOM AND have its src set.
|
||||
* The SDK appends the iframe element before assigning src, so a bare
|
||||
* `state: 'attached'` wait races the src read.
|
||||
*/
|
||||
async waitForIframe(options?: { timeout?: number }): Promise<void> {
|
||||
const locator = this.page.locator(EmbeddedPage.SELECTORS.IFRAME);
|
||||
await locator.waitFor({
|
||||
state: 'attached',
|
||||
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
|
||||
});
|
||||
await expect(locator).toHaveAttribute('src', /.+/, {
|
||||
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dashboard content to render inside the iframe.
|
||||
* Looks for the grid-container which indicates charts are loading/loaded.
|
||||
*/
|
||||
async waitForDashboardContent(options?: { timeout?: number }): Promise<void> {
|
||||
const frame = this.iframe;
|
||||
await frame
|
||||
.locator('.grid-container, [data-test="grid-container"]')
|
||||
.first()
|
||||
.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a chart cell that has finished loading: it contains a real viz
|
||||
* element (svg, canvas, table) AND no longer hosts the `Loading` spinner
|
||||
* (`data-test="loading-indicator"`). Excluding the spinner matters —
|
||||
* the spinner itself renders an SVG, so a `:has(svg)`-only check can match
|
||||
* a still-loading chart for the wrong reason.
|
||||
*/
|
||||
static readonly RENDERED_CHART_SELECTOR =
|
||||
'[data-test="chart-container"]:has(svg, canvas, table):not(:has([data-test="loading-indicator"]))';
|
||||
|
||||
/**
|
||||
* Wait for at least one chart to finish rendering — viz drawn AND no
|
||||
* loading spinner in that cell.
|
||||
*/
|
||||
async waitForChartRendered(options?: { timeout?: number }): Promise<void> {
|
||||
await this.iframe
|
||||
.locator(EmbeddedPage.RENDERED_CHART_SELECTOR)
|
||||
.first()
|
||||
.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout ?? EMBEDDED.CHART_RENDER,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the dashboard title input inside the iframe.
|
||||
* Returned as a `Locator` so callers can use `expect(...).toBeVisible()` /
|
||||
* `.toBeHidden()` with auto-retry instead of one-shot `.isVisible()`.
|
||||
*/
|
||||
get titleLocator(): Locator {
|
||||
return this.iframe.locator(
|
||||
'[data-test="dashboard-header-container"] [data-test="editable-title-input"]',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status text from the test app.
|
||||
*/
|
||||
async getStatus(): Promise<string> {
|
||||
return (
|
||||
(await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error text, if any.
|
||||
*/
|
||||
async getError(): Promise<string> {
|
||||
const errorEl = this.page.locator(EmbeddedPage.SELECTORS.ERROR);
|
||||
const display = await errorEl.evaluate(el => getComputedStyle(el).display);
|
||||
if (display === 'none') return '';
|
||||
return (await errorEl.textContent()) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Locator, Response } from '@playwright/test';
|
||||
import { Page, Locator, Response, expect } from '@playwright/test';
|
||||
import { AceEditor } from '../components/core/AceEditor';
|
||||
import { AgGrid } from '../components/core/AgGrid';
|
||||
import { Button } from '../components/core/Button';
|
||||
@@ -25,6 +25,7 @@ import { EditableTabs } from '../components/core/EditableTabs';
|
||||
import { Popover } from '../components/core/Popover';
|
||||
import { Select } from '../components/core/Select';
|
||||
import { waitForPost } from '../helpers/api/intercepts';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
@@ -62,12 +63,17 @@ export class SqlLabPage {
|
||||
// ── Navigation ──
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(URL.SQLLAB, { waitUntil: 'domcontentloaded' });
|
||||
await gotoWithRetry(this.page, URL.SQLLAB, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
}
|
||||
|
||||
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
|
||||
// SQL Lab with dev server can be slow on first load (webpack HMR + React hydration)
|
||||
const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION;
|
||||
// SQL Lab is the heaviest bundle in Superset — the editor tabs container
|
||||
// doesn't render until the lazy chunk and async tab state (tabstateview)
|
||||
// both resolve. On cold-cache CI workers under werkzeug load this can
|
||||
// exceed 15 s, so use SLOW_TEST (60 s) rather than QUERY_EXECUTION here.
|
||||
const timeout = options?.timeout ?? TIMEOUT.SLOW_TEST;
|
||||
await this.editorTabs.element.waitFor({ state: 'visible', timeout });
|
||||
}
|
||||
|
||||
@@ -310,15 +316,28 @@ export class SqlLabPage {
|
||||
*/
|
||||
async executeQuery(sql: string): Promise<Response> {
|
||||
await this.setQuery(sql);
|
||||
// Run Query is disabled until BOTH sql is set (just done) AND a
|
||||
// database is selected. On fresh CI users the default database may
|
||||
// not be populated when ensureEditorReady() returns, so block here
|
||||
// until the button is actually clickable before kicking off the
|
||||
// response/loading watchers — otherwise their 15 s timers run out
|
||||
// before the click can even fire. Use SLOW_TEST: under werkzeug
|
||||
// load default-db bootstrap can take >15 s.
|
||||
await expect(this.runQueryButton.element).toBeEnabled({
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
// Use SLOW_TEST for /sqllab/execute/ — under werkzeug stress the
|
||||
// round-trip can exceed 15 s even for trivial queries because the
|
||||
// dev server time-shares a single Python thread across all workers.
|
||||
const responsePromise = waitForPost(this.page, 'api/v1/sqllab/execute/', {
|
||||
timeout: TIMEOUT.QUERY_EXECUTION,
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
// Start observing the loading indicator BEFORE clicking Run so we
|
||||
// catch it even for fast queries. QueryStatusBar (.ant-steps) appears
|
||||
// when SQL Lab enters the running state and unmounts the results grid.
|
||||
const loadingStarted = this.resultsPane
|
||||
.locator('.ant-steps')
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.SLOW_TEST });
|
||||
await this.runQueryButton.click();
|
||||
const [, response] = await Promise.all([loadingStarted, responsePromise]);
|
||||
return response;
|
||||
@@ -335,7 +354,11 @@ export class SqlLabPage {
|
||||
expectHeader: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION;
|
||||
// AG Grid is heavy and lazy-rendered. Under werkzeug stress the FE
|
||||
// sometimes takes >15 s to hydrate results after the query returns.
|
||||
// Default to SLOW_TEST so a slow grid mount doesn't masquerade as a
|
||||
// query failure (the response status was already asserted upstream).
|
||||
const timeout = options?.timeout ?? TIMEOUT.SLOW_TEST;
|
||||
// Wait for QueryStatusBar to disappear — proves the loading → ready
|
||||
// transition completed. If already hidden (fast query finished before
|
||||
// this call), resolves immediately since executeQuery() already observed
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
expectStatusOneOf,
|
||||
expectValidExportZip,
|
||||
} from '../../helpers/api/assertions';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
|
||||
@@ -62,8 +63,11 @@ test('should delete a chart with confirmation', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await chartListPage.clickDeleteAction(chartName);
|
||||
@@ -81,12 +85,14 @@ test('should delete a chart with confirmation', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify chart is removed from list
|
||||
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
|
||||
// Verify chart is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(chartListPage.getChartRow(chartName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: API returns 404
|
||||
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
|
||||
@@ -111,8 +117,11 @@ test('should edit chart name via properties modal', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click edit action to open properties modal
|
||||
await chartListPage.clickEditAction(chartName);
|
||||
@@ -137,7 +146,7 @@ test('should edit chart name via properties modal', async ({
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
@@ -164,8 +173,11 @@ test('should export a chart as a zip file', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
@@ -186,7 +198,7 @@ test('should bulk delete multiple charts', async ({
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway charts for bulk delete
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
@@ -202,9 +214,14 @@ test('should bulk delete multiple charts', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created charts appear.
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
@@ -214,7 +231,7 @@ test('should bulk delete multiple charts', async ({
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await chartListPage.clickBulkAction('Delete');
|
||||
await chartListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -229,13 +246,17 @@ test('should bulk delete multiple charts', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both charts are removed from list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
|
||||
// Verify both charts are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: Both return 404
|
||||
for (const chart of [chart1, chart2]) {
|
||||
@@ -259,8 +280,11 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
|
||||
await cardListPage.gotoCardView();
|
||||
await cardListPage.waitForCardLoad();
|
||||
|
||||
// Verify chart card is visible
|
||||
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart card appears.
|
||||
await expect(cardListPage.getChartCard(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Open card dropdown and click edit
|
||||
await cardListPage.clickCardEditAction(chartName);
|
||||
@@ -285,13 +309,18 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify the renamed card appears in card view and old name is gone
|
||||
await expect(cardListPage.getChartCard(newName)).toBeVisible();
|
||||
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
|
||||
// (the old card name is removed from the DOM after the rename re-render).
|
||||
await expect(cardListPage.getChartCard(newName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(cardListPage.getChartCard(chartName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: API returns updated name
|
||||
const response = await apiGetChart(page, chartId);
|
||||
@@ -304,6 +333,11 @@ test('should bulk export multiple charts', async ({
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway charts for bulk export
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
@@ -318,9 +352,14 @@ test('should bulk export multiple charts', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created charts appear.
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
@@ -329,11 +368,15 @@ test('should bulk export multiple charts', async ({
|
||||
await chartListPage.selectChartCheckbox(chart1.name);
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple charts can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await chartListPage.clickBulkAction('Export');
|
||||
await chartListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains both charts
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user