Compare commits

...

12 Commits

Author SHA1 Message Date
Elizabeth Thompson
8716f627ee fix(tests): update tiled-fallback test and guard double error() call
- Rename and update test_tiled_screenshot_failure_returns_none_without_fallback
  to reflect new behavior: falsy tiled result falls back to standard screenshot
  instead of returning None
- Guard the else: cache_payload.error() call in compute_and_cache to only fire
  when status is not already ERROR, preserving the original failure timestamp
- Remove stale PT004 ignore (rule removed in ruff 0.9.7)
- Apply ruff 0.9.7 formatting to match CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:58:28 +00:00
Elizabeth Thompson
ec23f11ce6 fix(tests): resolve PT001 ruff version mismatch and improve tiled fallback test
Add PT001 to ruff ignore list so local and CI ruff versions don't conflict over
fixture parentheses style. Also fix test_tiled_fallback_triggered_on_empty_bytes
to patch page.screenshot directly instead of the static method, which was
returning a MagicMock in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:58:28 +00:00
Elizabeth Thompson
ce2f8b53fd fix(screenshots): catch empty-bytes tiled result and set ERROR on falsy image
Two bugs that could cause blank PDFs to re-trigger indefinitely:

1. webdriver.py: tiled fallback used `if img is None:` which let b""
   (returned by combine_screenshot_tiles on an empty tile list) pass
   through silently. Changed to `if not img:` to catch both cases.

2. screenshots.py: compute_and_cache had no else branch after
   `if image: cache_payload.update(image)`. When get_screenshot returned
   None or b"" without raising, status stayed at COMPUTING instead of
   ERROR, causing is_computing_stale() to re-trigger the task
   indefinitely after THUMBNAIL_ERROR_CACHE_TTL. Added
   `else: cache_payload.error()` so failed screenshots always transition
   to ERROR and use the controlled TTL back-off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:58:04 +00:00
Elizabeth Thompson
4d5b8163d5 fix(screenshots): add debug logs for blank-PDF investigation in Playwright path
Add debug-level logging at each decision point in WebDriverPlaywright so that
enabling DEBUG logging exposes the full screenshot pipeline:

- Which path was taken (tiled / standard-below-threshold / tiled-disabled)
  including chart count and dashboard height when the tiling decision is made
- "Waiting for all spinners to clear" with SCREENSHOT_LOAD_WAIT value at the
  start of each wait, so a timeout message has clear context
- "All spinners cleared" confirmation after wait_for_function succeeds
- "Taking screenshot of url %s as user %s" immediately before capture, rather
  than at the misleading early position it previously occupied
- "Screenshot result: N bytes" (or "Tiled screenshot result: N bytes") after
  capture so a blank or suspiciously small result is visible in the log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:58:04 +00:00
Elizabeth Thompson
e28176302f fix(screenshots): apply animation wait after spinner wait in Playwright non-tiled path
ECharts animations play after chart data loads (after the loading spinner
clears). The Playwright screenshot path was applying the global
SCREENSHOT_SELENIUM_ANIMATION_WAIT *before* waiting for spinners to clear,
meaning the animation buffer was consumed before data had even loaded.

Move the animation wait to after `wait_for_function` (the global spinner
check) in both non-tiled code paths — when SCREENSHOT_TILED_ENABLED is
False, and when it is True but the dashboard is below the tiling threshold.
The tiled path already handles this correctly per-tile inside
`take_tiled_screenshot`, which was fixed by PRs #39895 and #40694.

This brings the thumbnail API / Celery screenshot path to parity with the
report-email path on small-to-medium dashboards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:58:04 +00:00
Vansh Gilhotra
6f12d17313 fix(charts): show user-friendly error for HTTP 413 payload too large (#37131)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 11:21:59 -07:00
abhyudaytomar
09c7ba14df fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:46 -07:00
Elizabeth Thompson
3ec4bd23c4 fix(deps): restore np.nan in offset_metrics_df empty branch (#41267)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:49:38 -07:00
yousoph
f6ce105450 fix(pandas-postprocessing): handle prophet errors and validate minimum data points for forecast (#41180)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:44:23 -07:00
Evan Rusackas
7bb4e82a82 fix(dashboard): Remove 308 redirect when creating new dashboards (#41343)
Co-authored-by: ericsong <eric.song@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:31:31 -07:00
Kamil Gabryjelski
2d78a8733c fix(plugin-chart-ag-grid-table): show correct percent-metric totals in summary row (#41247)
Signed-off-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2026-06-24 19:21:00 +02:00
Evan Rusackas
3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
40 changed files with 1501 additions and 200 deletions

View File

@@ -375,6 +375,7 @@ select = [
ignore = [
"S101",
"PT001", # pytest-fixture-incorrect-parentheses-style: different ruff versions disagree
"PT006",
"T201",
"N999",

View File

@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args),
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
get guestTokenHeaderName() {
try {
return getInstance().guestTokenHeaderName;

View File

@@ -150,6 +150,26 @@ export default class SupersetClientClass {
}
}
/**
* POST request that returns a blob for file downloads.
* Unlike postForm, this uses AJAX so errors can be caught and handled.
* @param endpoint - API endpoint
* @param payload - Request payload
* @returns Promise resolving to Response with blob
*/
async postBlob(
endpoint: string,
payload: Record<string, any>,
): Promise<Response> {
await this.ensureAuth();
return this.post({
endpoint,
postPayload: payload,
parseMethod: 'raw',
stringify: false,
});
}
async reAuthenticate() {
return this.init(true);
}

View File

@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
| 'get'
| 'post'
| 'postForm'
| 'postBlob'
| 'put'
| 'request'
| 'init'

View File

@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
getUrl: (...args: unknown[]) => string;
};
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
expect(typeof SupersetClient.configure).toBe('function');
expect(typeof SupersetClient.init).toBe('function');
expect(typeof SupersetClient.get).toBe('function');
expect(typeof SupersetClient.post).toBe('function');
expect(typeof SupersetClient.postForm).toBe('function');
expect(typeof SupersetClient.postBlob).toBe('function');
expect(typeof SupersetClient.delete).toBe('function');
expect(typeof SupersetClient.put).toBe('function');
expect(typeof SupersetClient.request).toBe('function');
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
expect(typeof SupersetClient.reAuthenticate).toBe('function');
});
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
expect(SupersetClient.init).toThrow();
expect(SupersetClient.get).toThrow();
expect(SupersetClient.post).toThrow();
expect(SupersetClient.postForm).toThrow();
expect(SupersetClient.postBlob).toThrow();
expect(SupersetClient.delete).toThrow();
expect(SupersetClient.put).toThrow();
expect(SupersetClient.request).toThrow();

View File

@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
expect(authSpy).toHaveBeenCalledTimes(0);
});
});
describe('.postBlob()', () => {
const protocol = 'https:';
const host = 'host';
const mockPostBlobEndpoint = '/api/v1/chart/data';
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
let authSpy: jest.SpyInstance;
let client: SupersetClientClass;
beforeEach(async () => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
client = new SupersetClientClass({ protocol, host });
await client.init();
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
const mockResponse = new Response('csv data', { status: 200 });
const postSpy = jest
.spyOn(client, 'post')
.mockResolvedValue(mockResponse);
const response = await client.postBlob(
mockPostBlobEndpoint,
postBlobPayload,
);
expect(authSpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledWith({
endpoint: mockPostBlobEndpoint,
postPayload: postBlobPayload,
parseMethod: 'raw',
stringify: false,
});
expect(response).toBe(mockResponse);
});
test('passes payload in request body', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 200,
body: 'csv data',
});
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
});
test('rejects when response is not ok', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 413,
body: 'Payload Too Large',
});
await expect(
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
).rejects.toMatchObject({ status: 413 });
});
});
});

