Compare commits

..

10 Commits

Author SHA1 Message Date
Joe Li
4fda35b955 test(dashboard): tighten assertions and drop dead diagnostics
theme.spec asserted twice on the same thing: the 403 array was populated under
the same /api/v1/theme/ guard as the request array, so it could only be
non-empty when the first assertion had already failed. Record the status
alongside each theme request instead, keeping the 403 signal in the failure
output with one assertion doing the work.

delete-display-control logged four console lines that no assertion read, one
under a name (goneAfterSave) that meant the opposite of the value it held.
The screenshots at each step already tell that story. Name the deliberately
non-fatal chart wait (settleCharts) rather than repeating a bare .catch three
times, and record why the pause before the final assertion is load-bearing:
toHaveCount(0) passes the moment the control is absent, so the wait is what
gives a reappearance the window this regression test exists to catch.
2026-07-31 13:53:33 -07:00
Joe Li
25bb94ac8d test(dashboard): share the native-filter metadata builder across specs
Three dashboard specs each hand-rolled the same json_metadata envelope for a
filtered dashboard, differing only in the filter's defaultDataMask. Extract
buildSelectFilter/buildFilterJsonMetadata so the envelope has one definition.
The generated metadata is byte-identical to the literals it replaces.

Also drop the local findDatasetIdByName in the Mixed-chart spec: it issued a
raw page.request.get, which bypasses the transient-retry wrapper every other
API call in the suite goes through, and hand-rolled the rison filter. The
shared getDatasetByName does both correctly. Route the two id reads in these
specs through extractIdFromResponse so they tolerate either response shape.
2026-07-31 13:53:21 -07:00
Joe Li
bb8db2fec9 test(dashboard): fix loader wait missing ancestor-hidden spinners
waitForLoadersToSettle read getComputedStyle().display on each loader
element itself. display does not inherit, so a spinner inside a
display:none ancestor computes to its own display:block and was treated
as visible, hanging the wait until the timeout rather than resolving.

Use Playwright's :visible engine, which accounts for ancestors. Verified
against a live browser: for a loader under a display:none ancestor the
old check reports display:block/visible while :visible correctly matches
zero elements. The already-settled case still resolves in ~2ms, so there
is no timeout penalty.

Drops the opacity === '0' branch as dead: the sole producer of
aria-label="Loading" is the Loading component, which renders opacity 0.25
when muted and 1.0 otherwise, never 0.

Also inlines builderTabs() into its only caller and drops a conditional
spread that JSON serialization already handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:55:31 -07:00
Joe Li
66d411be9a test(dashboard): extract the shared edit-mode setup into one helper
All three tests opened with the same nine-line preamble: create the empty
published dashboard, construct the page object, navigate, wait, enter edit
mode. Collapse it into openEmptyDashboardInEditMode, which absorbs the
dashboard options that were hoisted into a module constant for the same
reason.

A helper rather than a beforeEach hook: a hook cannot return the page object,
so the dashboard would have to become a file-scoped binding mutated between
tests. Each test keeps its setup explicit and self-contained instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:35:39 -07:00
Joe Li
e590018090 Merge branch 'master' into dashboard-pw-editmode
Resolves the DashboardPage conflict introduced by #42017, which extracted
the native-filter-bar methods into a DashboardFilterBar component. That
component already carries both refinements this branch made to
openNativeFiltersConfigModal and applyFiltersIfEnabled, so master's side
is taken for the whole filter-bar surface; the edit-mode helpers and the
locator-based wait rework are kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:19:17 -07:00
Joe Li
4209d697e9 test(dashboard): delete the migrated Cypress spec and tighten markdown commit
Two review follow-ups on the edit-mode Playwright migration:

- Delete cypress-base/cypress/e2e/dashboard/editmode.test.ts. The Playwright
  spec is a strict superset of its single remaining test, so leaving the
  Cypress file tracked duplicated coverage rather than reducing it. Its `drag`
  helper in cypress/utils/index.ts had no other callers and goes with it.

- The markdown commit assertion could pass without the component leaving edit
  mode: the wrapper contains "Test resize" while ace is still mounted. Assert
  the ace document is gone first, which is what the preview swap in
  Markdown.tsx actually produces. Verified by mutation — removing the blur call
  now fails the test.

Also corrects the file header, which described a "Color consistency" block that
upstream had already dropped from the Cypress spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:58:26 -07:00
Joe Li
5a198e8b96 test(dashboard): wait on the grid, not a chart, for chart-less dashboards
Hardening waitForChartsToLoad to require a chart grid component broke
theme.spec, whose dashboard is created from dashboard_title/published/
theme_id alone — apiPostDashboard passes the payload straight through, so
that dashboard has no position_json and its grid renders empty. No chart
component ever attaches and the wait times out. Previously the loader-only
check passed vacuously there, which is the same weakness the hardening
removed, so restoring the old call is not an option.

Add waitForGridToLoad for that case: the grid container renders whatever
the grid holds, so it gives the "got past the header" evidence a chart
component cannot. Waits for it attached rather than visible, since an empty
grid collapses to zero height. Extract the shared loader-settle step into a
private helper; waitForChartsToLoad keeps its behavior unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:52:07 -07:00
Joe Li
5822b94b98 test(dashboard): harden dashboard page-object waits
waitForChartsToLoad only checked that no loading indicator was visible.
Loader absence is also the state of a dashboard whose grid has not mounted
yet, so calling it straight after waitForLoad — which resolves on the
header container, rendered well before the grid — returned immediately and
let callers assert against an empty page. Wait for at least one chart grid
component to attach first, so the absence of loaders means "charts
finished" rather than "charts have not begun".

applyFiltersIfEnabled swallowed locator-resolution errors through
`.catch(() => false)`, collapsing a missing Apply button into the same
silent no-op as a disabled one. Wait for the button, then branch on
enabled-ness only.

Also switch waitForLoad, enterEditMode, openHeaderActionsMenu and
openNativeFiltersConfigModal from page.click/page.waitForSelector to the
locator idiom used everywhere else in the file, correct the
blurToDashboardTitle docstring (clicking the title focuses its input in
edit mode — a state change, not a no-op), note that dropTarget only
resolves while the grid is empty, and inline the createDashboard
passthrough in the edit-mode spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:19:21 -07:00
Joe Li
dc7593f978 test(dashboard): address review of edit-mode Playwright helpers
Follow-up to the edit-mode component migration, applying a review of the
new spec, page object and drag-and-drop helper.

Correctness:
- Resolve the dataset through getDatasetByName and create the chart through
  apiPostChart instead of calling page.request directly. Both wrap the request
  in the shared transient-retry helper, which exists because the dev server
  drops connections under concurrent load; the direct call had no such cover.
- The resize assertion was guarded by `if (handleBox && boxBefore)` while only
  handleBox was asserted non-null, so a null bounding box silently skipped the
  assertion and the test still passed. DashboardPage.resizeComponent now throws
  on a missing box and returns the before/after heights for the spec to assert.
- Replace the deprecated Locator.type() with pressSequentially().

Reuse over duplication:
- Drop the local dashboard-creation helper in favour of createTestDashboard,
  which already handles both response shapes and cleanup registration; it gains
  an optional `published` flag. Chart ids come from extractIdFromResponse.
- Generated names use the worker's parallel index, matching the convention in
  the sibling helpers, rather than a performance.now() suffix.

Page object and readability:
- Move the ace, resize-handle and title selectors out of the spec behind
  DashboardPage accessors. These stay locators rather than routing through the
  AceEditor component, whose setText/getText work through the ace API and so
  would bypass the keystroke path under test and lose web-first retries.
- Name the react-dnd settle delay and the markdown retry budgets.
- Make openBuilderTab private, drop the unused deleteChartHolder index, remove
  the unused GRID_CONTENT selector, correct the dropTarget docstring, type
  layout-element labels as a union, and prefix the locator getters with `get`
  to match the rest of the class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:55:18 -07:00
Joe Li
8435a9ca42 test(dashboard): migrate edit-mode component E2E tests to Playwright
Port the Cypress dashboard edit-mode 'Components' tests (add chart, remove
chart, add markdown) to Playwright as true E2E. The dashboard grid uses
react-dnd with the HTML5 backend, so drags are driven by synthetic native
drag events with a shared DataTransfer (new helpers/dnd.ts). Tests are
hermetic: charts/dashboards are created from birth_names via the API and
cleaned up by the testAssets fixture.

The 21 skipped 'Color consistency' Cypress tests are intentionally not
ported: they assert per-series colors via an .nv-legend-symbol SVG fill that
no longer exists (ECharts renders to canvas, not DOM-inspectable); that
color-precedence logic is covered by Jest/RTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:06:24 -07:00
46 changed files with 1279 additions and 2156 deletions

View File

@@ -1,82 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
import { drag } from 'cypress/utils';
import { interceptGet } from './utils';
import { interceptFiltering as interceptCharts } from '../explore/utils';
function editDashboard() {
cy.getBySel('edit-dashboard-button').click();
}
function dragComponent(
component = 'Unicode Cloud',
target = 'card-title',
withFiltering = true,
) {
if (withFiltering) {
cy.getBySel('dashboard-charts-filter-search-input').type(component, {
force: true,
});
cy.wait('@filtering');
}
cy.wait(500);
drag(`[data-test="${target}"]`, component).to(
'[data-test="grid-content"] [data-test="dragdroppable-object"]',
);
}
function visitEdit(sampleDashboard = SAMPLE_DASHBOARD_1) {
interceptCharts();
interceptGet();
if (sampleDashboard === SAMPLE_DASHBOARD_1) {
cy.createSampleDashboards([0]);
}
cy.visit(sampleDashboard);
cy.wait('@get');
editDashboard();
cy.get('.grid-container').should('exist');
cy.wait('@filtering');
cy.wait(500);
}
describe('Dashboard edit', () => {
describe('Components', () => {
beforeEach(() => {
visitEdit();
});
it('should add charts', () => {
cy.get('body').then($body => {
if ($body.find('.ant-modal-wrap').length > 0) {
cy.get('body').type('{esc}', { force: true });
cy.wait(1000);
cy.get('.ant-modal-close').click({ force: true });
cy.wait(500);
}
});
cy.get('input[type="checkbox"]').scrollIntoView();
cy.get('input[type="checkbox"]').click({ force: true });
dragComponent();
cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
});
});
});

View File

