Compare commits

...

13 Commits

Author SHA1 Message Date
Evan Rusackas
1a16b2acec fix(semantic-layers): show a friendly warning when schema enrichment falls back
Enrichment failures (connection refused, driver quirks, etc.) were
toasted to the user verbatim, which reads as raw internals and varies
by driver. Replace it with a stable, translatable message pointing at
the server logs, where the full exception is already recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:42:56 -07:00
dependabot[bot]
83ea9a1025 chore(deps): bump js-yaml from 5.2.1 to 5.2.2 in /docs (#42395)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 13:33:36 -07:00
dependabot[bot]
e9f61c1e66 chore(deps): bump postcss from 8.5.15 to 8.5.23 in /superset-frontend (#42396)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 13:33:22 -07:00
dependabot[bot]
857a194c66 chore(deps): bump postcss from 8.5.16 to 8.5.23 in /docs (#42397)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 13:33:09 -07:00
Joe Li
5ffdac1871 fix(mcp): align pooled screenshot override signature (#42384) 2026-07-24 13:32:31 -07:00
Joe Li
52552c8278 test(dashboard): migrate dashboard force-refresh control to Playwright (#41433)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:32:09 -07:00
Joe Li
134cae07ae fix(ui): update stale Ant Design v6 internal class selectors (#42146)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:31:45 -07:00
Evan Rusackas
f3fa1c7d4f fix(reports): write a single execution log row per report run (#29857) (#41966)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-24 13:30:31 -07:00
Evan Rusackas
99841fb504 fix(async): include datasets and rev claims in guest channel HMAC (#41487)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:29:58 -07:00
Evan Rusackas
3a53c4ebc3 chore(deps): raise SQLAlchemy lower bound to >=1.4.43 (#41123)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:29:30 -07:00
Joe Li
f7d0ec3a2c chore: add @rusackas to migration code owners (#42398) 2026-07-24 13:18:33 -07:00
Bhargava Vadlamani
7eceebce1c feat(dbspec): Support apache datafusion dbspec (#42374) 2026-07-24 11:56:30 -07:00
Amin Ghadersohi
3bdf134aaa fix(logging): stop noisy LocalProxy-not-mapped warning for guest users (#42306)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 13:06:20 -04:00
51 changed files with 1159 additions and 200 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1,6 +1,6 @@
# Notify all committers of DB migration changes, per SIP-59
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe @rusackas
# Notify some committers of changes in the components

View File

@@ -66,7 +66,7 @@
"caniuse-lite": "^1.0.30001806",
"docusaurus-plugin-openapi-docs": "^5.1.2",
"docusaurus-theme-openapi-docs": "^5.1.2",
"js-yaml": "^5.2.0",
"js-yaml": "^5.2.2",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",

View File

@@ -1,16 +1,16 @@
{
"generated": "2026-04-17T17:08:35.132Z",
"statistics": {
"totalDatabases": 80,
"withDocumentation": 80,
"withConnectionString": 80,
"withDrivers": 28,
"totalDatabases": 81,
"withDocumentation": 81,
"withConnectionString": 81,
"withDrivers": 29,
"withAuthMethods": 5,
"supportsJoins": 74,
"supportsSubqueries": 77,
"supportsJoins": 75,
"supportsSubqueries": 78,
"supportsDynamicSchema": 29,
"supportsCatalog": 20,
"averageScore": 54,
"averageScore": 53,
"maxScore": 201,
"byCategory": {
"Cloud - AWS": [
@@ -20,6 +20,7 @@
],
"Query Engines": [
"Amazon Athena",
"Apache DataFusion",
"Apache Drill",
"Apache Hive",
"Apache Impala",
@@ -94,6 +95,7 @@
"Vertica"
],
"Apache Projects": [
"Apache DataFusion",
"Apache Doris",
"Apache Drill",
"Apache Druid",
@@ -108,6 +110,7 @@
"Databricks Interactive Cluster"
],
"Open Source": [
"Apache DataFusion",
"Apache Doris",
"Apache Drill",
"Apache Druid",
@@ -9698,6 +9701,97 @@
"supports_dynamic_schema": true,
"supports_catalog": true,
"supports_dynamic_catalog": true
},
"Apache DataFusion": {
"time_grains": {
"SECOND": true,
"FIVE_SECONDS": true,
"THIRTY_SECONDS": true,
"MINUTE": true,
"FIVE_MINUTES": true,
"TEN_MINUTES": true,
"FIFTEEN_MINUTES": true,
"THIRTY_MINUTES": true,
"HALF_HOUR": false,
"HOUR": true,
"SIX_HOURS": false,
"DAY": true,
"WEEK": true,
"WEEK_STARTING_SUNDAY": false,
"WEEK_STARTING_MONDAY": false,
"WEEK_ENDING_SATURDAY": false,
"WEEK_ENDING_SUNDAY": false,
"MONTH": true,
"QUARTER": true,
"QUARTER_YEAR": false,
"YEAR": true
},
"module": "superset.db_engine_specs.datafusion",
"limit_method": 1,
"limit_clause": true,
"joins": true,
"subqueries": true,
"alias_in_select": true,
"alias_in_orderby": true,
"time_groupby_inline": false,
"alias_to_source_column": false,
"order_by_not_in_select": true,
"expressions_in_orderby": false,
"cte_in_subquery": true,
"max_column_name": null,
"sql_comments": true,
"escaped_colons": true,
"masked_encrypted_extra": false,
"column_type_mapping": false,
"function_names": false,
"user_impersonation": false,
"file_upload": false,
"get_extra_table_metadata": false,
"dbapi_exception_mapping": false,
"custom_errors": false,
"dynamic_schema": false,
"catalog": false,
"dynamic_catalog": false,
"ssh_tunneling": true,
"query_cancelation": false,
"get_metrics": false,
"where_latest_partition": false,
"expand_data": false,
"query_cost_estimation": false,
"sql_validation": false,
"score": 24,
"max_score": 201,
"documentation": {
"description": "DataFusion is a highly performant query engine",
"logo": "datafusion.png",
"homepage_url": "https://datafusion.apache.org/",
"categories": [
"Query Engines",
"Open Source",
"Apache Projects"
],
"pypi_packages": [
"flightsql-dbapi"
],
"connection_string": "datafusion://host:port",
"drivers": [
{
"name": "Arrow Flight SQL (Recommended)",
"pypi_package": "flightsql-dbapi",
"connection_string": "datafusion://host:port",
"is_recommended": true
}
],
"category": "Apache Projects"
},
"engine": "datafusion",
"engine_name": "Apache DataFusion",
"engine_aliases": [],
"default_driver": "flightsql",
"supports_file_upload": false,
"supports_dynamic_schema": false,
"supports_catalog": false,
"supports_dynamic_catalog": false
}
}
}

BIN
docs/static/img/databases/datafusion.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -10155,10 +10155,10 @@ js-yaml@=4.2.0:
dependencies:
argparse "^2.0.1"
js-yaml@^5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.1.tgz#bb3f2e87295ebfe6ef0a75e17c62834daeff9ce2"
integrity sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==
js-yaml@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.2.tgz#497bfe63f0b0db11c7bbc5ce8bc568e836c8b08c"
integrity sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==
dependencies:
argparse "^2.0.1"
@@ -11681,10 +11681,10 @@ mz@^2.7.0:
object-assign "^4.0.1"
thenify-all "^1.0.0"
nanoid@^3.3.12:
version "3.3.15"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316"
integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==
nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
natural-compare@^1.4.0:
version "1.4.0"
@@ -12974,11 +12974,11 @@ postcss-zindex@^6.0.2:
integrity sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==
postcss@^8.4.21, postcss@^8.4.24, postcss@^8.4.33, postcss@^8.5.4:
version "8.5.16"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.16.tgz#1230ce0b5df354c24c0ea45f99ce5f6a88279d28"
integrity sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==
version "8.5.23"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594"
integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==
dependencies:
nanoid "^3.3.12"
nanoid "^3.3.16"
picocolors "^1.1.1"
source-map-js "^1.2.1"

View File

@@ -106,7 +106,7 @@ dependencies = [
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"slack_sdk>=3.43.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.12.0, <31",
@@ -142,6 +142,7 @@ databricks = [
"databricks-sql-connector>=4.2.6, <4.4.0",
"databricks-sqlalchemy==1.0.5",
]
datafusion = ["flightsql-dbapi>=0.2.0, <0.3"]
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]

View File

@@ -97,7 +97,7 @@ export const databasesPage = {
infoAlert: '.ant-alert',
serviceAccountInput: '[name="credentials_info"]',
connectionStep: {
modal: '.ant-modal-content',
modal: '.ant-modal-container',
modalBody: '.ant-modal-body',
stepTitle: '.css-7x6kk > h4',
helperBottom: '.helper-bottom',
@@ -261,7 +261,7 @@ export const datasetsList = {
},
},
deleteDatasetModal: {
modal: '.ant-modal-content',
modal: '.ant-modal-container',
deleteInput: dataTestLocator('delete-modal-input'),
deleteButton: dataTestLocator('modal-confirm-button'),
text: '.css-kxmt87',
@@ -474,7 +474,7 @@ export const exploreView = {
},
chartAreaItem: '.nv-legend-text',
viewQueryModal: {
container: '.ant-modal-content',
container: '.ant-modal-container',
closeButton: 'button.ant-modal-close',
},
embedCodeModal: {
@@ -482,7 +482,7 @@ export const exploreView = {
textfield: dataTestLocator('embed-code-textarea'),
},
saveModal: {
modal: '.ant-modal-content',
modal: '.ant-modal-container',
chartNameInput: dataTestLocator('new-chart-name'),
dashboardNameInput: '.ant-select-input',
addToDashboardInput: dataTestLocator(
@@ -578,7 +578,7 @@ export const exploreView = {
},
},
editDatasetModal: {
container: '.ant-modal-content',
container: '.ant-modal-container',
datasetTabsContainer: dataTestLocator('edit-dataset-tabs'),
saveButton: dataTestLocator('datasource-modal-save'),
metricsTab: {
@@ -617,12 +617,12 @@ export const dashboardView = {
closeButton: dataTestLocator('close-button'),
},
saveModal: {
modal: '.ant-modal-content',
modal: '.ant-modal-container',
dashboardNameInput: '.ant-input',
saveButton: dataTestLocator('modal-save-dashboard-button'),
},
dashboardProperties: {
modal: '.ant-modal-content',
modal: '.ant-modal-container',
dashboardTitleInput: dataTestLocator('dashboard-title-input'),
modalButton: '[type="button"]',
},

View File

@@ -33382,9 +33382,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
@@ -33402,7 +33402,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -33552,9 +33552,9 @@
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -44648,7 +44648,7 @@
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mapbox": "^9.3.7",
"@deck.gl/mapbox": "~9.3.7",
"@deck.gl/mesh-layers": "~9.2.5",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",

View File

@@ -84,7 +84,7 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
`
}
.ant-modal-content {
.ant-modal-container {
background-color: ${theme.colorBgContainer};
display: flex;
flex-direction: column;
@@ -196,7 +196,7 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
height: 100%;
}
.ant-modal-content {
.ant-modal-container {
height: 100%;
.ant-modal-body {

View File

@@ -60,18 +60,26 @@ test('should render with success icon', () => {
expect(screen.getByLabelText('check-circle')).toBeInTheDocument();
});
// The stripes are painted on antd's inner progress track, so assert the
// computed style of that element — asserting the outer container would pass
// whether or not the gradient ever reached the track.
const getTrack = (container: HTMLElement) =>
container.querySelector('.ant-progress-track') as HTMLElement;
test('should render with stripes', () => {
const stripedProps = {
...mockedProps,
striped: true,
};
const { container } = render(<ProgressBar {...stripedProps} />);
expect(container).toHaveStyle(
`background-image: 'linear-gradient(
45deg,rgba(255, 255, 255, 0.15) 25%,
transparent 25%, transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%, transparent) !important'`,
expect(getComputedStyle(getTrack(container)).backgroundImage).toContain(
'linear-gradient(45deg',
);
});
test('should render without stripes by default', () => {
const { container } = render(<ProgressBar {...mockedProps} />);
expect(getComputedStyle(getTrack(container)).backgroundImage).not.toContain(
'linear-gradient',
);
});

View File

@@ -29,10 +29,10 @@ const ProgressBar = styled(({ striped, ...props }: ProgressBarProps) => (
<AntdProgress data-test="progress-bar" {...props} />
))`
position: static;
.ant-progress-inner {
.ant-progress-rail {
position: static;
}
.ant-progress-bg {
.ant-progress-track {
position: static;
${({ striped }) =>
striped &&

View File

@@ -857,7 +857,7 @@ const AsyncSelect = forwardRef(
getPopupContainer={
getPopupContainer ||
((triggerNode: HTMLElement) =>
(triggerNode?.closest('.ant-modal-content') as HTMLElement) ||
(triggerNode?.closest('.ant-modal-container') as HTMLElement) ||
(triggerNode.parentNode as HTMLElement))
}
headerPosition={headerPosition}

View File

@@ -912,7 +912,7 @@ const Select = forwardRef(
getPopupContainer={
getPopupContainer ||
((triggerNode: HTMLElement) =>
(triggerNode?.closest('.ant-modal-content') as HTMLElement) ||
(triggerNode?.closest('.ant-modal-container') as HTMLElement) ||
(triggerNode.parentNode as HTMLElement))
}
headerPosition={headerPosition}

View File

@@ -61,7 +61,7 @@ const StyledTable = styled(AntTable)(
text-overflow: ellipsis;
}
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
.ant-spin .ant-spin-dot {
width: ${theme.sizeUnit * 12}px;
height: unset;
}

View File

@@ -198,7 +198,7 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
}
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
.ant-spin .ant-spin-dot {
width: ${theme.sizeXXL}px;
height: unset;
}

View File

@@ -31,10 +31,22 @@
* the exact class names our CSS depends on, so a future antd bump that renames
* one fails here with a clear pointer instead of shipping a visual regression.
*/
import { render } from '@superset-ui/core/spec';
// eslint-disable-next-line no-restricted-imports
import { Collapse, Modal, Popover, Steps, Tabs, Tag, Tooltip } from 'antd';
import { Select } from './Select';
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import {
// eslint-disable-next-line no-restricted-imports
Alert,
Collapse,
Modal,
Popover,
Progress,
Spin,
Steps,
Tabs,
Tag,
Tooltip,
} from 'antd';
import { Modal as SupersetModal } from './Modal';
import { AsyncSelect, Select } from './Select';
const antClasses = (root: ParentNode): string[] => {
const classes = new Set<string>();
@@ -176,9 +188,17 @@ test('Steps classes (QueryStatusBar, ChartCreation, SQL Lab loading detection)',
'ant-steps-item-icon',
'ant-steps-item-title',
'ant-steps-item-rail',
'ant-steps-item-content',
]),
);
expect(classes).not.toContain('ant-steps-item-tail');
// antd 6 removed the nested `.ant-steps-item-description`; the description text
// now renders directly inside `.ant-steps-item-content`. ChartCreation styles
// the description by targeting `.ant-steps-item-content` for exactly this reason.
expect(classes).not.toContain('ant-steps-item-description');
expect(container.querySelector('.ant-steps-item-content')).toHaveTextContent(
'd',
);
});
test('Select suffix (arrow) class (plugin-chart-table page-size Select targets it)', () => {
@@ -216,13 +236,126 @@ test('Collapse panel/body classes (Collapse.tsx, VizTypeGallery, config modals)'
expect(classes).not.toContain('ant-collapse-content-box');
});
test('Modal body class (many *.styles.ts modal overrides target it)', () => {
test('Modal container + body classes (many *.styles.ts modal overrides target them)', () => {
render(
<Modal open title="t">
body
</Modal>,
);
expect(antClasses(document.body)).toContain('ant-modal-body');
const classes = antClasses(document.body);
// antd 6 renamed the modal content wrapper `.ant-modal-content` ->
// `.ant-modal-container`. Several `.styles.ts` overrides target it, and Select /
// popup `getPopupContainer`/`.closest()` lookups anchor popups to it so their
// menus stay clipped inside the modal. `-body`/`-close` are unchanged but the
// report-screenshot error-modal flow in `webdriver.py` walks all three, so pin
// the whole chain it depends on.
expect(classes).toEqual(
expect.arrayContaining([
'ant-modal-container',
'ant-modal-body',
'ant-modal-close',
]),
);
expect(classes).not.toContain('ant-modal-content');
});
test.each([
[
'Select',
<Select
ariaLabel="in-modal"
options={[{ label: 'Alpha', value: 'a' }]}
key="sync"
/>,
],
[
'AsyncSelect',
<AsyncSelect
ariaLabel="in-modal"
options={async () => ({
data: [{ label: 'Alpha', value: 'a' }],
totalCount: 1,
})}
key="async"
/>,
],
])(
'%s popup mounts inside the enclosing modal container',
async (_name, select) => {
// Select/AsyncSelect anchor their popup with
// `triggerNode.closest('.ant-modal-container')` so the menu is clipped by
// the modal instead of escaping to <body> and scrolling away from its
// trigger. The class contract above only proves the class exists — this
// asserts the containment behaviour it exists for.
render(
<SupersetModal show title="t" onHide={() => {}}>
{select}
</SupersetModal>,
);
await userEvent.click(screen.getByRole('combobox'));
await screen.findByTitle('Alpha');
await waitFor(() => {
const popup = document.querySelector('.ant-select-dropdown');
expect(popup).not.toBeNull();
// Assert the *direct* parent, not just an ancestor: when the `.closest()`
// lookup misses, the `triggerNode.parentNode` fallback still sits inside
// the modal, so an `ancestor` check passes either way and proves nothing.
expect(popup?.parentElement).toHaveClass('ant-modal-container');
});
},
);
test('Progress rail/track classes (ProgressBar + DatabaseModal striped overrides target them)', () => {
const { container } = render(<Progress percent={50} />);
const classes = antClasses(container);
// antd 6 renamed `.ant-progress-inner` -> `.ant-progress-rail` and
// `.ant-progress-bg` -> `.ant-progress-track`. ProgressBar's styled wrapper
// paints the striped gradient on the track; DatabaseModal sizes the rail.
expect(classes).toEqual(
expect.arrayContaining(['ant-progress-rail', 'ant-progress-track']),
);
expect(classes).not.toContain('ant-progress-inner');
expect(classes).not.toContain('ant-progress-bg');
});
test('Alert title/actions classes (ImportModal, DatabaseModal, SqlEditor, native-filter overrides target them)', () => {
const { container } = render(
<Alert
type="info"
title="msg"
description="desc"
action={<span>x</span>}
/>,
);
const classes = antClasses(container);
// antd 6 renamed `.ant-alert-message` -> `.ant-alert-title` and pluralised the
// action slot `.ant-alert-action` -> `.ant-alert-actions`. Several `.styles.ts`
// overrides and the SQL Lab / native-filter modal footers target these.
expect(classes).toEqual(
expect.arrayContaining(['ant-alert-title', 'ant-alert-actions']),
);
expect(classes).not.toContain('ant-alert-message');
expect(classes).not.toContain('ant-alert-action');
});
test('Spin nested structure (Table/VirtualTable spinner sizing overrides target it)', () => {
const { container } = render(
<Spin spinning>
<div>content</div>
</Spin>,
);
const classes = antClasses(container);
// antd 6 dropped the `.ant-spin-nested-loading` wrapper: `.ant-spin` is now the
// outer element and `.ant-spin-dot` lives beneath it. Table/VirtualTable size the
// dot via `.ant-spin .ant-spin-dot`, so that descendant chain must hold.
expect(classes).toEqual(
expect.arrayContaining(['ant-spin', 'ant-spin-container', 'ant-spin-dot']),
);
expect(classes).not.toContain('ant-spin-nested-loading');
expect(
container.querySelector('.ant-spin-dot')?.closest('.ant-spin'),
).not.toBeNull();
});
test('Tag keeps its v5 trailing margin (GlobalStyles parity rule)', () => {

View File

@@ -63,6 +63,26 @@ export class Menu {
}
}
/**
* Selects a top-level menu item by its accessible name.
*
* Ant Design menu items render as `div[role="menuitem"]` labelled by their
* text, so the accessible name is the stable handle — most items carry no
* `data-test`. Use this for flat items; use {@link selectSubmenuItem} for
* items nested under a submenu.
*
* @param itemText - The accessible name of the item (e.g., "Refresh dashboard")
* @param options - Optional timeout settings
*/
async selectItem(
itemText: string,
options?: { timeout?: number },
): Promise<void> {
await this.locator
.getByRole('menuitem', { name: itemText, exact: true })
.click({ timeout: options?.timeout ?? TIMEOUT.FORM_LOAD });
}
/**
* Opens a submenu and selects an item within it.
* Uses hover as primary approach, falls back to keyboard then dispatchEvent.

View File

@@ -506,7 +506,7 @@ test('save flow and first dashboard screenshots', async ({ page }) => {
await expect(saveButton).toBeVisible({ timeout: 10000 });
await saveButton.click();
const modal = page.locator('.ant-modal-content').filter({
const modal = page.locator('.ant-modal-container').filter({
has: page.locator('[data-test="save-modal-body"]'),
});
await expect(modal).toBeVisible({ timeout: 10000 });

View File

@@ -45,7 +45,7 @@ images:
alt: ""
source_file: docs/using-superset/creating-your-first-dashboard.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
image_url: "https://superset.apache.org/img/tutorial/tutorial_03a_database_connection_string_link.png"
@@ -53,7 +53,7 @@ images:
alt: ""
source_file: docs/using-superset/creating-your-first-dashboard.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
image_url: "https://superset.apache.org/img/tutorial/tutorial_03b_connection_string_details.png"
@@ -61,7 +61,7 @@ images:
alt: ""
source_file: docs/using-superset/creating-your-first-dashboard.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
image_url: "https://superset.apache.org/img/tutorial/tutorial_09_add_new_table.png"
@@ -69,7 +69,7 @@ images:
alt: ""
source_file: docs/using-superset/creating-your-first-dashboard.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
image_url: "https://superset.apache.org/img/tutorial/tutorial_calculated_column.png"
@@ -93,7 +93,7 @@ images:
alt: ""
source_file: docs/using-superset/creating-your-first-dashboard.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
image_url: "https://superset.apache.org/img/tutorial/tutorial_explore_run.jpg"
@@ -141,7 +141,7 @@ images:
alt: ""
source_file: docs/using-superset/exploring-data.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/exploring-data"
image_url: "https://superset.apache.org/img/tutorial/annotation.png"
@@ -197,7 +197,7 @@ images:
alt: ""
source_file: docs/using-superset/exploring-data.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/exploring-data"
image_url: "https://superset.apache.org/img/tutorial/markdown.png"
@@ -261,7 +261,7 @@ images:
alt: ""
source_file: docs/using-superset/exploring-data.mdx
app_path: null
selector: .ant-modal-content
selector: .ant-modal-container
- type: tutorial
page_url: "https://superset.apache.org/user-docs/using-superset/exploring-data"
image_url: "https://superset.apache.org/img/tutorial/select_dates_pivot_table.png"

View File

@@ -167,6 +167,23 @@ export class DashboardPage {
);
}
/**
* The dashboard header actions dropdown menu. Call after
* {@link openHeaderActionsMenu}, which is what makes the menu visible.
*/
private headerActionsMenu(): Menu {
return new Menu(this.page, DashboardPage.SELECTORS.HEADER_ACTIONS_MENU);
}
/**
* Trigger a dashboard-level force refresh via the header actions menu.
* Re-runs every chart's query with `force=true`, bypassing the cache.
*/
async forceRefresh(): Promise<void> {
await this.openHeaderActionsMenu();
await this.headerActionsMenu().selectItem('Refresh dashboard');
}
/**
* Selects an option from the Download submenu.
* Opens the header actions menu, navigates to Download submenu,
@@ -177,10 +194,7 @@ export class DashboardPage {
async selectDownloadOption(optionText: string): Promise<Download> {
await this.openHeaderActionsMenu();
const menu = new Menu(
this.page,
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
);
const menu = this.headerActionsMenu();
const downloadPromise = this.page.waitForEvent('download');
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;

View File

@@ -0,0 +1,152 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* E2E migration of the Cypress "Dashboard top-level controls" suite
* (dashboard/controls.test.ts).
*
* The genuine end-to-end behaviour is the dashboard-level force refresh: every
* chart on the dashboard must re-query the backend with `force=true`. This can
* only be verified against a real backend, so it is migrated here.
*
* Scope note on cache bypass: the original Cypress test ran against the
* pre-seeded World Health dashboard and asserted `is_cached === false` on the
* refreshed responses, proving force-refresh busted a populated cache. This
* migration asserts the observable network contract instead — every chart
* re-queries with `force=true` and each response is accepted (200) — and does
* not assert `is_cached`. Tying the assertion to cache state would mean
* reasoning about whatever the initial dashboard load left in the DATA_CACHE and
* racing its 30s TTL, reintroducing the flakiness this migration exists to
* remove. `force=true` is itself the server-side instruction to bypass the
* cache, so asserting it fires on every distinct chart — and each request is
* accepted (200) — is a direct, deterministic check of the force-refresh
* behaviour.
*
* The other case ("should allow chart level refresh") only asserted the menu
* item's `ant-dropdown-menu-item-disabled` class — a DOM/state assertion with no
* backend round-trip, and one the original itself flagged as flaky. It belongs
* in component/RTL coverage and is intentionally not migrated.
*
* CI green => force refresh re-queries every distinct chart with force=true and
* each request returns 200.
* CI red => force refresh did not re-query every chart, or a request failed.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import {
createDashboardWithCharts,
sliceIdFromChartDataUrl,
} from './dashboard-test-helpers';
testWithAssets(
'dashboard-level force refresh re-queries every chart with force=true',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const { dashboardId, charts } = await createDashboardWithCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
chartNamePrefix: 'controls',
dashboardTitlePrefix: 'controls_force_refresh',
chartSpecs: [
{ viz_type: 'big_number_total', params: { metric: 'count' } },
{
viz_type: 'table',
params: {
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
row_limit: 100,
},
},
],
},
);
const chartIds = charts.map(chart => chart.id);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.waitForChartsToLoad();
// Capture the force-refresh chart-data round-trips, keyed by the chart each
// one queried for. A `page.on('response')` listener rather than the
// `waitForPost` helper: those resolve on a single response, and this needs
// to collect every chart's request and correlate them by slice id. Set up
// after the initial load so only the forced requests are recorded. Statuses
// accumulate per chart rather than being overwritten, so a failure followed
// by a successful retry still surfaces, provided both responses land before
// the assertions read the map. That is a defence-in-depth choice more than a
// guarantee: the client retries only 502/503/504, so an application-level
// failure is never retried away to begin with.
const forcedStatusesBySliceId = new Map<number, number[]>();
page.on('response', response => {
const request = response.request();
if (request.method() !== 'POST') {
return;
}
const url = new URL(response.url());
// Match `force` exactly as a query param — a substring check would also
// accept `enforce=true` or `force=trueish`.
if (
!url.pathname.includes('/api/v1/chart/data') ||
url.searchParams.get('force') !== 'true'
) {
return;
}
const sliceId = sliceIdFromChartDataUrl(response.url());
if (sliceId !== undefined) {
const statuses = forcedStatusesBySliceId.get(sliceId) ?? [];
statuses.push(response.status());
forcedStatusesBySliceId.set(sliceId, statuses);
}
});
await dashboard.forceRefresh();
// Every chart — identified by slice_id, not merely by request count — must
// fire its own forced re-query, and no foreign chart may: polling the
// distinct set (rather than the raw request count) rejects a regression that
// refreshes one chart twice while skipping another. On timeout the diff
// names the slice ids that never arrived.
const byId = (a: number, b: number) => a - b;
await expect
.poll(() => [...forcedStatusesBySliceId.keys()].sort(byId), {
message:
'every dashboard chart should issue its own forced /api/v1/chart/data request',
timeout: TIMEOUT.API_RESPONSE,
})
.toEqual([...chartIds].sort(byId));
// Every forced request recorded for a chart was accepted — all attempts, not
// just the last. The poll above already established that the recorded slice
// ids are exactly the dashboard's charts, so iterating the map needs no
// presence or emptiness guard.
for (const [sliceId, statuses] of forcedStatusesBySliceId) {
expect(
statuses.every(status => status === 200),
`chart ${sliceId}'s forced /api/v1/chart/data responses should all be 200, got [${statuses}]`,
).toBe(true);
}
},
);

View File

@@ -35,18 +35,14 @@
*/
import type { Locator } from '@playwright/test';
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
import {
apiPostDashboard,
buildSingleRowDashboardLayout,
type DashboardLayoutChart,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { type DashboardLayoutChart } from '../../helpers/api/dashboard';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import {
createDashboardWithCharts,
sliceIdFromChartDataUrl,
} from './dashboard-test-helpers';
const DATASET_NAME = 'birth_names';
const ECHARTS_SERIES_COLOR: [number, number, number] = [31, 168, 201];
type ChartOutput = 'big-number' | 'table' | 'echarts';
@@ -85,22 +81,22 @@ async function expectChartOutput(
const value = chart.locator(
'.superset-legacy-chart-big-number .header-line',
);
await expect(value).toBeVisible();
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
await expect(value).toHaveText(/\d/);
return;
}
if (output === 'table') {
await expect(
chart.locator('table tbody tr:not(:has(.dt-no-results))').first(),
).toBeVisible();
).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
return;
}
const canvas = chart.locator('canvas').first();
await expect(canvas).toBeVisible();
await expect(canvas).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
await expect
.poll(() => canvasColorPixelCount(canvas, ECHARTS_SERIES_COLOR), {
timeout: TIMEOUT.API_RESPONSE * 2,
timeout: TIMEOUT.CHART_RENDER,
message: 'ECharts canvas should paint the configured data series',
})
.toBeGreaterThan(20);
@@ -109,17 +105,14 @@ async function expectChartOutput(
testWithAssets(
'dashboard loads and every chart renders via real queries',
async ({ page, testAssets }) => {
// Building + loading a multi-chart dashboard chains several slow queries.
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Building + loading a multi-chart dashboard chains several slow queries:
// API setup per chart, then an uncached render for each. A loaded runner has
// been seen spending ~45s on that chain, which leaves the standard slow
// budget no room to actually spend TIMEOUT.CHART_RENDER.
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST * 2);
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const datasource = `${datasetId}__table`;
// A spread of viz types that all render cleanly from the birth_names dataset.
// A spread of viz types that all render cleanly from the birth_names dataset,
// each paired with the terminal output it should paint.
const chartSpecs: {
viz_type: string;
output: ChartOutput;
@@ -128,14 +121,12 @@ testWithAssets(
{
viz_type: 'big_number_total',
output: 'big-number',
params: { datasource, viz_type: 'big_number_total', metric: 'count' },
params: { metric: 'count' },
},
{
viz_type: 'table',
output: 'table',
params: {
datasource,
viz_type: 'table',
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
@@ -146,8 +137,6 @@ testWithAssets(
viz_type: 'echarts_timeseries_line',
output: 'echarts',
params: {
datasource,
viz_type: 'echarts_timeseries_line',
x_axis: 'ds',
xAxisForceCategorical: true,
time_grain_sqla: 'P1Y',
@@ -160,49 +149,23 @@ testWithAssets(
},
];
// Parallel-safe suffix so chart/dashboard names never collide across workers.
const uniqueSuffix = `${Date.now()}_${testWithAssets.info().parallelIndex}`;
// Create each chart via the API.
const charts: CreatedChart[] = [];
for (const spec of chartSpecs) {
const sliceName = `load_smoke_${spec.viz_type}_${uniqueSuffix}`;
const resp = await apiPostChart(page, {
slice_name: sliceName,
viz_type: spec.viz_type,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(spec.params),
const { dashboardId, charts: createdCharts } =
await createDashboardWithCharts(page, testAssets, testWithAssets.info(), {
datasetName: 'birth_names',
chartNamePrefix: 'load_smoke',
dashboardTitlePrefix: 'load_smoke',
chartSpecs,
});
expect(resp.ok()).toBe(true);
const chartId = await extractIdFromResponse(resp);
testAssets.trackChart(chartId);
charts.push({ id: chartId, sliceName, output: spec.output });
}
// Pair each created chart back to the output it should paint (same order).
const charts: CreatedChart[] = createdCharts.map((chart, index) => ({
...chart,
output: chartSpecs[index].output,
}));
const chartIds = charts.map(chart => chart.id);
// Lay all charts out in a single row.
const positionJson = buildSingleRowDashboardLayout(charts);
const dashResp = await apiPostDashboard(page, {
dashboard_title: `load_smoke_${uniqueSuffix}`,
published: true,
position_json: JSON.stringify(positionJson),
});
expect(dashResp.ok()).toBe(true);
const dashboardId = await extractIdFromResponse(dashResp);
testAssets.trackDashboard(dashboardId);
// Associate every chart with the dashboard so they actually render.
for (const chartId of chartIds) {
await apiPutChart(page, chartId, { dashboards: [dashboardId] });
}
// Record the real chart-data round-trips the dashboard makes on load,
// keyed by the chart each one queried for. The chart-data POST carries its
// slice id in the encoded `form_data={"slice_id":<id>}` query param (see
// chartAction.ts), so parsing it lets us prove every chart queried — not
// just that some chart did.
// keyed by the chart each one queried for, so we can prove every chart
// queried — not just that some chart did.
const chartDataStatusBySliceId = new Map<number, number>();
page.on('response', response => {
const request = response.request();
@@ -212,17 +175,9 @@ testWithAssets(
) {
return;
}
const formData = new URL(response.url()).searchParams.get('form_data');
if (!formData) {
return;
}
try {
const sliceId = JSON.parse(formData).slice_id;
if (typeof sliceId === 'number') {
chartDataStatusBySliceId.set(sliceId, response.status());
}
} catch {
// Not a slice-id form_data payload; ignore.
const sliceId = sliceIdFromChartDataUrl(response.url());
if (sliceId !== undefined) {
chartDataStatusBySliceId.set(sliceId, response.status());
}
});

View File

@@ -18,8 +18,41 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { expect, type TestAssets } from '../../helpers/fixtures';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
import {
apiPostDashboard,
buildSingleRowDashboardLayout,
type DashboardLayoutChart,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
/**
* Extracts the chart id that a `/api/v1/chart/data` request was issued for.
*
* The chart-data POST carries its slice id in the encoded
* `form_data={"slice_id":<id>}` query param (see `chartAction.ts`). Parsing it
* lets a test tie each request back to a specific chart and assert that every
* chart queried — rather than only counting requests, which cannot distinguish
* "all charts queried once" from "one chart queried twice, another skipped".
*
* @param url - The chart-data request or response URL
* @returns The slice id, or undefined if the URL carries no parsable one
*/
export function sliceIdFromChartDataUrl(url: string): number | undefined {
const formData = new URL(url).searchParams.get('form_data');
if (!formData) {
return undefined;
}
try {
const sliceId = JSON.parse(formData).slice_id;
return typeof sliceId === 'number' ? sliceId : undefined;
} catch {
// Not a slice-id form_data payload.
return undefined;
}
}
interface TestDashboardResult {
id: number;
@@ -72,3 +105,87 @@ export async function createTestDashboard(
return { id, name };
}
export interface DashboardChartSpec {
/** Sent as the chart's top-level `viz_type` and injected into its params. */
viz_type: string;
/**
* Chart params minus `datasource` and `viz_type` — the helper injects both
* (the datasource is resolved from the dataset, so callers never thread the
* dataset id through their spec).
*/
params: Record<string, unknown>;
}
interface CreateDashboardWithChartsOptions {
/** Example dataset the charts query (e.g. 'birth_names'). */
datasetName: string;
/** Chart slice-name prefix: `${chartNamePrefix}_${viz_type}_${suffix}`. */
chartNamePrefix: string;
/** Dashboard title prefix: `${dashboardTitlePrefix}_${suffix}`. */
dashboardTitlePrefix: string;
chartSpecs: DashboardChartSpec[];
}
/**
* Builds a published dashboard via the API: creates each chart, lays them out in
* a single row, and associates them so they render. Every created chart and the
* dashboard are registered for fixture cleanup. Charts are returned in the same
* order as `chartSpecs`, so callers can pair them back to per-spec metadata by
* index.
*/
export async function createDashboardWithCharts(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options: CreateDashboardWithChartsOptions,
): Promise<{ dashboardId: number; charts: DashboardLayoutChart[] }> {
const dataset = await getDatasetByName(page, options.datasetName);
if (!dataset) {
throw new Error(`Dataset ${options.datasetName} not found`);
}
const datasource = `${dataset.id}__table`;
// Parallel-safe suffix so chart/dashboard names never collide across workers.
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const charts: DashboardLayoutChart[] = [];
for (const spec of options.chartSpecs) {
const sliceName = `${options.chartNamePrefix}_${spec.viz_type}_${uniqueSuffix}`;
const resp = await apiPostChart(page, {
slice_name: sliceName,
viz_type: spec.viz_type,
datasource_id: dataset.id,
datasource_type: 'table',
params: JSON.stringify({
// Caller params first so the helper-owned datasource/viz_type always win
// and a stray key in a spec cannot repoint the chart at another dataset.
...spec.params,
datasource,
viz_type: spec.viz_type,
}),
});
expect(resp.ok()).toBe(true);
const chartId = await extractIdFromResponse(resp);
testAssets.trackChart(chartId);
charts.push({ id: chartId, sliceName });
}
// Lay all charts out in a single row.
const positionJson = buildSingleRowDashboardLayout(charts);
const dashResp = await apiPostDashboard(page, {
dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
published: true,
position_json: JSON.stringify(positionJson),
});
expect(dashResp.ok()).toBe(true);
const dashboardId = await extractIdFromResponse(dashResp);
testAssets.trackDashboard(dashboardId);
// Associate every chart with the dashboard so they actually render.
for (const chart of charts) {
await apiPutChart(page, chart.id, { dashboards: [dashboardId] });
}
return { dashboardId, charts };
}

View File

@@ -41,6 +41,7 @@ import {
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
@@ -118,7 +119,7 @@ testWithAssets(
await dashboardPage.waitForChartsToLoad();
const canvas = page.locator('[data-test="chart-container"] canvas').first();
await canvas.waitFor({ state: 'visible', timeout: 30_000 });
await canvas.waitFor({ state: 'visible', timeout: TIMEOUT.CHART_RENDER });
// Read the configured interval colors back from the rendered canvas.
// Poll because the gauge paints shortly after the chart container appears.

View File

@@ -72,6 +72,15 @@ export const TIMEOUT = {
*/
QUERY_EXECUTION: 30000, // 30s for SQL queries
/**
* A chart going from mounted to painted with real data.
* Navigating to a dashboard only waits for its header, so the first chart
* asserted absorbs the whole uncached query round-trip — more than the global
* expect timeout allows on a loaded runner. Charts query in parallel, so a
* dashboard full of them pays this roughly once.
*/
CHART_RENDER: 30000, // 30s for a chart to query and paint
/**
* Extended test timeout for multi-step tests (page load + query execution + assertions).
* Use with test.setTimeout() when the default 30s test timeout is insufficient.
@@ -89,5 +98,5 @@ export const EMBEDDED = {
/** Timeout for dashboard content to render inside the iframe */
DASHBOARD_RENDER: 30000, // 30s
/** Timeout for individual chart cells to finish rendering */
CHART_RENDER: 30000, // 30s
CHART_RENDER: TIMEOUT.CHART_RENDER,
} as const;

View File

@@ -902,7 +902,7 @@ const SqlEditor: FC<Props> = ({
css={css`
margin-bottom: ${theme.sizeUnit * 2}px;
padding-top: ${theme.sizeUnit * 4}px;
.ant-alert-action {
.ant-alert-actions {
align-self: center;
}
`}

View File

@@ -53,7 +53,7 @@ const StyledDatasourceModal = styled(Modal)`
top: ${TOP_MARGIN_VH}vh;
padding-bottom: 0;
&& .ant-modal-content {
&& .ant-modal-container {
max-height: ${MODAL_HEIGHT_VH}vh;
margin-top: 0;
margin-bottom: 0;

View File

@@ -22,7 +22,7 @@ import { css, SupersetTheme } from '@apache-superset/core/theme';
export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
margin: ${theme.sizeUnit * 4}px 0;
.ant-alert-message {
.ant-alert-title {
margin: 0;
}
`;

View File

@@ -32,7 +32,7 @@ const StyledAlert = styled(Alert)`
text-align: left;
flex: 1;
& .ant-alert-action {
& .ant-alert-actions {
align-self: center;
}
`;

View File

@@ -66,7 +66,7 @@ export const BaseModalWrapper = styled(StyledModal)<BaseModalWrapperProps>`
.ant-modal-body {
flex: 1 1 auto;
}
.ant-modal-content {
.ant-modal-container {
height: 100%;
}
`}

View File

@@ -43,7 +43,7 @@ export function CancelConfirmationAlert({
css={{
textAlign: 'left',
flex: 1,
'& .ant-alert-action': { alignSelf: 'center' },
'& .ant-alert-actions': { alignSelf: 'center' },
}}
description={children}
action={

View File

@@ -158,7 +158,7 @@ export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
margin: ${theme.sizeUnit * 4}px 0;
.ant-alert-message {
.ant-alert-title {
margin: 0;
}
`;
@@ -535,7 +535,7 @@ export const StyledCatalogTable = styled.div`
export const StyledUploadWrapper = styled.div`
margin: ${({ theme }) => theme.sizeUnit * 4}px;
.ant-progress-inner {
.ant-progress-rail {
display: none;
}
`;

View File

@@ -140,7 +140,7 @@ function GroupListModal({
value: role.id,
label: role.name,
}))}
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
/>
</FormItem>
<FormItem name="users" label={t('Users')}>

View File

@@ -60,7 +60,7 @@ export const PermissionsField = ({
placeholder={t('Select permissions')}
options={options}
loading={loading}
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
data-test="permissions-select"
/>
</FormItem>

View File

@@ -202,7 +202,7 @@ function UserListModal({
label: role.name,
}))}
getPopupContainer={trigger =>
trigger.closest('.ant-modal-content')
trigger.closest('.ant-modal-container')
}
/>
</FormItem>
@@ -221,7 +221,7 @@ function UserListModal({
label: group.name,
}))}
getPopupContainer={trigger =>
trigger.closest('.ant-modal-content')
trigger.closest('.ant-modal-container')
}
/>
</FormItem>

View File

@@ -139,11 +139,10 @@ const StyledContainer = styled.div`
&&&& .ant-steps-item-content {
overflow: unset;
.ant-steps-item-description {
margin-top: ${theme.sizeUnit}px;
padding-bottom: ${theme.sizeUnit}px;
}
/* antd 6 removed .ant-steps-item-description; the description text is now
rendered directly in .ant-steps-item-content. */
margin-top: ${theme.sizeUnit}px;
padding-bottom: ${theme.sizeUnit}px;
}
&&&& .ant-tooltip-open {

View File

@@ -249,6 +249,11 @@ class AsyncQueryManager:
"iat": token.get("iat"),
"exp": token.get("exp"),
"aud": token.get("aud"),
# ``datasets`` and ``rev`` are optional scope claims, so tokens
# that differ only in their dataset allowlist or revocation
# version still derive distinct channels.
"datasets": token.get("datasets"),
"rev": token.get("rev"),
},
sort_keys=True,
).encode("utf-8")

View File

@@ -227,22 +227,47 @@ class BaseReportState:
def create_log(self, error_message: Optional[str] = None) -> None:
"""
Creates a Report execution log, uses the current computed last_value for Alerts
A single execution transitions through the WORKING state before reaching a
terminal state (SUCCESS/ERROR/NOOP/GRACE). To avoid duplicated rows in the
execution-log view (see issue #29857), the terminal result is written onto
the WORKING "trigger" row created earlier in the same execution rather than
inserting a second row. The WORKING row is only used as a placeholder while
the execution is in flight (``find_last_entered_working_log`` relies on it
for working-timeout detection), so promoting it in place keeps one row per
execution ``uuid`` without losing that behavior. The intentional
error-notification marker row is a terminal-to-terminal transition, so it is
still recorded as a distinct row.
"""
from sqlalchemy.orm.exc import StaleDataError
try:
log = ReportExecutionLog(
scheduled_dttm=self._scheduled_dttm,
start_dttm=self._start_dttm,
end_dttm=datetime.now(timezone.utc).replace(tzinfo=None),
value=self._report_schedule.last_value,
value_row_json=self._report_schedule.last_value_row_json,
state=self._report_schedule.last_state,
error_message=error_message,
report_schedule=self._report_schedule,
uuid=self._execution_id,
# Reuse the in-flight WORKING trigger row for this execution, if any,
# so a single execution surfaces as a single log entry.
log = (
db.session.query(ReportExecutionLog)
.filter(
ReportExecutionLog.uuid == self._execution_id,
ReportExecutionLog.state == ReportState.WORKING,
ReportExecutionLog.error_message.is_(None),
)
.first()
if self._report_schedule.last_state != ReportState.WORKING
else None
)
db.session.add(log)
if log is None:
log = ReportExecutionLog(
scheduled_dttm=self._scheduled_dttm,
start_dttm=self._start_dttm,
report_schedule=self._report_schedule,
uuid=self._execution_id,
)
db.session.add(log)
log.end_dttm = datetime.now(timezone.utc).replace(tzinfo=None)
log.value = self._report_schedule.last_value
log.value_row_json = self._report_schedule.last_value_row_json
log.state = self._report_schedule.last_state
log.error_message = error_message
db.session.commit() # pylint: disable=consider-using-transaction
except StaleDataError as ex:
# Report schedule was modified or deleted by another process

View File

@@ -0,0 +1,92 @@
# 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.
from datetime import datetime
from typing import Any
from sqlalchemy import types
from superset.constants import TimeGrain
from superset.db_engine_specs import BaseEngineSpec
from superset.db_engine_specs.base import DatabaseCategory
class DataFusionEngineSpec(BaseEngineSpec):
engine_name = "Apache DataFusion"
engine = "datafusion"
drivers = {
"flightsql": "Arrow Flight SQL protocol for DataFusion",
}
default_driver = "flightsql"
sqlalchemy_uri_placeholder = "datafusion://host:port"
supports_file_upload = False
metadata = {
"description": "DataFusion is a highly performant query engine",
"logo": "datafusion.png",
"homepage_url": "https://datafusion.apache.org/",
"categories": [
DatabaseCategory.QUERY_ENGINES,
DatabaseCategory.OPEN_SOURCE,
DatabaseCategory.APACHE_PROJECTS,
],
"pypi_packages": ["flightsql-dbapi"],
"connection_string": "datafusion://host:port",
"drivers": [
{
"name": "Arrow Flight SQL (Recommended)",
"pypi_package": "flightsql-dbapi",
"connection_string": "datafusion://host:port",
"is_recommended": True,
}
],
}
_time_grain_expressions = {
None: "{col}",
TimeGrain.SECOND: "DATE_TRUNC('second', {col})",
TimeGrain.FIVE_SECONDS: "DATE_TRUNC('minute', {col}) + INTERVAL '5 seconds' * FLOOR(EXTRACT(SECOND FROM {col}) / 5)", # noqa: E501
TimeGrain.THIRTY_SECONDS: "DATE_TRUNC('minute', {col}) + INTERVAL '30 seconds' * FLOOR(EXTRACT(SECOND FROM {col}) / 30)", # noqa: E501
TimeGrain.MINUTE: "DATE_TRUNC('minute', {col})",
TimeGrain.FIVE_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '5 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 5)", # noqa: E501
TimeGrain.TEN_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '10 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 10)", # noqa: E501
TimeGrain.FIFTEEN_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '15 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 15)", # noqa: E501
TimeGrain.THIRTY_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '30 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 30)", # noqa: E501
TimeGrain.HOUR: "DATE_TRUNC('hour', {col})",
TimeGrain.DAY: "DATE_TRUNC('day', {col})",
TimeGrain.WEEK: "DATE_TRUNC('week', {col})",
TimeGrain.MONTH: "DATE_TRUNC('month', {col})",
TimeGrain.QUARTER: "DATE_TRUNC('quarter', {col})",
TimeGrain.YEAR: "DATE_TRUNC('year', {col})",
}
@classmethod
def epoch_to_dttm(cls) -> str:
return "from_unixtime({col})"
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
) -> str | None:
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"TO_DATE('{dttm.date().isoformat()}', 'YYYY-MM-DD')"
if isinstance(sqla_type, types.DateTime):
dttm_formatted = dttm.isoformat(sep=" ", timespec="milliseconds")
return f"""TO_TIMESTAMP('{dttm_formatted}', 'YYYY-MM-DD HH24:MI:SS.FFF')"""
return None

View File

@@ -50,7 +50,10 @@ class PooledBaseScreenshot(BaseScreenshot):
"""
def get_screenshot(
self, user: User, window_size: WindowSize | None = None
self,
user: User,
window_size: WindowSize | None = None,
log_context: str | None = None,
) -> bytes | None:
"""
Generate screenshot using pooled WebDriver with retry logic for reliability.
@@ -58,6 +61,9 @@ class PooledBaseScreenshot(BaseScreenshot):
Args:
user: User context for authentication
window_size: Optional window size override
log_context: Accepted for signature compatibility with
BaseScreenshot; the pooled Selenium path does not emit the
per-tile readiness logs that use it.
Returns:
Screenshot as PNG bytes or None if failed

View File

@@ -614,8 +614,15 @@ class SemanticLayerRestApi(BaseSupersetApi):
warning: str | None = None
try:
schema = cls.get_configuration_schema(parsed_config)
except Exception as ex: # pylint: disable=broad-except
warning = str(ex)
except Exception: # pylint: disable=broad-except
# Show a stable, user-friendly message in the UI; the exception
# detail goes to the server log below.
warning = str(
t(
"Could not load metadata for this configuration; "
"showing the default form. See the server logs for details."
)
)
logger.exception(
"Error enriching semantic layer configuration schema for type %s",
sl_type,

View File

@@ -28,6 +28,7 @@ from typing import Any, Callable, cast, Literal
from flask import g, has_request_context, request
from flask_appbuilder.const import API_URI_RIS_KEY
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.exc import SQLAlchemyError
from superset.extensions import stats_logger_manager
@@ -193,11 +194,14 @@ class AbstractEventLogger(ABC):
if user_id is None and has_request_context():
try:
actual_user = g.get("user", None)
if actual_user is not None:
# Guest/anonymous users (e.g. embedded dashboards) are never
# DB-mapped, so adding them to the session always fails.
# This is expected and not worth logging.
if actual_user is not None and sa_inspect(actual_user, raiseerr=False):
db.session.add(actual_user)
user_id = get_user_id()
except Exception as ex:
logging.warning("Failed to add user to db session: %s", ex)
logger.debug("Failed to add user to db session: %s", ex)
user_id = None
payload = collect_request_payload()
if object_ref:

View File

@@ -240,8 +240,8 @@ class WebDriverPlaywright(WebDriverProxy):
alert_div.get_by_role("button").click()
# wait for modal to show up
page.locator(".ant-modal-content").wait_for(state="visible")
err_msg_div = page.locator(".ant-modal-content .ant-modal-body")
page.locator(".ant-modal-container").wait_for(state="visible")
err_msg_div = page.locator(".ant-modal-container .ant-modal-body")
#
# # collect error message
error_messages.append(err_msg_div.text_content())
@@ -250,10 +250,10 @@ class WebDriverPlaywright(WebDriverProxy):
error_as_html = err_msg_div.inner_html().replace("'", "\\'")
#
# # close modal after collecting error messages
page.locator(".ant-modal-content .ant-modal-close").click()
page.locator(".ant-modal-container .ant-modal-close").click()
#
# # wait until the modal becomes invisible
page.locator(".ant-modal-content").wait_for(state="detached")
page.locator(".ant-modal-container").wait_for(state="detached")
try:
# Even if some errors can't be updated in the screenshot,
# keep all the errors in the server log and do not fail the loop
@@ -759,7 +759,7 @@ class WebDriverSelenium(WebDriverProxy):
app.config["SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE"],
).until(
EC.visibility_of_any_elements_located(
(By.CLASS_NAME, "ant-modal-content")
(By.CLASS_NAME, "ant-modal-container")
)
)[0]

View File

@@ -19,6 +19,7 @@
# To set the images of your preferred database, you may create a mapping here with engine and locations of the relevant images. The image can be hosted locally inside your static/file directory or online (e.g. S3).
# DB_IMAGES:
# datafusion: "path/to/image/datafusion.jpg"
# postgresql: "path/to/image/postgres.jpg"
# bigquery: "path/to/s3bucket/bigquery.jpg"
# snowflake: "path/to/image/snowflake.jpg"

View File

@@ -21,10 +21,11 @@ from datetime import timedelta
from typing import Any, Optional
from unittest.mock import patch
from flask import current_app # noqa: F401
from flask import current_app, g # noqa: F401
from freezegun import freeze_time
from superset import security_manager
from superset.security.guest_token import GuestUser
from superset.utils.log import (
AbstractEventLogger,
DBEventLogger,
@@ -231,6 +232,88 @@ class TestEventLogger(unittest.TestCase):
assert logger.records[0]["user_id"] == None # noqa: E711
def test_log_with_context_guest_user_skips_warning(self):
"""Guest/anonymous users are never DB-mapped; adding them to the
session is expected to be a no-op, not a warning-worthy failure."""
class DummyEventLogger(AbstractEventLogger):
def __init__(self):
self.records = []
def log(
self,
user_id: Optional[int],
action: str,
dashboard_id: Optional[int],
duration_ms: Optional[int],
slice_id: Optional[int],
referrer: Optional[str],
*args: Any,
**kwargs: Any,
):
self.records.append(
{**kwargs, "user_id": user_id, "duration": duration_ms}
)
logger = DummyEventLogger()
with app.test_request_context():
g.user = GuestUser(
token={"user": {"username": "guest"}, "resources": []},
roles=[],
)
with self.assertNoLogs(level="WARNING"):
logger.log_with_context(
action="foo",
duration=timedelta(seconds=1),
log_to_statsd=False,
)
assert logger.records[0]["user_id"] is None
@patch("superset.db")
def test_log_with_context_unexpected_add_failure_logs_debug(self, mock_db):
"""A genuinely unexpected failure (not the guest-user case) should
still be surfaced, but at debug level rather than warning."""
class DummyEventLogger(AbstractEventLogger):
def __init__(self):
self.records = []
def log(
self,
user_id: Optional[int],
action: str,
dashboard_id: Optional[int],
duration_ms: Optional[int],
slice_id: Optional[int],
referrer: Optional[str],
*args: Any,
**kwargs: Any,
):
self.records.append(
{**kwargs, "user_id": user_id, "duration": duration_ms}
)
logger = DummyEventLogger()
mock_db.session.add.side_effect = Exception("boom")
with app.test_request_context():
# A DB-mapped (but unpersisted) user instance so sa_inspect()
# treats it as mapped and the code proceeds to db.session.add(),
# independent of any test-database fixture data.
g.user = security_manager.user_model()
with self.assertNoLogs(level="WARNING"):
with self.assertLogs("superset.utils.log", level="DEBUG") as debug_logs:
logger.log_with_context(
action="foo",
duration=timedelta(seconds=1),
log_to_statsd=False,
)
assert "Failed to add user to db session" in debug_logs.output[0]
assert logger.records[0]["user_id"] is None
@patch.object(DBEventLogger, "log")
def test_log_this_with_context_and_extra_payload(self, mock_log):
logger = DBEventLogger()

View File

@@ -19,7 +19,7 @@ from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Optional
from unittest.mock import call, Mock, patch
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
from flask.ctx import AppContext
@@ -161,15 +161,26 @@ def assert_log(state: str, error_message: Optional[str] = None):
db.session.commit()
logs = db.session.query(ReportExecutionLog).all()
if state == ReportState.ERROR:
# On error we send an email
assert len(logs) == 3
else:
if state == ReportState.WORKING:
# A report that is already in the WORKING state logs an extra WORKING row
# for the refused re-computation, on top of the row seeded by the fixture.
assert len(logs) == 2
elif state == ReportState.ERROR:
# On error we also send a notification, which is recorded as a separate
# error-notification marker row.
assert len(logs) == 2
else:
# A single execution yields a single log row: the terminal result replaces
# the WORKING "trigger" row rather than adding a second row (issue #29857).
assert len(logs) == 1
log_states = [log.state for log in logs]
assert ReportState.WORKING in log_states
assert state in log_states
assert error_message in [log.error_message for log in logs]
# Previously a standalone WORKING "trigger" row always contributed a ``None``
# error_message, so the default ``None`` match was trivially satisfied and never
# verified the terminal row. With the result now written onto that single row,
# only assert an explicitly expected message.
if error_message is not None:
assert error_message in [log.error_message for log in logs]
for log in logs:
if log.state == ReportState.WORKING:
@@ -805,6 +816,45 @@ def test_email_chart_report_schedule(
assert_log(ReportState.SUCCESS)
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "create_report_email_chart"
)
@patch("superset.reports.notifications.email.send_email_smtp")
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
def test_email_chart_report_schedule_single_log_per_execution(
screenshot_mock,
email_mock,
create_report_email_chart,
):
"""
ExecuteReport Command: a single execution should produce a single log row.
Regression for #29857: the Alerts & Reports execution log shows duplicated
entries for a single execution. Each execution transitions through the
WORKING state and then a terminal state (SUCCESS/ERROR), and every
transition writes a ReportExecutionLog row sharing the same execution
``uuid``. As a result one execution surfaces as two rows in the log view
(the "trigger" row and the "result" row). This test asserts that one
execution -- identified by its execution uuid -- yields exactly one log row.
"""
screenshot_mock.return_value = SCREENSHOT_FILE
with freeze_time("2020-01-01T00:00:00Z"):
AsyncExecuteReportScheduleCommand(
TEST_ID, create_report_email_chart.id, datetime.utcnow()
).run()
db.session.commit()
logs = (
db.session.query(ReportExecutionLog)
.filter(ReportExecutionLog.uuid == UUID(TEST_ID))
.all()
)
# A single execution (one uuid) must map to a single log entry, not one
# row per intermediate state transition.
assert len(logs) == 1
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "create_report_email_chart_alpha_owner"
)

View File

@@ -223,6 +223,44 @@ def test_parse_channel_id_from_request_as_guest_user_differs_per_token(
assert first != second
@mock.patch("superset.is_feature_enabled")
def test_parse_channel_id_from_request_as_guest_user_differs_per_scope(
is_feature_enabled_mock, async_query_manager
):
"""
Tokens that differ only in the optional ``datasets`` allowlist or ``rev``
revocation version must still derive distinct channel ids, otherwise two
differently scoped embedded sessions would collide on the same stream.
"""
is_feature_enabled_mock.return_value = True
base_token = {
"user": {},
"resources": [{"type": "dashboard", "id": "some-uuid"}],
"rls_rules": [{"clause": '"STATEID" = 3'}],
"iat": 1700000000.0,
"exp": 1700000300.0,
"aud": "http://0.0.0.0:8080/",
"type": "guest",
}
request = Mock()
request.cookies = {}
g.user = security_manager.get_guest_user_from_token(dict(base_token))
baseline = async_query_manager.parse_channel_id_from_request(request)
g.user = security_manager.get_guest_user_from_token({**base_token, "datasets": [1]})
with_datasets = async_query_manager.parse_channel_id_from_request(request)
g.user = security_manager.get_guest_user_from_token({**base_token, "rev": 1})
with_rev = async_query_manager.parse_channel_id_from_request(request)
assert baseline != with_datasets
assert baseline != with_rev
assert with_datasets != with_rev
@mark.parametrize(
"cache_type, cache_backend",
[

View File

@@ -2468,6 +2468,9 @@ def test_create_log_success_commits(mocker: MockerFixture) -> None:
state._report_schedule = schedule
mock_db = mocker.patch("superset.commands.report.execute.db")
# No in-flight WORKING "trigger" row exists for this execution, so create_log
# inserts a fresh row rather than promoting an existing one.
mock_db.session.query.return_value.filter.return_value.first.return_value = None
mock_log_cls = mocker.patch(
"superset.commands.report.execute.ReportExecutionLog",
return_value=mocker.Mock(),

View File

@@ -0,0 +1,101 @@
# 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.
from datetime import datetime
import pytest
from superset.db_engine_specs.datafusion import DataFusionEngineSpec
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
from tests.unit_tests.fixtures.common import dttm # noqa: F401
@pytest.mark.parametrize(
"target_type,expected_result",
[
("Date", "TO_DATE('2019-01-02', 'YYYY-MM-DD')"),
(
"DateTime",
"TO_TIMESTAMP('2019-01-02 03:04:05.678', 'YYYY-MM-DD HH24:MI:SS.FFF')",
),
(
"TimeStamp",
"TO_TIMESTAMP('2019-01-02 03:04:05.678', 'YYYY-MM-DD HH24:MI:SS.FFF')",
),
("UnknownType", None),
],
)
def test_convert_dttm(
target_type: str,
expected_result: str | None,
dttm: datetime, # noqa: F811
) -> None:
assert_convert_dttm(DataFusionEngineSpec, target_type, expected_result, dttm)
def test_epoch_to_dttm() -> None:
assert DataFusionEngineSpec.epoch_to_dttm().format(col="ts") == "from_unixtime(ts)"
@pytest.mark.parametrize(
"time_grain,expected",
[
(None, "ts"),
("PT1S", "DATE_TRUNC('second', ts)"),
(
"PT5S",
"DATE_TRUNC('minute', ts) + INTERVAL '5 seconds' * "
"FLOOR(EXTRACT(SECOND FROM ts) / 5)",
),
(
"PT30S",
"DATE_TRUNC('minute', ts) + INTERVAL '30 seconds' * "
"FLOOR(EXTRACT(SECOND FROM ts) / 30)",
),
("PT1M", "DATE_TRUNC('minute', ts)"),
(
"PT5M",
"DATE_TRUNC('hour', ts) + INTERVAL '5 minutes' * "
"FLOOR(EXTRACT(MINUTE FROM ts) / 5)",
),
(
"PT10M",
"DATE_TRUNC('hour', ts) + INTERVAL '10 minutes' * "
"FLOOR(EXTRACT(MINUTE FROM ts) / 10)",
),
(
"PT15M",
"DATE_TRUNC('hour', ts) + INTERVAL '15 minutes' * "
"FLOOR(EXTRACT(MINUTE FROM ts) / 15)",
),
(
"PT30M",
"DATE_TRUNC('hour', ts) + INTERVAL '30 minutes' * "
"FLOOR(EXTRACT(MINUTE FROM ts) / 30)",
),
("PT1H", "DATE_TRUNC('hour', ts)"),
("P1D", "DATE_TRUNC('day', ts)"),
("P1W", "DATE_TRUNC('week', ts)"),
("P1M", "DATE_TRUNC('month', ts)"),
("P3M", "DATE_TRUNC('quarter', ts)"),
("P1Y", "DATE_TRUNC('year', ts)"),
],
)
def test_time_grain_expressions(time_grain: str | None, expected: str) -> None:
assert (
DataFusionEngineSpec._time_grain_expressions[time_grain].format(col="ts")
== expected
)

View File

@@ -0,0 +1,36 @@
# 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.
from unittest.mock import MagicMock, patch
from superset.mcp_service.screenshot.pooled_screenshot import PooledBaseScreenshot
@patch("superset.mcp_service.screenshot.pooled_screenshot.retry_screenshot_operation")
def test_get_screenshot_accepts_base_log_context(
mock_retry_screenshot_operation: MagicMock,
) -> None:
screenshot = PooledBaseScreenshot("http://example.com", "digest")
user = MagicMock()
screenshot.get_screenshot(user, log_context="cache_key=abc")
mock_retry_screenshot_operation.assert_called_once_with(
screenshot._get_screenshot_internal, # pylint: disable=protected-access
user,
None,
)

View File

@@ -1186,7 +1186,12 @@ def test_configuration_schema_enrichment_error_fallback(
assert response.status_code == 200
assert response.json["result"] == {"type": "object"}
assert response.json["warning"] == "connection failed"
# The warning is a stable, user-friendly message; the exception detail
# only goes to the server log.
assert response.json["warning"] == (
"Could not load metadata for this configuration; "
"showing the default form. See the server logs for details."
)
assert mock_cls.get_configuration_schema.call_count == 2