mirror of
https://github.com/apache/superset.git
synced 2026-06-25 01:19:17 +00:00
Compare commits
12 Commits
fix/105973
...
reports-sc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8716f627ee | ||
|
|
ec23f11ce6 | ||
|
|
ce2f8b53fd | ||
|
|
4d5b8163d5 | ||
|
|
e28176302f | ||
|
|
6f12d17313 | ||
|
|
09c7ba14df | ||
|
|
3ec4bd23c4 | ||
|
|
f6ce105450 | ||
|
|
7bb4e82a82 | ||
|
|
2d78a8733c | ||
|
|
3261d10270 |
@@ -375,6 +375,7 @@ select = [
|
||||
|
||||
ignore = [
|
||||
"S101",
|
||||
"PT001", # pytest-fixture-incorrect-parentheses-style: different ruff versions disagree
|
||||
"PT006",
|
||||
"T201",
|
||||
"N999",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
|
||||
| 'get'
|
||||
| 'post'
|
||||
| 'postForm'
|
||||
| 'postBlob'
|
||||
| 'put'
|
||||
| 'request'
|
||||
| 'init'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function DashboardTable({
|
||||
name: t('Dashboard'),
|
||||
buttonStyle: 'secondary',
|
||||
onClick: () => {
|
||||
navigateTo('/dashboard/new', { assign: true });
|
||||
navigateTo('/dashboard/new/', { assign: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -89,7 +89,7 @@ const dropdownItems = [
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
|
||||
@@ -105,7 +105,7 @@ const dropdownItems = [
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
|
||||
@@ -232,7 +232,7 @@ const RightMenu = ({
|
||||
},
|
||||
{
|
||||
label: t('Dashboard'),
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: (
|
||||
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
|
||||
),
|
||||
|
||||
@@ -762,7 +762,7 @@ function DashboardList(props: DashboardListProps) {
|
||||
name: t('Dashboard'),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
navigateTo('/dashboard/new', { assign: true });
|
||||
navigateTo('/dashboard/new/', { assign: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
35
superset-frontend/src/utils/sanitizeDocumentTitle.test.ts
Normal file
35
superset-frontend/src/utils/sanitizeDocumentTitle.test.ts
Normal 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 你好');
|
||||
});
|
||||
27
superset-frontend/src/utils/sanitizeDocumentTitle.ts
Normal file
27
superset-frontend/src/utils/sanitizeDocumentTitle.ts
Normal 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+0000–U+001F, U+007F–U+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, '');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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+0000–U+001F) and C1 (U+007F–U+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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user