@@ -79,34 +79,6 @@ export function waitForChartLoad(chart: ChartSpec) {
});
}
/**
* Drag an element and drop it to another element.
* Usage:
* drag(source).to(target);
*/
export function drag(selector: string, content: string | number | RegExp) {
const dataTransfer = { data: {} };
return {
to(target: string | Cypress.Chainable) {
cy.get('.dragdroppable')
.contains(selector, content)
.trigger('mousedown', { which: 1, force: true });
cy.get('.dragdroppable')
.contains(selector, content)
.trigger('dragstart', { dataTransfer, force: true });
cy.get('.dragdroppable')
.contains(selector, content)
.trigger('drag', { force: true });
(typeof target === 'string' ? cy.get(target) : target)
.trigger('dragover', { dataTransfer, force: true })
.trigger('drop', { dataTransfer, force: true })
.trigger('dragend', { dataTransfer, force: true })
.trigger('mouseup', { which: 1, force: true });
},
};
}
export function resize(selector: string) {
return {
to(cordX: number, cordY: number) {

View File

@@ -6333,6 +6333,13 @@
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@ltd/j-toml": {
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.38.0.tgz",
"integrity": "sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==",
"dev": true,
"license": "LGPL-3.0"
},
"node_modules/@luma.gl/constants": {
"version": "9.2.6",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz",
@@ -7581,9 +7588,9 @@
}
},
"node_modules/@nx/nx-darwin-arm64": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.8.tgz",
"integrity": "sha512-IM1geDyWPFsS565de9dByYNZ5I3j8FQZvNUp9LIYw1dNu70sCWbAT0glw0anholOwlAb7JWEscoUeV7ouRBW0A==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.6.1.tgz",
"integrity": "sha512-lixkEBGFdEsUiqEZg9LIyjfiTv12Sg1Es/yUgrdOQUAZu+5oiUPMoybyBwrvINl+fZw+PLh66jOmB4GSP2aUMQ==",
"cpu": [
"arm64"
],
@@ -7595,9 +7602,9 @@
]
},
"node_modules/@nx/nx-darwin-x64": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.8.tgz",
"integrity": "sha512-X/AyooJCmAwHyp9f//bspkAmtaPsv2lPEKe6OiOScsyxJITP4nw+rOfIAJ4Ar64WQcMAY8WLP+ys4ah0K6DZSw==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.6.1.tgz",
"integrity": "sha512-HvgtOtuWnEf0dpfWb05N0ptdFg040YgzsKFhXg6+qaBJg5Hg0e0AXPKaSgh2PCqCIDlKu40YtwVgF7KXxXAGlA==",
"cpu": [
"x64"
],
@@ -7609,9 +7616,9 @@
]
},
"node_modules/@nx/nx-freebsd-x64": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.8.tgz",
"integrity": "sha512-mgdqiFag8txon4XugDtNj+hq+X9Whn8LBMuqwJSez93L1fralzpQFSUip7XnE7ejQRr5Zewg3BFscGlsk8Cy3A==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.6.1.tgz",
"integrity": "sha512-g2wUltGX+7/+mdTV5d6ODa0ylrNu/krgb9YdrsbhW6oZeXYm2LeLOAnYqIlL/Kx140NLrb5Kcz7bi7JrBAw4Ow==",
"cpu": [
"x64"
],
@@ -7623,9 +7630,9 @@
]
},
"node_modules/@nx/nx-linux-arm-gnueabihf": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.8.tgz",
"integrity": "sha512-g7ojIloHGFI7wuMnUdg7io42EL7C7ae4zAolNVj7eXZM54amn3qoNjwbyqaVbBQvUNRiAKJizGyn1IhKpzvG9A==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.6.1.tgz",
"integrity": "sha512-TTqisFPAPrj35EihvzotBbajS+0bX++PQggmRVmDmGwSTrpySRJwZnKNHYDqP6s9tigDvkNJOJftK+GkBEFRRA==",
"cpu": [
"arm"
],
@@ -7637,16 +7644,13 @@
]
},
"node_modules/@nx/nx-linux-arm64-gnu": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.8.tgz",
"integrity": "sha512-Kws8e7W4epfqpTWYaV7KLKaj33o+9JtJa2rErx/XFTtMXhfVzOs5hC4oIrZsYpgk1DuT0APp7UDvX06q2kdAVQ==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.6.1.tgz",
"integrity": "sha512-uIkPcanSTIcyh7/6LOoX0YpGO/7GkVhMRgyM9Mg/7ItFjCtRaeuPEPrJESsaNeB5zIVVhI4cXbGrM9NDnagiiw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7654,16 +7658,13 @@
]
},
"node_modules/@nx/nx-linux-arm64-musl": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.8.tgz",
"integrity": "sha512-fpnyVFL+mSqLdKBPk8+n/rHUEXiem7Xwr9cIOd2Dd5zxQ5wcwj4qSYTNRsBPI2qDFo3esHliwaoFJZULbTHldw==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.6.1.tgz",
"integrity": "sha512-eqkG8s/7remiRZ1Lo2zIrFLSNsQ/0x9fAj++CV1nqFE+rfykPQhC48F8pqsq6tUQpI5HqRQEfQgv4CnFNpLR+w==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7671,16 +7672,13 @@
]
},
"node_modules/@nx/nx-linux-x64-gnu": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.8.tgz",
"integrity": "sha512-KRbkSthClEwkbpt/LLJmBJhIOvOsyrp9OICisF9p3mOODjaxAuYmyOgrVreg09BT2F4hXN3JXpvt9eIZpTCRsw==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.6.1.tgz",
"integrity": "sha512-6DhSupCcDa6BYzQ48qsMK4LIdIO+y4E+4xuUBkX2YTGOZh58gctELCv7Gi6/FhiC8rzVzM7hDcygOvHCGc30zA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7688,16 +7686,13 @@
]
},
"node_modules/@nx/nx-linux-x64-musl": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.8.tgz",
"integrity": "sha512-pdPMWko1yZI5ZNI6/t2Y5rQPGgV/ah5DW+mlHo2aFKav4lnxvgisEh9e4HtOsYBfK/9PyYZ5u3M9hLSaMiJ75w==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.6.1.tgz",
"integrity": "sha512-QqtfaBhdfLRKGucpP8RSv7KJ51XRWpfUcXPhkb/1dKP/b9/Z0kpaCgczGHdrAtX9m6haWw+sQXYGxnStZIg/TQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7705,9 +7700,9 @@
]
},
"node_modules/@nx/nx-win32-arm64-msvc": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.8.tgz",
"integrity": "sha512-yYDu3pj7AXu7LtN/T/bA4TMWWWbmvEE4wa8UW8ZU5JeWYblyXQA7HyYpPAb4fLzCtHb/dIrNDghVBRIeAAcnSw==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.6.1.tgz",
"integrity": "sha512-8pTWXphY5IIgY3edZ5SfzP8yPjBqoAxRV5snAYDctF4e0OC1nDOUims70jLesMle8DTSWiHPSfbLVfp2HkU9WQ==",
"cpu": [
"arm64"
],
@@ -7719,9 +7714,9 @@
]
},
"node_modules/@nx/nx-win32-x64-msvc": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.8.tgz",
"integrity": "sha512-cSr0qMt/GgM2aOBFsapxllnIv55j0gAz/6eWvqEOx5sS8d3X1G0IgazZnPbjW2c8gfSYaBZ9UmYnfapWIO/jqg==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.6.1.tgz",
"integrity": "sha512-XMYrtsR5O39uNR4fVpFs65rVB09FyLXvUM735r2rO7IUWWHxHWTAgVcc+gqQaAchBPqR9f1q+3u2i1Inub3Cdw==",
"cpu": [
"x64"
],
@@ -14103,6 +14098,50 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@yarnpkg/parsers": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.2.tgz",
"integrity": "sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"js-yaml": "^3.10.0",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@yarnpkg/parsers/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/@yarnpkg/parsers/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@yeoman/namespace": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yeoman/namespace/-/namespace-2.1.0.tgz",
@@ -14943,6 +14982,16 @@
"node": ">= 6"
}
},
"node_modules/axios/node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/b4a": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@@ -18771,9 +18820,9 @@
}
},
"node_modules/dotenv-expand": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
"integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -18999,9 +19048,9 @@
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -21208,6 +21257,19 @@
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -21269,6 +21331,46 @@
],
"license": "MIT"
},
"node_modules/front-matter": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz",
"integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1"
}
},
"node_modules/front-matter/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/front-matter/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -22884,9 +22986,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -28319,36 +28421,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.0",
"is-unicode-supported": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
@@ -31102,143 +31174,65 @@
"license": "MIT"
},
"node_modules/nx": {
"version": "22.7.8",
"resolved": "https://registry.npmjs.org/nx/-/nx-22.7.8.tgz",
"integrity": "sha512-ceEhmaGCvY7oi7L7G/Nm/UZSnbfwaJxU1XbDLro7CTmVcnrmxAnxExCp0J/Tpf1xQaMZtwxgV7QATdKA/7vLQw==",
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/nx/-/nx-22.6.1.tgz",
"integrity": "sha512-b4eo52o5aCVt3oG6LPYvD2Cul3JFBMgr2p9OjMBIo6oU6QfSR693H2/UuUMepLtO6jcIniPKOcIrf6Ue8aXAww==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@emnapi/core": "1.4.5",
"@emnapi/runtime": "1.4.5",
"@emnapi/wasi-threads": "1.0.4",
"@jest/diff-sequences": "30.0.1",
"@ltd/j-toml": "^1.38.0",
"@napi-rs/wasm-runtime": "0.2.4",
"@tybys/wasm-util": "0.9.0",
"@yarnpkg/lockfile": "1.1.0",
"@yarnpkg/lockfile": "^1.1.0",
"@yarnpkg/parsers": "3.0.2",
"@zkochan/js-yaml": "0.0.7",
"agent-base": "6.0.2",
"ansi-colors": "4.1.3",
"ansi-regex": "5.0.1",
"ansi-styles": "4.3.0",
"argparse": "2.0.1",
"asynckit": "0.4.0",
"axios": "1.18.1",
"balanced-match": "4.0.3",
"base64-js": "1.5.1",
"bl": "4.1.0",
"brace-expansion": "5.0.8",
"buffer": "5.7.1",
"call-bind-apply-helpers": "1.0.2",
"chalk": "4.1.2",
"axios": "^1.12.0",
"cli-cursor": "3.1.0",
"cli-spinners": "2.6.1",
"cliui": "8.0.1",
"clone": "1.0.4",
"color-convert": "2.0.1",
"color-name": "1.1.4",
"combined-stream": "1.0.8",
"debug": "4.4.3",
"defaults": "1.0.4",
"define-lazy-prop": "2.0.0",
"delayed-stream": "1.0.0",
"dotenv": "16.4.7",
"dotenv-expand": "12.0.3",
"dunder-proto": "1.0.1",
"ejs": "5.0.1",
"emoji-regex": "8.0.0",
"end-of-stream": "1.4.5",
"enquirer": "2.3.6",
"es-define-property": "1.0.1",
"es-errors": "1.3.0",
"es-object-atoms": "1.1.1",
"es-set-tostringtag": "2.1.0",
"escalade": "3.2.0",
"escape-string-regexp": "1.0.5",
"cliui": "^8.0.1",
"dotenv": "~16.4.5",
"dotenv-expand": "~11.0.6",
"ejs": "^3.1.7",
"enquirer": "~2.3.6",
"figures": "3.2.0",
"flat": "5.0.2",
"follow-redirects": "1.16.0",
"form-data": "4.0.6",
"fs-constants": "1.0.0",
"function-bind": "1.1.2",
"get-caller-file": "2.0.5",
"get-intrinsic": "1.3.0",
"get-proto": "1.0.1",
"gopd": "1.2.0",
"has-flag": "4.0.0",
"has-symbols": "1.1.0",
"has-tostringtag": "1.0.2",
"hasown": "2.0.4",
"https-proxy-agent": "5.0.1",
"ieee754": "1.2.1",
"ignore": "7.0.5",
"inherits": "2.0.4",
"is-docker": "2.2.1",
"is-fullwidth-code-point": "3.0.0",
"is-interactive": "1.0.0",
"is-unicode-supported": "0.1.0",
"is-wsl": "2.2.0",
"json5": "2.2.3",
"flat": "^5.0.2",
"front-matter": "^4.0.2",
"ignore": "^7.0.5",
"jest-diff": "^30.0.2",
"jsonc-parser": "3.2.0",
"lines-and-columns": "2.0.3",
"log-symbols": "4.1.0",
"math-intrinsics": "1.1.0",
"mime-db": "1.52.0",
"mime-types": "2.1.35",
"mimic-fn": "2.1.0",
"minimatch": "10.2.5",
"minimist": "1.2.8",
"ms": "2.1.3",
"npm-run-path": "4.0.1",
"once": "1.4.0",
"onetime": "5.1.2",
"open": "8.4.2",
"minimatch": "10.2.4",
"npm-run-path": "^4.0.1",
"open": "^8.4.0",
"ora": "5.3.0",
"path-key": "3.1.1",
"picocolors": "1.1.1",
"proxy-from-env": "2.1.0",
"readable-stream": "3.6.2",
"require-directory": "2.1.1",
"picocolors": "^1.1.0",
"resolve.exports": "2.0.3",
"restore-cursor": "3.1.0",
"safe-buffer": "5.2.1",
"semver": "7.7.4",
"signal-exit": "3.0.7",
"smol-toml": "1.6.1",
"string_decoder": "1.3.0",
"string-width": "4.2.3",
"strip-ansi": "6.0.1",
"strip-bom": "3.0.0",
"supports-color": "7.2.0",
"tar-stream": "2.2.0",
"tmp": "0.2.7",
"tree-kill": "1.2.2",
"tsconfig-paths": "4.2.0",
"tslib": "2.8.1",
"util-deprecate": "1.0.2",
"wcwidth": "1.0.1",
"wrap-ansi": "7.0.0",
"wrappy": "1.0.2",
"y18n": "5.0.8",
"yaml": "2.9.0",
"yargs": "17.7.2",
"semver": "^7.6.3",
"string-width": "^4.2.3",
"tar-stream": "~2.2.0",
"tmp": "~0.2.1",
"tree-kill": "^1.2.2",
"tsconfig-paths": "^4.1.2",
"tslib": "^2.3.0",
"yaml": "^2.6.0",
"yargs": "^17.6.2",
"yargs-parser": "21.1.1"
},
"bin": {
"nx": "dist/bin/nx.js",
"nx-cloud": "dist/bin/nx-cloud.js"
"nx": "bin/nx.js",
"nx-cloud": "bin/nx-cloud.js"
},
"optionalDependencies": {
"@nx/nx-darwin-arm64": "22.7.8",
"@nx/nx-darwin-x64": "22.7.8",
"@nx/nx-freebsd-x64": "22.7.8",
"@nx/nx-linux-arm-gnueabihf": "22.7.8",
"@nx/nx-linux-arm64-gnu": "22.7.8",
"@nx/nx-linux-arm64-musl": "22.7.8",
"@nx/nx-linux-x64-gnu": "22.7.8",
"@nx/nx-linux-x64-musl": "22.7.8",
"@nx/nx-win32-arm64-msvc": "22.7.8",
"@nx/nx-win32-x64-msvc": "22.7.8"
"@nx/nx-darwin-arm64": "22.6.1",
"@nx/nx-darwin-x64": "22.6.1",
"@nx/nx-freebsd-x64": "22.6.1",
"@nx/nx-linux-arm-gnueabihf": "22.6.1",
"@nx/nx-linux-arm64-gnu": "22.6.1",
"@nx/nx-linux-arm64-musl": "22.6.1",
"@nx/nx-linux-x64-gnu": "22.6.1",
"@nx/nx-linux-x64-musl": "22.6.1",
"@nx/nx-win32-arm64-msvc": "22.6.1",
"@nx/nx-win32-x64-msvc": "22.6.1"
},
"peerDependencies": {
"@swc-node/register": "^1.11.1",
@@ -31253,75 +31247,47 @@
}
}
},
"node_modules/nx/node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
"integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
"node_modules/nx/node_modules/@jest/schemas": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
"integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/nx/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
"integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/nx/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
"integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/nx/node_modules/@jest/diff-sequences": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
"integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
"dev": true,
"license": "MIT",
"@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/nx/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"node_modules/nx/node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
"dev": true,
"license": "MIT"
},
"node_modules/nx/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/nx/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/nx/node_modules/balanced-match": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
"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": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/nx/node_modules/brace-expansion": {
@@ -31337,60 +31303,6 @@
"node": "20 || >=22"
}
},
"node_modules/nx/node_modules/clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/nx/node_modules/ejs": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.1.tgz",
"integrity": "sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.12.18"
}
},
"node_modules/nx/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/nx/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/nx/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/nx/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -31401,27 +31313,30 @@
"node": ">= 4"
}
},
"node_modules/nx/node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"node_modules/nx/node_modules/jest-diff": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
"integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
"dependencies": {
"@jest/diff-sequences": "30.4.0",
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"pretty-format": "30.4.1"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/nx/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -31430,45 +31345,20 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/nx/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/nx/node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/nx/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"node_modules/nx/node_modules/pretty-format": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
"@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
"react-is-18": "npm:react-is@^18.3.1",
"react-is-19": "npm:react-is@^19.2.5"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/nx/node_modules/yargs": {
@@ -32144,6 +32034,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.0",
"is-unicode-supported": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/os-homedir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
@@ -33696,16 +33616,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -37668,19 +37578,6 @@
"npm": ">= 3.0.0"
}
},
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",

View File

@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Unconditional pause between drag events, letting react-dnd's HTML5 backend
* commit its monitor state before the next event fires. Deliberately not in
* `TIMEOUT`: that object holds wait *ceilings* (a wait may finish sooner),
* whereas this is a fixed sleep that always costs what it says.
*/
const REACT_DND_SETTLE_MS = 50;
/**
* Drives an HTML5 drag-and-drop using synthetic native drag events.
*
* The dashboard grid uses react-dnd with the HTML5 backend
* (`react-dnd-html5-backend`), which listens for native `dragstart` /
* `dragenter` / `dragover` / `drop` events rather than the mouse events that
* Playwright's built-in `locator.dragTo()` produces. To trigger it we dispatch
* the native drag sequence ourselves, threading a single shared `DataTransfer`
* object through every event so react-dnd's monitor sees a consistent payload.
*
* Mirrors the synthetic-event sequence used by the deprecated Cypress `drag`
* helper (cypress-base/cypress/utils/index.ts).
*
* @param page - Playwright page (used to mint the shared DataTransfer)
* @param source - The draggable element (or a descendant; drag events bubble)
* @param target - The drop target element
*/
export async function html5DragAndDrop(
page: Page,
source: Locator,
target: Locator,
): Promise<void> {
// Note: we intentionally do not scrollIntoView the source. The chart card list
// is virtualized, so a separate scroll action can detach the element between
// resolution and use; dispatchEvent only requires the node to be attached.
// A single DataTransfer shared across every event in the sequence: react-dnd's
// HTML5 backend reads/writes drag state through it, so reusing one handle is
// what makes the monitor treat this as one coherent drag.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await source.dispatchEvent('dragstart', { dataTransfer });
// react-dnd's HTML5 backend commits monitor state (the active drag source) on a
// microtask after dragstart; a short settle avoids a race where dragover/drop
// fire before the backend considers a drag to be in progress.
await page.waitForTimeout(REACT_DND_SETTLE_MS);
// dragenter must precede dragover for react-dnd to register the hover target.
await target.dispatchEvent('dragenter', { dataTransfer });
await target.dispatchEvent('dragover', { dataTransfer });
await page.waitForTimeout(REACT_DND_SETTLE_MS);
await target.dispatchEvent('drop', { dataTransfer });
await source.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose();
}

View File