View File

@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. The totals query must rename percent-metric
// columns the same way (`metric` -> `%metric`) so the footer can look them
// up; without it the totals row renders 0.000%. We deliberately reuse only
// this rule and not the full `postProcessing` array, which may also contain
// a time-comparison operator that must not run on the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
metrics.concat(percentMetrics),
getMetricLabel,
);
postProcessing = [
{
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
contributionPostProcessing = {
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
];
};
postProcessing = [contributionPostProcessing];
}
// Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) {
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
extras: totalsExtras, // Use extras with AG Grid WHERE removed
row_limit: 0,
row_offset: 0,
post_processing: [],
// Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation.
});

View File

@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.columns).toEqual([]);
expect(totalsQuery.row_limit).toBe(0);
});
test('should reapply percent-metric contribution op to totals query', () => {
// Regression test for #37627: when a percent metric is configured and
// Show Summary (show_totals) is enabled, the totals query must rename
// percent-metric columns (`metric` -> `%metric`) so the footer can
// look them up. Otherwise the totals row renders 0.000%.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
// No server pagination -> queries[1] is the totals query.
const totalsQuery = queries[1];
const contributionRule = {
operation: 'contribution',
options: {
columns: ['count'],
rename_columns: ['%count'],
},
};
expect(queries[0].post_processing).toContainEqual(contributionRule);
expect(totalsQuery.post_processing).toEqual([contributionRule]);
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
time_compare: ['1 year ago'],
comparison_type: 'values',
});
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
const totalsQuery = queries[1];
expect(totalsQuery.post_processing).toEqual([]);
});
});
describe('Integration - all filter types together', () => {

View File

@@ -86,6 +86,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
const postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. Without it the totals row's percent-metric
// columns are keyed `metric` instead of `%metric`, so the footer renders
// 0.000%. We reuse only this rule and not the full `postProcessing` array,
// which may also contain a time-comparison operator that must not run on
// the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -137,12 +144,6 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
orderby = [[metrics[0], false]];
}
// add postprocessing for percent metrics only when in aggregation mode
type PercentMetricCalculationMode = 'row_limit' | 'all_records';
const calculationMode: PercentMetricCalculationMode =
(formData.percent_metric_calculation as PercentMetricCalculationMode) ||
'row_limit';
if (percentMetrics && percentMetrics.length > 0) {
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
formData,
@@ -162,23 +163,14 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
getMetricLabel,
);
if (calculationMode === 'all_records') {
postProcessing.push({
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
});
} else {
postProcessing.push({
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
});
}
contributionPostProcessing = {
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
};
postProcessing.push(contributionPostProcessing);
}
// Add the operator for the time comparison if some is selected
@@ -357,7 +349,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
columns: [],
row_limit: 0,
row_offset: 0,
post_processing: [],
// Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined,
orderby: undefined,
});

View File

