Compare commits

...

18 Commits

Author SHA1 Message Date
sadpandajoe
c38c10f5fe chore(ci): format dashboard drill-to-detail spec
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 14:13:58 +00:00
Joe Li
47a566b91f test(dashboard): migrate full drill-to-detail chart parity to Playwright
Address PR review (parity gap): the migrated drill-to-detail suite only
covered Pie, Line, Table, Pivot and Big Number, while the original Cypress
suite drilled the whole non-skipped chart matrix. Add the missing cases so
the migration claim holds:

- Canvas (echarts) charts via a geometry-independent scan that reads the
  value the drill submenu offers and asserts it round-trips into the modal:
  Scatter (enlarged markers + dense scan), generic/smooth/step time-series,
  Mixed, Box plot, Funnel, Gauge, Treemap.
- Big Number with Trendline: whole-chart drill (no datum filter).
- "Drill to detail by all" on a two-dimension point applies every dimension.
- Table drilling by each of multiple dimension values.

The header docstring's exclusion note (Bar, Area, World Map, Radar — skipped
upstream) is now accurate for what remains out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:13:49 +00:00
Joe Li
6251fdfc3d test(dashboard): migrate drill-to-detail E2E from Cypress to Playwright
Port the Cypress "Drill to detail modal" suite (which was fully
`describe.skip`'d) to the Playwright E2E framework. Each test API-builds a
hermetic single-chart dashboard from birth_names, renders it, drives the real
drill menus, and asserts the backend round-trip (the `/datasource/samples`
POST and the filter the modal applies) — genuine E2E, not DOM-class checks.

The original suite was skipped due to Cypress auto-scrolling the chart under
its sticky header before each action and obstructing right-clicks. That failure
mode is Cypress-specific; Playwright scrolls once and the target stays put.

Coverage:
- Modal mechanics: open from the chart "More Options" menu, paginate, reload
  resets to page 1.
- Big number value right-click drills the whole chart (no filter).
- Table cell right-click drills by that value; clearing the filter restores the
  full set.
- Pivot table cell right-click drills by the cell value.
- Pie slice and line point right-click (canvas) drill by the value under the
  cursor.

Canvas charts previously relied on hard-coded pixel coordinates to hit a slice
or point. Instead, the canvas tests scan a small set of candidate points until
the context menu offers an enabled "Drill to detail by" submenu, then drill by
whichever value the menu offers and assert that same value round-trips into the
modal — keeping the assertion independent of exact chart geometry.

Excluded (matching the original's own skips): Bar, Area, World Map, Radar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:08:29 +00:00
Evan Rusackas
d5e75c4813 fix(ci): correct setup-node version comment in tag-release.yml (#42039) 2026-07-14 19:43:13 +07:00
dependabot[bot]
cc32d16e0c chore(deps-dev): bump @typescript-eslint/parser from 8.62.0 to 8.63.0 in /superset-websocket (#42021)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:28 -07:00
dependabot[bot]
258f4c035b chore(deps-dev): bump typescript-eslint from 8.62.1 to 8.63.0 in /superset-websocket (#42022)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:25 -07:00
dependabot[bot]
d49365757c chore(deps-dev): bump @typescript-eslint/parser from 8.61.0 to 8.63.0 in /docs (#42025)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:18 -07:00
dependabot[bot]
68881a60ca chore(deps): bump caniuse-lite from 1.0.30001802 to 1.0.30001803 in /docs (#42026)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:14 -07:00
dependabot[bot]
41efdb3082 chore(deps-dev): bump webpack-sources from 3.5.0 to 3.5.1 in /superset-frontend (#42027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:11 -07:00
dependabot[bot]
e86dcf7b82 chore(deps): bump actions/setup-java from 5.4.0 to 5.5.0 (#42028)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:08 -07:00
dependabot[bot]
35d05cc278 chore(deps-dev): bump @swc/plugin-emotion from 14.14.1 to 14.15.0 in /superset-frontend (#42029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:04 -07:00
dependabot[bot]
115ce12ff7 chore(deps-dev): bump @typescript-eslint/parser from 8.62.1 to 8.63.0 in /superset-frontend (#42030)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:14:01 -07:00
dependabot[bot]
ccfccdf237 chore(deps-dev): bump oxlint from 1.72.0 to 1.73.0 in /superset-frontend (#42031)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 05:13:56 -07:00
Antonio Rivero
42523f8cc4 fix(async-queries): back off polling when async event requests keep failing (#42012) 2026-07-14 12:57:45 +02:00
Hans Yu
e165762bb7 refactor: begin transaction to automatically commit (#41979)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-13 22:45:40 -07:00
Hans Yu
8afad27c84 chore: convert warning to error if parameters not passed as collection (#41977) 2026-07-13 21:25:23 -07:00
Evan Rusackas
e420812eb7 test(filters): guard BIGINT filter value precision (#27510) (#41963)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-13 21:20:38 -07:00
Mike Bridge
993a43396a fix: restore all-files pre-commit checks (#42014)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Joe Li <joe@preset.io>
2026-07-13 17:35:55 -07:00
24 changed files with 2002 additions and 309 deletions

View File

@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "temurin"
java-version: "11"

View File

@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "temurin"
java-version: "11"

View File

@@ -71,7 +71,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "zulu"
java-version: "21"

View File

@@ -75,7 +75,7 @@ jobs:
- name: Use Node.js 20
# zizmor: ignore[cache-poisoning] - node only runs the supersetbot CLI; no dependency cache is enabled
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
package-manager-cache: false
@@ -133,7 +133,7 @@ jobs:
- name: Use Node.js 20
# zizmor: ignore[cache-poisoning] - node only runs the supersetbot CLI; no dependency cache is enabled
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
package-manager-cache: false

View File

@@ -63,7 +63,7 @@
"@swc/core": "^1.15.43",
"antd": "^6.5.0",
"baseline-browser-mapping": "^2.10.40",
"caniuse-lite": "^1.0.30001802",
"caniuse-lite": "^1.0.30001803",
"docusaurus-plugin-openapi-docs": "^5.1.0",
"docusaurus-theme-openapi-docs": "^5.1.0",
"js-yaml": "^5.2.0",
@@ -90,7 +90,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.61.0",
"@typescript-eslint/parser": "^8.63.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",

View File

@@ -5518,7 +5518,7 @@
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.62.1", "@typescript-eslint/parser@^8.61.0":
"@typescript-eslint/parser@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.62.1.tgz#d3f7ba18f1bf78bfb7256fea021d1927b48e7080"
integrity sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==
@@ -5529,6 +5529,17 @@
"@typescript-eslint/visitor-keys" "8.62.1"
debug "^4.4.3"
"@typescript-eslint/parser@^8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.63.0.tgz#2993338c379903e6afc72c3532ae6e15b97334d4"
integrity sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==
dependencies:
"@typescript-eslint/scope-manager" "8.63.0"
"@typescript-eslint/types" "8.63.0"
"@typescript-eslint/typescript-estree" "8.63.0"
"@typescript-eslint/visitor-keys" "8.63.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.62.1.tgz#78d880eb1cf6859b5ec263d04f95403e9f90ae47"
@@ -5538,6 +5549,15 @@
"@typescript-eslint/types" "^8.62.1"
debug "^4.4.3"
"@typescript-eslint/project-service@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.63.0.tgz#01a3d0550a860127444a9939749ab434591cd71a"
integrity sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.63.0"
"@typescript-eslint/types" "^8.63.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz#7ee65e9a6eb3ccdc4816593a4ff38840306de88a"
@@ -5546,11 +5566,29 @@
"@typescript-eslint/types" "8.62.1"
"@typescript-eslint/visitor-keys" "8.62.1"
"@typescript-eslint/scope-manager@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz#c9cf7ecd234f7ec346f62e5c7d28dfaf4d507b4d"
integrity sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==
dependencies:
"@typescript-eslint/types" "8.63.0"
"@typescript-eslint/visitor-keys" "8.63.0"
"@typescript-eslint/tsconfig-utils@8.62.1", "@typescript-eslint/tsconfig-utils@^8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz#e2b5f24fe721044189cb7e81117c96d75979d627"
integrity sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==
"@typescript-eslint/tsconfig-utils@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz#f7e1cf9a029bb71f4027ffa4194ba82afb564cd3"
integrity sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==
"@typescript-eslint/tsconfig-utils@^8.63.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
"@typescript-eslint/type-utils@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz#ebd30b13bacb13070917259a23309cf644121f9a"
@@ -5562,11 +5600,21 @@
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.62.1", "@typescript-eslint/types@^8.62.1":
"@typescript-eslint/types@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.1.tgz#c58be954e483b2fc98275374d5bcb40b99842dc1"
integrity sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==
"@typescript-eslint/types@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.63.0.tgz#6b32b0a5913520554d81a986acfffba2d32b6963"
integrity sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==
"@typescript-eslint/types@^8.62.1", "@typescript-eslint/types@^8.63.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
"@typescript-eslint/typescript-estree@8.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz#98c1bb17635d5b026b24193a8d29188ac64380ff"
@@ -5582,6 +5630,21 @@
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/typescript-estree@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz#a6f9c9cd290e98203ad29850b0d72529dc83be73"
integrity sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==
dependencies:
"@typescript-eslint/project-service" "8.63.0"
"@typescript-eslint/tsconfig-utils" "8.63.0"
"@typescript-eslint/types" "8.63.0"
"@typescript-eslint/visitor-keys" "8.63.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.62.1":
version "8.62.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.62.1.tgz#1622b75c7e6df308181dd0b44855dc4228da0457"
@@ -5600,6 +5663,14 @@
"@typescript-eslint/types" "8.62.1"
eslint-visitor-keys "^5.0.0"
"@typescript-eslint/visitor-keys@8.63.0":
version "8.63.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz#2853a8e33b52f23570338f96cc89cb223daabd44"
integrity sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==
dependencies:
"@typescript-eslint/types" "8.63.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.2.tgz#a03ad82cd5676414d068ba86f880c5681194aadf"
@@ -6567,10 +6638,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001802:
version "1.0.30001802"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz#2671fb13d468930586c56ffa80feb1c51e18ec69"
integrity sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001803:
version "1.0.30001803"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz#b2a5d696e042bc8304dcd4942c39fe330fbbcb24"
integrity sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==
ccount@^2.0.0:
version "2.0.1"

View File

@@ -37,6 +37,8 @@ filterwarnings =
# error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:The autoload parameter is deprecated:sqlalchemy.exc.RemovedIn20Warning
# error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
# error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
# error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning

View File

@@ -188,7 +188,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.43",
"@swc/plugin-emotion": "^14.14.1",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -216,7 +216,7 @@
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -260,7 +260,7 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxlint": "^1.72.0",
"oxlint": "^1.73.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"prettier": "3.9.4",
@@ -289,7 +289,7 @@
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
"webpack-sources": "^3.5.0",
"webpack-sources": "^3.5.1",
"webpack-visualizer-plugin2": "^2.0.0"
},
"engines": {
@@ -8443,9 +8443,9 @@
]
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz",
"integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz",
"integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==",
"cpu": [
"arm"
],
@@ -8460,9 +8460,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz",
"integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz",
"integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==",
"cpu": [
"arm64"
],
@@ -8477,9 +8477,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz",
"integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz",
"integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==",
"cpu": [
"arm64"
],
@@ -8494,9 +8494,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz",
"integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz",
"integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==",
"cpu": [
"x64"
],
@@ -8511,9 +8511,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz",
"integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz",
"integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==",
"cpu": [
"x64"
],
@@ -8528,9 +8528,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz",
"integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz",
"integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==",
"cpu": [
"arm"
],
@@ -8545,9 +8545,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz",
"integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz",
"integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==",
"cpu": [
"arm"
],
@@ -8562,13 +8562,16 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz",
"integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz",
"integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8579,13 +8582,16 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz",
"integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz",
"integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8596,13 +8602,16 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz",
"integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz",
"integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8613,13 +8622,16 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz",
"integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz",
"integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8630,13 +8642,16 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz",
"integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz",
"integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8647,13 +8662,16 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz",
"integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz",
"integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8664,13 +8682,16 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz",
"integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz",
"integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8681,13 +8702,16 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz",
"integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz",
"integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8698,9 +8722,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz",
"integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz",
"integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==",
"cpu": [
"arm64"
],
@@ -8715,9 +8739,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz",
"integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz",
"integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==",
"cpu": [
"arm64"
],
@@ -8732,9 +8756,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz",
"integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz",
"integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==",
"cpu": [
"ia32"
],
@@ -8749,9 +8773,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz",
"integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz",
"integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==",
"cpu": [
"x64"
],
@@ -11326,9 +11350,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "14.14.1",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.14.1.tgz",
"integrity": "sha512-2EgdUStcBO6Zwpept8JVEKmP4AInL6UoYbcWq9G5blc5MSjRW4bn3KUTVEBrxCAklGq/79YBjsV/sqKqgqg4zA==",
"version": "14.15.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.15.0.tgz",
"integrity": "sha512-nCsTO7mOOPz2UnT3N6YWb014uI0CVxeKg53A/KM/CvuSIE6H3KPkhaziJQ3q2jI3u3LfFuDKEnU5ZmB1330Dqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -12741,16 +12765,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz",
"integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3"
},
"engines": {
@@ -12765,6 +12789,175 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.63.0",
"@typescript-eslint/types": "^8.63.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.63.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
@@ -32150,9 +32343,9 @@
}
},
"node_modules/oxlint": {
"version": "1.72.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz",
"integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==",
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz",
"integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -32165,28 +32358,28 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.72.0",
"@oxlint/binding-android-arm64": "1.72.0",
"@oxlint/binding-darwin-arm64": "1.72.0",
"@oxlint/binding-darwin-x64": "1.72.0",
"@oxlint/binding-freebsd-x64": "1.72.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.72.0",
"@oxlint/binding-linux-arm-musleabihf": "1.72.0",
"@oxlint/binding-linux-arm64-gnu": "1.72.0",
"@oxlint/binding-linux-arm64-musl": "1.72.0",
"@oxlint/binding-linux-ppc64-gnu": "1.72.0",
"@oxlint/binding-linux-riscv64-gnu": "1.72.0",
"@oxlint/binding-linux-riscv64-musl": "1.72.0",
"@oxlint/binding-linux-s390x-gnu": "1.72.0",
"@oxlint/binding-linux-x64-gnu": "1.72.0",
"@oxlint/binding-linux-x64-musl": "1.72.0",
"@oxlint/binding-openharmony-arm64": "1.72.0",
"@oxlint/binding-win32-arm64-msvc": "1.72.0",
"@oxlint/binding-win32-ia32-msvc": "1.72.0",
"@oxlint/binding-win32-x64-msvc": "1.72.0"
"@oxlint/binding-android-arm-eabi": "1.73.0",
"@oxlint/binding-android-arm64": "1.73.0",
"@oxlint/binding-darwin-arm64": "1.73.0",
"@oxlint/binding-darwin-x64": "1.73.0",
"@oxlint/binding-freebsd-x64": "1.73.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.73.0",
"@oxlint/binding-linux-arm-musleabihf": "1.73.0",
"@oxlint/binding-linux-arm64-gnu": "1.73.0",
"@oxlint/binding-linux-arm64-musl": "1.73.0",
"@oxlint/binding-linux-ppc64-gnu": "1.73.0",
"@oxlint/binding-linux-riscv64-gnu": "1.73.0",
"@oxlint/binding-linux-riscv64-musl": "1.73.0",
"@oxlint/binding-linux-s390x-gnu": "1.73.0",
"@oxlint/binding-linux-x64-gnu": "1.73.0",
"@oxlint/binding-linux-x64-musl": "1.73.0",
"@oxlint/binding-openharmony-arm64": "1.73.0",
"@oxlint/binding-win32-arm64-msvc": "1.73.0",
"@oxlint/binding-win32-ia32-msvc": "1.73.0",
"@oxlint/binding-win32-x64-msvc": "1.73.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.22.1",
"oxlint-tsgolint": ">=0.24.0",
"vite-plus": "*"
},
"peerDependenciesMeta": {
@@ -42210,9 +42403,9 @@
}
},
"node_modules/webpack-sources": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
"integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz",
"integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -273,7 +273,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.43",
"@swc/plugin-emotion": "^14.14.1",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -301,7 +301,7 @@
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -345,7 +345,7 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxlint": "^1.72.0",
"oxlint": "^1.73.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"prettier": "3.9.4",
@@ -374,7 +374,7 @@
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
"webpack-sources": "^3.5.0",
"webpack-sources": "^3.5.1",
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {

View File

@@ -18,7 +18,7 @@
*/
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { Button, Menu } from '../components/core';
import { NativeFiltersConfigModal } from '../components/modals';
import { gotoWithRetry } from '../helpers/navigation';
import { TIMEOUT } from '../utils/constants';
@@ -176,4 +176,121 @@ export class DashboardPage {
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
// ---------------------------------------------------------------------------
// Drill to detail
//
// Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
// the chart's "More Options" header menu, and a right-click context menu on
// the chart body (a cell, the big-number value, or a canvas data point). Both
// open the same DrillDetailModal, which renders the underlying sample rows for
// the (optionally filtered) chart by calling the `/datasource/samples` API.
// ---------------------------------------------------------------------------
/**
* Locator for a chart card on the dashboard by its visualization type, e.g.
* `table`, `pie`, `big_number_total`.
*/
chartByVizType(vizType: string): Locator {
return this.page.locator(`[data-test-viz-type="${vizType}"]`);
}
/**
* Open the "Drill to detail" item from a chart's "More Options" header menu.
* This is the whole-chart entry point (no row-level filters applied).
*/
async openDrillToDetailFromMenu(vizType: string): Promise<void> {
const moreOptions = new Button(
this.page,
this.chartByVizType(vizType).getByLabel('More Options', { exact: true }),
);
await moreOptions.click();
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
*/
drillModal(): Locator {
return this.page.getByRole('dialog', { name: /^Drill to detail:/ });
}
/**
* Close the drill-to-detail modal if it is open (idempotent).
*/
async closeDrillModal(): Promise<void> {
const close = this.page.locator('[data-test="close-drilltodetail-modal"]');
if (await close.count()) {
await close.first().click();
await this.drillModal().waitFor({ state: 'hidden' });
}
}
/**
* The applied-filter value tags inside the drill modal (`<col>=<val>`).
*/
drillFilterValues(): Locator {
return this.page.locator('[data-test="filter-val"]');
}
/**
* Click the plain "Drill to detail" item in an open chart context menu
* (whole chart, no row-level filter).
*/
async contextMenuDrillToDetail(): Promise<void> {
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The "Drill to detail by" submenu parent (title) in an open context menu.
* Targeted by its submenu-title element rather than role+name because antd
* appends the arrow-icon name ("right") to the accessible name, and the leaf
* items ("Drill to detail by boy") would otherwise match a role+name lookup.
*/
private drillBySubmenuTitle(): Locator {
return this.page.locator('.ant-dropdown-menu-submenu-title', {
hasText: 'Drill to detail by',
});
}
/** The "Drill to detail by" submenu popup (its leaf value items live here). */
private drillBySubmenu(): Locator {
return this.page.locator('.chart-context-submenu');
}
/**
* From an open chart context menu, hover the "Drill to detail by" submenu and
* click the entry for a specific value (e.g. "boy", "1965", "all").
*/
async contextMenuDrillToDetailBy(value: string): Promise<void> {
await this.drillBySubmenuTitle().hover();
await this.drillBySubmenu()
.getByRole('menuitem', {
name: `Drill to detail by ${value}`,
exact: true,
})
.click();
}
/**
* From an open chart context menu, hover "Drill to detail by" and return the
* concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
* aggregate "all" entry. Used by canvas charts where the value under the
* cursor is data-dependent: the test drills by whatever the menu actually
* offers and asserts that same value round-trips into the modal, which keeps
* the assertion independent of exact pixel/slice geometry.
*/
async drillByOfferedValues(): Promise<string[]> {
await this.drillBySubmenuTitle().hover();
const items = this.drillBySubmenu().locator('[role="menuitem"]');
await items.first().waitFor();
const labels = await items.allInnerTexts();
return labels
.map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
.filter(v => v.length > 0 && v.toLowerCase() !== 'all');
}
}

View File

@@ -0,0 +1,850 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* E2E migration of the Cypress "Drill to detail modal" suite
* (dashboard/drilltodetail.test.ts).
*
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
* chart — optionally filtered to a single data point — by either the chart's
* "More Options" header menu or a right-click context menu on the chart body.
* The modal calls the real `/datasource/samples` API, so this is genuinely
* end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
* dataset, renders it in the browser, drives the real menus, and asserts the
* resulting backend round-trip (the samples POST and the filter the modal
* applies).
*
* Why the original suite was fully `describe.skip`:
* "it has issues with autoscrolling and the locked title flakes intricately
* when the rightClick is obstructed by the title."
* That failure mode is Cypress-specific — Cypress auto-scrolls the target under
* the sticky chart header before every action. Playwright scrolls once and the
* target stays put, so the entry points are portable here.
*
* What is migrated, and how it is kept deterministic:
* - Modal mechanics (open from header menu, pagination, reload-resets-page)
* and the no-filter big-number drill use stable DOM elements.
* - Table and Pivot drills right-click real DOM cells (no canvas pixels).
* - Canvas (echarts) charts — Pie, Line, Scatter, generic/smooth/step
* time-series, Mixed, Box plot, Funnel, Gauge, Treemap — DID rely on
* hard-coded pixel coordinates in Cypress to land on a specific slice/point.
* Instead of reproducing those brittle pixels, these tests scan a stable
* region of the canvas (see `rightClickCanvasDatum`), read whichever value
* the drill submenu actually offers for the point under the cursor, drill by
* that value, and assert the SAME value round-trips into the modal filter.
* This exercises the full canvas → contextmenu → datum → samples pipeline
* while staying independent of exact geometry. `Big Number with Trendline`
* drills the whole chart (no datum filter), like `Big Number`.
*
* Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
* World Map, Radar — skipped upstream for chart-specific reasons.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import type { Page } from '@playwright/test';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
/**
* Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
* tests can assert the *invariant* (filtered < unfiltered) without hard-coding
* the dataset-specific totals the original Cypress suite baked in.
*/
function parseRowCount(text: string): number {
const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
if (!m) return NaN;
let n = parseFloat(m[1].replace(/,/g, ''));
const suffix = m[2].toLowerCase();
if (suffix === 'k') n *= 1e3;
if (suffix === 'm') n *= 1e6;
return n;
}
interface ChartSpec {
vizType: string;
sliceName: string;
params: Record<string, unknown>;
}
/**
* API-build a hermetic single-chart dashboard from birth_names and return its
* id. Mirrors the build pattern used by the other migrated dashboard specs
* (create chart → build positionJson → create dashboard → link chart).
*/
async function buildSingleChartDashboard(
page: Page,
testAssets: {
trackChart(id: number): void;
trackDashboard(id: number): void;
},
spec: ChartSpec,
): Promise<number> {
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const datasource = `${datasetId}__table`;
const chartResp = await apiPost(page, 'api/v1/chart/', {
slice_name: `${spec.sliceName}_${Date.now()}`,
viz_type: spec.vizType,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify({
datasource,
viz_type: spec.vizType,
...spec.params,
}),
});
expect(chartResp.ok()).toBe(true);
const chart = await chartResp.json();
const chartId: number = chart.id ?? chart.result?.id;
testAssets.trackChart(chartId);
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: { chartId, width: 12, height: 60, sliceName: spec.sliceName },
},
};
const dashResp = await apiPostDashboard(page, {
dashboard_title: `${spec.sliceName}_dash_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
expect(linkResp.ok()).toBe(true);
return dashboardId;
}
/**
* Right-click an echarts canvas until a data point is hit — i.e. until the
* context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
* that item disabled, as a plain menu item rather than a submenu title).
*
* echarts renders to a single canvas, so there is no per-datum DOM element to
* target and the exact pixel of a mark depends on chart geometry (donut hole,
* legend size, axis padding). Rather than hard-code Cypress's brittle pixel
* coordinates, this scans a small set of candidate points — a radial ring for
* pie/radial charts, a grid for cartesian charts — and stops at the first that
* lands on a mark. The drill value is then whatever that mark represents, so the
* caller asserts a value round-trip rather than a specific geometry.
*/
async function rightClickCanvasDatum(
page: Page,
canvas: ReturnType<Page['locator']>,
pattern: 'ring' | 'grid' | 'dense',
): Promise<void> {
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas has no bounding box');
const ringPoints = (): Array<{ x: number; y: number }> => {
const pts: Array<{ x: number; y: number }> = [];
const cx = box.width / 2;
const cy = box.height / 2;
const minSide = Math.min(box.width, box.height);
for (const rf of [0.3, 0.22, 0.38]) {
for (let a = 0; a < 360; a += 45) {
const rad = (a * Math.PI) / 180;
pts.push({
x: cx + Math.cos(rad) * minSide * rf,
y: cy + Math.sin(rad) * minSide * rf,
});
}
}
return pts;
};
const gridPoints = (): Array<{ x: number; y: number }> => {
const pts: Array<{ x: number; y: number }> = [];
for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
pts.push({ x: box.width * xf, y: box.height * yf });
}
}
return pts;
};
// 'dense' merges both scans for radial/stacked shapes (gauge, funnel, box
// plot) whose drillable marks don't fall neatly on a single ring or grid.
let candidates: Array<{ x: number; y: number }>;
if (pattern === 'ring') candidates = ringPoints();
else if (pattern === 'grid') candidates = gridPoints();
else candidates = [...gridPoints(), ...ringPoints()];
// The submenu *title* element only exists when "Drill to detail by" is an
// enabled submenu (a real datum was hit); a miss renders a disabled item.
const enabledDrillBy = page
.locator('.ant-dropdown-menu-submenu-title')
.filter({ hasText: 'Drill to detail by' });
for (const pt of candidates) {
await canvas.click({ button: 'right', position: pt });
const hit = await enabledDrillBy
.waitFor({ state: 'visible', timeout: 400 })
.then(() => true)
.catch(() => false);
if (hit) return;
await page.keyboard.press('Escape');
}
throw new Error(
`no drillable datum found on canvas after scanning ${candidates.length} points`,
);
}
/** A samples POST fired (proves the modal hit the real backend). */
function expectSamplesPost(page: Page) {
return page.waitForResponse(
r =>
r.url().includes('/datasource/samples') &&
r.request().method() === 'POST',
{ timeout: TIMEOUT.API_RESPONSE },
);
}
async function loadDashboardWithChart(
dashboard: DashboardPage,
dashboardId: number,
vizType: string,
): Promise<void> {
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard
.chartByVizType(vizType)
.locator('[data-test="chart-container"]')
.first()
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
await dashboard.waitForChartsToLoad();
}
/**
* Full canvas-drill round-trip for an echarts (canvas-rendered) chart: build a
* hermetic single-chart dashboard, render it, right-click a real datum, drill by
* whatever value the submenu offers under the cursor, and assert that same value
* lands in the modal filter. Geometry-independent — see rightClickCanvasDatum.
* Reused across every canvas viz type so each migrated chart is a thin caller.
*/
async function expectCanvasDrillByValueRoundTrips(
page: Page,
testAssets: {
trackChart(id: number): void;
trackDashboard(id: number): void;
},
spec: ChartSpec,
pattern: 'ring' | 'grid' | 'dense',
): Promise<void> {
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, spec);
await loadDashboardWithChart(dashboard, dashboardId, spec.vizType);
const canvas = dashboard
.chartByVizType(spec.vizType)
.locator('canvas')
.first();
await expect(canvas).toBeVisible();
await rightClickCanvasDatum(page, canvas, pattern);
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
}
// Shared form-data fragment for the echarts time-series family (line/scatter/
// generic/smooth/step): one temporal axis, one metric, split by gender series.
const TIMESERIES_PARAMS = {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
row_limit: 1000,
};
testWithAssets(
'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'big_number_total',
sliceName: 'drill_bignum',
params: { metric: 'count', adhoc_filters: [] },
});
await loadDashboardWithChart(dashboard, dashboardId, 'big_number_total');
// Open the modal from the chart's "More Options" header menu.
const samplesOnOpen = expectSamplesPost(page);
await dashboard.openDrillToDetailFromMenu('big_number_total');
await samplesOnOpen;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(modal).toContainText('Drill to detail:');
// The metadata bar and a real row count prove the modal loaded backend data.
await expect(modal.locator('[data-test="metadata-bar"]')).toBeVisible();
await expect(modal.locator('[data-test="row-count-label"]')).toContainText(
'rows',
);
// No drill filter was applied (whole-chart drill).
await expect(dashboard.drillFilterValues()).toHaveCount(0);
// The full dataset spans multiple pages, and the grid has rendered rows.
const pageItems = modal.locator('.ant-pagination-item');
expect(await pageItems.count()).toBeGreaterThan(1);
await expect(modal.locator('.virtual-table-cell').first()).toBeVisible();
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'1',
);
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
// active page to 2.
const samplesOnPage2 = expectSamplesPost(page);
await modal.locator('.ant-pagination-item').nth(1).click();
await samplesOnPage2;
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'2',
);
// Reload re-fetches and resets back to the first page.
const samplesOnReload = expectSamplesPost(page);
await modal.getByRole('button', { name: 'Reload' }).click();
await samplesOnReload;
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'1',
);
},
);
testWithAssets(
'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'big_number_total',
sliceName: 'drill_bignum_rc',
params: { metric: 'count', adhoc_filters: [] },
});
await loadDashboardWithChart(dashboard, dashboardId, 'big_number_total');
// Right-click the rendered number itself opens the context menu.
const samples = expectSamplesPost(page);
await dashboard
.chartByVizType('big_number_total')
.locator('.header-line')
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal()).toBeVisible();
// Whole-chart drill: no per-value filter tag.
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(
dashboard.drillModal().locator('[data-test="row-count-label"]'),
).toContainText('rows');
},
);
testWithAssets(
'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'table',
sliceName: 'drill_table',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
});
await loadDashboardWithChart(dashboard, dashboardId, 'table');
// Right-click the "boy" dimension cell and drill by it.
const samplesOnDrill = expectSamplesPost(page);
await dashboard
.chartByVizType('table')
.getByText('boy', { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy('boy');
await samplesOnDrill;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText('boy');
const filteredCount = parseRowCount(
await modal.locator('[data-test="row-count-label"]').innerText(),
);
expect(filteredCount).toBeGreaterThan(0);
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
const samplesOnClear = expectSamplesPost(page);
await modal.locator('[data-test="filter-col"]').getByLabel('Close').click();
await samplesOnClear;
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect
.poll(async () =>
parseRowCount(
await modal.locator('[data-test="row-count-label"]').innerText(),
),
)
.toBeGreaterThan(filteredCount);
},
);
testWithAssets(
'drill-to-detail modal: pivot table cell right-click drills by the cell value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'pivot_table_v2',
sliceName: 'drill_pivot',
params: {
groupbyRows: ['gender'],
groupbyColumns: [],
metrics: ['count'],
aggregateFunction: 'Sum',
rowTotals: false,
colTotals: false,
},
});
await loadDashboardWithChart(dashboard, dashboardId, 'pivot_table_v2');
await dashboard
.chartByVizType('pivot_table_v2')
.locator('[role="gridcell"]')
.first()
.click({ button: 'right' });
// The cell's row dimension determines the offered value; drill by it and
// assert the same value lands in the modal filter.
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
},
);
testWithAssets(
'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'pie',
sliceName: 'drill_pie',
params: { groupby: ['gender'], metric: 'count' },
});
await loadDashboardWithChart(dashboard, dashboardId, 'pie');
const canvas = dashboard.chartByVizType('pie').locator('canvas').first();
await expect(canvas).toBeVisible();
// Pie is a donut by default (center is a hole), so scan the ring for a slice.
await rightClickCanvasDatum(page, canvas, 'ring');
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
},
);
testWithAssets(
'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'echarts_timeseries_line',
sliceName: 'drill_line',
params: {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
row_limit: 1000,
},
});
await loadDashboardWithChart(
dashboard,
dashboardId,
'echarts_timeseries_line',
);
const canvas = dashboard
.chartByVizType('echarts_timeseries_line')
.locator('canvas')
.first();
await expect(canvas).toBeVisible();
// Scan the plot grid for a point on one of the series lines.
await rightClickCanvasDatum(page, canvas, 'grid');
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
},
);
testWithAssets(
'drill-to-detail modal: big number with trendline right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'big_number',
sliceName: 'drill_bignum_trend',
params: {
metric: 'count',
x_axis: 'ds',
time_grain_sqla: 'P1Y',
adhoc_filters: [],
},
});
await loadDashboardWithChart(dashboard, dashboardId, 'big_number');
// Right-click the rendered number opens the context menu; whole-chart drill.
const samples = expectSamplesPost(page);
await dashboard
.chartByVizType('big_number')
.locator('.header-line')
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(
dashboard.drillModal().locator('[data-test="row-count-label"]'),
).toContainText('rows');
},
);
testWithAssets(
'drill-to-detail modal: scatter chart point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'echarts_timeseries_scatter',
sliceName: 'drill_scatter',
// Enlarge the markers so a region scan reliably lands on a point;
// scatter's default dots are a few pixels wide and a sparse grid misses
// them.
params: { ...TIMESERIES_PARAMS, markerSize: 20 },
},
'dense',
);
},
);
testWithAssets(
'drill-to-detail modal: generic time-series point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'echarts_timeseries',
sliceName: 'drill_generic',
params: TIMESERIES_PARAMS,
},
'grid',
);
},
);
testWithAssets(
'drill-to-detail modal: smooth line point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'echarts_timeseries_smooth',
sliceName: 'drill_smooth',
params: TIMESERIES_PARAMS,
},
'grid',
);
},
);
testWithAssets(
'drill-to-detail modal: step line point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'echarts_timeseries_step',
sliceName: 'drill_step',
params: TIMESERIES_PARAMS,
},
'grid',
);
},
);
testWithAssets(
'drill-to-detail modal: mixed time-series point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'mixed_timeseries',
sliceName: 'drill_mixed',
params: {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
metrics_b: ['count'],
groupby_b: ['gender'],
row_limit: 1000,
},
},
'grid',
);
},
);
testWithAssets(
'drill-to-detail modal: box plot right-click (canvas) drills by the box value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'box_plot',
sliceName: 'drill_boxplot',
params: {
groupby: ['gender'],
metrics: ['count'],
columns: ['ds'],
},
},
'dense',
);
},
);
testWithAssets(
'drill-to-detail modal: funnel segment right-click (canvas) drills by the segment value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'funnel',
sliceName: 'drill_funnel',
params: { groupby: ['gender'], metric: 'count' },
},
'dense',
);
},
);
testWithAssets(
'drill-to-detail modal: gauge right-click (canvas) drills by the gauge value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'gauge_chart',
sliceName: 'drill_gauge',
params: { groupby: ['gender'], metric: 'count' },
},
'dense',
);
},
);
testWithAssets(
'drill-to-detail modal: treemap tile right-click (canvas) drills by the tile value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
{
vizType: 'treemap_v2',
sliceName: 'drill_treemap',
params: { metric: 'count', groupby: ['gender'] },
},
'dense',
);
},
);
testWithAssets(
'drill-to-detail modal: drilling a time-series point "by all" applies every dimension of that point',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'echarts_timeseries_line',
sliceName: 'drill_all',
// Two groupby dimensions so each point genuinely carries more than one
// drillable value — the whole point of "Drill to detail by all".
params: { ...TIMESERIES_PARAMS, groupby: ['gender', 'state'] },
});
await loadDashboardWithChart(
dashboard,
dashboardId,
'echarts_timeseries_line',
);
const canvas = dashboard
.chartByVizType('echarts_timeseries_line')
.locator('canvas')
.first();
await expect(canvas).toBeVisible();
await rightClickCanvasDatum(page, canvas, 'grid');
// A line point carries two dimensions (the temporal value and the gender
// series), so "Drill to detail by all" must apply both as filters.
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThanOrEqual(2);
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy('all');
await samples;
await expect(dashboard.drillModal()).toBeVisible();
expect(await dashboard.drillFilterValues().count()).toBeGreaterThanOrEqual(
2,
);
},
);
testWithAssets(
'drill-to-detail modal: table drills correctly by each of multiple dimension values',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'table',
sliceName: 'drill_table_multi',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
});
await loadDashboardWithChart(dashboard, dashboardId, 'table');
for (const value of ['boy', 'girl']) {
const samples = expectSamplesPost(page);
await dashboard
.chartByVizType('table')
.getByText(value, { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
await dashboard.closeDrillModal();
}
},
);

View File

@@ -177,6 +177,28 @@ describe('Filter utils', () => {
);
});
test('Regression for #27510: formats large BIGINT column values as exact strings without precision loss', () => {
// A BIGINT column value with 16+ digits arrives as a native `bigint`
// (decoded from the `json-bigint` parse of the chart data response).
// Historically the filter UI rendered it as a raw BigNumber object
// ({ s, e, c }), throwing React error #31, or coerced it through a JS
// number and silently corrupted the trailing digits. The label
// formatter must turn it into its exact decimal string so the Select
// filter option renders correctly and the value keeps full precision.
const formatter = getDataRecordFormatter();
const largeBigInt = 1234567890125123456n;
expect(formatter(largeBigInt, GenericDataType.Numeric)).toEqual(
'1234567890125123456',
);
expect(formatter(largeBigInt, GenericDataType.String)).toEqual(
'1234567890125123456',
);
// Guard: routing the same value through a JS number (the pre-fix
// behavior) loses precision, which is exactly what broke the filter.
expect(String(Number(largeBigInt))).not.toEqual('1234567890125123456');
});
test('formatter with defined formatters returns expected values', () => {
const formatter = getDataRecordFormatter({
timeFormatter: getTimeFormatter(TimeFormats.DATABASE_DATETIME),

View File

@@ -173,6 +173,237 @@ describe('asyncEvent middleware', () => {
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1);
});
test('backs off exponentially when polling requests keep failing', async () => {
// stop the real-timer polling loop started by beforeEach before
// switching to fake timers, so all polls run on the fake clock
mockedIsFeatureEnabled.mockReturnValueOnce(false);
asyncEvent.init(config);
jest.useFakeTimers();
try {
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, { status: 403 });
asyncEvent.init(config);
asyncEvent.waitForAsyncData(asyncPendingEvent).catch(() => {});
// first poll fires after the configured delay and fails
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
// next poll is delayed by 2x the configured delay, so nothing yet
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(2);
// after the second failure the delay grows to 4x
await jest.advanceTimersByTimeAsync(
3 * config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(2);
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(3);
} finally {
jest.useRealTimers();
}
});
test('resumes the configured polling delay after a successful poll', async () => {
// stop the real-timer polling loop started by beforeEach before
// switching to fake timers, so all polls run on the fake clock
mockedIsFeatureEnabled.mockReturnValueOnce(false);
asyncEvent.init(config);
jest.useFakeTimers();
try {
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, { status: 403 });
asyncEvent.init(config);
asyncEvent.waitForAsyncData(asyncPendingEvent).catch(() => {});
// two failed polls: 1x delay, then 2x delay
await jest.advanceTimersByTimeAsync(
3 * config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(2);
// subsequent polls succeed, resetting the backoff
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, {
status: 200,
body: { result: [] },
});
// third poll fires 4x delay after the second failure and succeeds
await jest.advanceTimersByTimeAsync(
4 * config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
// polling is back to the configured delay
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(2);
} finally {
jest.useRealTimers();
}
});
test('caps the polling backoff delay at 60 seconds', async () => {
const MAX_ERROR_POLLING_DELAY_MS = 60000;
// stop the real-timer polling loop started by beforeEach before
// switching to fake timers, so all polls run on the fake clock
mockedIsFeatureEnabled.mockReturnValueOnce(false);
asyncEvent.init(config);
jest.useFakeTimers();
try {
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, { status: 403 });
asyncEvent.init(config);
asyncEvent.waitForAsyncData(asyncPendingEvent).catch(() => {});
// first poll fires after the configured delay and fails
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
// walk the uncapped backoff: after failure N the next delay is
// 2^N times the configured delay, which stays below the cap through
// failure 10 (50ms * 2^10 = 51.2s)
for (let failures = 1; failures <= 10; failures += 1) {
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY * 2 ** failures,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(
failures + 1,
);
}
// after failure 11 the uncapped delay would be 102.4s, so the cap
// takes over: no poll just before the 60s mark...
await jest.advanceTimersByTimeAsync(MAX_ERROR_POLLING_DELAY_MS - 1);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(11);
// ...and the next poll fires exactly at 60s
await jest.advanceTimersByTimeAsync(1);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(12);
// additional failures remain capped at 60s
await jest.advanceTimersByTimeAsync(MAX_ERROR_POLLING_DELAY_MS);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(13);
} finally {
jest.useRealTimers();
}
});
test('does not start a second loop when re-initialized during an in-flight poll', async () => {
// stop the real-timer polling loop started by beforeEach before
// switching to fake timers, so all polls run on the fake clock
mockedIsFeatureEnabled.mockReturnValueOnce(false);
asyncEvent.init(config);
jest.useFakeTimers();
try {
fetchMock.clearHistory().removeRoutes();
let resolveFetch: (response: any) => void = () => {};
fetchMock.get(
EVENTS_ENDPOINT,
new Promise(resolve => {
resolveFetch = resolve;
}),
);
asyncEvent.init(config);
asyncEvent.waitForAsyncData(asyncPendingEvent).catch(() => {});
// first poll fires and stays in-flight
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
// re-init while that fetch is pending, then let it resolve; the
// stale invocation must not schedule a second loop
asyncEvent.init(config);
asyncEvent.waitForAsyncData(asyncPendingEvent).catch(() => {});
resolveFetch({ status: 200, body: { result: [] } });
await jest.advanceTimersByTimeAsync(0);
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, {
status: 200,
body: { result: [] },
});
// exactly one poll per delay from here on
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(2);
} finally {
jest.useRealTimers();
}
});
test('does not resume polling when re-initialized with the feature disabled during an in-flight poll', async () => {
// stop the real-timer polling loop started by beforeEach before
// switching to fake timers, so all polls run on the fake clock
mockedIsFeatureEnabled.mockReturnValueOnce(false);
asyncEvent.init(config);
jest.useFakeTimers();
try {
fetchMock.clearHistory().removeRoutes();
let resolveFetch: (response: any) => void = () => {};
fetchMock.get(
EVENTS_ENDPOINT,
new Promise(resolve => {
resolveFetch = resolve;
}),
);
asyncEvent.init(config);
asyncEvent.waitForAsyncData(asyncPendingEvent).catch(() => {});
// first poll fires and stays in-flight
await jest.advanceTimersByTimeAsync(
config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
// disable the feature and re-init while the fetch is pending; the
// stale invocation must not restart the stopped loop when it resumes
mockedIsFeatureEnabled.mockReturnValueOnce(false);
asyncEvent.init(config);
resolveFetch({ status: 200, body: { result: [] } });
await jest.advanceTimersByTimeAsync(0);
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, {
status: 200,
body: { result: [] },
});
await jest.advanceTimersByTimeAsync(
10 * config.GLOBAL_ASYNC_QUERIES_POLLING_DELAY,
);
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(0);
} finally {
jest.useRealTimers();
}
});
// Regression guard for the motivating CodeQL case: a job_id that collides
// with a built-in Object property (e.g. "__proto__"/"constructor") must be
// routed through the Map-based registries without triggering prototype

View File

@@ -58,6 +58,9 @@ const LOCALSTORAGE_KEY = 'last_async_event_id';
const POLLING_URL = '/api/v1/async_event/';
const MAX_RETRIES = 6;
const RETRY_DELAY = 100;
// Cap for the exponential backoff applied when polling requests fail
// repeatedly (e.g. expired session, server or network errors)
const MAX_ERROR_POLLING_DELAY_MS = 60000;
let config: AppConfig;
let transport: string;
@@ -66,6 +69,11 @@ let pollingTimeoutId: number;
let listenersByJobId: Map<string, ListenerFn>;
let retriesByJobId: Map<string, number>;
let lastReceivedEventId: string | null | undefined;
let consecutivePollingErrorCount = 0;
// Incremented on every init() so polling invocations that are already
// awaiting a fetch when re-init happens can detect they are stale and
// stop, instead of mutating fresh state or scheduling a second loop
let pollingGeneration = 0;
const addListener = (id: string, fn: ListenerFn) => {
listenersByJobId.set(id, fn);
@@ -170,19 +178,34 @@ export const processEvents = async (events: AsyncEvent[]) => {
});
};
const getPollingDelay = () => {
if (!consecutivePollingErrorCount) return pollingDelayMs;
const backoffDelayMs = pollingDelayMs * 2 ** consecutivePollingErrorCount;
return Math.max(
pollingDelayMs,
Math.min(backoffDelayMs, MAX_ERROR_POLLING_DELAY_MS),
);
};
const loadEventsFromApi = async () => {
const generation = pollingGeneration;
const eventArgs = lastReceivedEventId ? { last_id: lastReceivedEventId } : {};
if (listenersByJobId.size) {
try {
const { result: events } = await fetchEvents(eventArgs);
if (generation !== pollingGeneration) return;
consecutivePollingErrorCount = 0;
if (events?.length) await processEvents(events);
} catch (err) {
if (generation !== pollingGeneration) return;
consecutivePollingErrorCount += 1;
logging.warn(err);
}
}
if (generation !== pollingGeneration) return;
if (transport === TRANSPORT_POLLING) {
pollingTimeoutId = window.setTimeout(loadEventsFromApi, pollingDelayMs);
pollingTimeoutId = window.setTimeout(loadEventsFromApi, getPollingDelay());
}
};
@@ -232,12 +255,14 @@ const wsConnect = (): void => {
};
export const init = (appConfig?: AppConfig) => {
if (!isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) return;
pollingGeneration += 1;
if (pollingTimeoutId) clearTimeout(pollingTimeoutId);
if (!isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) return;
listenersByJobId = new Map();
retriesByJobId = new Map();
lastReceivedEventId = null;
consecutivePollingErrorCount = 0;
config = appConfig || getBootstrapData().common.conf;
transport = config.GLOBAL_ASYNC_QUERIES_TRANSPORT || TRANSPORT_POLLING;

View File

@@ -25,14 +25,14 @@
"@types/node": "^26.1.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/parser": "^8.63.0",
"eslint": "^10.5.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"prettier": "^3.9.4",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"typescript-eslint": "^8.63.0",
"vitest": "^4.1.10"
},
"engines": {
@@ -741,17 +741,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
"integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
"integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/type-utils": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/type-utils": "8.63.0",
"@typescript-eslint/utils": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -764,22 +764,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.62.1",
"@typescript-eslint/parser": "^8.63.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz",
"integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3"
},
"engines": {
@@ -794,15 +794,145 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
"integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.1",
"@typescript-eslint/types": "^8.62.1",
"@typescript-eslint/tsconfig-utils": "^8.63.0",
"@typescript-eslint/types": "^8.63.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.63.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.63.0",
"@typescript-eslint/types": "^8.63.0",
"debug": "^4.4.3"
},
"engines": {
@@ -817,14 +947,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
"integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1"
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -835,9 +965,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
"integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -852,15 +982,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
"integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz",
"integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0",
"@typescript-eslint/utils": "8.63.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -877,9 +1007,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -891,16 +1021,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
"integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.62.1",
"@typescript-eslint/tsconfig-utils": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -919,16 +1049,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1"
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -943,13 +1073,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
"integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/types": "8.63.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -2861,16 +2991,41 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
"integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz",
"integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.62.1",
"@typescript-eslint/parser": "8.62.1",
"@typescript-eslint/eslint-plugin": "8.63.0",
"@typescript-eslint/parser": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0",
"@typescript-eslint/utils": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1"
"@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -34,14 +34,14 @@
"@types/node": "^26.1.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/parser": "^8.63.0",
"eslint": "^10.5.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"prettier": "^3.9.4",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"typescript-eslint": "^8.63.0",
"vitest": "^4.1.10"
},
"engines": {

View File

@@ -417,12 +417,12 @@ class SupersetShillelaghAdapter(Adapter):
query = self._build_sql(bounds, order, limit, offset)
with self.engine_context() as engine:
connection = engine.connect()
rows = connection.execute(query)
for i, row in enumerate(rows):
data = dict(zip(self.columns, row, strict=False))
data["rowid"] = data[self._rowid] if self._rowid else i
yield data
with engine.connect() as connection:
rows = connection.execute(query)
for i, row in enumerate(rows):
data = dict(zip(self.columns, row, strict=False))
data["rowid"] = data[self._rowid] if self._rowid else i
yield data
@check_dml
def insert_row(self, row: Row) -> int:
@@ -445,15 +445,16 @@ class SupersetShillelaghAdapter(Adapter):
query = self._table.insert().values(**row)
with self.engine_context() as engine:
connection = engine.connect()
result = connection.execute(query)
with engine.begin() as connection:
result = connection.execute(query)
# return rowid
if self._rowid:
return result.inserted_primary_key[0]
# return rowid
if self._rowid:
return result.inserted_primary_key[0]
query = select(func.count()).select_from(self._table)
return connection.execute(query).scalar()
with engine.connect() as connection:
return connection.execute(query).scalar()
@check_dml
@has_rowid
@@ -464,8 +465,8 @@ class SupersetShillelaghAdapter(Adapter):
query = self._table.delete().where(self._table.c[self._rowid] == row_id)
with self.engine_context() as engine:
connection = engine.connect()
connection.execute(query)
with engine.begin() as connection:
connection.execute(query)
@check_dml
@has_rowid
@@ -488,5 +489,5 @@ class SupersetShillelaghAdapter(Adapter):
)
with self.engine_context() as engine:
connection = engine.connect()
connection.execute(query)
with engine.begin() as connection:
connection.execute(query)

View File

@@ -55,6 +55,52 @@ from superset.sql.parse import SQLScript
logger = logging.getLogger(__name__)
async def _validate_non_destructive_sql(
request: ExecuteSqlRequest,
ctx: Context,
database: Any,
sql_preview: str,
) -> ExecuteSqlResponse | None:
"""Return an error response when SQL cannot safely be executed."""
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
try:
sql_to_check: str = request.sql
if request.template_params:
from superset.jinja_context import get_template_processor
tp = get_template_processor(database=database)
sql_to_check = tp.process_template(
request.sql, **request.template_params
)
script = SQLScript(sql_to_check, database.db_engine_spec.engine)
if script.has_destructive():
await ctx.error("Destructive DDL blocked: sql_preview=%r" % sql_preview)
return ExecuteSqlResponse(
success=False,
error=(
"Destructive DDL statements (DROP, TRUNCATE, ALTER) "
"are not allowed through MCP. Use the Superset SQL "
"Lab UI for administrative database operations."
),
error_type=SupersetErrorType.DML_NOT_ALLOWED_ERROR.value,
)
except Exception as parse_err:
await ctx.error(
"DDL pre-check failed to parse SQL, blocking query: %s" % str(parse_err)
)
return ExecuteSqlResponse(
success=False,
error=(
"SQL could not be parsed for security validation. "
"Please check your SQL syntax and try again."
),
error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
)
return None
@tool(
tags=["mutate"],
class_permission_name="SQLLab",
@@ -122,44 +168,11 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
# Fail-closed: if parsing fails, block the query rather than
# allowing potentially destructive SQL to bypass the check.
# Render Jinja2 templates first so templated SQL can be parsed.
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
try:
sql_to_check = request.sql
if request.template_params:
from superset.jinja_context import get_template_processor
tp = get_template_processor(database=database)
sql_to_check = tp.process_template(
request.sql, **request.template_params
)
script = SQLScript(sql_to_check, database.db_engine_spec.engine)
if script.has_destructive():
await ctx.error(
"Destructive DDL blocked: sql_preview=%r" % sql_preview
)
return ExecuteSqlResponse(
success=False,
error=(
"Destructive DDL statements (DROP, TRUNCATE, ALTER) "
"are not allowed through MCP. Use the Superset SQL "
"Lab UI for administrative database operations."
),
error_type=SupersetErrorType.DML_NOT_ALLOWED_ERROR.value,
)
except Exception as parse_err:
await ctx.error(
"DDL pre-check failed to parse SQL, blocking query: %s"
% str(parse_err)
)
return ExecuteSqlResponse(
success=False,
error=(
"SQL could not be parsed for security validation. "
"Please check your SQL syntax and try again."
),
error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
)
validation_error: (
ExecuteSqlResponse | None
) = await _validate_non_destructive_sql(request, ctx, database, sql_preview)
if validation_error is not None:
return validation_error
# 3. Build QueryOptions and execute query
cache_opts = CacheOptions(force_refresh=True) if request.force_refresh else None

View File

@@ -121,12 +121,13 @@ def test_time_grain_expressions(dttm: str, grain: str, expected: str) -> None:
from superset.db_engine_specs.sqlite import SqliteEngineSpec
engine = create_engine("sqlite://")
connection = engine.connect()
connection.execute(text("CREATE TABLE t (dttm DATETIME)"))
connection.execute(text("INSERT INTO t VALUES (:dttm)"), {"dttm": dttm})
with engine.begin() as connection:
connection.execute(text("CREATE TABLE t (dttm DATETIME)"))
connection.execute(text("INSERT INTO t VALUES (:dttm)"), {"dttm": dttm})
# pylint: disable=protected-access
expression = SqliteEngineSpec._time_grain_expressions[grain].format(col="dttm")
sql = f"SELECT {expression} FROM t" # noqa: S608
result = connection.execute(text(sql)).scalar()
with engine.connect() as connection:
result = connection.execute(text(sql)).scalar()
assert result == expected

View File

@@ -63,16 +63,17 @@ def database1(session: Session) -> Iterator["Database"]:
@pytest.fixture
def table1(session: Session, database1: "Database") -> Iterator[None]:
with database1.get_sqla_engine() as engine:
conn = engine.connect()
conn.execute(
text("CREATE TABLE table1 (a INTEGER NOT NULL PRIMARY KEY, b INTEGER)")
)
conn.execute(text("INSERT INTO table1 (a, b) VALUES (1, 10), (2, 20)"))
with engine.begin() as conn:
conn.execute(
text("CREATE TABLE table1 (a INTEGER NOT NULL PRIMARY KEY, b INTEGER)")
)
conn.execute(text("INSERT INTO table1 (a, b) VALUES (1, 10), (2, 20)"))
db.session.commit()
yield
conn.execute(text("DROP TABLE table1"))
with engine.begin() as conn:
conn.execute(text("DROP TABLE table1"))
db.session.commit()
@@ -99,16 +100,19 @@ def database2(session: Session) -> Iterator["Database"]:
@pytest.fixture
def table2(session: Session, database2: "Database") -> Iterator[None]:
with database2.get_sqla_engine() as engine:
conn = engine.connect()
conn.execute(
text("CREATE TABLE table2 (a INTEGER NOT NULL PRIMARY KEY, b TEXT)")
)
conn.execute(text("INSERT INTO table2 (a, b) VALUES (1, 'ten'), (2, 'twenty')"))
with engine.begin() as conn:
conn.execute(
text("CREATE TABLE table2 (a INTEGER NOT NULL PRIMARY KEY, b TEXT)")
)
conn.execute(
text("INSERT INTO table2 (a, b) VALUES (1, 'ten'), (2, 'twenty')")
)
db.session.commit()
yield
conn.execute(text("DROP TABLE table2"))
with engine.begin() as conn:
conn.execute(text("DROP TABLE table2"))
db.session.commit()
@@ -137,9 +141,9 @@ def test_superset(mocker: MockerFixture, app_context: None, table1: None) -> Non
# Skip test if superset:// dialect can't be loaded (common in Docker)
pytest.skip(f"Superset dialect not available: {e}")
conn = engine.connect()
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20)]
with engine.connect() as conn:
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20)]
@with_config(
@@ -178,9 +182,9 @@ def test_superset_limit(mocker: MockerFixture, app_context: None, table1: None)
# Skip test if superset:// dialect can't be loaded (common in Docker)
pytest.skip(f"Superset dialect not available: {e}")
conn = engine.connect()
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10)]
with engine.connect() as conn:
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10)]
@with_feature_flags(ENABLE_SUPERSET_META_DB=True)
@@ -213,16 +217,16 @@ def test_superset_joins(
# Skip test if superset:// dialect can't be loaded (common in Docker)
pytest.skip(f"Superset dialect not available: {e}")
conn = engine.connect()
results = conn.execute(
text("""
SELECT t1.b, t2.b
FROM "database1.table1" AS t1
JOIN "database2.table2" AS t2
ON t1.a = t2.a
""")
)
assert list(results) == [(10, "ten"), (20, "twenty")]
with engine.connect() as conn:
results = conn.execute(
text("""
SELECT t1.b, t2.b
FROM "database1.table1" AS t1
JOIN "database2.table2" AS t2
ON t1.a = t2.a
""")
)
assert list(results) == [(10, "ten"), (20, "twenty")]
@with_feature_flags(ENABLE_SUPERSET_META_DB=True)
@@ -257,22 +261,27 @@ def test_dml(
# Skip test if superset:// dialect can't be loaded (common in Docker)
pytest.skip(f"Superset dialect not available: {e}")
conn = engine.connect()
with engine.begin() as conn:
conn.execute(text('INSERT INTO "database1.table1" (a, b) VALUES (3, 30)'))
with engine.connect() as conn:
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20), (3, 30)]
with engine.begin() as conn:
conn.execute(text('UPDATE "database1.table1" SET b=35 WHERE a=3'))
with engine.connect() as conn:
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20), (3, 35)]
with engine.begin() as conn:
conn.execute(text('DELETE FROM "database1.table1" WHERE b>20'))
with engine.connect() as conn:
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20)]
conn.execute(text('INSERT INTO "database1.table1" (a, b) VALUES (3, 30)'))
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20), (3, 30)]
conn.execute(text('UPDATE "database1.table1" SET b=35 WHERE a=3'))
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20), (3, 35)]
conn.execute(text('DELETE FROM "database1.table1" WHERE b>20'))
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20)]
with pytest.raises(ProgrammingError) as excinfo:
conn.execute(
text("""INSERT INTO "database2.table2" (a, b) VALUES (3, 'thirty')""")
)
with engine.begin() as conn:
with pytest.raises(ProgrammingError) as excinfo:
conn.execute(
text("""INSERT INTO "database2.table2" (a, b) VALUES (3, 'thirty')""")
)
assert str(excinfo.value).strip() == (
"(shillelagh.exceptions.ProgrammingError) DML not enabled in database "
'"database2"\n[SQL: INSERT INTO "database2.table2" (a, b) '
@@ -324,9 +333,9 @@ def test_security_manager(
# Skip test if superset:// dialect can't be loaded (common in Docker)
pytest.skip(f"Superset dialect not available: {e}")
conn = engine.connect()
with pytest.raises(SupersetSecurityException) as excinfo:
conn.execute(text('SELECT * FROM "database1.table1"'))
with engine.connect() as conn:
with pytest.raises(SupersetSecurityException) as excinfo:
conn.execute(text('SELECT * FROM "database1.table1"'))
assert str(excinfo.value) == (
"You need access to the following tables: `table1`,\n "
"`all_database_access` or `all_datasource_access` permission"
@@ -358,13 +367,13 @@ def test_allowed_dbs(mocker: MockerFixture, app_context: None, table1: None) ->
# Skip test if superset:// dialect can't be loaded (common in Docker)
pytest.skip(f"Superset dialect not available: {e}")
conn = engine.connect()
with engine.connect() as conn:
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20)]
results = conn.execute(text('SELECT * FROM "database1.table1"'))
assert list(results) == [(1, 10), (2, 20)]
with pytest.raises(ProgrammingError) as excinfo:
conn.execute(text('SELECT * FROM "database2.table2"'))
with engine.connect() as conn:
with pytest.raises(ProgrammingError) as excinfo:
conn.execute(text('SELECT * FROM "database2.table2"'))
assert str(excinfo.value) == (
"""
(shillelagh.exceptions.ProgrammingError) Unsupported table: database2.table2

View File

@@ -103,7 +103,7 @@ def _indexes(engine: Engine) -> set[str]:
def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -115,7 +115,7 @@ def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
def test_downgrade_drops_deleted_at_column_and_index(engine: Engine) -> None:
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -138,7 +138,7 @@ def test_downgrade_preserves_soft_deleted_row_data(engine: Engine) -> None:
hard-delete, restore, or rename BEFORE downgrading. See the
"Rollback note" in ``UPDATING.md``.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -198,7 +198,7 @@ def test_sqlite_slug_constraint_swap_is_a_no_op(engine: Engine) -> None:
"pre-condition: legacy slug constraint must exist before the migration runs"
)
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -207,7 +207,7 @@ def test_sqlite_slug_constraint_swap_is_a_no_op(engine: Engine) -> None:
"upgrade() must keep the legacy slug constraint on SQLite (no-op branch)"
)
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.downgrade()
@@ -222,7 +222,7 @@ def test_upgrade_is_idempotent(engine: Engine) -> None:
idempotent skip-if-exists; running ``upgrade()`` twice must not
raise.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -233,7 +233,7 @@ def test_downgrade_is_idempotent(engine: Engine) -> None:
"""``drop_columns`` / ``drop_index`` are skip-if-not-exists; running
``downgrade()`` twice must not raise.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()

View File

@@ -88,7 +88,7 @@ def _indexes(engine: Engine) -> set[str]:
def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
"""upgrade() adds the nullable ``deleted_at`` column and its index."""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -101,7 +101,7 @@ def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
def test_downgrade_drops_deleted_at_column_and_index(engine: Engine) -> None:
"""downgrade() removes both schema artifacts (column and index)."""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -124,7 +124,7 @@ def test_downgrade_preserves_soft_deleted_row_data(engine: Engine) -> None:
hard-delete, restore, or rename BEFORE downgrading. See the
"Rollback note" in ``UPDATING.md``.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -171,7 +171,7 @@ def test_upgrade_is_idempotent(engine: Engine) -> None:
idempotent skip-if-exists; running ``upgrade()`` twice must not
raise.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -182,7 +182,7 @@ def test_downgrade_is_idempotent(engine: Engine) -> None:
"""``drop_columns`` / ``drop_index`` are skip-if-not-exists; running
``downgrade()`` twice must not raise.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()

View File

@@ -85,7 +85,7 @@ def _indexes(engine: Engine) -> set[str]:
def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -97,7 +97,7 @@ def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
def test_downgrade_drops_deleted_at_column_and_index(engine: Engine) -> None:
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -120,7 +120,7 @@ def test_downgrade_preserves_soft_deleted_row_data(engine: Engine) -> None:
hard-delete, restore, or rename BEFORE downgrading. See the
"Rollback note" in ``UPDATING.md``.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -170,7 +170,7 @@ def test_upgrade_is_idempotent(engine: Engine) -> None:
idempotent skip-if-exists; running ``upgrade()`` twice must not
raise.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
@@ -181,7 +181,7 @@ def test_downgrade_is_idempotent(engine: Engine) -> None:
"""``drop_columns`` / ``drop_index`` are skip-if-not-exists; running
``downgrade()`` twice must not raise.
"""
with engine.connect() as conn:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()

View File

@@ -3491,6 +3491,7 @@ def test_map_query_object_shifts_time_offset_via_temporal_range_filter(
main_query, offset_query = queries[0], queries[1]
def time_filter_bounds(query: SemanticQuery) -> tuple[datetime, datetime]:
assert query.filters is not None
gte = next(
f.value
for f in query.filters
@@ -3505,6 +3506,8 @@ def test_map_query_object_shifts_time_offset_via_temporal_range_filter(
and f.column.name == "order_date"
and f.operator == Operator.LESS_THAN
)
assert isinstance(gte, datetime)
assert isinstance(lt, datetime)
return gte, lt
main_gte, main_lt = time_filter_bounds(main_query)