@@ -18,11 +18,23 @@
*/
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { Button, Input, Menu, Tabs } from '../components/core';
import { DashboardFilterBar } from '../components/dashboard';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
/** Tabs of the dashboard builder side pane, by their rendered label. */
type BuilderTab = 'Charts' | 'Layout elements';
/**
* Built-in draggable layout elements, by their rendered label (see
* `src/dashboard/components/gridComponents/new/`). Extension-provided elements
* carry dynamic names and are not covered here.
*/
type LayoutElementLabel =
'Tabs' | 'Row' | 'Column' | 'Header' | 'Text / Markdown' | 'Divider';
/**
* Dashboard Page object for interacting with dashboards.
*/
@@ -32,9 +44,28 @@ export class DashboardPage {
private static readonly SELECTORS = {
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
CHART_GRID_COMPONENT: '[data-test="chart-grid-component"]',
// `:visible` so the locator empties out as loaders hide; see
// waitForLoadersToSettle.
LOADING_INDICATOR: '[aria-label="Loading"]:visible',
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
// The header-actions-menu is the data-test for the dropdown menu content
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
EDIT_BUTTON: '[data-test="edit-dashboard-button"]',
BUILDER_PANE: '[data-test="dashboard-builder-sidepane"]',
CHARTS_SEARCH: '[data-test="dashboard-charts-filter-search-input"]',
CHART_CARD: '[data-test="chart-card"]',
EMPTY_DROPTARGET: '[data-test="grid-content"] .empty-droptarget',
NEW_COMPONENT: '[data-test="new-component"]',
CHART_HOLDER: '[data-test="dashboard-component-chart-holder"]',
GRID_CONTENT: '[data-test="grid-content"]',
DELETE_COMPONENT: '[data-test="dashboard-delete-component-button"]',
MARKDOWN_EDITOR: '[data-test="dashboard-markdown-editor"]',
EDITABLE_TITLE: '[data-test="editable-title-input"]',
// Ace exposes no data-test hooks; these are its own stable DOM classes.
ACE_CONTENT: '.ace_content',
ACE_TEXT_INPUT: '.ace_text-input',
RESIZE_HANDLE_BOTTOM: '.resizable-container-handle--bottom',
} as const;
constructor(page: Page) {
@@ -60,12 +91,16 @@ export class DashboardPage {
/**
* Wait for the dashboard header to be visible.
*
* The header container renders well before the grid does, so this only
* establishes that the dashboard route mounted — pair it with
* {@link waitForChartsToLoad} before asserting on chart content.
*/
async waitForLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
await this.page.waitForSelector(DashboardPage.SELECTORS.DASHBOARD_HEADER, {
timeout,
});
await this.page
.locator(DashboardPage.SELECTORS.DASHBOARD_HEADER)
.waitFor({ state: 'visible', timeout });
}
/**
@@ -73,37 +108,80 @@ export class DashboardPage {
*/
getChart(chartId: number): Locator {
return this.page.locator(
`[data-test="chart-grid-component"][data-test-chart-id="${chartId}"]`,
`${DashboardPage.SELECTORS.CHART_GRID_COMPONENT}[data-test-chart-id="${chartId}"]`,
);
}
/**
* Wait for all charts on the dashboard to finish loading.
* Waits until no loading indicators are visible on the page.
* Wait for the dashboard's charts to mount and finish loading.
*
* Waiting only for loading indicators to clear is not enough: the grid mounts
* its spinners after the header renders, so a "no visible loader" check
* called straight after {@link waitForLoad} passes instantly against a
* dashboard that has not started rendering anything. Waiting for at least one
* chart grid component first makes the absence of loaders mean "charts
* finished" rather than "charts have not begun".
*
* Only for dashboards that have charts — on an empty one this waits out
* `timeout` rather than returning. Use {@link waitForGridToLoad} there.
*/
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
// Use browser-context evaluation to check visibility directly.
// Loading indicators ([aria-label="Loading"]) may persist in the DOM as hidden
// elements after charts finish loading. This checks that none are currently visible,
// returning immediately when charts are already loaded (no timeout penalty).
await this.page.waitForFunction(
() => {
const loaders = document.querySelectorAll('[aria-label="Loading"]');
if (loaders.length === 0) return true;
return Array.from(loaders).every(el => {
const style = getComputedStyle(el);
return (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
);
});
},
undefined,
{ timeout },
);
await this.page
.locator(DashboardPage.SELECTORS.CHART_GRID_COMPONENT)
.first()
.waitFor({ state: 'attached', timeout });
await this.waitForLoadersToSettle(timeout);
}
/**
* Wait for the dashboard grid to mount and any loading indicators to clear.
*
* The counterpart to {@link waitForChartsToLoad} for a dashboard with no
* charts on it: the grid container renders whatever the grid holds, so it
* gives the "the page got past the header" evidence that a chart component
* cannot. Prefer {@link waitForChartsToLoad} whenever charts are expected —
* this cannot tell a grid that rendered empty from one whose charts have not
* begun rendering.
*/
async waitForGridToLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
// Attached rather than visible: an empty grid collapses to zero height,
// which Playwright counts as not visible.
await this.page
.locator(DashboardPage.SELECTORS.GRID_CONTENT)
.first()
.waitFor({ state: 'attached', timeout });
await this.waitForLoadersToSettle(timeout);
}
/**
* Resolve once no loading indicator is visible.
*
* Loading indicators persist in the DOM as hidden elements after charts
* finish, so this waits for none to be *visible* rather than for none to
* exist. The `:visible` engine resolves to zero elements when they are all
* hidden, which is what `detached` then matches — and it returns immediately
* when they are already settled, with no timeout penalty.
*
* Deliberately not a `getComputedStyle` check in an evaluated function:
* `display` does not inherit, so a loader inside a `display: none` ancestor
* computes to its own `display: block` and reads as visible, hanging the wait
* until the timeout. Playwright's visibility check accounts for ancestors.
*
* Loader absence is also the state of a dashboard that has not started
* rendering, which is why every caller pairs this with a wait for the content
* it expects.
*/
private async waitForLoadersToSettle(timeout: number): Promise<void> {
await this.page
.locator(DashboardPage.SELECTORS.LOADING_INDICATOR)
.first()
.waitFor({ state: 'detached', timeout });
}
/**
@@ -136,14 +214,13 @@ export class DashboardPage {
* Open the dashboard header actions menu (three-dot menu)
*/
async openHeaderActionsMenu(): Promise<void> {
await this.page.click(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER);
await this.page
.locator(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER)
.click();
// Wait for the dropdown menu to appear
await this.page.waitForSelector(
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
{
state: 'visible',
},
);
await this.page
.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU)
.waitFor({ state: 'visible' });
}
/**
@@ -178,4 +255,192 @@ export class DashboardPage {
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
/**
* Enter dashboard edit mode and wait for the builder side pane to appear.
*/
async enterEditMode(): Promise<void> {
const editButton = new Button(
this.page,
DashboardPage.SELECTORS.EDIT_BUTTON,
);
await editButton.click();
await this.page
.locator(DashboardPage.SELECTORS.BUILDER_PANE)
.waitFor({ state: 'visible' });
}
/**
* The builder side pane's tab bar (Charts / Layout elements).
*/
/**
* Switch the builder side pane to one of its tabs.
* @param tab - 'Charts' (existing slices) or 'Layout elements' (new components)
*/
private async openBuilderTab(tab: BuilderTab): Promise<void> {
// Scoped to `.ant-tabs` because that is the root the shared Tabs component
// expects.
const builderTabs = new Tabs(
this.page,
this.page
.locator(`${DashboardPage.SELECTORS.BUILDER_PANE} .ant-tabs`)
.first(),
);
await builderTabs.clickTab(tab);
}
/**
* Locator for chart-holder components currently placed on the grid.
* Markdown components are chart holders too — use
* {@link getMarkdownEditors} when the assertion must exclude them.
*/
getChartHolders(): Locator {
return this.page.locator(DashboardPage.SELECTORS.CHART_HOLDER);
}
/**
* Drag an existing chart from the Charts pane onto the dashboard grid.
* Requires edit mode to be active.
* @param sliceName - The slice name to search for and drag
*/
async addChartByName(sliceName: string): Promise<void> {
await this.openBuilderTab('Charts');
const search = new Input(this.page, DashboardPage.SELECTORS.CHARTS_SEARCH);
await search.fill(sliceName);
const card = this.page
.locator(DashboardPage.SELECTORS.CHART_CARD)
.filter({ hasText: sliceName })
.first();
await card.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, card, this.dropTarget());
}
/**
* Drag a new Layout element (by its label) onto the dashboard grid.
* Requires edit mode to be active.
* @param label - The new-component label, e.g. 'Text / Markdown'
*/
async addLayoutElement(label: LayoutElementLabel): Promise<void> {
await this.openBuilderTab('Layout elements');
const source = this.page
.locator(DashboardPage.SELECTORS.NEW_COMPONENT)
.filter({ hasText: label })
.first();
await source.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, source, this.dropTarget());
}
/**
* The grid's empty drop target, which the grid renders while in edit mode.
*
* Only resolves while the grid is still empty. Dropping a second component
* needs a target relative to the already-placed one, not this.
*/
private dropTarget(): Locator {
return this.page.locator(DashboardPage.SELECTORS.EMPTY_DROPTARGET).first();
}
/**
* Hover the first placed chart-holder and click its delete button (edit mode).
*/
async deleteChartHolder(): Promise<void> {
const holder = this.getChartHolders().first();
await holder.hover();
const deleteButton = new Button(
this.page,
holder.locator(DashboardPage.SELECTORS.DELETE_COMPONENT),
);
await deleteButton.click();
}
/**
* Locator for markdown editor components on the grid.
*/
getMarkdownEditors(): Locator {
return this.page.locator(DashboardPage.SELECTORS.MARKDOWN_EDITOR);
}
/**
* The rendered ace document inside a markdown component. Present only once
* the component has entered its editing state.
*
* Exposed as a locator rather than routed through the `AceEditor` component:
* that component reads and writes through `ace.edit(...)` in page context,
* which both bypasses the real keystroke path under test and gives up
* web-first retries on assertions.
*
* @param markdownEditor - A locator from {@link getMarkdownEditors}
*/
getMarkdownAceContent(markdownEditor: Locator): Locator {
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_CONTENT);
}
/**
* Ace's hidden textarea inside a markdown component — the element that
* receives keystrokes.
*
* @param markdownEditor - A locator from {@link getMarkdownEditors}
*/
getMarkdownAceInput(markdownEditor: Locator): Locator {
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_TEXT_INPUT);
}
/**
* Click the dashboard title, moving focus off whichever grid component holds
* it. Committing a markdown edit needs a click on some other element, and the
* title is the one that is always present regardless of what is on the grid.
*
* In edit mode the click focuses the title's input. That is a state change,
* not a no-op — but it edits nothing on its own, so it leaves the component
* under test untouched.
*/
async blurToDashboardTitle(): Promise<void> {
await this.page
.locator(DashboardPage.SELECTORS.EDITABLE_TITLE)
.first()
.click();
}
/**
* Drag a grid component's bottom resize handle down by `deltaY` pixels.
* Requires edit mode. Uses the mouse because the resize handle is driven by
* `react-resizable`, which tracks real pointer movement.
*
* @param component - The grid component to resize
* @param deltaY - Pixels to drag downwards (positive grows the component)
* @returns The component's height before and after the drag
*/
async resizeComponent(
component: Locator,
deltaY: number,
): Promise<{ heightBefore: number; heightAfter: number }> {
const boxBefore = await component.boundingBox();
if (!boxBefore) {
throw new Error('Cannot resize a component that is not visible');
}
const handle = component
.locator(DashboardPage.SELECTORS.RESIZE_HANDLE_BOTTOM)
.last();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error('Resize handle is not visible');
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await this.page.mouse.move(startX, startY);
await this.page.mouse.down();
// Multiple steps so react-resizable sees a drag rather than a teleport.
await this.page.mouse.move(startX, startY + deltaY, { steps: 10 });
await this.page.mouse.up();
const boxAfter = await component.boundingBox();
if (!boxAfter) {
throw new Error('Component disappeared during resize');
}
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
}
}

View File

