mirror of
https://github.com/apache/superset.git
synced 2026-08-02 20:12:27 +00:00
Compare commits
11 Commits
dashboard-
...
fix-sql-la
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09c9410bdc | ||
|
|
7d5e8ad785 | ||
|
|
22c305f758 | ||
|
|
6929d032b8 | ||
|
|
f6c574edd8 | ||
|
|
b452c1634d | ||
|
|
1bfbce3cfd | ||
|
|
85bab0b07c | ||
|
|
0981b1101a | ||
|
|
f607e17e3a | ||
|
|
6c8763bf5a |
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,34 @@ 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) {
|
||||
|
||||
649
superset-frontend/package-lock.json
generated
649
superset-frontend/package-lock.json
generated
@@ -6333,13 +6333,6 @@
|
||||
"@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",
|
||||
@@ -7588,9 +7581,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/nx-darwin-arm64": {
|
||||
"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==",
|
||||
"version": "22.7.8",
|
||||
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.8.tgz",
|
||||
"integrity": "sha512-IM1geDyWPFsS565de9dByYNZ5I3j8FQZvNUp9LIYw1dNu70sCWbAT0glw0anholOwlAb7JWEscoUeV7ouRBW0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -7602,9 +7595,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-darwin-x64": {
|
||||
"version": "22.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.6.1.tgz",
|
||||
"integrity": "sha512-HvgtOtuWnEf0dpfWb05N0ptdFg040YgzsKFhXg6+qaBJg5Hg0e0AXPKaSgh2PCqCIDlKu40YtwVgF7KXxXAGlA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7616,9 +7609,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-freebsd-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7630,9 +7623,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -7644,13 +7637,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7658,13 +7654,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7672,13 +7671,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7686,13 +7688,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7700,9 +7705,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -7714,9 +7719,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@nx/nx-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -14098,50 +14103,6 @@
|
||||
"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",
|
||||
@@ -14982,16 +14943,6 @@
|
||||
"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",
|
||||
@@ -18820,9 +18771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-expand": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
|
||||
"integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
@@ -19048,9 +18999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -21257,19 +21208,6 @@
|
||||
"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",
|
||||
@@ -21331,46 +21269,6 @@
|
||||
],
|
||||
"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",
|
||||
@@ -22986,9 +22884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -28421,6 +28319,36 @@
|
||||
"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",
|
||||
@@ -31174,65 +31102,143 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nx": {
|
||||
"version": "22.6.1",
|
||||
"resolved": "https://registry.npmjs.org/nx/-/nx-22.6.1.tgz",
|
||||
"integrity": "sha512-b4eo52o5aCVt3oG6LPYvD2Cul3JFBMgr2p9OjMBIo6oU6QfSR693H2/UuUMepLtO6jcIniPKOcIrf6Ue8aXAww==",
|
||||
"version": "22.7.8",
|
||||
"resolved": "https://registry.npmjs.org/nx/-/nx-22.7.8.tgz",
|
||||
"integrity": "sha512-ceEhmaGCvY7oi7L7G/Nm/UZSnbfwaJxU1XbDLro7CTmVcnrmxAnxExCp0J/Tpf1xQaMZtwxgV7QATdKA/7vLQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@emnapi/core": "1.4.5",
|
||||
"@emnapi/runtime": "1.4.5",
|
||||
"@emnapi/wasi-threads": "1.0.4",
|
||||
"@jest/diff-sequences": "30.0.1",
|
||||
"@napi-rs/wasm-runtime": "0.2.4",
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"@yarnpkg/parsers": "3.0.2",
|
||||
"@tybys/wasm-util": "0.9.0",
|
||||
"@yarnpkg/lockfile": "1.1.0",
|
||||
"@zkochan/js-yaml": "0.0.7",
|
||||
"axios": "^1.12.0",
|
||||
"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",
|
||||
"cli-cursor": "3.1.0",
|
||||
"cli-spinners": "2.6.1",
|
||||
"cliui": "^8.0.1",
|
||||
"dotenv": "~16.4.5",
|
||||
"dotenv-expand": "~11.0.6",
|
||||
"ejs": "^3.1.7",
|
||||
"enquirer": "~2.3.6",
|
||||
"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",
|
||||
"figures": "3.2.0",
|
||||
"flat": "^5.0.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"jest-diff": "^30.0.2",
|
||||
"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",
|
||||
"jsonc-parser": "3.2.0",
|
||||
"lines-and-columns": "2.0.3",
|
||||
"minimatch": "10.2.4",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"open": "^8.4.0",
|
||||
"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",
|
||||
"ora": "5.3.0",
|
||||
"picocolors": "^1.1.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",
|
||||
"resolve.exports": "2.0.3",
|
||||
"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",
|
||||
"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",
|
||||
"yargs-parser": "21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"nx": "bin/nx.js",
|
||||
"nx-cloud": "bin/nx-cloud.js"
|
||||
"nx": "dist/bin/nx.js",
|
||||
"nx-cloud": "dist/bin/nx-cloud.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc-node/register": "^1.11.1",
|
||||
@@ -31247,47 +31253,75 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.0"
|
||||
},
|
||||
"@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",
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"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.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
|
||||
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nx/node_modules/brace-expansion": {
|
||||
@@ -31303,6 +31337,60 @@
|
||||
"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",
|
||||
@@ -31313,30 +31401,27 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/diff-sequences": "30.4.0",
|
||||
"@jest/get-type": "30.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"pretty-format": "30.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nx/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -31345,20 +31430,45 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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"
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nx/node_modules/yargs": {
|
||||
@@ -32034,36 +32144,6 @@
|
||||
"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",
|
||||
@@ -33616,6 +33696,16 @@
|
||||
"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",
|
||||
@@ -37578,6 +37668,19 @@
|
||||
"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",
|
||||
|
||||
@@ -1,74 +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 { 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();
|
||||
}
|
||||
@@ -18,23 +18,11 @@
|
||||
*/
|
||||
|
||||
import { Page, Download, Locator } from '@playwright/test';
|
||||
import { Button, Input, Menu, Tabs } from '../components/core';
|
||||
import { Menu } 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.
|
||||
*/
|
||||
@@ -44,28 +32,9 @@ 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) {
|
||||
@@ -91,16 +60,12 @@ 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
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_HEADER)
|
||||
.waitFor({ state: 'visible', timeout });
|
||||
await this.page.waitForSelector(DashboardPage.SELECTORS.DASHBOARD_HEADER, {
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,80 +73,37 @@ export class DashboardPage {
|
||||
*/
|
||||
getChart(chartId: number): Locator {
|
||||
return this.page.locator(
|
||||
`${DashboardPage.SELECTORS.CHART_GRID_COMPONENT}[data-test-chart-id="${chartId}"]`,
|
||||
`[data-test="chart-grid-component"][data-test-chart-id="${chartId}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Wait for all charts on the dashboard to finish loading.
|
||||
* Waits until no loading indicators are visible on the page.
|
||||
*/
|
||||
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
|
||||
|
||||
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 });
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,13 +136,14 @@ export class DashboardPage {
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
async openHeaderActionsMenu(): Promise<void> {
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER)
|
||||
.click();
|
||||
await this.page.click(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER);
|
||||
// Wait for the dropdown menu to appear
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU)
|
||||
.waitFor({ state: 'visible' });
|
||||
await this.page.waitForSelector(
|
||||
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
|
||||
{
|
||||
state: 'visible',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,192 +178,4 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,8 @@ 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';
|
||||
@@ -64,10 +59,12 @@ testWithAssets(
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(chartResp);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
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,
|
||||
@@ -77,17 +74,39 @@ testWithAssets(
|
||||
},
|
||||
]);
|
||||
|
||||
const jsonMetadata = buildFilterJsonMetadata({
|
||||
chartsInScope: [chartId],
|
||||
nativeFilters: [
|
||||
buildSelectFilter({
|
||||
datasetId,
|
||||
column: FILTER_COLUMN,
|
||||
chartsInScope: [chartId],
|
||||
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: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
});
|
||||
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()}`,
|
||||
@@ -96,7 +115,8 @@ testWithAssets(
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
// Associate chart with the dashboard so it actually renders
|
||||
|
||||
@@ -62,8 +62,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +86,6 @@ 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()) {
|
||||
@@ -110,113 +106,6 @@ 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;
|
||||
|
||||
@@ -30,12 +30,7 @@ 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' });
|
||||
@@ -77,7 +72,8 @@ testWithAssets(
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(chartResp);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const positionJson = buildSingleRowDashboardLayout([
|
||||
@@ -90,21 +86,33 @@ 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 = buildFilterJsonMetadata({
|
||||
chartsInScope: [chartId],
|
||||
nativeFilters: [
|
||||
buildSelectFilter({
|
||||
datasetId,
|
||||
column: FILTER_COLUMN,
|
||||
chartsInScope: [chartId],
|
||||
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: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chartCustomizations: [
|
||||
chart_customization_config: [
|
||||
{
|
||||
id: customizationId,
|
||||
type: 'CHART_CUSTOMIZATION',
|
||||
@@ -119,7 +127,13 @@ 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()}`,
|
||||
@@ -128,7 +142,8 @@ testWithAssets(
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
@@ -140,22 +155,14 @@ testWithAssets(
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad({ timeout: 30000 });
|
||||
|
||||
/**
|
||||
* 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();
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
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.
|
||||
@@ -165,26 +172,40 @@ 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 settleCharts();
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
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 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 dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
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'),
|
||||
|
||||
@@ -1,200 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
);
|
||||
@@ -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 dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
@@ -86,9 +86,10 @@ testWithAssets(
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(chartResp);
|
||||
const chartId: number = (await chartResp.json()).id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const positionJson = buildSingleRowDashboardLayout([
|
||||
{
|
||||
id: chartId,
|
||||
@@ -97,20 +98,39 @@ testWithAssets(
|
||||
height: 60,
|
||||
},
|
||||
]);
|
||||
// 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],
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
defaultValue: FILTER_VALUE,
|
||||
}),
|
||||
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: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
});
|
||||
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,
|
||||
@@ -118,7 +138,8 @@ testWithAssets(
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
|
||||
@@ -128,15 +128,16 @@ 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/ 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.
|
||||
// 4. Instrument network: track any /api/v1/theme/ requests and 403 responses
|
||||
const themeApiRequests: string[] = [];
|
||||
const forbiddenResponses: string[] = [];
|
||||
page.on('response', response => {
|
||||
const url = response.url();
|
||||
if (url.includes('/api/v1/theme/')) {
|
||||
themeApiRequests.push(`${response.status()} ${url}`);
|
||||
themeApiRequests.push(url);
|
||||
}
|
||||
if (response.status() === 403 && url.includes('/api/v1/theme/')) {
|
||||
forbiddenResponses.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,19 +152,14 @@ 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).
|
||||
// The dashboard is created with no position_json, so its grid renders
|
||||
// empty — there is no chart to wait for, only the grid itself.
|
||||
// 7. Assert dashboard fully loads (not stuck on infinite spinner)
|
||||
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
await dashboardPage.waitForGridToLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
} finally {
|
||||
// Cleanup: delete test resources using admin context
|
||||
if (dashboardId) {
|
||||
|
||||
@@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({
|
||||
fallback,
|
||||
compact,
|
||||
closable = true,
|
||||
errorMitigationFunction,
|
||||
}: Props) {
|
||||
// Check if a custom error message component was registered for this message
|
||||
if (error) {
|
||||
@@ -77,6 +78,7 @@ export function ErrorMessageWithStackTrace({
|
||||
error={error}
|
||||
source={source}
|
||||
subtitle={subtitle}
|
||||
errorMitigationFunction={errorMitigationFunction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import * as reduxHooks from 'react-redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore, Store } from 'redux';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { act, 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,15 +166,55 @@ 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 () => {
|
||||
render(setup());
|
||||
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
|
||||
render(setup());
|
||||
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(() => {
|
||||
@@ -234,4 +274,22 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
@@ -58,15 +58,16 @@ 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
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function OAuth2RedirectMessage({
|
||||
error,
|
||||
source,
|
||||
closable,
|
||||
errorMitigationFunction,
|
||||
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
|
||||
const { extra, level } = error;
|
||||
|
||||
@@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const lastHandledTabIdRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthComplete = (tabId?: string) => {
|
||||
if (tabId !== extra.tab_id) {
|
||||
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (source === 'sqllab' && query) {
|
||||
|
||||
if (errorMitigationFunction) {
|
||||
errorMitigationFunction();
|
||||
} else if (source === 'sqllab' && query) {
|
||||
dispatch(reRunQuery(query));
|
||||
} else if (source === 'explore') {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
@@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({
|
||||
'Tables',
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHandledTabIdRef.current = tabId;
|
||||
};
|
||||
|
||||
const channel =
|
||||
@@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
channel?.close();
|
||||
};
|
||||
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
|
||||
}, [
|
||||
source,
|
||||
extra.tab_id,
|
||||
dispatch,
|
||||
query,
|
||||
chartId,
|
||||
chartList,
|
||||
dashboardId,
|
||||
errorMitigationFunction,
|
||||
]);
|
||||
|
||||
const body = (
|
||||
<p>
|
||||
|
||||
@@ -27,6 +27,7 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
|
||||
subtitle?: ReactNode;
|
||||
compact?: boolean;
|
||||
closable?: boolean;
|
||||
errorMitigationFunction?: () => void;
|
||||
};
|
||||
|
||||
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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);
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
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';
|
||||
@@ -33,6 +34,8 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
|
||||
}) => (
|
||||
<IconButton
|
||||
onClick={onDelete}
|
||||
label={t('Delete component')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -73,3 +73,17 @@ 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();
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -63,6 +64,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
hideVisibleLabel,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
@@ -75,6 +77,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
isDisabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
data-test={dataTest}
|
||||
@@ -91,7 +94,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label && <StyledSpan>{label}</StyledSpan>}
|
||||
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
|
||||
</StyledButton>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
|
||||
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
@@ -200,6 +200,15 @@ 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(
|
||||
|
||||
@@ -247,6 +247,8 @@ const Column = (props: ColumnProps) => {
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleChangeFocus(true)}
|
||||
label={t('Column settings')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.SettingOutlined iconSize="m" />}
|
||||
/>
|
||||
</HoverMenu>
|
||||
|
||||
@@ -145,7 +145,9 @@ describe('Header', () => {
|
||||
const deleteComponent = jest.fn();
|
||||
setup({ editMode: true, deleteComponent });
|
||||
|
||||
const trashButton = screen.getByRole('button', { name: 'delete' });
|
||||
const trashButton = screen.getByRole('button', {
|
||||
name: 'Delete component',
|
||||
});
|
||||
fireEvent.click(trashButton);
|
||||
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -240,6 +240,15 @@ 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(
|
||||
|
||||
@@ -293,6 +293,8 @@ const Row = memo((props: RowProps) => {
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
<IconButton
|
||||
onClick={() => handleChangeFocus(true)}
|
||||
label={t('Row settings')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.SettingOutlined iconSize="l" />}
|
||||
/>
|
||||
</HoverMenu>
|
||||
|
||||
@@ -35,6 +35,5 @@ export const Basic: StoryFn<typeof DatasetPanel> = args => (
|
||||
Basic.args = {
|
||||
tableName: 'example_table',
|
||||
loading: false,
|
||||
hasError: false,
|
||||
columnList: exampleColumns,
|
||||
};
|
||||
|
||||
@@ -77,7 +77,6 @@ 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`)}
|
||||
@@ -97,7 +96,6 @@ 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')}
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
* 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';
|
||||
@@ -31,8 +33,6 @@ 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 hasError={false} columnList={[]} loading={false} />, {
|
||||
render(<DatasetPanel columnList={[]} loading={false} />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
@@ -70,17 +70,9 @@ describe('DatasetPanel', () => {
|
||||
});
|
||||
|
||||
test('renders a no columns screen', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="Name"
|
||||
hasError={false}
|
||||
columnList={[]}
|
||||
loading={false}
|
||||
/>,
|
||||
{
|
||||
useRouter: true,
|
||||
},
|
||||
);
|
||||
render(<DatasetPanel tableName="Name" columnList={[]} loading={false} />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
|
||||
expect(blankDatasetImg).toBeVisible();
|
||||
@@ -91,17 +83,9 @@ describe('DatasetPanel', () => {
|
||||
});
|
||||
|
||||
test('renders a loading screen', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="Name"
|
||||
hasError={false}
|
||||
columnList={[]}
|
||||
loading
|
||||
/>,
|
||||
{
|
||||
useRouter: true,
|
||||
},
|
||||
);
|
||||
render(<DatasetPanel tableName="Name" columnList={[]} loading />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
const loadingIndicator = screen.getByTestId('loading-indicator');
|
||||
expect(loadingIndicator).toBeVisible();
|
||||
@@ -113,7 +97,12 @@ describe('DatasetPanel', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="Name"
|
||||
hasError
|
||||
error={{
|
||||
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
|
||||
extra: null,
|
||||
level: 'error',
|
||||
message: 'Structured backend failure',
|
||||
}}
|
||||
columnList={[]}
|
||||
loading={false}
|
||||
/>,
|
||||
@@ -124,8 +113,9 @@ describe('DatasetPanel', () => {
|
||||
|
||||
const errorTitle = screen.getByText(ERROR_TITLE);
|
||||
expect(errorTitle).toBeVisible();
|
||||
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
|
||||
const errorDescription = screen.getByText('Structured backend failure');
|
||||
expect(errorDescription).toBeVisible();
|
||||
expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' });
|
||||
});
|
||||
|
||||
test('renders a table with columns displayed', async () => {
|
||||
@@ -133,7 +123,6 @@ describe('DatasetPanel', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName={tableName}
|
||||
hasError={false}
|
||||
columnList={exampleColumns}
|
||||
loading={false}
|
||||
/>,
|
||||
@@ -159,7 +148,6 @@ describe('DatasetPanel', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="example_table"
|
||||
hasError={false}
|
||||
columnList={exampleColumns}
|
||||
loading={false}
|
||||
datasets={exampleDataset}
|
||||
|
||||
@@ -21,11 +21,13 @@ 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';
|
||||
@@ -146,6 +148,10 @@ 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};
|
||||
@@ -167,6 +173,7 @@ 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;
|
||||
@@ -201,9 +208,13 @@ export interface IDatasetPanelProps {
|
||||
*/
|
||||
columnList: ITableColumn[];
|
||||
/**
|
||||
* Boolean indicating if there is an error state
|
||||
* Error returned while loading the table metadata
|
||||
*/
|
||||
hasError: boolean;
|
||||
error?: SupersetError;
|
||||
/**
|
||||
* Function used to retry loading the table metadata after error mitigation
|
||||
*/
|
||||
errorMitigationFunction?: () => void;
|
||||
/**
|
||||
* Boolean indicating if the component is in a loading state
|
||||
*/
|
||||
@@ -256,11 +267,11 @@ const DatasetPanel = ({
|
||||
tableName,
|
||||
columnList,
|
||||
loading,
|
||||
hasError,
|
||||
error,
|
||||
errorMitigationFunction,
|
||||
datasets,
|
||||
}: IDatasetPanelProps) => {
|
||||
const hasColumns = Boolean(columnList?.length > 0);
|
||||
const datasetNames = datasets?.map(dataset => dataset.table_name);
|
||||
const hasColumns = columnList.length > 0;
|
||||
const tableWithDataset = datasets?.find(
|
||||
dataset => dataset.table_name === tableName,
|
||||
);
|
||||
@@ -278,7 +289,19 @@ const DatasetPanel = ({
|
||||
);
|
||||
}
|
||||
if (!loading) {
|
||||
if (!loading && tableName && hasColumns && !hasError) {
|
||||
if (error) {
|
||||
component = (
|
||||
<ErrorContainer>
|
||||
<ErrorMessageWithStackTrace
|
||||
error={error}
|
||||
errorMitigationFunction={errorMitigationFunction}
|
||||
source="crud"
|
||||
subtitle={error.message}
|
||||
title={ERROR_TITLE}
|
||||
/>
|
||||
</ErrorContainer>
|
||||
);
|
||||
} else if (tableName && hasColumns) {
|
||||
component = (
|
||||
<>
|
||||
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
|
||||
@@ -312,13 +335,7 @@ const DatasetPanel = ({
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
component = (
|
||||
<MessageContent
|
||||
hasColumns={hasColumns}
|
||||
hasError={hasError}
|
||||
tableName={tableName}
|
||||
/>
|
||||
);
|
||||
component = <MessageContent tableName={tableName} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,11 +343,12 @@ const DatasetPanel = ({
|
||||
<>
|
||||
{tableName && (
|
||||
<>
|
||||
{datasetNames?.includes(tableName) &&
|
||||
renderExistingDatasetAlert(tableWithDataset)}
|
||||
{tableWithDataset && renderExistingDatasetAlert(tableWithDataset)}
|
||||
<StyledHeader
|
||||
position={
|
||||
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
|
||||
!loading && (hasColumns || error)
|
||||
? EPosition.RELATIVE
|
||||
: EPosition.ABSOLUTE
|
||||
}
|
||||
title={tableName || ''}
|
||||
>
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
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 DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
|
||||
|
||||
jest.mock(
|
||||
@@ -29,17 +35,29 @@ 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({
|
||||
json: {
|
||||
name: 'my_table',
|
||||
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
|
||||
},
|
||||
} as any);
|
||||
const getSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(tableMetadataResponse('my_table', 'id'));
|
||||
|
||||
render(
|
||||
<DatasetPanelWrapper
|
||||
@@ -58,3 +76,99 @@ 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]);
|
||||
});
|
||||
|
||||
@@ -65,27 +65,17 @@ 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 { hasError, tableName, hasColumns } = props;
|
||||
let currentImage: string | undefined = 'empty-dataset.svg';
|
||||
const { tableName } = props;
|
||||
let currentImage = 'empty-dataset.svg';
|
||||
let currentTitle = SELECT_TABLE_TITLE;
|
||||
let currentDescription = renderEmptyDescription();
|
||||
if (hasError) {
|
||||
currentTitle = ERROR_TITLE;
|
||||
currentDescription = <>{ERROR_DESCRIPTION}</>;
|
||||
currentImage = undefined;
|
||||
} else if (tableName && !hasColumns) {
|
||||
if (tableName) {
|
||||
currentImage = 'no-columns.svg';
|
||||
currentTitle = NO_COLUMNS_TITLE;
|
||||
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import {
|
||||
ErrorTypeEnum,
|
||||
getClientErrorObject,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import type { SupersetError } 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';
|
||||
@@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
|
||||
/**
|
||||
* Interface for the getTableMetadata API call
|
||||
*/
|
||||
interface IColumnProps {
|
||||
interface TableMetadataRequest {
|
||||
/**
|
||||
* Unique id of the database
|
||||
*/
|
||||
@@ -43,6 +48,10 @@ interface IColumnProps {
|
||||
* 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 {
|
||||
@@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps {
|
||||
* The selected database object (used to check engine capabilities)
|
||||
*/
|
||||
database?: Partial<DatabaseObject> | null;
|
||||
setHasColumns?: Function;
|
||||
setHasColumns?: (hasColumns: boolean) => void;
|
||||
datasets?: DatasetObject[] | undefined;
|
||||
}
|
||||
|
||||
@@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({
|
||||
}: IDatasetPanelWrapperProps) => {
|
||||
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const tableNameRef = useRef(tableName);
|
||||
const [error, setError] = useState<SupersetError>();
|
||||
const requestIdRef = useRef(0);
|
||||
const currentRequestRef = useRef<TableMetadataRequest>();
|
||||
const supportsSchemas = database?.supports_schemas;
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
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) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const table = isIDatabaseTable(response?.json)
|
||||
? (response.json as IDatabaseTable)
|
||||
: undefined;
|
||||
if (table?.name === tableName) {
|
||||
setColumnList(table.columns);
|
||||
setHasColumns?.(table.columns.length > 0);
|
||||
setHasError(false);
|
||||
setError(undefined);
|
||||
} else {
|
||||
const message = 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);
|
||||
}
|
||||
} else {
|
||||
setColumnList([]);
|
||||
setHasColumns?.(false);
|
||||
setHasError(true);
|
||||
addDangerToast(
|
||||
t(
|
||||
'The API response from %s does not match the IDatabaseTable interface.',
|
||||
path,
|
||||
),
|
||||
);
|
||||
logging.error(
|
||||
t(
|
||||
'The API response from %s does not match the IDatabaseTable interface.',
|
||||
path,
|
||||
),
|
||||
} catch (caughtError) {
|
||||
const clientError = await getClientErrorObject(
|
||||
caughtError as Parameters<typeof getClientErrorObject>[0],
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setColumnList([]);
|
||||
setHasColumns?.(false);
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
},
|
||||
[setHasColumns],
|
||||
);
|
||||
|
||||
const retryGetTableMetadata = useCallback(() => {
|
||||
if (currentRequestRef.current) {
|
||||
getTableMetadata(currentRequestRef.current);
|
||||
}
|
||||
};
|
||||
}, [getTableMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
tableNameRef.current = tableName;
|
||||
const schemaRequired = database?.supports_schemas !== false;
|
||||
const schemaRequired = supportsSchemas !== false;
|
||||
if (tableName && dbId && (schema || !schemaRequired)) {
|
||||
getTableMetadata({ tableName, dbId, schema: schema || undefined });
|
||||
const request = {
|
||||
tableName,
|
||||
dbId,
|
||||
catalog,
|
||||
schema: schema || undefined,
|
||||
};
|
||||
currentRequestRef.current = request;
|
||||
getTableMetadata(request);
|
||||
} else if (currentRequestRef.current) {
|
||||
currentRequestRef.current = undefined;
|
||||
requestIdRef.current += 1;
|
||||
setColumnList([]);
|
||||
setError(undefined);
|
||||
setHasColumns?.(false);
|
||||
setLoading(false);
|
||||
}
|
||||
// getTableMetadata is a const and should not be in dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableName, dbId, schema, database]);
|
||||
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
}, [
|
||||
tableName,
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
supportsSchemas,
|
||||
getTableMetadata,
|
||||
setHasColumns,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DatasetPanel
|
||||
columnList={columnList}
|
||||
hasError={hasError}
|
||||
error={error}
|
||||
errorMitigationFunction={retryGetTableMetadata}
|
||||
loading={loading}
|
||||
tableName={tableName}
|
||||
datasets={datasets}
|
||||
|
||||
@@ -138,7 +138,6 @@ describe('DatasetLayout', () => {
|
||||
<DatasetPanelComponent
|
||||
tableName="large_table"
|
||||
columnList={manyColumns}
|
||||
hasError={false}
|
||||
loading={false}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from croniter import croniter
|
||||
from croniter import croniter, CroniterBadDateError
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import ValidationError
|
||||
@@ -29,6 +29,7 @@ from superset.commands.report.exceptions import (
|
||||
ChartNotSavedValidationError,
|
||||
DashboardNotFoundValidationError,
|
||||
DashboardNotSavedValidationError,
|
||||
ReportScheduleCrontabNotValidError,
|
||||
ReportScheduleEitherChartOrDashboardError,
|
||||
ReportScheduleForbiddenError,
|
||||
ReportScheduleFrequencyNotAllowed,
|
||||
@@ -288,13 +289,18 @@ class BaseReportScheduleCommand(BaseCommand):
|
||||
return
|
||||
|
||||
iterations = 60 if minimum_interval <= 3660 else 24
|
||||
schedule = croniter(cron_schedule)
|
||||
current_exec = next(schedule)
|
||||
try:
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
@@ -133,6 +133,23 @@ 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
|
||||
|
||||
@@ -125,23 +125,17 @@ 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)
|
||||
comment = table_comment.get("text")
|
||||
return comment[0]
|
||||
except IndexError:
|
||||
return comment
|
||||
return table_comment.get("text")
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.error("Unexpected error while fetching table comment", exc_info=True)
|
||||
logger.exception(ex)
|
||||
return comment
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_prequeries(
|
||||
|
||||
@@ -161,6 +161,15 @@ 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.
|
||||
# Swallow rollback failures so a session/connection too broken to roll
|
||||
# back doesn't replace the original exception with one the backoff
|
||||
# decorator won't retry on.
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.warning("Failed to roll back session in get_query", exc_info=True)
|
||||
raise SqlLabException("Failed at getting query") from ex
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,29 @@ 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:
|
||||
@@ -251,7 +274,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
|
||||
return screenshot_tiles[0]
|
||||
|
||||
|
||||
def take_tiled_screenshot(
|
||||
def take_tiled_screenshot( # noqa: C901
|
||||
page: "Page",
|
||||
element_name: str,
|
||||
tile_height: int,
|
||||
@@ -274,6 +297,12 @@ def take_tiled_screenshot(
|
||||
|
||||
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
|
||||
@@ -286,6 +315,15 @@ def take_tiled_screenshot(
|
||||
# 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}")
|
||||
@@ -320,9 +358,44 @@ def take_tiled_screenshot(
|
||||
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
|
||||
logger.info("Taking %s screenshot tiles", num_tiles)
|
||||
|
||||
screenshot_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"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -332,17 +405,31 @@ def take_tiled_screenshot(
|
||||
)
|
||||
# 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). Only check
|
||||
# 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
|
||||
# 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_wait_start = time.monotonic()
|
||||
tile_load_wait = min(load_wait, remaining_budget)
|
||||
try:
|
||||
page.wait_for_function(
|
||||
CHART_HOLDERS_READY_JS,
|
||||
timeout=load_wait * 1000,
|
||||
timeout=tile_load_wait * 1000,
|
||||
)
|
||||
except PlaywrightTimeout:
|
||||
elapsed = time.monotonic() - tile_wait_start
|
||||
@@ -354,14 +441,21 @@ def take_tiled_screenshot(
|
||||
# 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 (load_wait=%ss)%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 (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.",
|
||||
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,
|
||||
)
|
||||
@@ -377,12 +471,36 @@ def take_tiled_screenshot(
|
||||
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.
|
||||
# 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
|
||||
if animation_wait > 0:
|
||||
page.wait_for_timeout(animation_wait * 1000)
|
||||
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,
|
||||
)
|
||||
|
||||
# Calculate what portion of the element we want to capture for this tile
|
||||
tile_start_in_element = i * tile_height
|
||||
@@ -431,6 +549,12 @@ def take_tiled_screenshot(
|
||||
|
||||
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
|
||||
|
||||
@@ -85,6 +85,26 @@ 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,
|
||||
@@ -147,6 +167,13 @@ 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 (
|
||||
@@ -263,6 +290,14 @@ 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
|
||||
@@ -331,15 +366,28 @@ class BaseScreenshot:
|
||||
image = None
|
||||
|
||||
# Cache the result (success or error) to avoid immediate retries
|
||||
if image:
|
||||
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:
|
||||
with event_logger.log_context(
|
||||
f"screenshot.cache.{self.thumbnail_type}"
|
||||
):
|
||||
cache_payload.update(image)
|
||||
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()
|
||||
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()
|
||||
|
||||
logger.info("Caching thumbnail: %s", cache_key)
|
||||
self.cache.set(cache_key, cache_payload.to_dict())
|
||||
|
||||
@@ -47,6 +47,7 @@ from superset.utils.screenshot_utils import (
|
||||
CHART_HOLDERS_READY_JS,
|
||||
FIND_CHART_HOLDER_STATES_JS,
|
||||
resolve_screenshot_task_budget_seconds,
|
||||
ScreenshotTaskBudgetExceededError,
|
||||
take_tiled_screenshot,
|
||||
)
|
||||
|
||||
@@ -61,10 +62,6 @@ PLAYWRIGHT_INSTALL_MESSAGE = (
|
||||
)
|
||||
|
||||
|
||||
class ScreenshotTaskBudgetExceededError(RuntimeError):
|
||||
"""Raised when no safe task budget remains before screenshot capture."""
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
@@ -482,16 +479,31 @@ 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")
|
||||
for slice_container_elem in slice_container_locator.all():
|
||||
# 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:
|
||||
slice_container_elem.wait_for()
|
||||
rendered_chart_count += 1
|
||||
except PlaywrightTimeout:
|
||||
logger.exception(
|
||||
"Timed out waiting for chart containers to draw at url %s",
|
||||
# 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)",
|
||||
url,
|
||||
rendered_chart_count,
|
||||
len(slice_container_elems),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
selenium_animation_wait = app.config[
|
||||
@@ -529,19 +541,38 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height
|
||||
)
|
||||
|
||||
if dashboard_height == 0:
|
||||
logger.warning(
|
||||
# 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(
|
||||
"Could not determine dashboard height for element %s "
|
||||
"at url %s; falling back to standard screenshot behavior",
|
||||
"at url %s (%s chart containers found); %s",
|
||||
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 = (
|
||||
chart_count >= chart_threshold
|
||||
or dashboard_height > height_threshold
|
||||
) and dashboard_height > tile_height
|
||||
use_tiled = likely_large_dashboard and (
|
||||
height_unknown or dashboard_height > tile_height
|
||||
)
|
||||
|
||||
if use_tiled:
|
||||
logger.info(
|
||||
@@ -563,18 +594,23 @@ 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, "
|
||||
"falling back to standard screenshot"
|
||||
)
|
||||
"Tiled screenshot failed for url %s and no "
|
||||
"safe fallback exists; failing the capture",
|
||||
url,
|
||||
)
|
||||
img = WebDriverPlaywright._get_screenshot(
|
||||
page, element, element_name
|
||||
raise PlaywrightTimeout(
|
||||
f"Tiled screenshot failed for url {url}"
|
||||
)
|
||||
logger.debug(
|
||||
"Tiled screenshot result: %d bytes for url: %s",
|
||||
len(img) if img else 0,
|
||||
len(img),
|
||||
url,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -26,7 +26,10 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from superset.commands.report.base import BaseReportScheduleCommand
|
||||
from superset.commands.report.exceptions import ReportScheduleFrequencyNotAllowed
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleCrontabNotValidError,
|
||||
ReportScheduleFrequencyNotAllowed,
|
||||
)
|
||||
from superset.reports.models import ReportScheduleType
|
||||
|
||||
REPORT_TYPES = {
|
||||
@@ -174,6 +177,28 @@ 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(
|
||||
|
||||
@@ -39,13 +39,16 @@ 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"))
|
||||
@@ -69,6 +72,22 @@ 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.
|
||||
|
||||
@@ -35,7 +35,9 @@ from superset.sql.parse import SQLStatement, Table
|
||||
from superset.sql_lab import (
|
||||
execute_query,
|
||||
execute_sql_statements,
|
||||
get_query,
|
||||
get_sql_results,
|
||||
SqlLabException,
|
||||
)
|
||||
from superset.utils.rls import apply_rls, get_predicates_for_table
|
||||
from tests.conftest import with_config
|
||||
@@ -71,6 +73,58 @@ 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()
|
||||
|
||||
|
||||
def test_get_query_swallows_rollback_failure(
|
||||
mocker: MockerFixture, app: SupersetApp
|
||||
) -> None:
|
||||
"""
|
||||
If the session/connection is too broken for `rollback()` itself to succeed,
|
||||
that failure must not replace the original lookup error: `get_query` still
|
||||
needs to raise `SqlLabException` so the `backoff` decorator's retry contract
|
||||
(which only matches on `SqlLabException`) isn't bypassed.
|
||||
"""
|
||||
mocker.patch("backoff._sync.time.sleep")
|
||||
|
||||
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"
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.sql_lab.db.session.rollback",
|
||||
side_effect=Exception("connection already closed"),
|
||||
)
|
||||
|
||||
with pytest.raises(SqlLabException):
|
||||
get_query(query_id=1)
|
||||
|
||||
|
||||
@with_config(
|
||||
{
|
||||
"SQLLAB_PAYLOAD_MAX_MB": 50,
|
||||
|
||||
@@ -33,6 +33,10 @@ 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."""
|
||||
@@ -92,7 +96,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 = b"fake_screenshot_data"
|
||||
fake_bytes = FAKE_PNG_BYTES
|
||||
BaseScreenshot.cache = MockCache()
|
||||
BaseScreenshot.cache.set("key", fake_bytes)
|
||||
cache_payload = screenshot_obj.get_from_cache_key("key")
|
||||
@@ -108,10 +112,10 @@ class TestComputeAndCache:
|
||||
BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None
|
||||
)
|
||||
get_screenshot = mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
resize_image = mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
BaseScreenshot.cache = MockCache()
|
||||
return {
|
||||
|
||||
@@ -37,6 +37,10 @@ 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."""
|
||||
@@ -83,11 +87,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=b"image_data"
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
# Mock resize_image to avoid PIL errors with fake image data
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
BaseScreenshot.cache = MockCache()
|
||||
return get_screenshot
|
||||
@@ -161,13 +165,15 @@ class TestCacheOnlyOnSuccess:
|
||||
screenshot_obj: BaseScreenshot,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING."""
|
||||
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING,
|
||||
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"",
|
||||
)
|
||||
mock_logger = mocker.patch("superset.utils.screenshots.logger")
|
||||
BaseScreenshot.cache = MockCache()
|
||||
|
||||
screenshot_obj.compute_and_cache(user=mock_user, force=True)
|
||||
@@ -177,6 +183,43 @@ 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,
|
||||
@@ -197,14 +240,14 @@ class TestCacheOnlyOnSuccess:
|
||||
"Cache should be set to COMPUTING before screenshot starts"
|
||||
)
|
||||
assert cached_value["status"] == "Computing"
|
||||
return b"image_data"
|
||||
return FAKE_PNG_BYTES
|
||||
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot",
|
||||
side_effect=check_cache_during_screenshot,
|
||||
)
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
|
||||
screenshot_obj.compute_and_cache(user=mock_user, force=True)
|
||||
@@ -429,11 +472,11 @@ class TestIntegrationCacheBugFix:
|
||||
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
|
||||
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image"
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
# Mock resize to avoid PIL errors
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
|
||||
# Should trigger task because COMPUTING is stale
|
||||
@@ -482,3 +525,72 @@ 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
|
||||
|
||||
@@ -25,8 +25,11 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -453,12 +456,17 @@ 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 # load_wait
|
||||
assert warning_args[6] == "" # no log_context passed
|
||||
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
|
||||
# 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[7] == [{"chartId": "42", "state": "waiting_on_database"}]
|
||||
assert warning_args[12] == [{"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
|
||||
@@ -484,7 +492,7 @@ class TestTakeTiledScreenshot:
|
||||
)
|
||||
|
||||
warning_args = mock_logger.warning.call_args[0]
|
||||
assert warning_args[6] == " [execution_id=abc-123]"
|
||||
assert warning_args[11] == " [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).
|
||||
@@ -646,3 +654,311 @@ 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]"
|
||||
|
||||
@@ -919,21 +919,197 @@ class TestWebDriverPlaywrightErrorHandling:
|
||||
)
|
||||
|
||||
assert result == b"fake_screenshot"
|
||||
mock_logger.warning.assert_any_call(
|
||||
"Could not determine dashboard height for element %s at url %s; "
|
||||
"falling back to standard screenshot behavior",
|
||||
# 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,
|
||||
"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_tiled_screenshot_failure_falls_back_to_standard_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(
|
||||
self, mock_take_tiled, mock_logger, mock_browser_manager
|
||||
) -> None:
|
||||
"""When take_tiled_screenshot returns None, fall back to standard screenshot."""
|
||||
"""When take_tiled_screenshot returns None, fail loudly instead of
|
||||
falling back to an unguarded standard screenshot."""
|
||||
from superset.utils.webdriver import PlaywrightTimeout
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "test_user"
|
||||
|
||||
@@ -947,7 +1123,8 @@ 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
|
||||
# page.screenshot is used by _get_screenshot for the "standalone" element;
|
||||
# it must never be reached by the failure path under test.
|
||||
mock_page.screenshot.return_value = b"fallback_screenshot"
|
||||
|
||||
def evaluate_side_effect(script):
|
||||
@@ -983,14 +1160,20 @@ class TestWebDriverPlaywrightErrorHandling:
|
||||
mock_auth.return_value = mock_context
|
||||
|
||||
driver = WebDriverPlaywright("chrome")
|
||||
result = driver.get_screenshot(
|
||||
"http://example.com", "standalone", mock_user
|
||||
)
|
||||
# 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)
|
||||
|
||||
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, falling back to standard screenshot"),
|
||||
"Tiled screenshot failed for url %s and no safe fallback "
|
||||
"exists; failing the capture",
|
||||
"http://example.com",
|
||||
)
|
||||
|
||||
|
||||
@@ -1514,10 +1697,13 @@ class TestWebDriverPlaywrightAnimationWaitOrder:
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
||||
@patch("superset.utils.webdriver.app")
|
||||
def test_tiled_fallback_triggered_on_empty_bytes(
|
||||
def test_tiled_empty_bytes_raises_without_fallback(
|
||||
self, mock_app, mock_take_tiled, mock_browser_manager
|
||||
):
|
||||
"""Tiled fallback fires when take_tiled_screenshot returns b"" (not None)."""
|
||||
"""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
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "test_user"
|
||||
mock_app.config = {
|
||||
@@ -1532,20 +1718,24 @@ 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);
|
||||
# configure that return value so we can assert the fallback was reached
|
||||
# _get_screenshot("standalone") calls page.screenshot(full_page=True); it
|
||||
# must never be reached by the failure path under test.
|
||||
mock_page.screenshot.return_value = b"fallback"
|
||||
|
||||
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
|
||||
result = WebDriverPlaywright("chrome").get_screenshot(
|
||||
"http://example.com", "standalone", mock_user
|
||||
)
|
||||
# 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
|
||||
)
|
||||
|
||||
assert result == b"fallback"
|
||||
# Tiled path was taken (take_tiled_screenshot was called)
|
||||
mock_take_tiled.assert_called_once()
|
||||
# Standard screenshot was called as fallback (full_page=True for "standalone")
|
||||
mock_page.screenshot.assert_called_with(full_page=True)
|
||||
# Standard screenshot must never be called as a fallback
|
||||
mock_page.screenshot.assert_not_called()
|
||||
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
|
||||
Reference in New Issue
Block a user