@@ -236,6 +236,83 @@ describe('plugin-chart-table', () => {
expect(queries).toHaveLength(1);
expect(queries[0].post_processing).toEqual([]);
});
test('should reapply contribution op to totals query in row_limit mode', () => {
// Regression test for #37627: with a percent metric and Show Summary
// (show_totals) enabled, the totals query must rename percent-metric
// columns (`metric` -> `%metric`) so the footer can look them up.
// Otherwise the totals row renders 0.000%.
const formData = {
...baseFormDataWithPercents,
show_totals: true,
};
const { queries } = buildQuery(formData);
// row_limit mode + show_totals -> [main, totals].
expect(queries).toHaveLength(2);
const contributionRule = {
operation: 'contribution',
options: {
columns: ['sum_sales'],
rename_columns: ['%sum_sales'],
},
};
expect(queries[1]).toMatchObject({
columns: [],
post_processing: [contributionRule],
});
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const formData = {
...baseFormDataWithPercents,
show_totals: true,
time_compare: ['1 year ago'],
comparison_type: 'values',
};
const { queries } = buildQuery(formData);
// row_limit mode + show_totals -> [main, totals].
expect(queries).toHaveLength(2);
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const formData = {
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: ['count'],
percent_metrics: [],
groupby: ['category'],
show_totals: true,
};
const { queries } = buildQuery(formData);
expect(queries).toHaveLength(2);
expect(queries[1].post_processing).toEqual([]);
});
});
describe('Testing for server pagination with search filter', () => {

View File

@@ -632,6 +632,35 @@ function processFile(filepath) {
}
}
/**
* Application source trees that must be authored in TypeScript. Matches the
* top-level `src/` directory as well as each package/plugin `src/` directory.
*/
const TS_ONLY_SOURCE_PATTERN =
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
/**
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
* added under the application source trees (including test files). Build
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
* `webpack.config.js`) live outside these trees and are intentionally allowed.
*
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
*/
function checkTypeScriptOnlySource(candidateFiles) {
candidateFiles.forEach(file => {
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${file}: frontend source must be TypeScript. ` +
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
`TypeScript; no new .js/.jsx files in src/).`,
);
errorCount += 1;
}
});
}
/**
* Main function
*/
@@ -666,6 +695,22 @@ function main() {
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
];
// Enforce TypeScript-only source. Run this on the raw file list (before the
// ignore patterns below strip out tests/stories) so that e.g. a new
// `*.test.jsx` is still rejected.
const tsOnlyCandidates =
args.length === 0
? glob.sync('{src,packages/*/src,plugins/*/src}/**/*.{js,jsx}', {
ignore: [
'**/node_modules/**',
'**/esm/**',
'**/lib/**',
'**/dist/**',
],
})
: args.map(f => f.replace(/^superset-frontend\//, ''));
checkTypeScriptOnlySource(tsOnlyCandidates);
// If no files specified, check all
if (files.length === 0) {
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
@@ -706,22 +751,23 @@ function main() {
if (files.length === 0) {
// eslint-disable-next-line no-console
console.log('No files to check.');
return;
} else {
// eslint-disable-next-line no-console
console.log(
`Checking ${files.length} files for Superset custom rules...\n`,
);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
}
// eslint-disable-next-line no-console
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
// eslint-disable-next-line no-console
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
@@ -740,4 +786,5 @@ module.exports = {
checkNoFaIcons,
checkI18nTemplates,
checkUntranslatedStrings,
checkTypeScriptOnlySource,
};

View File

@@ -481,7 +481,7 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id;
const exportTable = useCallback(
(format: string, isFullCSV: boolean, isPivot = false) => {
async (format: string, isFullCSV: boolean, isPivot = false) => {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
@@ -556,24 +556,48 @@ const Chart = (props: ChartProps) => {
}
: baseOwnState;
exportChart({
formData:
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
resultType,
resultFormat: format,
force: true,
ownState: exportOwnState,
onStartStreamingExport: shouldUseStreaming
? (exportParams: JsonObject) => {
setIsStreamingModalVisible(true);
startExport({
...(exportParams as Record<string, unknown>),
filename,
expectedRows: actualRowCount,
} as Parameters<typeof startExport>[0]);
}
: null,
});
try {
await exportChart({
formData:
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
resultType,
resultFormat: format,
force: true,
ownState: exportOwnState,
onStartStreamingExport: shouldUseStreaming
? (exportParams: JsonObject) => {
setIsStreamingModalVisible(true);
startExport({
...(exportParams as Record<string, unknown>),
filename,
expectedRows: actualRowCount,
} as Parameters<typeof startExport>[0]);
}
: null,
});
} catch (error) {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
boundActionCreators.addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
boundActionCreators.addDangerToast(errorMessage);
}
}
},
[
sliceSliceId,
@@ -585,6 +609,7 @@ const Chart = (props: ChartProps) => {
chartState,
props.id,
boundActionCreators.logEvent,
boundActionCreators.addDangerToast,
queriesResponse,
startExport,
resetExport,

View File

@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
import {
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// Update document title when dashboard title changes
useEffect(() => {
if (pageTitle) {
document.title = pageTitle;
document.title = sanitizeDocumentTitle(pageTitle);
}
}, [pageTitle]);

View File

@@ -66,6 +66,7 @@ import {
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from 'src/logger/LogUtils';
import { getUrlParam } from 'src/utils/urlUtils';
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import cx from 'classnames';
import * as chartActions from 'src/components/Chart/chartAction';
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
// Update document title when slice name changes
useEffect(() => {
if (props.sliceName) {
document.title = props.sliceName;
document.title = sanitizeDocumentTitle(props.sliceName);
}
}, [props.sliceName]);

View File

@@ -339,7 +339,34 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
const exportCSV = useCallback(() => {
const handleExportError = useCallback(
(error: unknown) => {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
addDangerToast(errorMessage);
}
},
[addDangerToast],
);
const exportCSV = useCallback(async () => {
if (!canDownloadCSV) return null;
// Determine row count for streaming threshold check
@@ -378,26 +405,31 @@ export const useExploreAdditionalActionsMenu = (
filename = `${safeChartName}${timestamp}.csv`;
}
return exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'full',
resultFormat: 'csv',
onStartStreamingExport: shouldUseStreaming
? exportParams => {
if (exportParams.url) {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
url: exportParams.url,
filename,
expectedRows: actualRowCount,
exportType: exportParams.exportType as 'csv' | 'xlsx',
});
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'full',
resultFormat: 'csv',
onStartStreamingExport: shouldUseStreaming
? exportParams => {
if (exportParams.url) {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
url: exportParams.url,
filename,
expectedRows: actualRowCount,
exportType: exportParams.exportType as 'csv' | 'xlsx',
});
}
}
}
: null,
});
: null,
});
} catch (error) {
handleExportError(error);
}
return null;
}, [
canDownloadCSV,
latestQueryFormData,
@@ -406,46 +438,59 @@ export const useExploreAdditionalActionsMenu = (
streamingThreshold,
slice,
startExport,
handleExportError,
]);
const exportCSVPivoted = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const exportCSVPivoted = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportJson = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const exportJson = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportExcel = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const exportExcel = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const copyLink = useCallback(async () => {
try {
@@ -805,7 +850,7 @@ export const useExploreAdditionalActionsMenu = (
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
onClick: async () => {
// Use 'results' to export the *current view* (as opposed to 'full').
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
if (
@@ -820,12 +865,16 @@ export const useExploreAdditionalActionsMenu = (
slice?.slice_name || 'current_view',
);
} else {
exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'csv',
});
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
}
setIsDropdownVisible(false);
dispatch(
@@ -1058,6 +1107,7 @@ export const useExploreAdditionalActionsMenu = (
exportCSVPivoted,
exportExcel,
exportJson,
handleExportError,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,

View File

@@ -0,0 +1,150 @@
/**
* 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 { ComponentType } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { useExploreAdditionalActionsMenu } from './index';
import * as exploreUtils from 'src/explore/exploreUtils';
jest.mock('src/explore/exploreUtils', () => ({
__esModule: true,
...jest.requireActual('src/explore/exploreUtils'),
exportChart: jest.fn(),
getChartKey: jest.fn(() => 'test_chart_key'),
}));
const mockExportChart = exploreUtils.exportChart as jest.Mock;
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (component: ComponentType) => component,
useToasts: () => ({
addDangerToast: mockAddDangerToast,
addSuccessToast: jest.fn(),
}),
}));
jest.mock('src/logger/actions', () => ({
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
getChartMetadataRegistry: jest.fn(() => ({
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
})),
}));
const defaultProps = {
latestQueryFormData: {
datasource: '1__table',
viz_type: 'pivot_table_v2',
},
canDownloadCSV: true,
slice: { slice_id: 1, slice_name: 'Test Chart' },
ownState: {},
dashboards: [],
onOpenInEditor: jest.fn(),
onOpenPropertiesModal: jest.fn(),
showReportModal: jest.fn(),
setCurrentReportDeleting: jest.fn(),
};
type TestComponentProps = typeof defaultProps;
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
const TestComponent = (props: TestComponentProps) => {
const [menu] = useExploreAdditionalActionsMenu(
props.latestQueryFormData as HookParams[0],
props.canDownloadCSV,
props.slice as HookParams[2],
props.onOpenInEditor,
props.onOpenPropertiesModal,
props.ownState as HookParams[5],
props.dashboards as HookParams[6],
props.showReportModal,
props.setCurrentReportDeleting,
);
return <div>{menu}</div>;
};
beforeEach(() => {
jest.clearAllMocks();
mockExportChart.mockResolvedValue(undefined);
});
test('shows 413 error toast when exportCSV fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to original .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(
<TestComponent
{...defaultProps}
latestQueryFormData={{
datasource: '1__table',
viz_type: 'table',
}}
ownState={{}}
/>,
{ useRedux: true },
);
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
userEvent.click(await screen.findByText('Export to .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});

View File

@@ -18,6 +18,11 @@
*/
import { exportChart } from '.';
jest.mock('src/utils/export', () => ({
...jest.requireActual('src/utils/export'),
downloadBlob: jest.fn(),
}));
// Mock pathUtils to control app root prefix
jest.mock('src/utils/pathUtils', () => ({
ensureAppRoot: jest.fn((path: string) => path),
@@ -27,6 +32,7 @@ jest.mock('src/utils/pathUtils', () => ({
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
postBlob: jest.fn(),
postForm: jest.fn(),
get: jest.fn().mockResolvedValue({ json: {} }),
post: jest.fn().mockResolvedValue({ json: {} }),
@@ -41,6 +47,14 @@ jest.mock('@superset-ui/core', () => ({
const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
const { downloadBlob } = jest.requireMock('src/utils/export');
const mockBlob = new Blob(['test data'], { type: 'text/csv' });
const createMockExportResponse = (headers: Headers = new Headers()) => ({
headers,
blob: jest.fn().mockResolvedValue(mockBlob),
});
// Minimal formData that won't trigger legacy API (useLegacyApi = false)
const baseFormData = {
@@ -113,22 +127,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as
expect(callArgs.exportType).toBe('xlsx');
});
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot
// Regression test for the double-prefix bug: SupersetClient.postBlob adds appRoot
// internally via getUrl(), so the URL passed must NOT already be prefixed.
test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => {
test('exportChart v1 API calls postBlob with unprefixed URL when app root is configured', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const appRoot = '/analytics';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0];
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postBlob.mock.calls[0];
expect(url).toBe('/api/v1/chart/data');
expect(url).not.toContain(appRoot);
expect(downloadBlob).toHaveBeenCalled();
});
test('exportChart passes csv exportType for CSV exports', async () => {
@@ -240,9 +256,10 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
});
test('exportChart legacy API calls postForm with relative URL', async () => {
test('exportChart legacy API calls postBlob with relative URL', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
ensureAppRoot.mockImplementation((path: string) => path);
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
getChartMetadataRegistry.mockReturnValue({
get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
@@ -259,10 +276,11 @@ test('exportChart legacy API calls postForm with relative URL', async () => {
resultType: 'full',
});
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0];
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postBlob.mock.calls[0];
expect(url).toBe('/superset/explore_json/?csv=true');
expect(url).not.toMatch(/^https?:\/\//);
expect(downloadBlob).toHaveBeenCalled();
});
test('exportChart legacy API includes force param when force=true', async () => {
@@ -289,3 +307,187 @@ test('exportChart legacy API includes force param when force=true', async () =>
const callArgs = onStartStreamingExport.mock.calls[0][0];
expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
});
test('exportChart successfully exports chart as CSV', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
resultType: 'full',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
expect(mockResponse.blob).toHaveBeenCalled();
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringContaining('.csv'),
);
});
test('exportChart successfully exports chart as Excel', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'xlsx',
resultType: 'results',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
expect(mockResponse.blob).toHaveBeenCalled();
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringContaining('.xlsx'),
);
});
test('exportChart throws error with status 413 when payload is too large', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockErrorResponse = new Response('Payload Too Large', {
status: 413,
statusText: 'Payload Too Large',
});
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'csv',
}),
).rejects.toMatchObject({
status: 413,
message: expect.stringContaining('413'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart throws error with status 500 for server errors', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockErrorResponse = new Response('Internal Server Error', {
status: 500,
statusText: 'Internal Server Error',
});
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'json',
}),
).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('500'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart enhances errors without status property', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const genericError = new Error('Network error');
SupersetClient.postBlob.mockRejectedValue(genericError);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'csv',
}),
).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('Network error'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart uses streaming export when onStartStreamingExport is provided', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockStreamingHandler = jest.fn();
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
onStartStreamingExport: mockStreamingHandler as unknown as null,
});
expect(mockStreamingHandler).toHaveBeenCalledTimes(1);
expect(mockStreamingHandler).toHaveBeenCalledWith(
expect.objectContaining({
url: '/api/v1/chart/data',
exportType: 'csv',
}),
);
expect(SupersetClient.postBlob).not.toHaveBeenCalled();
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart generates correct filename with timestamp', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
const mockDate = new Date('2025-01-14T12:34:56.789Z');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringMatching(
/^chart_export_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.csv$/,
),
);
jest.spyOn(global, 'Date').mockRestore();
});
test('exportChart uses filename from Content-Disposition header', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse(
new Headers({
'Content-Disposition': 'attachment; filename="export.zip"',
}),
);
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(mockBlob, 'export.zip');
});
test('exportChart uses zip extension when Content-Type is application/zip', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockDate = new Date('2025-01-14T12:34:56.789Z');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const mockResponse = createMockExportResponse(
new Headers({
'Content-Type': 'application/zip',
}),
);
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
'chart_export_2025-01-14T12-34-56.zip',
);
jest.spyOn(global, 'Date').mockRestore();
});

View File

@@ -34,6 +34,7 @@ import { availableDomains } from 'src/utils/hostNamesConfig';
import { safeStringify } from 'src/utils/safeStringify';
import { optionLabel } from 'src/utils/common';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { downloadBlob, getFilenameFromResponse } from 'src/utils/export';
import { URL_PARAMS } from 'src/constants';
import {
DISABLE_INPUT_OPERATORS,
@@ -398,11 +399,54 @@ export const exportChart = async ({
exportSource: 'chart',
});
} else {
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
// Use AJAX blob download instead of form submission to enable error handling.
// SupersetClient.postBlob calls getUrl({ endpoint }) internally, which prepends
// appRoot — so the URL must NOT be pre-prefixed here.
SupersetClient.postForm(url as string, {
form_data: safeStringify(payload),
});
try {
const response = await SupersetClient.postBlob(url as string, {
form_data: safeStringify(payload),
});
const extension = resultFormat === 'xlsx' ? 'xlsx' : resultFormat;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, -5);
const fallbackFilename = `chart_export_${timestamp}.${extension}`;
const filename = getFilenameFromResponse(response, fallbackFilename);
const blob = await response.blob();
downloadBlob(blob, filename);
} catch (error) {
if (error instanceof Response) {
const responseError = new Error(
`HTTP ${error.status} ${error.statusText}`,
) as Error & {
status: number;
statusText: string;
response: Response;
};
responseError.status = error.status;
responseError.statusText = error.statusText;
responseError.response = error;
throw responseError;
}
const exportError = error as Error & {
status?: number;
originalError?: unknown;
};
if (!exportError.status) {
const enhancedError = new Error(
exportError.message || 'Export failed',
) as Error & { status: number; originalError: unknown };
enhancedError.status = 500;
enhancedError.originalError = error;
throw enhancedError;
}
throw error;
}
}
};

View File

@@ -243,7 +243,7 @@ test('handles create dashboard button click', async () => {
const createButton = screen.getByRole('button', { name: /dashboard$/i });
await userEvent.click(createButton);
expect(assignMock).toHaveBeenCalledWith('/dashboard/new');
expect(assignMock).toHaveBeenCalledWith('/dashboard/new/');
locationSpy.mockRestore();
});

View File

@@ -203,7 +203,7 @@ function DashboardTable({
name: t('Dashboard'),
buttonStyle: 'secondary',
onClick: () => {
navigateTo('/dashboard/new', { assign: true });
navigateTo('/dashboard/new/', { assign: true });
},
},
{

View File

@@ -57,7 +57,7 @@ const LABELS = {
const REDIRECTS = {
create: {
[WelcomeTable.Charts]: '/chart/add',
[WelcomeTable.Dashboards]: '/dashboard/new',
[WelcomeTable.Dashboards]: '/dashboard/new/',
// navigateTo() applies the application root internally; keep this
// relative so the prefix isn't added twice.
[WelcomeTable.SavedQueries]: '/sqllab?new=true',

View File

@@ -89,7 +89,7 @@ const dropdownItems = [
},
{
label: 'Dashboard',
url: '/dashboard/new',
url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard',
perm: 'can_write',
view: 'Dashboard',

View File

@@ -105,7 +105,7 @@ const dropdownItems = [
},
{
label: 'Dashboard',
url: '/dashboard/new',
url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard',
perm: 'can_write',
view: 'Dashboard',

View File

@@ -232,7 +232,7 @@ const RightMenu = ({
},
{
label: t('Dashboard'),
url: '/dashboard/new',
url: '/dashboard/new/',
icon: (
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
),

View File

@@ -762,7 +762,7 @@ function DashboardList(props: DashboardListProps) {
name: t('Dashboard'),
buttonStyle: 'primary',
onClick: () => {
navigateTo('/dashboard/new', { assign: true });
navigateTo('/dashboard/new/', { assign: true });
},
});
}

View File

@@ -19,7 +19,7 @@
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { parse as parseContentDisposition } from 'content-disposition';
import handleResourceExport from './export';
import handleResourceExport, { getFilenameFromResponse } from './export';
// Mock dependencies
jest.mock('@superset-ui/core', () => ({
@@ -454,3 +454,59 @@ test.each(doublePrefixTestCases)(
(ensureAppRoot as jest.Mock).mockImplementation((path: string) => path);
},
);
test('getFilenameFromResponse returns filename from Content-Disposition', () => {
(parseContentDisposition as jest.Mock).mockReturnValueOnce({
parameters: { filename: 'server_export.csv' },
});
const response = {
headers: new Headers({
'Content-Disposition': 'attachment; filename="server_export.csv"',
}),
} as Response;
expect(getFilenameFromResponse(response, 'fallback.csv')).toBe(
'server_export.csv',
);
});
test('getFilenameFromResponse uses zip extension when Content-Type is zip', () => {
const response = {
headers: new Headers({
'Content-Type': 'application/zip',
}),
} as Response;
expect(getFilenameFromResponse(response, 'chart_export_2025.csv')).toBe(
'chart_export_2025.zip',
);
});
test('getFilenameFromResponse returns fallback when no headers match', () => {
const response = {
headers: new Headers(),
} as Response;
expect(getFilenameFromResponse(response, 'chart_export_2025.csv')).toBe(
'chart_export_2025.csv',
);
});
test('getFilenameFromResponse falls back when Content-Disposition parsing fails', () => {
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
throw new Error('Parse error');
});
const response = {
headers: new Headers({
'Content-Disposition': 'invalid',
}),
} as Response;
expect(getFilenameFromResponse(response, 'fallback.csv')).toBe(
'fallback.csv',
);
expect(logging.warn).toHaveBeenCalledWith(
'Failed to parse Content-Disposition header:',
expect.any(Error),
);
});

View File

@@ -29,7 +29,35 @@ const MAX_BLOB_SIZE = 100 * 1024 * 1024;
* @param blob - The blob to download
* @param fileName - The filename to use for the download
*/
function downloadBlob(blob: Blob, fileName: string): void {
/**
* Derives a download filename from response headers, falling back when absent.
*/
export function getFilenameFromResponse(
response: Response,
fallback: string,
): string {
const disposition = response.headers.get('Content-Disposition');
if (disposition) {
try {
const parsed = parseContentDisposition(disposition);
if (parsed?.parameters?.filename) {
return parsed.parameters.filename;
}
} catch (error) {
logging.warn('Failed to parse Content-Disposition header:', error);
}
}
const contentType = response.headers.get('Content-Type') ?? '';
if (contentType.includes('zip')) {
const base = fallback.replace(/\.[^.]+$/, '');
return `${base}.zip`;
}
return fallback;
}
export function downloadBlob(blob: Blob, fileName: string): void {
const url = window.URL.createObjectURL(blob);
try {
const a = document.createElement('a');

View File

@@ -0,0 +1,35 @@
/**
* 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 { sanitizeDocumentTitle } from './sanitizeDocumentTitle';
test('removes all C0 control characters including tab/LF/CR', () => {
expect(sanitizeDocumentTitle('a\x08b')).toBe('ab');
expect(sanitizeDocumentTitle('x\x09y')).toBe('xy');
expect(sanitizeDocumentTitle('x\ny')).toBe('xy');
expect(sanitizeDocumentTitle('x\ry')).toBe('xy');
});
test('removes DEL and C1 controls', () => {
expect(sanitizeDocumentTitle('a\x7Fb')).toBe('ab');
expect(sanitizeDocumentTitle('a\x9Fb')).toBe('ab');
});
test('leaves normal text unchanged', () => {
expect(sanitizeDocumentTitle('Dashboard 你好')).toBe('Dashboard 你好');
});

View File

@@ -0,0 +1,27 @@
/**
* 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.
*/
/**
* Strip all C0/C1 control characters (U+0000U+001F, U+007FU+009F).
* Headless browsers (Playwright/Chromium) can hang or crash when document.title
* contains characters such as U+0008 (backspace).
*/
export function sanitizeDocumentTitle(title: string): string {
return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
}

View File

@@ -77,6 +77,7 @@ from superset.utils import json
from superset.utils.core import HeaderDataType, override_user, recipients_string_to_list
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
from superset.utils.decorators import logs_context, transaction
from superset.utils.file import sanitize_title
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
from superset.utils.slack import get_channels_with_search, SlackChannelTypes
@@ -701,7 +702,7 @@ class BaseReportState:
error_text = "Unexpected missing csv file"
if error_text:
return NotificationContent(
name=self._report_schedule.name,
name=sanitize_title(self._report_schedule.name),
text=error_text,
header_data=header_data,
url=url,
@@ -714,15 +715,15 @@ class BaseReportState:
embedded_data = self._get_embedded_data()
if self._report_schedule.email_subject:
name = self._report_schedule.email_subject
name = sanitize_title(self._report_schedule.email_subject)
else:
if self._report_schedule.chart:
name = (
name = sanitize_title(
f"{self._report_schedule.name}: "
f"{self._report_schedule.chart.slice_name}"
)
else:
name = (
name = sanitize_title(
f"{self._report_schedule.name}: "
f"{self._report_schedule.dashboard.dashboard_title}"
)
@@ -821,7 +822,7 @@ class BaseReportState:
self._execution_id,
)
notification_content = NotificationContent(
name=name, text=message, header_data=header_data, url=url
name=sanitize_title(name), text=message, header_data=header_data, url=url
)
# filter recipients to recipients who are also owners

View File

@@ -1979,7 +1979,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if offset_metrics_df.empty:
offset_metrics_df = pd.DataFrame(
{
col: [np.NaN]
col: [np.nan]
for col in join_keys + list(metrics_mapping.values())
}
)

View File

@@ -14,10 +14,23 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import re
from werkzeug.utils import secure_filename
# All C0 (U+0000U+001F) and C1 (U+007FU+009F) control characters.
# Stripping every control char (including tab, LF, CR) keeps titles safe for
# SMTP headers, Content-Disposition filenames, and headless-browser document.title.
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
def sanitize_title(title: str) -> str:
"""Remove all C0/C1 control characters from a title string."""
return _CONTROL_CHARS_RE.sub("", title)
def get_filename(model_name: str, model_id: int, skip_id: bool = False) -> str:
model_name = sanitize_title(model_name)
slug = secure_filename(model_name)
filename = slug if skip_id else f"{slug}_{model_id}"
return filename if slug else str(model_id)

View File

@@ -71,13 +71,21 @@ def _prophet_fit_and_predict( # pylint: disable=too-many-arguments
)
if df["ds"].dt.tz:
df["ds"] = df["ds"].dt.tz_convert(None)
model.fit(df)
future = model.make_future_dataframe(periods=periods, freq=freq)
forecast = model.predict(future)[["ds", "yhat", "yhat_lower", "yhat_upper"]]
try:
model.fit(df)
future = model.make_future_dataframe(periods=periods, freq=freq)
forecast = model.predict(future)[["ds", "yhat", "yhat_lower", "yhat_upper"]]
except Exception as ex: # noqa: BLE001
raise InvalidPostProcessingError(
_(
"Unable to generate forecast: %(error)s",
error=str(ex),
)
) from ex
return forecast.join(df.set_index("ds"), on="ds").set_index(["ds"])
def prophet( # pylint: disable=too-many-arguments
def prophet( # pylint: disable=too-many-arguments # noqa: C901
df: DataFrame,
time_grain: str,
periods: int,
@@ -136,6 +144,8 @@ def prophet( # pylint: disable=too-many-arguments
raise InvalidPostProcessingError(_("DataFrame must include temporal column"))
if len(df.columns) < 2:
raise InvalidPostProcessingError(_("DataFrame include at least one series"))
if len(df) < 2:
raise InvalidPostProcessingError(_("Forecast requires at least 2 data points"))
target_df = DataFrame()

View File

@@ -26,6 +26,8 @@ from pandas import DataFrame, NamedAgg
from superset.constants import TimeGrain
from superset.exceptions import InvalidPostProcessingError
_PANDAS_VERSION = tuple(int(x) for x in pd.__version__.split(".")[:2])
NUMPY_FUNCTIONS: dict[str, Callable[..., Any]] = {
"average": np.average,
"argmin": np.argmin,
@@ -76,18 +78,18 @@ ALLOWLIST_CUMULATIVE_FUNCTIONS = (
)
PROPHET_TIME_GRAIN_MAP: dict[str, str] = {
TimeGrain.SECOND: "S",
TimeGrain.SECOND: "s",
TimeGrain.MINUTE: "min",
TimeGrain.FIVE_MINUTES: "5min",
TimeGrain.TEN_MINUTES: "10min",
TimeGrain.FIFTEEN_MINUTES: "15min",
TimeGrain.THIRTY_MINUTES: "30min",
TimeGrain.HOUR: "H",
TimeGrain.HOUR: "h",
TimeGrain.DAY: "D",
TimeGrain.WEEK: "W",
TimeGrain.MONTH: "M",
TimeGrain.QUARTER: "Q",
TimeGrain.YEAR: "A",
TimeGrain.MONTH: "ME" if _PANDAS_VERSION >= (2, 2) else "M",
TimeGrain.QUARTER: "QE" if _PANDAS_VERSION >= (2, 2) else "Q",
TimeGrain.YEAR: "YE" if _PANDAS_VERSION >= (2, 2) else "A",
TimeGrain.WEEK_STARTING_SUNDAY: "W-SUN",
TimeGrain.WEEK_STARTING_MONDAY: "W-MON",
TimeGrain.WEEK_ENDING_SATURDAY: "W-SAT",

View File

@@ -312,6 +312,10 @@ class BaseScreenshot:
if image:
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 (line 302 or 308 above).
cache_payload.error()
logger.info("Caching thumbnail: %s", cache_key)
self.cache.set(cache_key, cache_payload.to_dict())

View File

@@ -342,15 +342,6 @@ class WebDriverPlaywright(WebDriverProxy):
selenium_animation_wait = app.config[
"SCREENSHOT_SELENIUM_ANIMATION_WAIT"
]
logger.debug(
"Wait %i seconds for chart animation", selenium_animation_wait
)
page.wait_for_timeout(selenium_animation_wait * 1000)
logger.debug(
"Taking a PNG screenshot of url %s as user %s",
url,
user.username if user else "None",
)
if app.config["SCREENSHOT_REPLACE_UNEXPECTED_ERRORS"]:
unexpected_errors = WebDriverPlaywright.find_unexpected_errors(page)
if unexpected_errors:
@@ -415,21 +406,38 @@ class WebDriverPlaywright(WebDriverProxy):
load_wait=self._screenshot_load_wait,
animation_wait=selenium_animation_wait,
)
if img is None:
logger.error(
"Tiled screenshot failed at url %s; "
"not falling back to avoid sending a blank PDF",
url,
if not img:
logger.warning(
(
"Tiled screenshot failed, "
"falling back to standard screenshot"
)
)
return None
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
logger.debug(
"Tiled screenshot result: %d bytes for url: %s",
len(img) if img else 0,
url,
)
else:
logger.debug(
"Dashboard below tiling threshold "
"(%s charts, %spx height); using standard screenshot "
"for url: %s",
chart_count,
dashboard_height,
url,
)
# Standard screenshot captures the full element including
# below-the-fold content, so wait for all spinners globally.
try:
logger.debug(
"Wait for loading element of charts to be gone"
" at url: %s",
"Waiting for all spinners to clear at url: %s "
"(SCREENSHOT_LOAD_WAIT=%ss)",
url,
self._screenshot_load_wait,
)
page.wait_for_function(
"() => document.querySelectorAll("
@@ -444,16 +452,40 @@ class WebDriverPlaywright(WebDriverProxy):
self._screenshot_load_wait,
)
raise
logger.debug("All spinners cleared for url: %s", url)
if selenium_animation_wait > 0:
logger.debug(
"Wait %i seconds for chart animation",
selenium_animation_wait,
)
page.wait_for_timeout(selenium_animation_wait * 1000)
logger.debug(
"Taking screenshot of url %s as user %s",
url,
user.username if user else "None",
)
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
logger.debug(
"Screenshot result: %d bytes for url: %s",
len(img) if img else 0,
url,
)
else:
logger.debug(
"Tiled screenshots disabled; using standard screenshot "
"for url: %s",
url,
)
# Standard screenshot captures the full element including
# below-the-fold content, so wait for all spinners globally.
try:
logger.debug(
"Wait for loading element of charts to be gone at url: %s",
"Waiting for all spinners to clear at url: %s "
"(SCREENSHOT_LOAD_WAIT=%ss)",
url,
self._screenshot_load_wait,
)
page.wait_for_function(
"() => document.querySelectorAll('.loading').length === 0",
@@ -467,9 +499,26 @@ class WebDriverPlaywright(WebDriverProxy):
self._screenshot_load_wait,
)
raise
logger.debug("All spinners cleared for url: %s", url)
if selenium_animation_wait > 0:
logger.debug(
"Wait %i seconds for chart animation",
selenium_animation_wait,
)
page.wait_for_timeout(selenium_animation_wait * 1000)
logger.debug(
"Taking screenshot of url %s as user %s",
url,
user.username if user else "None",
)
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
logger.debug(
"Screenshot result: %d bytes for url: %s",
len(img) if img else 0,
url,
)
except PlaywrightTimeout:
raise

View File

@@ -16,6 +16,7 @@
# under the License.
from datetime import datetime
from importlib.util import find_spec
from unittest.mock import patch
import pandas as pd
import pytest
@@ -186,6 +187,43 @@ def test_prophet_incorrect_time_grain():
)
def test_prophet_insufficient_data():
single_row_df = pd.DataFrame(
{
DTTM_ALIAS: [datetime(2022, 1, 1)],
"sales": [100.0],
}
)
with pytest.raises(InvalidPostProcessingError, match="at least 2 data points"):
prophet(
df=single_row_df,
time_grain="P1D",
periods=3,
confidence_interval=0.9,
)
def test_prophet_fit_error():
if find_spec("prophet") is None:
pytest.skip("prophet not installed")
with patch(
"superset.utils.pandas_postprocessing.prophet._prophet_fit_and_predict"
) as mock_fit:
mock_fit.side_effect = InvalidPostProcessingError(
"Unable to generate forecast: Dataframe has fewer than 2 non-NaN rows."
)
with pytest.raises(
InvalidPostProcessingError, match="Unable to generate forecast"
):
prophet(
df=prophet_df,
time_grain="P1D",
periods=3,
confidence_interval=0.9,
)
def test_prophet_uncertainty_lower_bound_can_be_negative_for_negative_series():
"""
Regression for #21734: when the input series contains negative values,

View File

@@ -16,7 +16,7 @@
# under the License.
import pytest
from superset.utils.file import get_filename
from superset.utils.file import get_filename, sanitize_title
@pytest.mark.parametrize(
@@ -34,6 +34,9 @@ from superset.utils.file import get_filename
("你好", 475, True, "475"),
("Energy Sankey 你好", 475, False, "Energy_Sankey_475"),
("Energy Sankey 你好", 475, True, "Energy_Sankey"),
("Energy\x08Sankey", 132, False, "EnergySankey_132"),
("Energy\x08Sankey", 132, True, "EnergySankey"),
("Sales\x7fReport", 1, False, "SalesReport_1"),
],
)
def test_get_filename(
@@ -41,3 +44,20 @@ def test_get_filename(
) -> None:
original_filename = get_filename(model_name, model_id, skip_id)
assert expected_filename == original_filename
@pytest.mark.parametrize(
("raw", "expected"),
[
("normal", "normal"),
("a\x08b", "ab"),
("x\x09y", "xy"),
("x\ny", "xy"),
("x\ry", "xy"),
("\x00\x01\x02", ""),
("a\x7fb", "ab"),
("a\x9fb", "ab"),
],
)
def test_sanitize_title(raw: str, expected: str) -> None:
assert sanitize_title(raw) == expected

View File

@@ -149,6 +149,25 @@ class TestCacheOnlyOnSuccess:
assert cached_value["status"] == "Updated"
assert cached_value["image"] is not None
def test_cache_error_status_when_screenshot_returns_empty_bytes(
self, mocker: MockerFixture, screenshot_obj, mock_user
):
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING."""
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
return_value=b"",
)
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
def test_no_intermediate_cache_during_computing(
self, mocker: MockerFixture, screenshot_obj, mock_user
):

View File

@@ -29,7 +29,7 @@ from superset.utils.webdriver import (
)
@pytest.fixture
@pytest.fixture()
def mock_app():
"""Mock Flask app with webdriver configuration."""
app = MagicMock()
@@ -910,11 +910,10 @@ class TestWebDriverPlaywrightErrorHandling:
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
@patch("superset.utils.webdriver.take_tiled_screenshot")
def test_tiled_screenshot_failure_returns_none_without_fallback(
def test_tiled_screenshot_failure_falls_back_to_standard_screenshot(
self, mock_take_tiled, mock_logger, mock_browser_manager
) -> None:
"""When take_tiled_screenshot fails, return None rather than fall back to a
potentially blank standard screenshot."""
"""When take_tiled_screenshot returns None, fall back to standard screenshot."""
mock_user = MagicMock()
mock_user.username = "test_user"
@@ -928,7 +927,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
mock_element.screenshot.return_value = b"should_not_be_called"
# page.screenshot is used by _get_screenshot for the "standalone" element
mock_page.screenshot.return_value = b"fallback_screenshot"
def evaluate_side_effect(script):
if "querySelectorAll" in script:
@@ -938,7 +938,7 @@ class TestWebDriverPlaywrightErrorHandling:
return None
mock_page.evaluate.side_effect = evaluate_side_effect
mock_take_tiled.return_value = None # tiled screenshot fails
mock_take_tiled.return_value = None # tiled screenshot returns None
with patch("superset.utils.webdriver.app") as mock_app:
mock_app.config = {
@@ -964,13 +964,240 @@ class TestWebDriverPlaywrightErrorHandling:
driver = WebDriverPlaywright("chrome")
result = driver.get_screenshot(
"http://example.com", "dashboard", mock_user
"http://example.com", "standalone", mock_user
)
assert result is None
mock_element.screenshot.assert_not_called()
mock_logger.error.assert_any_call(
"Tiled screenshot failed at url %s; "
"not falling back to avoid sending a blank PDF",
"http://example.com",
assert result == b"fallback_screenshot"
mock_take_tiled.assert_called_once()
mock_logger.warning.assert_any_call(
("Tiled screenshot failed, falling back to standard screenshot"),
)
class TestWebDriverPlaywrightAnimationWaitOrder:
"""Animation wait must run after the spinner wait, not before."""
_base_config = {
"WEBDRIVER_OPTION_ARGS": [],
"WEBDRIVER_WINDOW": {"pixel_density": 1},
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
"SCREENSHOT_SELENIUM_HEADSTART": 0,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 2,
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 30,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
}
def _make_pw_mocks(self, mock_sync_playwright):
mock_playwright_instance = MagicMock()
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
mock_element = MagicMock()
mock_sync_playwright.return_value.__enter__.return_value = (
mock_playwright_instance
)
mock_playwright_instance.chromium.launch.return_value = mock_browser
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
mock_page.locator.return_value = mock_element
mock_element.screenshot.return_value = b"screenshot"
return mock_context, mock_page
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver.sync_playwright")
@patch("superset.utils.webdriver.app")
def test_animation_wait_after_spinner_wait_tiled_disabled(
self, mock_app, mock_sync_playwright
):
"""Non-tiled path: animation wait runs after spinner wait_for_function."""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_app.config = {**self._base_config, "SCREENSHOT_TILED_ENABLED": False}
mock_context, mock_page = self._make_pw_mocks(mock_sync_playwright)
call_order: list[str] = []
def record_wait_for_function(*args, **kwargs):
call_order.append("spinner_wait")
def record_wait_for_timeout(ms):
if ms == 2 * 1000:
call_order.append("animation_wait")
mock_page.wait_for_function.side_effect = record_wait_for_function
mock_page.wait_for_timeout.side_effect = record_wait_for_timeout
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
WebDriverPlaywright("chrome").get_screenshot(
"http://example.com", "test-element", mock_user
)
assert "spinner_wait" in call_order
assert "animation_wait" in call_order
spinner_idx = call_order.index("spinner_wait")
anim_idx = call_order.index("animation_wait")
assert spinner_idx < anim_idx, (
"spinner wait must precede animation wait in non-tiled path"
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver.sync_playwright")
@patch("superset.utils.webdriver.app")
def test_animation_wait_after_spinner_wait_tiled_enabled_small_dashboard(
self, mock_app, mock_sync_playwright
):
"""Non-tiled path (tiled on, small dashboard): animation after spinner."""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_app.config = {
**self._base_config,
"SCREENSHOT_TILED_ENABLED": True,
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
}
mock_context, mock_page = self._make_pw_mocks(mock_sync_playwright)
# Small dashboard: 3 charts, 1000px height — below both thresholds
mock_page.evaluate.side_effect = [3, 1000]
call_order: list[str] = []
def record_wait_for_function(*args, **kwargs):
call_order.append("spinner_wait")
def record_wait_for_timeout(ms):
if ms == 2 * 1000:
call_order.append("animation_wait")
mock_page.wait_for_function.side_effect = record_wait_for_function
mock_page.wait_for_timeout.side_effect = record_wait_for_timeout
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
WebDriverPlaywright("chrome").get_screenshot(
"http://example.com", "test-element", mock_user
)
assert "spinner_wait" in call_order
assert "animation_wait" in call_order
assert call_order.index("spinner_wait") < call_order.index("animation_wait")
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver.sync_playwright")
@patch("superset.utils.webdriver.take_tiled_screenshot")
@patch("superset.utils.webdriver.app")
def test_tiled_path_passes_animation_wait_per_tile_no_global_wait(
self, mock_app, mock_take_tiled, mock_sync_playwright
):
"""Tiled path delegates animation_wait to take_tiled_screenshot; no global."""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_app.config = {
**self._base_config,
"SCREENSHOT_TILED_ENABLED": True,
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
}
mock_context, mock_page = self._make_pw_mocks(mock_sync_playwright)
# Large dashboard: 25 charts, 6000px height
mock_page.evaluate.side_effect = [25, 6000]
mock_take_tiled.return_value = b"tiled_screenshot"
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
result = WebDriverPlaywright("chrome").get_screenshot(
"http://example.com", "standalone", mock_user
)
assert result == b"tiled_screenshot"
mock_take_tiled.assert_called_once_with(
mock_page,
"standalone",
600,
load_wait=30,
animation_wait=2,
)
# The only wait_for_timeout call should be the 0ms headstart; no global
# animation wait should be issued (handled per-tile by take_tiled_screenshot)
animation_waits = [
call[0][0]
for call in mock_page.wait_for_timeout.call_args_list
if call[0][0] == 2 * 1000
]
assert animation_waits == [], (
"No global 2s animation wait_for_timeout should fire on the tiled path"
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver.sync_playwright")
@patch("superset.utils.webdriver.take_tiled_screenshot")
@patch("superset.utils.webdriver.app")
def test_tiled_fallback_triggered_on_empty_bytes(
self, mock_app, mock_take_tiled, mock_sync_playwright
):
"""Tiled fallback fires when take_tiled_screenshot returns b"" (not None)."""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_app.config = {
**self._base_config,
"SCREENSHOT_TILED_ENABLED": True,
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
}
mock_context, mock_page = self._make_pw_mocks(mock_sync_playwright)
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
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
)
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)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver.sync_playwright")
@patch("superset.utils.webdriver.app")
def test_animation_wait_skipped_when_zero(self, mock_app, mock_sync_playwright):
"""No extra wait_for_timeout call when SCREENSHOT_SELENIUM_ANIMATION_WAIT=0."""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_app.config = {
**self._base_config,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 0,
"SCREENSHOT_TILED_ENABLED": False,
}
mock_context, mock_page = self._make_pw_mocks(mock_sync_playwright)
with patch.object(WebDriverPlaywright, "auth", return_value=mock_context):
WebDriverPlaywright("chrome").get_screenshot(
"http://example.com", "test-element", mock_user
)
# Only headstart (0ms) should be called; no animation wait call
timeout_values = [
call[0][0] for call in mock_page.wait_for_timeout.call_args_list
]
assert timeout_values == [0], (
f"Expected only [0] (headstart), got {timeout_values}"
)