@@ -25,8 +25,13 @@ import {
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import { TIMEOUT } from '../../utils/constants';
import {
buildFilterJsonMetadata,
buildSelectFilter,
} from './dashboard-test-helpers';
const DATASET_NAME = 'birth_names';
const FILTER_COLUMN = 'gender';
@@ -59,12 +64,10 @@ testWithAssets(
params: JSON.stringify(chartParams),
});
expect(chartResp.ok()).toBe(true);
const chart = await chartResp.json();
const chartId: number = chart.id ?? chart.result?.id;
const chartId = await extractIdFromResponse(chartResp);
testAssets.trackChart(chartId);
// Create dashboard with chart in position_json and a native filter in json_metadata
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const positionJson = buildSingleRowDashboardLayout([
{
id: chartId,
@@ -74,39 +77,17 @@ testWithAssets(
},
]);
const jsonMetadata = {
native_filter_configuration: [
{
id: filterId,
name: 'Gender',
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [
{
datasetId,
column: { name: FILTER_COLUMN },
},
],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask: { filterState: {}, extraFormData: {} },
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
const jsonMetadata = buildFilterJsonMetadata({
chartsInScope: [chartId],
nativeFilters: [
buildSelectFilter({
datasetId,
column: FILTER_COLUMN,
chartsInScope: [chartId],
},
name: 'Gender',
}),
],
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
};
});
const dashResp = await apiPostDashboard(page, {
dashboard_title: `clear_all_repro_${Date.now()}`,
@@ -115,8 +96,7 @@ testWithAssets(
json_metadata: JSON.stringify(jsonMetadata),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
const dashboardId = await extractIdFromResponse(dashResp);
testAssets.trackDashboard(dashboardId);
// Associate chart with the dashboard so it actually renders

View File

@@ -62,6 +62,8 @@ interface TestDashboardResult {
interface CreateTestDashboardOptions {
/** Prefix for generated name (default: 'test_dashboard') */
prefix?: string;
/** Publish the dashboard on creation (default: false, the API default) */
published?: boolean;
}
/**
@@ -86,6 +88,8 @@ export async function createTestDashboard(
const response = await apiPostDashboard(page, {
dashboard_title: name,
// Serialized as JSON, which drops undefined — no need to omit the key.
published: options?.published,
});
if (!response.ok()) {
@@ -106,6 +110,113 @@ export async function createTestDashboard(
return { id, name };
}
/** Scope covering the whole dashboard — every filter built here is unscoped. */
const ROOT_SCOPE = { rootPath: ['ROOT_ID'], excluded: [] };
interface DataMask {
filterState: Record<string, unknown>;
extraFormData: Record<string, unknown>;
}
export interface NativeFilterConfig {
id: string;
name: string;
filterType: string;
type: string;
targets: Array<{ datasetId: number; column: { name: string } }>;
controlValues: Record<string, boolean>;
defaultDataMask: DataMask;
cascadeParentIds: string[];
scope: typeof ROOT_SCOPE;
chartsInScope: number[];
}
interface SelectFilterOptions {
/** Dataset backing the filtered column. */
datasetId: number;
/** Column the filter targets. */
column: string;
/** Charts the filter applies to. */
chartsInScope: number[];
/** Label shown in the filter bar (default: the column name). */
name?: string;
/**
* Value preselected when the dashboard loads. Omit for a filter that starts
* unset — the distinction is load-bearing: a preselected filter is applied to
* the initial chart-data request, an unset one is not.
*/
defaultValue?: string;
}
/**
* Builds one `filter_select` native filter for a dashboard's `json_metadata`.
* The filter id is generated here because no test needs to know it — filters are
* addressed through the filter bar UI, not by id.
*/
export function buildSelectFilter(
options: SelectFilterOptions,
): NativeFilterConfig {
const { datasetId, column, chartsInScope, name, defaultValue } = options;
return {
id: `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`,
name: name ?? column,
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [{ datasetId, column: { name: column } }],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask:
defaultValue === undefined
? { filterState: {}, extraFormData: {} }
: {
filterState: { value: [defaultValue] },
extraFormData: {
filters: [{ col: column, op: 'IN', val: [defaultValue] }],
},
},
cascadeParentIds: [],
scope: ROOT_SCOPE,
chartsInScope,
};
}
interface FilterMetadataOptions {
/** Charts the dashboard's global filter scope covers. */
chartsInScope: number[];
nativeFilters: NativeFilterConfig[];
/**
* Display Controls, serialized as-is. Kept untyped and pass-through: only one
* spec builds them, so a second builder would be speculative.
*/
chartCustomizations?: Record<string, unknown>[];
}
/**
* Builds the `json_metadata` envelope a filtered dashboard needs. Cross-filters
* are off so a click on one chart cannot perturb another test's assertions.
*/
export function buildFilterJsonMetadata(
options: FilterMetadataOptions,
): Record<string, unknown> {
return {
native_filter_configuration: options.nativeFilters,
...(options.chartCustomizations && {
chart_customization_config: options.chartCustomizations,
}),
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: ROOT_SCOPE,
chartsInScope: options.chartsInScope,
},
};
}
export interface DashboardChartSpec {
/** Sent as the chart's top-level `viz_type` and injected into its params. */
viz_type: string;

View File

@@ -30,7 +30,12 @@ import {
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import {
buildFilterJsonMetadata,
buildSelectFilter,
} from './dashboard-test-helpers';
// Record video regardless of pass/fail (before/after clips).
testWithAssets.use({ video: 'on' });
@@ -72,8 +77,7 @@ testWithAssets(
params: JSON.stringify(chartParams),
});
expect(chartResp.ok()).toBe(true);
const chart = await chartResp.json();
const chartId: number = chart.id ?? chart.result?.id;
const chartId = await extractIdFromResponse(chartResp);
testAssets.trackChart(chartId);
const positionJson = buildSingleRowDashboardLayout([
@@ -86,33 +90,21 @@ testWithAssets(
]);
// 2. json_metadata: one dashboard filter + one Display Control.
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const customizationId = `CHART_CUSTOMIZATION-${Math.random()
.toString(36)
.slice(2, 10)}`;
const jsonMetadata = {
native_filter_configuration: [
{
id: filterId,
name: 'Gender',
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask: { filterState: {}, extraFormData: {} },
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
const jsonMetadata = buildFilterJsonMetadata({
chartsInScope: [chartId],
nativeFilters: [
buildSelectFilter({
datasetId,
column: FILTER_COLUMN,
chartsInScope: [chartId],
},
name: 'Gender',
}),
],
chart_customization_config: [
chartCustomizations: [
{
id: customizationId,
type: 'CHART_CUSTOMIZATION',
@@ -127,13 +119,7 @@ testWithAssets(
removed: false,
},
],
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
};
});
const dashResp = await apiPostDashboard(page, {
dashboard_title: `display_control_repro_${Date.now()}`,
@@ -142,8 +128,7 @@ testWithAssets(
json_metadata: JSON.stringify(jsonMetadata),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
const dashboardId = await extractIdFromResponse(dashResp);
testAssets.trackDashboard(dashboardId);
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
@@ -155,14 +140,22 @@ testWithAssets(
const dashboardPage = new DashboardPage(page);
await dashboardPage.gotoById(dashboardId);
await dashboardPage.waitForLoad({ timeout: 30000 });
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
/**
* Best-effort settle after each mutation. Every assertion below targets the
* filter bar rather than chart content, so a chart that is still querying
* must not fail the test — but giving charts a chance to finish keeps the
* bar from being re-rendered underneath the assertions.
*/
const settleCharts = () =>
dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
await settleCharts();
const filterBar = await dashboardPage.waitForFilterBar();
// Both the Gender filter and the Time grain Display Control should render.
await expect(dashboardPage.getDisplayControlsHeader()).toBeVisible();
await expect(dashboardPage.getDisplayControl('Time grain')).toBeVisible();
// eslint-disable-next-line no-console
console.log('STEP 1: Display control "Time grain" is present in the bar.');
await shot('01-initial-bar');
// 4. Open the filters config modal via the settings gear.
@@ -172,40 +165,26 @@ testWithAssets(
// 5. Delete the "Time grain" Display Control in the modal sidebar.
await modal.removeDisplayControl('Time grain');
await expect(modal.getRemovedMarker()).toBeVisible();
// eslint-disable-next-line no-console
console.log('STEP 2: Display control marked (Removed) in modal.');
await shot('03-modal-removed');
// 6. Save the modal.
await modal.clickSave();
await modal.waitForHidden({ timeout: 20000 });
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
await settleCharts();
await shot('04-after-save');
const goneAfterSave = await dashboardPage
.getDisplayControl('Time grain')
.isVisible()
.catch(() => false);
// eslint-disable-next-line no-console
console.log(
`STEP 3: After save, "Time grain" visible in bar = ${goneAfterSave}`,
);
// 7. Click Apply Filters.
await filterBar.applyIfEnabled();
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
await settleCharts();
/**
* Hold before asserting. The bug this guards against is the control coming
* *back*, and `toHaveCount(0)` passes the instant it is absent — so without
* a pause the assertion can sample the gap before the re-render and pass on
* a dashboard that is about to fail. The wait is the reappearance window.
*/
await page.waitForTimeout(1500);
await shot('05-after-apply');
const reappeared = await dashboardPage
.getDisplayControl('Time grain')
.isVisible()
.catch(() => false);
// eslint-disable-next-line no-console
console.log(
`STEP 4: After Apply Filters, "Time grain" reappeared = ${reappeared}`,
);
// The deleted Display Control must stay gone.
await expect(
dashboardPage.getDisplayControl('Time grain'),

View File

@@ -0,0 +1,200 @@
/**
* 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.
*/
/**
* Dashboard edit-mode component tests — these replace the deprecated Cypress
* spec cypress-base/cypress/e2e/dashboard/editmode.test.ts, deleted in the same
* change. They cover the chart/markdown drag-and-drop workflows that the
* upstream Cypress notes flagged as the one part of edit mode that genuinely
* requires E2E coverage ("Chart drag/drop functionality requires true E2E
* testing"). The grid uses react-dnd with the HTML5 backend, so drags are
* driven by synthetic native drag events (see helpers/dnd.ts).
*
* Coverage here is a superset of the Cypress spec, which by the time of the
* migration held a single "should add charts" test — its "Color consistency"
* block had already been dropped upstream as permanently skipped (it read
* per-series colors off an `.nv-legend-symbol` SVG `fill` that ECharts, which
* renders to <canvas>, no longer produces). That color-precedence logic is
* covered by Jest/RTL, not by E2E.
*/
import {
testWithAssets,
expect,
type TestAssets,
} from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import { createTestDashboard } from './dashboard-test-helpers';
import type { Page, TestInfo } from '@playwright/test';
const DATASET_NAME = 'birth_names';
/**
* How long one click on the markdown component gets to bring up the ace editor
* before the retry loop tries again, and how long the whole loop gets. The
* per-attempt budget is deliberately short: the failure mode is a swallowed
* click, and retrying is cheaper than waiting out the full budget once.
*/
const MARKDOWN_EDIT_ATTEMPT_TIMEOUT = 2000;
const MARKDOWN_EDIT_TOTAL_TIMEOUT = 20000;
/** Downward drag distance for the resize assertion — several grid rows. */
const RESIZE_DELTA_PX = 150;
/** Create a hermetic chart from birth_names, NOT placed on any dashboard. */
async function createChart(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
): Promise<string> {
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const sliceName = `edit_mode_chart_${Date.now()}_${testInfo.parallelIndex}`;
const resp = await apiPostChart(page, {
slice_name: sliceName,
viz_type: 'big_number_total',
datasource_id: dataset.id,
datasource_type: 'table',
params: JSON.stringify({
datasource: `${dataset.id}__table`,
viz_type: 'big_number_total',
metric: 'count',
}),
});
expect(resp.ok()).toBe(true);
testAssets.trackChart(await extractIdFromResponse(resp));
return sliceName;
}
/**
* Create the empty published dashboard every test in this file starts from,
* open it, and enter edit mode. Returns the page object positioned on the
* builder, ready for a drag.
*/
async function openEmptyDashboardInEditMode(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
): Promise<DashboardPage> {
const { id } = await createTestDashboard(page, testAssets, testInfo, {
prefix: 'edit_mode',
published: true,
});
const dashboard = new DashboardPage(page);
await dashboard.gotoById(id);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
return dashboard;
}
testWithAssets(
'edit mode: add a chart to the dashboard via drag-and-drop',
async ({ page, testAssets }, testInfo) => {
const sliceName = await createChart(page, testAssets, testInfo);
const dashboard = await openEmptyDashboardInEditMode(
page,
testAssets,
testInfo,
);
await expect(dashboard.getChartHolders()).toHaveCount(0);
await dashboard.addChartByName(sliceName);
await expect(dashboard.getChartHolders()).toHaveCount(1);
},
);
testWithAssets(
'edit mode: remove an added chart from the dashboard',
async ({ page, testAssets }, testInfo) => {
const sliceName = await createChart(page, testAssets, testInfo);
const dashboard = await openEmptyDashboardInEditMode(
page,
testAssets,
testInfo,
);
await dashboard.addChartByName(sliceName);
await expect(dashboard.getChartHolders()).toHaveCount(1);
await dashboard.deleteChartHolder();
await expect(dashboard.getChartHolders()).toHaveCount(0);
},
);
testWithAssets(
'edit mode: add a markdown component via drag-and-drop',
async ({ page, testAssets }, testInfo) => {
// Heaviest edit-mode flow (drag + ace edit + commit + mouse resize); give it
// extra headroom so it stays reliable when the suite runs in parallel.
testWithAssets.slow();
const dashboard = await openEmptyDashboardInEditMode(
page,
testAssets,
testInfo,
);
await dashboard.addLayoutElement('Text / Markdown');
const editor = dashboard.getMarkdownEditors().first();
await expect(editor).toBeVisible();
// Enter edit mode by focusing the component. The markdown enters edit on a
// document-level focus handler attached after mount, so a single early click
// can be missed under load; retry until the ace editor appears. Click the
// rendered "Header 1" heading element specifically (never the trailing
// hyperlink in the default content), so a stray click can't navigate away.
const aceContent = dashboard.getMarkdownAceContent(editor);
const heading = editor.locator('h1', { hasText: 'Header 1' });
await expect(async () => {
if (await aceContent.isVisible()) return;
await heading.click();
await expect(aceContent).toBeVisible({
timeout: MARKDOWN_EDIT_ATTEMPT_TIMEOUT,
});
}).toPass({ timeout: MARKDOWN_EDIT_TOTAL_TIMEOUT });
await expect(aceContent).toContainText('Header 1');
await expect(aceContent).toContainText('markdown formatting');
// Replace the content and confirm the edit is reflected.
const aceInput = dashboard.getMarkdownAceInput(editor);
await aceInput.press('ControlOrMeta+a');
await aceInput.press('Delete');
await aceInput.pressSequentially('Test resize');
await expect(aceContent).toContainText('Test resize');
// Commit by clicking outside the component. Ace unmounting is what proves
// the component left its editing state — the wrapper contains "Test resize"
// either way, since ace holds that text before the click too.
await dashboard.blurToDashboardTitle();
await expect(aceContent).toBeHidden();
await expect(editor).toContainText('Test resize');
// Resize via the bottom handle and confirm the component grew taller.
const { heightBefore, heightAfter } = await dashboard.resizeComponent(
editor,
RESIZE_DELTA_PX,
);
expect(heightAfter).toBeGreaterThan(heightBefore);
},
);

View File

@@ -39,26 +39,26 @@ import {
apiPostDashboard,
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import {
buildFilterJsonMetadata,
buildSelectFilter,
} from './dashboard-test-helpers';
const DATASET_NAME = 'birth_names';
const FILTER_COLUMN = 'gender';
const FILTER_VALUE = 'boy';
async function findDatasetIdByName(page: any, name: string): Promise<number> {
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
const body = await resp.json();
if (!body.result?.length) {
throw new Error(`Dataset ${name} not found`);
}
return body.result[0].id;
}
testWithAssets(
'Mixed chart applies dashboard filter to both queries (#29519)',
async ({ page, testAssets }) => {
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const chartParams = {
datasource: `${datasetId}__table`,
@@ -86,10 +86,9 @@ testWithAssets(
params: JSON.stringify(chartParams),
});
expect(chartResp.ok()).toBe(true);
const chartId: number = (await chartResp.json()).id;
const chartId = await extractIdFromResponse(chartResp);
testAssets.trackChart(chartId);
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const positionJson = buildSingleRowDashboardLayout([
{
id: chartId,
@@ -98,39 +97,20 @@ testWithAssets(
height: 60,
},
]);
const jsonMetadata = {
native_filter_configuration: [
{
id: filterId,
name: 'Gender',
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask: {
filterState: { value: [FILTER_VALUE] },
extraFormData: {
filters: [{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] }],
},
},
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
// Preselect the filter value so it is already applied on the dashboard's
// first chart-data request — that request is what the assertions inspect.
const jsonMetadata = buildFilterJsonMetadata({
chartsInScope: [chartId],
nativeFilters: [
buildSelectFilter({
datasetId,
column: FILTER_COLUMN,
chartsInScope: [chartId],
},
name: 'Gender',
defaultValue: FILTER_VALUE,
}),
],
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
};
});
const dashResp = await apiPostDashboard(page, {
dashboard_title: `mixed_filter_repro_${Date.now()}`,
published: true,
@@ -138,8 +118,7 @@ testWithAssets(
json_metadata: JSON.stringify(jsonMetadata),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
const dashboardId = await extractIdFromResponse(dashResp);
testAssets.trackDashboard(dashboardId);
await apiPut(page, `api/v1/chart/${chartId}`, {

View File

@@ -128,16 +128,15 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
// --- NON-ADMIN USER PHASE (page has no cached auth via test.use) ---
// 4. Instrument network: track any /api/v1/theme/ requests and 403 responses
// 4. Instrument network: track any /api/v1/theme/ request, with its status.
// Recording the status rather than asserting on a separate 403-only array
// keeps the diagnostic — a failure prints whether the calls were forbidden
// or merely unexpected — without a second, subsumed assertion.
const themeApiRequests: string[] = [];
const forbiddenResponses: string[] = [];
page.on('response', response => {
const url = response.url();
if (url.includes('/api/v1/theme/')) {
themeApiRequests.push(url);
}
if (response.status() === 403 && url.includes('/api/v1/theme/')) {
forbiddenResponses.push(url);
themeApiRequests.push(`${response.status()} ${url}`);
}
});
@@ -152,14 +151,19 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
const dashboardPage = new DashboardPage(page);
await dashboardPage.gotoById(dashboardId!);
// 7. Assert dashboard fully loads (not stuck on infinite spinner)
// 7. Assert dashboard fully loads (not stuck on infinite spinner).
// The dashboard is created with no position_json, so its grid renders
// empty — there is no chart to wait for, only the grid itself.
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
await dashboardPage.waitForChartsToLoad();
await dashboardPage.waitForGridToLoad();
// 8. Assert no /api/v1/theme/ requests were made (theme data comes from dashboard response)
expect(themeApiRequests).toHaveLength(0);
// Assert no 403 responses on /api/v1/theme/ (scoped to avoid login/unrelated 403 noise)
expect(forbiddenResponses).toHaveLength(0);
// 8. A non-admin must render the themed dashboard without ever calling the
// theme API — theme data rides along on the dashboard response, and the
// endpoint itself is admin-only, so any call here would 403 and break them.
expect(
themeApiRequests,
'Non-admin dashboard load must not call the theme API',
).toHaveLength(0);
} finally {
// Cleanup: delete test resources using admin context
if (dashboardId) {

View File

@@ -62,7 +62,6 @@ export function ErrorMessageWithStackTrace({
fallback,
compact,
closable = true,
errorMitigationFunction,
}: Props) {
// Check if a custom error message component was registered for this message
if (error) {
@@ -78,7 +77,6 @@ export function ErrorMessageWithStackTrace({
error={error}
source={source}
subtitle={subtitle}
errorMitigationFunction={errorMitigationFunction}
/>
);
}

View File

@@ -20,7 +20,7 @@
import * as reduxHooks from 'react-redux';
import { Provider } from 'react-redux';
import { createStore, Store } from 'redux';
import { act, render, waitFor } from 'spec/helpers/testing-library';
import { render, waitFor } from 'spec/helpers/testing-library';
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
import { reRunQuery } from 'src/SqlLab/actions/sqlLab';
import { triggerQuery } from 'src/components/Chart/chartAction';
@@ -166,12 +166,10 @@ describe('OAuth2RedirectMessage Component', () => {
render(setup());
simulateBroadcastMessage({ tabId: 'tabId' });
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
expect(reRunQuery).toHaveBeenCalledTimes(1);
});
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
@@ -184,44 +182,6 @@ describe('OAuth2RedirectMessage Component', () => {
});
});
test('waits for the SQL Lab query before consuming the completion', async () => {
const initialState = {
sqlLab: {
queries: {},
queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }],
tabHistory: ['editor-id'],
},
explore: { slice: null },
charts: {},
dashboardInfo: {},
};
const delayedQueryStore = createStore(
(state: typeof initialState = initialState, action) =>
action.type === 'load-query'
? {
...state,
sqlLab: {
...state.sqlLab,
queries: { 'query-id': { sql: 'SELECT * FROM table' } },
},
}
: state,
);
render(setup({}, delayedQueryStore));
simulateBroadcastMessage({ tabId: 'tabId' });
expect(reRunQuery).not.toHaveBeenCalled();
act(() => {
delayedQueryStore.dispatch({ type: 'load-query' });
});
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
});
test('dispatches triggerQuery action for explore source upon receiving a correct message', async () => {
render(setup({ source: 'explore' }));
@@ -274,22 +234,4 @@ describe('OAuth2RedirectMessage Component', () => {
]);
});
});
test('runs scoped mitigation once instead of CRUD invalidation', async () => {
const errorMitigationFunction = jest.fn();
render(
setup({
source: 'crud' as ErrorSource,
errorMitigationFunction,
}),
);
simulateBroadcastMessage({ tabId: 'tabId' });
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(errorMitigationFunction).toHaveBeenCalledTimes(1);
});
expect(api.util.invalidateTags).not.toHaveBeenCalled();
});
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -58,16 +58,15 @@ interface OAuth2RedirectExtra {
*
* After the token has been stored, the opened tab will broadcast a message to the
* original tab and close itself. This component, running on the original tab, listens
* for same-origin BroadcastChannel and storage notifications and re-runs the query
* for the user once it receives the success message — be it in SQL Lab, Explore, or
* a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is
* echoed back so the original tab only reacts to its own OAuth2 flow.
* on a same-origin BroadcastChannel and re-runs the query for the user once it
* receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs
* share a "tab ID" (a UUID generated by the backend) which is echoed back through the
* channel so the original tab only reacts to its own OAuth2 flow.
*/
export function OAuth2RedirectMessage({
error,
source,
closable,
errorMitigationFunction,
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
const { extra, level } = error;
@@ -104,17 +103,13 @@ export function OAuth2RedirectMessage({
);
const dispatch = useDispatch();
const lastHandledTabIdRef = useRef<string>();
useEffect(() => {
const handleOAuthComplete = (tabId?: string) => {
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
if (tabId !== extra.tab_id) {
return;
}
if (errorMitigationFunction) {
errorMitigationFunction();
} else if (source === 'sqllab' && query) {
if (source === 'sqllab' && query) {
dispatch(reRunQuery(query));
} else if (source === 'explore') {
dispatch(triggerQuery(true, chartId));
@@ -128,11 +123,7 @@ export function OAuth2RedirectMessage({
'Tables',
]),
);
} else {
return;
}
lastHandledTabIdRef.current = tabId;
};
const channel =
@@ -165,16 +156,7 @@ export function OAuth2RedirectMessage({
window.removeEventListener('storage', handleStorage);
channel?.close();
};
}, [
source,
extra.tab_id,
dispatch,
query,
chartId,
chartList,
dashboardId,
errorMitigationFunction,
]);
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
const body = (
<p>

View File

@@ -27,7 +27,6 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
subtitle?: ReactNode;
compact?: boolean;
closable?: boolean;
errorMitigationFunction?: () => void;
};
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;

View File

@@ -1,38 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import DeleteComponentButton from './DeleteComponentButton';
test('exposes an accessible name without rendering visible label text', () => {
render(<DeleteComponentButton onDelete={jest.fn()} />);
expect(
screen.getByRole('button', { name: 'Delete component' }),
).toBeInTheDocument();
expect(screen.queryByText('Delete component')).not.toBeInTheDocument();
});
test('calls onDelete when clicked', () => {
const onDelete = jest.fn();
render(<DeleteComponentButton onDelete={onDelete} />);
fireEvent.click(screen.getByRole('button', { name: 'Delete component' }));
expect(onDelete).toHaveBeenCalledTimes(1);
});

View File

@@ -18,7 +18,6 @@
*/
import { MouseEventHandler, FC } from 'react';
import { t } from '@apache-superset/core/translation';
import { Icons } from '@superset-ui/core/components/Icons';
import type { IconType } from '@superset-ui/core/components/Icons/types';
import IconButton from './IconButton';
@@ -34,8 +33,6 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
}) => (
<IconButton
onClick={onDelete}
label={t('Delete component')}
hideVisibleLabel
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
/>
);

View File

@@ -73,17 +73,3 @@ test('renders the provided label', () => {
expect(screen.getByText('My Label')).toBeInTheDocument();
});
test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => {
render(
<IconButton
icon={icon}
onClick={jest.fn()}
label="My Label"
hideVisibleLabel
/>,
);
expect(screen.queryByText('My Label')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument();
});

View File

@@ -22,7 +22,6 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme';
interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
icon: JSX.Element;
label?: string;
hideVisibleLabel?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'data-test'?: string;
@@ -64,7 +63,6 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{
icon,
label,
hideVisibleLabel,
onClick,
onKeyDown,
disabled,
@@ -77,7 +75,6 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{...rest}
ref={ref}
type="button"
aria-label={label}
isDisabled={disabled}
aria-disabled={disabled}
data-test={dataTest}
@@ -94,7 +91,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
}}
>
{icon}
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
{label && <StyledSpan>{label}</StyledSpan>}
</StyledButton>
),
);

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import { fireEvent, render } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import IconButton from 'src/dashboard/components/IconButton';
@@ -200,15 +200,6 @@ test('should call deleteComponent when deleted', () => {
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('settings IconButton exposes an accessible name without visible label text', () => {
setup({ component: columnWithoutChildren, editMode: true });
expect(
screen.getByRole('button', { name: 'Column settings' }),
).toBeInTheDocument();
expect(screen.queryByText('Column settings')).not.toBeInTheDocument();
});
test('should pass its own width as availableColumnCount to children', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(

View File

@@ -247,8 +247,6 @@ const Column = (props: ColumnProps) => {
/>
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Column settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>

View File

@@ -145,9 +145,7 @@ describe('Header', () => {
const deleteComponent = jest.fn();
setup({ editMode: true, deleteComponent });
const trashButton = screen.getByRole('button', {
name: 'Delete component',
});
const trashButton = screen.getByRole('button', { name: 'delete' });
fireEvent.click(trashButton);
expect(deleteComponent).toHaveBeenCalledTimes(1);

View File

@@ -240,15 +240,6 @@ test('should call deleteComponent when deleted', () => {
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('settings IconButton exposes an accessible name without visible label text', () => {
setup({ component: rowWithoutChildren, editMode: true });
expect(
screen.getByRole('button', { name: 'Row settings' }),
).toBeInTheDocument();
expect(screen.queryByText('Row settings')).not.toBeInTheDocument();
});
test('should pass appropriate availableColumnCount to children', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(

View File

@@ -293,8 +293,6 @@ const Row = memo((props: RowProps) => {
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Row settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="l" />}
/>
</HoverMenu>

View File

@@ -35,5 +35,6 @@ export const Basic: StoryFn<typeof DatasetPanel> = args => (
Basic.args = {
tableName: 'example_table',
loading: false,
hasError: false,
columnList: exampleColumns,
};

View File

@@ -77,6 +77,7 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment',
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith(`${APP_ROOT}/explore/?datasource=1__table`)}
@@ -96,6 +97,7 @@ test('View Dataset passes an external explore_url through unprefixed', async ()
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith('https://external.example.com/custom-endpoint')}

View File

@@ -17,12 +17,10 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { ErrorTypeEnum } from '@superset-ui/core';
import DatasetPanel, {
REFRESHING,
tableColumnDefinition,
COLUMN_TITLE,
ERROR_TITLE,
} from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel';
import { exampleColumns, exampleDataset } from './fixtures';
import { ITableColumn } from './types';
@@ -33,6 +31,8 @@ import {
SELECT_TABLE_TITLE,
NO_COLUMNS_TITLE,
NO_COLUMNS_DESCRIPTION,
ERROR_TITLE,
ERROR_DESCRIPTION,
} from './MessageContent';
jest.mock(
@@ -47,7 +47,7 @@ jest.mock(
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasetPanel', () => {
test('renders a blank state DatasetPanel', () => {
render(<DatasetPanel columnList={[]} loading={false} />, {
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />, {
useRouter: true,
});
@@ -70,9 +70,17 @@ describe('DatasetPanel', () => {
});
test('renders a no columns screen', () => {
render(<DatasetPanel tableName="Name" columnList={[]} loading={false} />, {
useRouter: true,
});
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading={false}
/>,
{
useRouter: true,
},
);
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
expect(blankDatasetImg).toBeVisible();
@@ -83,9 +91,17 @@ describe('DatasetPanel', () => {
});
test('renders a loading screen', () => {
render(<DatasetPanel tableName="Name" columnList={[]} loading />, {
useRouter: true,
});
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading
/>,
{
useRouter: true,
},
);
const loadingIndicator = screen.getByTestId('loading-indicator');
expect(loadingIndicator).toBeVisible();
@@ -97,12 +113,7 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="Name"
error={{
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message: 'Structured backend failure',
}}
hasError
columnList={[]}
loading={false}
/>,
@@ -113,9 +124,8 @@ describe('DatasetPanel', () => {
const errorTitle = screen.getByText(ERROR_TITLE);
expect(errorTitle).toBeVisible();
const errorDescription = screen.getByText('Structured backend failure');
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
expect(errorDescription).toBeVisible();
expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' });
});
test('renders a table with columns displayed', async () => {
@@ -123,6 +133,7 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName={tableName}
hasError={false}
columnList={exampleColumns}
loading={false}
/>,
@@ -148,6 +159,7 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={exampleDataset}

View File

@@ -21,13 +21,11 @@ import { Alert } from '@apache-superset/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Loading } from '@superset-ui/core/components';
import type { SupersetError } from '@superset-ui/core';
import Table, {
ColumnsType,
TableSize,
} from '@superset-ui/core/components/Table';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { ErrorMessageWithStackTrace } from 'src/components';
import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils';
import { ITableColumn } from './types';
import MessageContent from './MessageContent';
@@ -148,10 +146,6 @@ const TableScrollContainer = styled.div`
right: 0;
`;
const ErrorContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit * 6}px;
`;
const StyledAlert = styled(Alert)`
${({ theme }) => `
border: 1px solid ${theme.colorInfoText};
@@ -173,7 +167,6 @@ const StyledAlert = styled(Alert)`
export const REFRESHING = t('Refreshing columns');
export const COLUMN_TITLE = t('Table columns');
export const ERROR_TITLE = t('An Error Occurred');
const pageSizeOptions = ['5', '10', '15', '25'];
const DEFAULT_PAGE_SIZE = 25;
@@ -208,13 +201,9 @@ export interface IDatasetPanelProps {
*/
columnList: ITableColumn[];
/**
* Error returned while loading the table metadata
* Boolean indicating if there is an error state
*/
error?: SupersetError;
/**
* Function used to retry loading the table metadata after error mitigation
*/
errorMitigationFunction?: () => void;
hasError: boolean;
/**
* Boolean indicating if the component is in a loading state
*/
@@ -267,11 +256,11 @@ const DatasetPanel = ({
tableName,
columnList,
loading,
error,
errorMitigationFunction,
hasError,
datasets,
}: IDatasetPanelProps) => {
const hasColumns = columnList.length > 0;
const hasColumns = Boolean(columnList?.length > 0);
const datasetNames = datasets?.map(dataset => dataset.table_name);
const tableWithDataset = datasets?.find(
dataset => dataset.table_name === tableName,
);
@@ -289,19 +278,7 @@ const DatasetPanel = ({
);
}
if (!loading) {
if (error) {
component = (
<ErrorContainer>
<ErrorMessageWithStackTrace
error={error}
errorMitigationFunction={errorMitigationFunction}
source="crud"
subtitle={error.message}
title={ERROR_TITLE}
/>
</ErrorContainer>
);
} else if (tableName && hasColumns) {
if (!loading && tableName && hasColumns && !hasError) {
component = (
<>
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
@@ -335,7 +312,13 @@ const DatasetPanel = ({
</>
);
} else {
component = <MessageContent tableName={tableName} />;
component = (
<MessageContent
hasColumns={hasColumns}
hasError={hasError}
tableName={tableName}
/>
);
}
}
@@ -343,12 +326,11 @@ const DatasetPanel = ({
<>
{tableName && (
<>
{tableWithDataset && renderExistingDatasetAlert(tableWithDataset)}
{datasetNames?.includes(tableName) &&
renderExistingDatasetAlert(tableWithDataset)}
<StyledHeader
position={
!loading && (hasColumns || error)
? EPosition.RELATIVE
: EPosition.ABSOLUTE
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
}
title={tableName || ''}
>

View File

@@ -16,14 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core';
import type { SupersetClientResponse } from '@superset-ui/core';
import {
DatabaseErrorMessage,
getErrorMessageComponentRegistry,
OAuth2RedirectMessage,
} from 'src/components/ErrorMessage';
import { render, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
@@ -35,29 +29,17 @@ jest.mock(
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
afterEach(() => {
errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR);
errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT);
jest.restoreAllMocks();
});
const tableMetadataResponse = (
name: string,
columnName: string,
): SupersetClientResponse => ({
response: new Response(),
json: {
name,
columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }],
},
});
test('fetches table metadata for schema-less database without schema', async () => {
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(tableMetadataResponse('my_table', 'id'));
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
name: 'my_table',
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
},
} as any);
render(
<DatasetPanelWrapper
@@ -76,99 +58,3 @@ test('fetches table metadata for schema-less database without schema', async ()
);
});
});
test('renders a fallback message for an unstructured metadata error', async () => {
jest.spyOn(SupersetClient, 'get').mockRejectedValue({
response: new Response('{}', {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
});
errorMessageRegistry.registerValue(
ErrorTypeEnum.GENERIC_BACKEND_ERROR,
DatabaseErrorMessage,
);
render(
<DatasetPanelWrapper
tableName="broken_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{ useRouter: true },
);
expect(
await screen.findByText('Unable to load columns for the selected table.'),
).toBeVisible();
});
test('retries only table metadata after matching OAuth completion', async () => {
const oauthError = {
error_type: ErrorTypeEnum.OAUTH2_REDIRECT,
message: 'OAuth authorization is required.',
extra: {
url: 'https://example.com/authorize',
tab_id: 'dataset-oauth-tab',
},
level: 'warning',
};
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValueOnce({
response: new Response(JSON.stringify({ errors: [oauthError] }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
})
.mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id'));
errorMessageRegistry.registerValue(
ErrorTypeEnum.OAUTH2_REDIRECT,
OAuth2RedirectMessage,
);
render(
<DatasetPanelWrapper
tableName="oauth_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{
initialState: {
charts: {},
dashboardInfo: {},
explore: {},
sqlLab: {
queries: {},
queryEditors: [],
tabHistory: [],
},
},
useRedux: true,
useRouter: true,
},
);
const authorizationLink = await screen.findByRole('link', {
name: /provide authorization/i,
});
expect(authorizationLink).toHaveAttribute(
'href',
'https://example.com/authorize',
);
expect(getSpy).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'oauth2_auth_complete',
newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }),
}),
);
});
expect(await screen.findByText('oauth_id')).toBeVisible();
expect(getSpy).toHaveBeenCalledTimes(2);
expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]);
});

View File

@@ -65,17 +65,27 @@ export const NO_COLUMNS_TITLE = t('No table columns');
export const NO_COLUMNS_DESCRIPTION = t(
'This database table does not contain any data. Please select a different table.',
);
export const ERROR_TITLE = t('An Error Occurred');
export const ERROR_DESCRIPTION = t(
'Unable to load columns for the selected table. Please select a different table.',
);
interface MessageContentProps {
hasError: boolean;
tableName?: string | null;
hasColumns: boolean;
}
export const MessageContent = (props: MessageContentProps) => {
const { tableName } = props;
let currentImage = 'empty-dataset.svg';
const { hasError, tableName, hasColumns } = props;
let currentImage: string | undefined = 'empty-dataset.svg';
let currentTitle = SELECT_TABLE_TITLE;
let currentDescription = renderEmptyDescription();
if (tableName) {
if (hasError) {
currentTitle = ERROR_TITLE;
currentDescription = <>{ERROR_DESCRIPTION}</>;
currentImage = undefined;
} else if (tableName && !hasColumns) {
currentImage = 'no-columns.svg';
currentTitle = NO_COLUMNS_TITLE;
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;

View File

@@ -16,14 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import {
ErrorTypeEnum,
getClientErrorObject,
SupersetClient,
} from '@superset-ui/core';
import type { SupersetError } from '@superset-ui/core';
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -35,7 +30,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
/**
* Interface for the getTableMetadata API call
*/
interface TableMetadataRequest {
interface IColumnProps {
/**
* Unique id of the database
*/
@@ -48,10 +43,6 @@ interface TableMetadataRequest {
* Name of the schema (optional for databases that don't support schemas)
*/
schema?: string | null;
/**
* Name of the catalog (optional for databases that don't support catalogs)
*/
catalog?: string | null;
}
export interface IDatasetPanelWrapperProps {
@@ -72,7 +63,7 @@ export interface IDatasetPanelWrapperProps {
* The selected database object (used to check engine capabilities)
*/
database?: Partial<DatabaseObject> | null;
setHasColumns?: (hasColumns: boolean) => void;
setHasColumns?: Function;
datasets?: DatasetObject[] | undefined;
}
@@ -87,131 +78,74 @@ const DatasetPanelWrapper = ({
}: IDatasetPanelWrapperProps) => {
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<SupersetError>();
const requestIdRef = useRef(0);
const currentRequestRef = useRef<TableMetadataRequest>();
const supportsSchemas = database?.supports_schemas;
const [hasError, setHasError] = useState(false);
const tableNameRef = useRef(tableName);
const getTableMetadata = useCallback(
async (props: TableMetadataRequest) => {
const { dbId, tableName, catalog, schema } = props;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
setLoading(true);
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
const getTableMetadata = async (props: IColumnProps) => {
const { dbId, tableName, schema } = props;
setLoading(true);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
if (requestId !== requestIdRef.current) {
return;
}
const table = isIDatabaseTable(response?.json)
? (response.json as IDatabaseTable)
: undefined;
if (table?.name === tableName) {
if (isIDatabaseTable(response?.json)) {
const table: IDatabaseTable = response.json as IDatabaseTable;
/**
* The user is able to click other table columns while the http call for last selected table column is made
* This check ensures we process the response that matches the last selected table name and ignore the others
*/
if (table.name === tableNameRef.current) {
setColumnList(table.columns);
setHasColumns?.(table.columns.length > 0);
setError(undefined);
} else {
const message = t(
setHasError(false);
}
} else {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
addDangerToast(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
);
setColumnList([]);
setHasColumns?.(false);
setError({
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message,
});
addDangerToast(message);
logging.error(message);
}
} catch (caughtError) {
const clientError = await getClientErrorObject(
caughtError as Parameters<typeof getClientErrorObject>[0],
),
);
logging.error(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
);
if (requestId === requestIdRef.current) {
const parsedError = clientError.errors?.[0] ?? {
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error' as const,
message:
clientError.error ||
clientError.message ||
clientError.statusText ||
t('Unable to load columns for the selected table.'),
};
setColumnList([]);
setHasColumns?.(false);
setError(parsedError);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
},
[setHasColumns],
);
const retryGetTableMetadata = useCallback(() => {
if (currentRequestRef.current) {
getTableMetadata(currentRequestRef.current);
}
}, [getTableMetadata]);
useEffect(() => {
const schemaRequired = supportsSchemas !== false;
if (tableName && dbId && (schema || !schemaRequired)) {
const request = {
tableName,
dbId,
catalog,
schema: schema || undefined,
};
currentRequestRef.current = request;
getTableMetadata(request);
} else if (currentRequestRef.current) {
currentRequestRef.current = undefined;
requestIdRef.current += 1;
} catch (error) {
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
setHasError(true);
} finally {
setLoading(false);
}
};
return () => {
requestIdRef.current += 1;
};
}, [
tableName,
dbId,
catalog,
schema,
supportsSchemas,
getTableMetadata,
setHasColumns,
]);
useEffect(() => {
tableNameRef.current = tableName;
const schemaRequired = database?.supports_schemas !== false;
if (tableName && dbId && (schema || !schemaRequired)) {
getTableMetadata({ tableName, dbId, schema: schema || undefined });
}
// getTableMetadata is a const and should not be in dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, dbId, schema, database]);
return (
<DatasetPanel
columnList={columnList}
error={error}
errorMitigationFunction={retryGetTableMetadata}
hasError={hasError}
loading={loading}
tableName={tableName}
datasets={datasets}

View File

@@ -138,6 +138,7 @@ describe('DatasetLayout', () => {
<DatasetPanelComponent
tableName="large_table"
columnList={manyColumns}
hasError={false}
loading={false}
/>
}

View File

@@ -17,7 +17,7 @@
import logging
from typing import Any, Optional
from croniter import croniter, CroniterBadDateError
from croniter import croniter
from flask import current_app as app
from flask_babel import gettext as _
from marshmallow import ValidationError
@@ -29,7 +29,6 @@ from superset.commands.report.exceptions import (
ChartNotSavedValidationError,
DashboardNotFoundValidationError,
DashboardNotSavedValidationError,
ReportScheduleCrontabNotValidError,
ReportScheduleEitherChartOrDashboardError,
ReportScheduleForbiddenError,
ReportScheduleFrequencyNotAllowed,
@@ -289,18 +288,13 @@ class BaseReportScheduleCommand(BaseCommand):
return
iterations = 60 if minimum_interval <= 3660 else 24
try:
schedule = croniter(cron_schedule)
current_exec = next(schedule)
schedule = croniter(cron_schedule)
current_exec = next(schedule)
for _i in range(iterations):
next_exec = next(schedule)
diff, current_exec = next_exec - current_exec, next_exec
if int(diff) < minimum_interval:
raise ReportScheduleFrequencyNotAllowed(
report_type=report_type, minimum_interval=minimum_interval
)
except CroniterBadDateError as ex:
raise ReportScheduleCrontabNotValidError(
cron_schedule=cron_schedule
) from ex
for _i in range(iterations):
next_exec = next(schedule)
diff, current_exec = next_exec - current_exec, next_exec
if int(diff) < minimum_interval:
raise ReportScheduleFrequencyNotAllowed(
report_type=report_type, minimum_interval=minimum_interval
)

View File

@@ -133,23 +133,6 @@ class ReportScheduleFrequencyNotAllowed(ValidationError): # noqa: N818
)
class ReportScheduleCrontabNotValidError(ValidationError): # noqa: N818
"""
Marshmallow validation error for a crontab that is syntactically valid
but never matches a real calendar date (e.g. February 30th)
"""
def __init__(self, cron_schedule: str = "") -> None:
super().__init__(
_(
"Invalid crontab schedule: %(cron_schedule)s never matches"
" a valid date",
cron_schedule=cron_schedule,
),
field_name="crontab",
)
class ChartNotSavedValidationError(ValidationError):
"""
Marshmallow validation error for charts that haven't been saved yet

View File

@@ -125,17 +125,23 @@ class Db2EngineSpec(BaseEngineSpec):
"""
Get comment of table from a given schema
Ibm Db2 return comments as tuples, so we need to get the first element
:param inspector: SqlAlchemy Inspector instance
:param table: Table instance
:return: comment of table
"""
comment = None
try:
table_comment = inspector.get_table_comment(table.table, table.schema)
return table_comment.get("text")
comment = table_comment.get("text")
return comment[0]
except IndexError:
return comment
except Exception as ex: # pylint: disable=broad-except
logger.error("Unexpected error while fetching table comment", exc_info=True)
logger.exception(ex)
return None
return comment
@classmethod
def get_prequeries(

View File

@@ -161,9 +161,6 @@ def get_query(query_id: int) -> Query:
try:
return db.session.query(Query).filter_by(id=query_id).one()
except Exception as ex:
# roll back so a poisoned session (e.g. PendingRollbackError after a
# failed flush) doesn't fail every subsequent backoff retry identically
db.session.rollback()
raise SqlLabException("Failed at getting query") from ex

View File

@@ -87,29 +87,6 @@ def resolve_screenshot_task_budget_seconds(
return None
# Fallback wall-clock budget, in seconds, for the entire tiled-screenshot
# operation (element lookup plus all per-tile readiness/animation waits
# combined), used when resolve_screenshot_task_budget_seconds() returns None
# (no Celery task context -- e.g. synchronous thumbnail generation -- or no
# usable task limit). The non-tiled readiness path treats None as "keep the
# configured SCREENSHOT_LOAD_WAIT" because it makes exactly one bounded wait;
# the tiled path cannot, because its per-tile waits accumulate: with N tiles,
# an uncapped load_wait allows N * load_wait of total wall-clock time, so the
# operation still needs one fixed total ceiling. Sized against the longest
# Celery hard task_time_limit observed in production for report execution
# (1740s), minus the same 300s cleanup margin the runtime derivation reserves
# for combining tiles, building the PDF, and delivering the notification.
TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS = 1440 # 1740s limit - 300s margin
class ScreenshotTaskBudgetExceededError(RuntimeError):
"""Raised when no safe task budget remains before screenshot capture."""
class TiledScreenshotBudgetExceededError(ScreenshotTaskBudgetExceededError):
"""Raised when the tiled-screenshot time budget runs out mid-capture."""
try:
from playwright.sync_api import TimeoutError as PlaywrightTimeout
except ImportError:
@@ -274,7 +251,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
return screenshot_tiles[0]
def take_tiled_screenshot( # noqa: C901
def take_tiled_screenshot(
page: "Page",
element_name: str,
tile_height: int,
@@ -297,12 +274,6 @@ def take_tiled_screenshot( # noqa: C901
Returns:
Combined screenshot bytes or None if failed
Raises:
TiledScreenshotBudgetExceededError: If the total time budget for the
tiled-screenshot operation runs out before every tile has been
verifiably captured. Callers must treat this as a hard failure
rather than fall back to an unchecked/partial screenshot.
"""
context_suffix = f" [{log_context}]" if log_context else ""
# Set right before re-raising the per-tile readiness timeout below, and
@@ -315,15 +286,6 @@ def take_tiled_screenshot( # noqa: C901
# match `except PlaywrightTimeout` and incorrectly propagate instead of
# degrading to `None` like every other unexpected error in this function.
readiness_timeout = False
# Cap the whole tiled operation against the running Celery task's own
# time limit, using the same runtime derivation as the non-tiled
# readiness wait (#42253/#42427). Unlike that path, a None budget does
# not mean "keep the configured timeout": per-tile waits accumulate, so
# the operation falls back to a fixed total ceiling instead.
wait_budget_seconds = resolve_screenshot_task_budget_seconds(log_context)
if wait_budget_seconds is None:
wait_budget_seconds = float(TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS)
start_time = time.monotonic()
try:
# Get the target element
element = page.locator(f".{element_name}")
@@ -358,44 +320,9 @@ def take_tiled_screenshot( # noqa: C901
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
logger.info("Taking %s screenshot tiles", num_tiles)
screenshot_tiles: list[bytes] = []
def _raise_if_budget_exhausted(elapsed: float, remaining_budget: float) -> None:
if remaining_budget > 0:
return
# A customer-side chart-loading issue (a slow/hung dashboard),
# not a Superset system fault, so this is a WARNING rather
# than an ERROR -- consistent with #38130/#38441, which
# deliberately downgraded screenshot timeout logs the same way.
logger.warning(
"Tiled screenshot time budget exhausted on tile %s/%s: "
"%s/%s tiles captured so far, %.1fs elapsed of a %.1fs "
"budget. Aborting instead of capturing remaining tiles "
"unchecked.%s",
i + 1,
num_tiles,
len(screenshot_tiles),
num_tiles,
elapsed,
wait_budget_seconds,
context_suffix,
)
raise TiledScreenshotBudgetExceededError(
f"Tiled screenshot budget of "
f"{wait_budget_seconds:.1f}s exhausted "
f"after {len(screenshot_tiles)}/{num_tiles} tiles"
)
screenshot_tiles = []
for i in range(num_tiles):
# Check the time budget before starting this tile's readiness wait.
# If it's already exhausted, we can no longer verify this (or any
# later) tile is actually ready to capture -- fail loudly instead
# of silently snapshotting a spinner or blank chart, or running
# past the Celery task time limit and getting SIGKILLed.
elapsed = time.monotonic() - start_time
remaining_budget = wait_budget_seconds - elapsed
_raise_if_budget_exhausted(elapsed, remaining_budget)
# Calculate scroll position to show this tile's content
scroll_y = dashboard_top + (i * tile_height)
@@ -405,31 +332,17 @@ def take_tiled_screenshot( # noqa: C901
)
# Wait for scroll to settle and content to load
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
# Recompute the remaining budget after the scroll-settle sleep --
# which itself consumes real wall-clock time -- rather than
# reusing the value from before it, so the readiness-check
# timeout below is capped against a fresh number instead of a
# stale one that would let each tile overrun the budget by up
# to one settle interval.
tile_wait_start = time.monotonic()
elapsed = tile_wait_start - start_time
remaining_budget = wait_budget_seconds - elapsed
_raise_if_budget_exhausted(elapsed, remaining_budget)
# Wait for every chart holder visible in the current viewport to reach
# a terminal state (rendered chart or error/empty state), capped at
# whatever remains of the total time budget so a slow dashboard
# degrades gracefully instead of exceeding it. Only check
# a terminal state (rendered chart or error/empty state). Only check
# viewport-visible chart holders to avoid blocking on virtualization
# placeholders rendered for off-screen charts. A holder that hasn't
# mounted anything yet does not satisfy this check -- unlike checking
# for the absence of `.loading`, which passes vacuously in that case.
tile_load_wait = min(load_wait, remaining_budget)
tile_wait_start = time.monotonic()
try:
page.wait_for_function(
CHART_HOLDERS_READY_JS,
timeout=tile_load_wait * 1000,
timeout=load_wait * 1000,
)
except PlaywrightTimeout:
elapsed = time.monotonic() - tile_wait_start
@@ -441,21 +354,14 @@ def take_tiled_screenshot( # noqa: C901
# made the same call for the other screenshot timeout paths.
logger.warning(
"Timed out after %.2fs waiting for %s chart container(s) to "
"become ready on tile %s/%s (waited %.1fs of a %ss requested "
"load_wait; %.1fs elapsed of a %.1fs total budget; %s/%s "
"tiles captured so far)%s; unready chart holders (chart id, "
"state): %s. Aborting tiled screenshot rather than capturing "
"a blank or partially-loaded tile.",
"become ready on tile %s/%s (load_wait=%ss)%s; unready chart "
"holders (chart id, state): %s. Aborting tiled screenshot "
"rather than capturing a blank or partially-loaded tile.",
elapsed,
len(unready_chart_holders),
i + 1,
num_tiles,
tile_load_wait,
load_wait,
time.monotonic() - start_time,
wait_budget_seconds,
len(screenshot_tiles),
num_tiles,
context_suffix,
unready_chart_holders,
)
@@ -471,36 +377,12 @@ def take_tiled_screenshot( # noqa: C901
load_wait,
context_suffix,
)
readiness_wait_elapsed = time.monotonic() - tile_wait_start
# Wait for chart animations (e.g. ECharts) to finish after spinner clears.
# The global animation wait before tiling only covers the first tile;
# subsequent tiles need their own wait after data loads. Capped at
# whatever remains of the budget; unlike the readiness wait above this
# is cosmetic settling, not a readiness check, so we simply skip it
# (rather than raise) once the budget runs out.
animation_wait_elapsed = 0.0
# subsequent tiles need their own wait after data loads.
if animation_wait > 0:
elapsed = time.monotonic() - start_time
remaining_budget = wait_budget_seconds - elapsed
tile_animation_wait = max(0, min(animation_wait, remaining_budget))
if tile_animation_wait > 0:
animation_wait_start = time.monotonic()
page.wait_for_timeout(tile_animation_wait * 1000)
animation_wait_elapsed = time.monotonic() - animation_wait_start
# Per-tile timing breakdown so slow dashboards can be profiled from
# logs alone. DEBUG rather than INFO: this fires once per tile, and
# large dashboards can have dozens of tiles per report run.
logger.debug(
"Tile %s/%s timing: %.2fs waiting for chart readiness, "
"%.2fs waiting for animations.%s",
i + 1,
num_tiles,
readiness_wait_elapsed,
animation_wait_elapsed,
context_suffix,
)
page.wait_for_timeout(animation_wait * 1000)
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * tile_height
@@ -549,12 +431,6 @@ def take_tiled_screenshot( # noqa: C901
return combined_screenshot
except TiledScreenshotBudgetExceededError:
# Budget exhaustion must fail cleanly, not be swallowed into the
# generic `return None` degradation below -- the raise carries the
# budget diagnostics to the caller, which fails the capture loudly
# (#42273) instead of receiving an anonymous empty result.
raise
except Exception as e:
if readiness_timeout:
# Let the per-tile readiness timeout propagate so the caller

View File

@@ -85,26 +85,6 @@ class ScreenshotCachePayloadType(TypedDict):
status: str
# Magic bytes for a cheap image sanity check. This is intentionally not a full
# decode: it's meant to catch 0-byte/corrupt/blank payloads before they're
# cached or served, not to validate the image is renderable.
PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n"
JPEG_MAGIC_BYTES = b"\xff\xd8\xff"
def validate_screenshot_image(image: bytes | None) -> str | None:
"""Cheaply validate screenshot bytes before they're cached or served.
:return: None if the bytes look like a usable image, otherwise a short
reason ("empty" or "undecodable") suitable for logging.
"""
if not image:
return "empty"
if not image.startswith((PNG_MAGIC_BYTES, JPEG_MAGIC_BYTES)):
return "undecodable"
return None
class ScreenshotCachePayload:
def __init__(
self,
@@ -167,13 +147,6 @@ class ScreenshotCachePayload:
def get_status(self) -> str:
return self.status.value
def get_invalid_image_reason(self) -> str | None:
"""Reason this payload's image should not be served/cached, or None if
it passes validation (or it isn't claiming a successful screenshot)."""
if self.status != StatusValues.UPDATED:
return None
return validate_screenshot_image(self._image)
def is_error_cache_ttl_expired(self) -> bool:
error_cache_ttl = app.config["THUMBNAIL_ERROR_CACHE_TTL"]
return (
@@ -290,14 +263,6 @@ class BaseScreenshot:
elif isinstance(payload, dict):
payload = cast(ScreenshotCachePayloadType, payload)
payload = ScreenshotCachePayload.from_dict(payload)
if invalid_reason := payload.get_invalid_image_reason():
logger.warning(
"Rejecting cached screenshot for %s: %s image payload; "
"treating as a cache miss",
cache_key,
invalid_reason,
)
return None
return payload
logger.info("Failed at getting from cache: %s", cache_key)
return None
@@ -366,28 +331,15 @@ class BaseScreenshot:
image = None
# Cache the result (success or error) to avoid immediate retries
invalid_reason = validate_screenshot_image(image)
# `image and` is redundant at runtime (validate_screenshot_image
# only returns None for truthy, well-formed bytes) but mypy can't
# infer that image is non-None from invalid_reason being None
# across the function-call boundary, so it's kept for narrowing.
if image and invalid_reason is None:
if image:
with event_logger.log_context(
f"screenshot.cache.{self.thumbnail_type}"
):
cache_payload.update(image)
else:
if invalid_reason:
logger.warning(
"Not caching screenshot result for %s: %s image payload",
cache_key,
invalid_reason,
)
if cache_payload.status != StatusValues.ERROR:
# Only call error() if not already set — avoids overwriting
# the timestamp recorded when the actual failure occurred
# above.
cache_payload.error()
elif cache_payload.status != StatusValues.ERROR:
# Only call error() if not already set — avoids overwriting
# the timestamp recorded when the actual failure occurred above.
cache_payload.error()
logger.info("Caching thumbnail: %s", cache_key)
self.cache.set(cache_key, cache_payload.to_dict())

View File

@@ -47,7 +47,6 @@ from superset.utils.screenshot_utils import (
CHART_HOLDERS_READY_JS,
FIND_CHART_HOLDER_STATES_JS,
resolve_screenshot_task_budget_seconds,
ScreenshotTaskBudgetExceededError,
take_tiled_screenshot,
)
@@ -62,6 +61,10 @@ PLAYWRIGHT_INSTALL_MESSAGE = (
)
class ScreenshotTaskBudgetExceededError(RuntimeError):
"""Raised when no safe task budget remains before screenshot capture."""
if TYPE_CHECKING:
from typing import Any
@@ -479,31 +482,16 @@ class WebDriverPlaywright(WebDriverProxy):
logger.exception("Timed out requesting url %s", url)
raise
slice_container_elems: list[Locator] = []
rendered_chart_count = 0
try:
# chart containers didn't render
logger.debug("Wait for chart containers to draw at url: %s", url)
slice_container_locator = page.locator(".chart-container")
# One-time snapshot: containers mounting after this point
# are neither waited on nor counted, so the progress
# numbers below describe the snapshot, not the final DOM.
slice_container_elems = slice_container_locator.all()
for slice_container_elem in slice_container_elems:
for slice_container_elem in slice_container_locator.all():
slice_container_elem.wait_for()
rendered_chart_count += 1
except PlaywrightTimeout:
# Customer-side chart loading is often just slow, not a
# Superset bug, so this is a WARNING (matching the other
# locate-wait timeouts below) rather than an ERROR -- but
# it still fails the screenshot; see the `raise` below.
logger.warning(
"Timed out waiting for chart containers to draw at url %s "
"(%s of %s chart containers rendered before the timeout)",
logger.exception(
"Timed out waiting for chart containers to draw at url %s",
url,
rendered_chart_count,
len(slice_container_elems),
exc_info=True,
)
raise
selenium_animation_wait = app.config[
@@ -541,38 +529,19 @@ class WebDriverPlaywright(WebDriverProxy):
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height
)
# A height of 0 means the DOM query above found no matching
# element (or it hadn't laid out yet), not that the
# dashboard is actually empty. Treat it as "unknown" rather
# than "fits in a single tile": chart_count alone already
# tells us whether this looks like a large dashboard, and
# that signal must not be silently vetoed just because we
# couldn't measure height, or a large dashboard could skip
# tiling and ship with unrendered below-the-fold charts.
height_unknown = dashboard_height == 0
likely_large_dashboard = (
chart_count >= chart_threshold
or dashboard_height > height_threshold
)
if height_unknown:
log_fn = (
logger.warning if likely_large_dashboard else logger.debug
)
log_fn(
if dashboard_height == 0:
logger.warning(
"Could not determine dashboard height for element %s "
"at url %s (%s chart containers found); %s",
"at url %s; falling back to standard screenshot behavior",
element_name,
url,
chart_count,
"attempting tiled screenshot anyway"
if likely_large_dashboard
else "falling back to standard screenshot behavior",
)
# Use tiled screenshots for large dashboards
use_tiled = likely_large_dashboard and (
height_unknown or dashboard_height > tile_height
)
use_tiled = (
chart_count >= chart_threshold
or dashboard_height > height_threshold
) and dashboard_height > tile_height
if use_tiled:
logger.info(
@@ -594,23 +563,18 @@ class WebDriverPlaywright(WebDriverProxy):
log_context=log_context,
)
if not img:
# _get_screenshot() has no wait/readiness logic at
# all, so falling back to it here would risk
# silently delivering a screenshot of spinners or
# a blank dashboard. Fail the capture loudly
# (report error, thumbnail cache ERROR) instead of
# guessing at a "safer" fallback.
logger.warning(
"Tiled screenshot failed for url %s and no "
"safe fallback exists; failing the capture",
url,
(
"Tiled screenshot failed, "
"falling back to standard screenshot"
)
)
raise PlaywrightTimeout(
f"Tiled screenshot failed for url {url}"
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
logger.debug(
"Tiled screenshot result: %d bytes for url: %s",
len(img),
len(img) if img else 0,
url,
)
else:

View File

@@ -26,10 +26,7 @@ from unittest.mock import patch
import pytest
from superset.commands.report.base import BaseReportScheduleCommand
from superset.commands.report.exceptions import (
ReportScheduleCrontabNotValidError,
ReportScheduleFrequencyNotAllowed,
)
from superset.commands.report.exceptions import ReportScheduleFrequencyNotAllowed
from superset.reports.models import ReportScheduleType
REPORT_TYPES = {
@@ -177,28 +174,6 @@ def test_validate_report_frequency_report_only(schedule: str) -> None:
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=5).total_seconds()),
report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
)
def test_validate_report_frequency_never_matching_crontab(report_type: str) -> None:
"""
Test the ``validate_report_frequency`` method with a crontab that is
syntactically valid but never matches a real calendar date (Feb 30th).
Such schedules pass ``croniter.is_valid()`` (purely syntactic) and thus
marshmallow schema validation, but raise ``CroniterBadDateError`` when
iterated. This should surface as a ``ValidationError`` rather than
propagating the raw croniter exception.
"""
with pytest.raises(ReportScheduleCrontabNotValidError):
BaseReportScheduleCommand().validate_report_frequency(
"0 0 30 2 *",
report_type,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config(

View File

@@ -39,16 +39,13 @@ def test_epoch_to_dttm() -> None:
def test_get_table_comment(mocker: MockerFixture):
"""
Test the `get_table_comment` method.
ibm_db_sa >= 0.4.1 returns the comment as a plain string (fixed in
https://github.com/ibmdb/python-ibmdbsa/pull/135), not a tuple as it
used to. Indexing into that string with `comment[0]` truncates every
DB2 table comment to its first character; this guards against that.
"""
from superset.db_engine_specs.db2 import Db2EngineSpec
mock_inspector = mocker.MagicMock()
mock_inspector.get_table_comment.return_value = {"text": "This is a table comment"}
mock_inspector.get_table_comment.return_value = {
"text": ("This is a table comment",)
}
assert (
Db2EngineSpec.get_table_comment(mock_inspector, Table("my_table", "my_schema"))
@@ -72,22 +69,6 @@ def test_get_table_comment_empty(mocker: MockerFixture):
)
def test_get_table_comment_unexpected_error(mocker: MockerFixture):
"""
Test that `get_table_comment` returns `None` instead of raising
when the inspector call fails unexpectedly.
"""
from superset.db_engine_specs.db2 import Db2EngineSpec
mock_inspector = mocker.MagicMock()
mock_inspector.get_table_comment.side_effect = Exception("boom")
assert (
Db2EngineSpec.get_table_comment(mock_inspector, Table("my_table", "my_schema"))
is None
)
def test_get_prequeries(mocker: MockerFixture) -> None:
"""
Test the ``get_prequeries`` method.

View File

@@ -35,7 +35,6 @@ from superset.sql.parse import SQLStatement, Table
from superset.sql_lab import (
execute_query,
execute_sql_statements,
get_query,
get_sql_results,
)
from superset.utils.rls import apply_rls, get_predicates_for_table
@@ -72,34 +71,6 @@ def test_execute_query(mocker: MockerFixture, app: None) -> None:
SupersetResultSet.assert_called_with([(42,)], cursor.description, db_engine_spec)
def test_get_query_rolls_back_session_before_retrying(
mocker: MockerFixture, app: SupersetApp
) -> None:
"""
A broken transaction (e.g. `PendingRollbackError` following a failed flush)
leaves the session unusable until `session.rollback()` is called, so without
it every `backoff` retry would reuse the same poisoned session and fail
identically. `get_query` must roll back on failure so each retry gets a
clean session and has a real chance to succeed.
"""
# avoid actually sleeping through the `backoff` decorator's retry interval
mocker.patch("backoff._sync.time.sleep")
expected_query = mocker.MagicMock()
mock_one = mocker.patch("superset.sql_lab.db.session.query")
mock_one.return_value.filter_by.return_value.one.side_effect = [
Exception("session is broken"),
expected_query,
]
mock_rollback = mocker.patch("superset.sql_lab.db.session.rollback")
result = get_query(query_id=1)
assert result is expected_query
assert mock_one.return_value.filter_by.return_value.one.call_count == 2
mock_rollback.assert_called_once()
@with_config(
{
"SQLLAB_PAYLOAD_MAX_MB": 50,

View File

@@ -33,10 +33,6 @@ from superset.utils.screenshots import (
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
# A minimal valid PNG header, used wherever a test needs bytes that pass
# ScreenshotCachePayload's image validation.
FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body"
class MockCache:
"""A class to manage screenshot cache."""
@@ -96,7 +92,7 @@ def test_get_cache_key(app_context, screenshot_obj):
def test_get_from_cache_key(mocker: MockerFixture, screenshot_obj):
"""get_from_cache_key should always return a ScreenshotCachePayload Object"""
# backwards compatibility test for retrieving plain bytes
fake_bytes = FAKE_PNG_BYTES
fake_bytes = b"fake_screenshot_data"
BaseScreenshot.cache = MockCache()
BaseScreenshot.cache.set("key", fake_bytes)
cache_payload = screenshot_obj.get_from_cache_key("key")
@@ -112,10 +108,10 @@ class TestComputeAndCache:
BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None
)
get_screenshot = mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data"
)
resize_image = mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
)
BaseScreenshot.cache = MockCache()
return {

View File

@@ -37,10 +37,6 @@ from superset.utils.screenshots import (
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
DISTRIBUTED_LOCK_PATH = "superset.utils.screenshots.DistributedLock"
# A minimal valid PNG header, used wherever a test needs bytes that pass
# ScreenshotCachePayload's image validation.
FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body"
class MockCache:
"""A class to manage screenshot cache for testing."""
@@ -87,11 +83,11 @@ class TestCacheOnlyOnSuccess:
mocker.patch(DISTRIBUTED_LOCK_PATH)
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
get_screenshot = mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"image_data"
)
# Mock resize_image to avoid PIL errors with fake image data
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
)
BaseScreenshot.cache = MockCache()
return get_screenshot
@@ -165,15 +161,13 @@ class TestCacheOnlyOnSuccess:
screenshot_obj: BaseScreenshot,
mock_user: MagicMock,
) -> None:
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING,
and must log a WARNING that includes the cache key."""
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING."""
mocker.patch(DISTRIBUTED_LOCK_PATH)
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
return_value=b"",
)
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
screenshot_obj.compute_and_cache(user=mock_user, force=True)
@@ -183,43 +177,6 @@ class TestCacheOnlyOnSuccess:
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
assert any(
cache_key in call.args and "empty" in call.args
for call in mock_logger.warning.call_args_list
)
def test_cache_error_status_when_screenshot_returns_garbage_bytes(
self,
mocker: MockerFixture,
screenshot_obj: BaseScreenshot,
mock_user: MagicMock,
) -> None:
"""Non-empty bytes without a valid image header must set ERROR, not be
cached as a success, and must log a WARNING that includes the cache key."""
mocker.patch(DISTRIBUTED_LOCK_PATH)
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
return_value=b"this-is-not-a-real-image",
)
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image",
return_value=b"this-is-not-a-real-image",
)
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
screenshot_obj.compute_and_cache(user=mock_user, force=True)
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
assert any(
cache_key in call.args and "undecodable" in call.args
for call in mock_logger.warning.call_args_list
)
def test_computing_status_written_to_cache_early(
self,
@@ -240,14 +197,14 @@ class TestCacheOnlyOnSuccess:
"Cache should be set to COMPUTING before screenshot starts"
)
assert cached_value["status"] == "Computing"
return FAKE_PNG_BYTES
return b"image_data"
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
side_effect=check_cache_during_screenshot,
)
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
)
screenshot_obj.compute_and_cache(user=mock_user, force=True)
@@ -472,11 +429,11 @@ class TestIntegrationCacheBugFix:
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image"
)
# Mock resize to avoid PIL errors
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image"
)
# Should trigger task because COMPUTING is stale
@@ -525,72 +482,3 @@ class TestIntegrationCacheBugFix:
assert payload._image == old_image
assert payload.status == StatusValues.COMPUTING
class TestReadSideImageValidation:
"""A cached payload that claims a successful screenshot (status UPDATED)
but carries invalid image bytes must be served as a cache miss, not
returned to the caller — this is what the dashboard/chart screenshot
endpoints call to fetch bytes to serve."""
def test_zero_byte_image_is_treated_as_cache_miss(
self, mocker: MockerFixture, screenshot_obj: BaseScreenshot
) -> None:
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
stale_payload = ScreenshotCachePayload(image=b"", status=StatusValues.UPDATED)
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is None
assert any(
cache_key in call.args and "empty" in call.args
for call in mock_logger.warning.call_args_list
)
def test_garbage_bytes_image_is_treated_as_cache_miss(
self, mocker: MockerFixture, screenshot_obj: BaseScreenshot
) -> None:
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
garbage_payload = ScreenshotCachePayload(image=b"not-an-image-at-all")
BaseScreenshot.cache.set(cache_key, garbage_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is None
assert any(
cache_key in call.args and "undecodable" in call.args
for call in mock_logger.warning.call_args_list
)
def test_valid_image_is_served_normally(
self, screenshot_obj: BaseScreenshot
) -> None:
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
valid_payload = ScreenshotCachePayload(image=FAKE_PNG_BYTES)
BaseScreenshot.cache.set(cache_key, valid_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is not None
assert result.get_image().read() == FAKE_PNG_BYTES
def test_pending_status_with_no_image_is_not_rejected(
self, screenshot_obj: BaseScreenshot
) -> None:
"""Non-UPDATED statuses (e.g. PENDING/COMPUTING) aren't claiming a
successful screenshot, so they should be returned as-is."""
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
pending_payload = ScreenshotCachePayload(status=StatusValues.PENDING)
BaseScreenshot.cache.set(cache_key, pending_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is not None
assert result.status == StatusValues.PENDING

View File

@@ -25,11 +25,8 @@ from superset.utils.screenshot_utils import (
combine_screenshot_tiles,
resolve_screenshot_task_budget_seconds,
SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS,
ScreenshotTaskBudgetExceededError,
SCROLL_SETTLE_TIMEOUT_MS,
take_tiled_screenshot,
TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS,
TiledScreenshotBudgetExceededError,
)
@@ -456,17 +453,12 @@ class TestTakeTiledScreenshot:
assert warning_args[2] == 1 # count of unready chart containers
assert warning_args[3] == 1 # tile index
assert warning_args[4] == 3 # total tiles
assert warning_args[5] == 30 # tile_load_wait (uncapped: budget remains)
assert warning_args[6] == 30 # requested load_wait
assert isinstance(warning_args[7], float) # total elapsed vs budget
assert warning_args[8] == 1440 # total budget (fixed fallback)
assert warning_args[9] == 0 # tiles captured so far
assert warning_args[10] == 3 # total tiles
assert warning_args[11] == "" # no log_context passed
assert warning_args[5] == 30 # load_wait
assert warning_args[6] == "" # no log_context passed
# Diagnostic payload identifies chart id AND the state it's stuck in
# (spinner mounted vs nothing mounted vs waiting-on-database) so a
# slow query can be told apart from the virtualization race.
assert warning_args[12] == [{"chartId": "42", "state": "waiting_on_database"}]
assert warning_args[7] == [{"chartId": "42", "state": "waiting_on_database"}]
def test_timeout_warning_includes_log_context(self, mock_page):
"""The log context (e.g. report execution id) is threaded through for
@@ -492,7 +484,7 @@ class TestTakeTiledScreenshot:
)
warning_args = mock_logger.warning.call_args[0]
assert warning_args[11] == " [execution_id=abc-123]"
assert warning_args[6] == " [execution_id=abc-123]"
def test_chart_holder_with_nothing_mounted_blocks_wait(self, mock_page):
"""Regression test for the vacuous-pass race (PR #39895).
@@ -654,311 +646,3 @@ class TestTakeTiledScreenshot:
sig = inspect.signature(take_tiled_screenshot)
assert sig.parameters["animation_wait"].default == 0
class TestTileWaitBudget:
"""The tiled operation's cumulative per-tile waits are capped by one
wall-clock budget derived from the running Celery task's own time limit
(resolve_screenshot_task_budget_seconds), falling back to a fixed total
ceiling outside Celery because per-tile waits accumulate."""
@pytest.fixture
def mock_page(self):
"""Create a mock Playwright page object for a 3-tile (5000px) dashboard."""
page = MagicMock()
element = MagicMock()
page.locator.return_value = element
page.evaluate.return_value = {
"height": 5000,
"top": 100,
"left": 50,
"width": 800,
}
page.screenshot.return_value = b"fake_screenshot_data"
return page
class _FakeClock:
"""Stateful monotonic() stand-in the test advances explicitly.
Robust to how many times the code under test samples the clock per
tile (budget check, per-tile wait timing, animation budget) -- only
explicit advances move time forward.
"""
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def test_budget_error_is_task_budget_error_subclass(self):
"""Callers can catch the whole budget-error family with the base
ScreenshotTaskBudgetExceededError type."""
assert issubclass(
TiledScreenshotBudgetExceededError, ScreenshotTaskBudgetExceededError
)
def test_per_tile_wait_shrinks_as_budget_depletes(self, mock_page, monkeypatch):
"""Each tile's readiness-wait timeout is capped at the remaining budget."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# Simulate slow tiles: the readiness wait itself consumes wall time,
# so each subsequent tile sees less remaining budget.
wait_durations = iter([950, 40, 5])
def slow_wait(*args, **kwargs):
clock.now += next(wait_durations)
mock_page.wait_for_function.side_effect = slow_wait
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=100
)
assert result is not None
timeouts = [
call[1]["timeout"] for call in mock_page.wait_for_function.call_args_list
]
# remaining budget at each tile's wait: 1000, 50, 10 seconds
# -> capped timeouts shrink
assert timeouts == [100 * 1000, 50 * 1000, 10 * 1000]
assert timeouts == sorted(timeouts, reverse=True)
def test_readiness_wait_uses_budget_recomputed_after_scroll_settle(
self, mock_page, monkeypatch
):
"""The readiness-wait timeout must be capped using the budget
recomputed *after* the scroll-settle sleep, not the stale value from
before it -- otherwise each tile could overrun the total budget by up
to one settle interval."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
# A single-tile dashboard to keep the scenario simple.
mock_page.evaluate.return_value = {
"height": 1000,
"top": 100,
"left": 50,
"width": 800,
}
clock = self._FakeClock()
# The scroll-settle sleep itself consumes 950s of wall-clock time,
# leaving only 50s of the 1000s budget by the time the readiness
# wait is capped.
mock_page.wait_for_timeout.side_effect = lambda *args, **kwargs: setattr(
clock, "now", clock.now + 950
)
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=999
)
timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
# Must reflect the post-settle remaining budget (50s), not the
# stale pre-settle value (1000s, which would have let load_wait's
# full 999s through uncapped).
assert timeout == 50 * 1000
def test_budget_exhausted_raises_and_stops_capturing(self, mock_page, monkeypatch):
"""Exhausting the budget aborts cleanly instead of capturing unchecked."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# Tile 0's readiness wait consumes the whole budget; tile 1's budget
# check then sees remaining <= 0 and raises before capturing.
mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr(
clock, "now", 1000.0
)
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with pytest.raises(TiledScreenshotBudgetExceededError):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=100
)
# Only the first tile was captured before the budget ran out.
assert mock_page.screenshot.call_count == 1
# Tiles were never combined -- the function raised before that point.
mock_combine.assert_not_called()
# Budget exhaustion is a customer chart-loading issue, not a Superset
# system fault, so it must log at WARNING (not ERROR) -- consistent
# with the #38130/#38441 precedent for screenshot timeout logging.
assert mock_logger.error.call_count == 0
mock_logger.warning.assert_called_once()
warning_args = mock_logger.warning.call_args[0]
assert "budget exhausted" in warning_args[0]
# tile index, tiles total, tiles captured, tiles total,
# elapsed seconds, budget seconds, log-context suffix
assert warning_args[1] == 2
assert warning_args[2] == 3
assert warning_args[3] == 1
assert warning_args[4] == 3
assert warning_args[5] == 1000
assert warning_args[6] == 1000
assert warning_args[7] == ""
def test_budget_exhausted_warning_includes_log_context(
self, mock_page, monkeypatch
):
"""log_context (e.g. report execution id) is appended to the warning."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# Tile 0's readiness wait consumes the whole budget; tile 1's budget
# check then sees remaining <= 0 and raises.
mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr(
clock, "now", 1000.0
)
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with pytest.raises(TiledScreenshotBudgetExceededError):
take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
load_wait=100,
log_context="execution_id=abc-123",
)
warning_args = mock_logger.warning.call_args[0]
assert warning_args[-1] == " [execution_id=abc-123]"
def test_budget_exhausted_before_first_tile_raises_without_capture(
self, mock_page, monkeypatch
):
"""No budget floor: a budget already exhausted by setup (element
lookup/dimension probing) raises before the first tile is captured,
matching the non-tiled path's raise-before-capture semantics."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# The dashboard-dimension evaluate() itself consumes the whole budget.
original_return = {"height": 5000, "top": 100, "left": 50, "width": 800}
def slow_evaluate(*args, **kwargs):
clock.now = 1000.0
return original_return
mock_page.evaluate.side_effect = slow_evaluate
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
with pytest.raises(TiledScreenshotBudgetExceededError):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=100
)
mock_page.screenshot.assert_not_called()
mock_combine.assert_not_called()
def test_no_celery_context_uses_fixed_total_fallback(self, mock_page):
"""Outside Celery the helper returns None; the tiled path must fall
back to the fixed total ceiling rather than running uncapped, because
per-tile waits accumulate across tiles."""
clock = self._FakeClock()
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
load_wait=10_000, # deliberately above the fallback
)
first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
assert first_timeout == TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS * 1000
def test_derived_task_budget_caps_tile_wait(self, mock_page):
"""Inside Celery, the tiled path caps waits using the same
task-derived budget as the non-tiled path (helper reuse, #42427)."""
task = MagicMock()
task.request.timelimit = (120, None) # (hard, soft): 120s hard limit
clock = self._FakeClock()
with patch("superset.utils.screenshot_utils.current_task", task):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=200
)
# margin = min(300, 120 * 0.2) = 24; budget = 120 - 24 = 96
first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
assert first_timeout == 96 * 1000
assert first_timeout < 200 * 1000
def test_fast_dashboard_matches_default_behavior(self, mock_page):
"""Well under budget, waits are not capped and behavior is unchanged."""
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
load_wait=30,
animation_wait=5,
)
assert result is not None
assert mock_page.screenshot.call_count == 3
for call in mock_page.wait_for_function.call_args_list:
assert call[1]["timeout"] == 30 * 1000
animation_calls = [
call
for call in mock_page.wait_for_timeout.call_args_list
if call[0][0] == 5 * 1000
]
assert len(animation_calls) == 3
def test_per_tile_timing_debug_line_logged(self, mock_page):
"""Each tile logs a DEBUG timing breakdown (readiness wait, animation
wait) so slow dashboards can be profiled from logs alone."""
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
log_context="cache_key=xyz",
)
timing_calls = [
call for call in mock_logger.debug.call_args_list if "timing" in call[0][0]
]
assert len(timing_calls) == 3
for i, call in enumerate(timing_calls):
args = call[0]
assert args[1] == i + 1 # tile index
assert args[2] == 3 # total tiles
assert args[-1] == " [cache_key=xyz]"

View File

@@ -919,197 +919,21 @@ class TestWebDriverPlaywrightErrorHandling:
)
assert result == b"fake_screenshot"
# chart_count (1) is well below the tiling threshold (20), so this is
# the benign/expected case and must not be logged as a WARNING.
mock_logger.debug.assert_any_call(
"Could not determine dashboard height for element %s "
"at url %s (%s chart containers found); %s",
"dashboard",
"http://example.com",
1,
mock_logger.warning.assert_any_call(
"Could not determine dashboard height for element %s at url %s; "
"falling back to standard screenshot behavior",
)
assert not any(
call.args and "Could not determine dashboard height" in call.args[0]
for call in mock_logger.warning.call_args_list
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
@patch("superset.utils.webdriver.take_tiled_screenshot")
def test_unknown_height_does_not_veto_tiling_for_large_dashboard(
self, mock_take_tiled, mock_logger, mock_browser_manager
):
"""
A large dashboard (by chart_count) whose height can't be measured
must still attempt tiling instead of being silently downgraded to a
standard screenshot, since below-the-fold charts may not have
rendered without the scroll-driven tiling pass.
"""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
mock_element = MagicMock()
mock_chart_container = MagicMock()
mock_browser_manager.get_browser.return_value = mock_browser
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
def locator_side_effect(selector):
if selector == ".chart-container":
locator = MagicMock()
locator.all.return_value = [mock_chart_container]
return locator
return mock_element
mock_page.locator.side_effect = locator_side_effect
mock_element.wait_for.return_value = None
mock_chart_container.wait_for.return_value = None
mock_page.wait_for_timeout.return_value = None
mock_take_tiled.return_value = b"tiled_screenshot"
def evaluate_side_effect(script):
if script == 'document.querySelectorAll(".chart-container").length':
return 25 # chart_count >= threshold
if "const target = document.querySelector" in script:
return 0 # height could not be determined
return None
mock_page.evaluate.side_effect = evaluate_side_effect
with patch("superset.utils.webdriver.app") as mock_app:
mock_app.config = {
"WEBDRIVER_OPTION_ARGS": [],
"WEBDRIVER_WINDOW": {"pixel_density": 1},
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
"SCREENSHOT_SELENIUM_HEADSTART": 5,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
"SCREENSHOT_TILED_ENABLED": True,
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
}
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
mock_auth.return_value = mock_context
driver = WebDriverPlaywright("chrome")
result = driver.get_screenshot(
"http://example.com", "dashboard", mock_user
)
assert result == b"tiled_screenshot"
mock_take_tiled.assert_called_once()
mock_logger.warning.assert_any_call(
"Could not determine dashboard height for element %s "
"at url %s (%s chart containers found); %s",
"dashboard",
"http://example.com",
25,
"attempting tiled screenshot anyway",
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
def test_chart_container_timeout_logs_warning_with_progress_and_raises(
self, mock_logger, mock_browser_manager
):
"""
Timing out while waiting for `.chart-container` elements to draw must
be logged as a WARNING (matching the other locate-wait timeouts in
this method, and the customer-side-slowness convention established
for these Playwright timeouts) with rendered/total progress, and must
still fail the screenshot by re-raising.
"""
from superset.utils.webdriver import PlaywrightTimeout
mock_user = MagicMock()
mock_user.username = "test_user"
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
mock_element = MagicMock()
mock_browser_manager.get_browser.return_value = mock_browser
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
timeout = PlaywrightTimeout()
rendered_ok = MagicMock()
rendered_ok.wait_for.return_value = None
never_renders = MagicMock()
never_renders.wait_for.side_effect = timeout
def locator_side_effect(selector):
if selector == ".chart-container":
locator = MagicMock()
locator.all.return_value = [rendered_ok, never_renders]
return locator
return mock_element
mock_page.locator.side_effect = locator_side_effect
mock_element.wait_for.return_value = None
with patch("superset.utils.webdriver.app") as mock_app:
mock_app.config = {
"WEBDRIVER_OPTION_ARGS": [],
"WEBDRIVER_WINDOW": {"pixel_density": 1},
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
"SCREENSHOT_SELENIUM_HEADSTART": 5,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
"SCREENSHOT_TILED_ENABLED": False,
}
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
mock_auth.return_value = mock_context
driver = WebDriverPlaywright("chrome")
with pytest.raises(PlaywrightTimeout) as exc_info:
driver.get_screenshot(
"http://example.com", "test-element", mock_user
)
assert exc_info.value is timeout
mock_logger.warning.assert_any_call(
"Timed out waiting for chart containers to draw at url %s "
"(%s of %s chart containers rendered before the timeout)",
"http://example.com",
1,
2,
exc_info=True,
)
mock_logger.exception.assert_not_called()
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
@patch("superset.utils.webdriver.take_tiled_screenshot")
def test_tiled_screenshot_failure_raises_without_fallback(
def test_tiled_screenshot_failure_falls_back_to_standard_screenshot(
self, mock_take_tiled, mock_logger, mock_browser_manager
) -> None:
"""When take_tiled_screenshot returns None, fail loudly instead of
falling back to an unguarded standard screenshot."""
from superset.utils.webdriver import PlaywrightTimeout
"""When take_tiled_screenshot returns None, fall back to standard screenshot."""
mock_user = MagicMock()
mock_user.username = "test_user"
@@ -1123,8 +947,7 @@ class TestWebDriverPlaywrightErrorHandling:
mock_context.new_page.return_value = mock_page
mock_page.locator.return_value = mock_element
mock_element.wait_for.return_value = None
# page.screenshot is used by _get_screenshot for the "standalone" element;
# it must never be reached by the failure path under test.
# page.screenshot is used by _get_screenshot for the "standalone" element
mock_page.screenshot.return_value = b"fallback_screenshot"
def evaluate_side_effect(script):
@@ -1160,20 +983,14 @@ class TestWebDriverPlaywrightErrorHandling:
mock_auth.return_value = mock_context
driver = WebDriverPlaywright("chrome")
# match= keeps this assertion meaningful even when playwright
# is not installed and PlaywrightTimeout aliases bare Exception.
with pytest.raises(
PlaywrightTimeout, match="Tiled screenshot failed for url"
):
driver.get_screenshot("http://example.com", "standalone", mock_user)
result = driver.get_screenshot(
"http://example.com", "standalone", mock_user
)
assert result == b"fallback_screenshot"
mock_take_tiled.assert_called_once()
mock_page.screenshot.assert_not_called()
mock_element.screenshot.assert_not_called()
mock_logger.warning.assert_any_call(
"Tiled screenshot failed for url %s and no safe fallback "
"exists; failing the capture",
"http://example.com",
("Tiled screenshot failed, falling back to standard screenshot"),
)
@@ -1697,13 +1514,10 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.take_tiled_screenshot")
@patch("superset.utils.webdriver.app")
def test_tiled_empty_bytes_raises_without_fallback(
def test_tiled_fallback_triggered_on_empty_bytes(
self, mock_app, mock_take_tiled, mock_browser_manager
):
"""Tiled failure raises when take_tiled_screenshot returns b"" (not None),
instead of silently falling through to an unguarded raw capture."""
from superset.utils.webdriver import PlaywrightTimeout
"""Tiled fallback fires when take_tiled_screenshot returns b"" (not None)."""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_app.config = {
@@ -1718,24 +1532,20 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
mock_page.evaluate.side_effect = [25, 6000]
# Empty bytes — falsy but not None; was silently passed through before the fix
mock_take_tiled.return_value = b""
# _get_screenshot("standalone") calls page.screenshot(full_page=True); it
# must never be reached by the failure path under test.
# _get_screenshot("standalone") calls page.screenshot(full_page=True);
# configure that return value so we can assert the fallback was reached
mock_page.screenshot.return_value = b"fallback"
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
# match= keeps this assertion meaningful even when playwright
# is not installed and PlaywrightTimeout aliases bare Exception.
with pytest.raises(
PlaywrightTimeout, match="Tiled screenshot failed for url"
):
WebDriverPlaywright("chrome").get_screenshot(
"http://example.com", "standalone", mock_user
)
result = WebDriverPlaywright("chrome").get_screenshot(
"http://example.com", "standalone", mock_user
)
assert result == b"fallback"
# Tiled path was taken (take_tiled_screenshot was called)
mock_take_tiled.assert_called_once()
# Standard screenshot must never be called as a fallback
mock_page.screenshot.assert_not_called()
# Standard screenshot was called as fallback (full_page=True for "standalone")
mock_page.screenshot.assert_called_with(full_page=True)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")