mirror of
https://github.com/apache/superset.git
synced 2026-07-28 17:42:40 +00:00
Compare commits
6 Commits
chore/depe
...
ci/fronten
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bdc6e0bed | ||
|
|
f7e29c15d5 | ||
|
|
e1d7e61514 | ||
|
|
987c5ada54 | ||
|
|
26b6f7bb5d | ||
|
|
a10c3f0b1d |
63
.github/workflows/dependabot-auto-approve.yml
vendored
63
.github/workflows/dependabot-auto-approve.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Auto-approve Dependabot patch bumps
|
||||
|
||||
# Posts an approving review on Dependabot PRs that only bump a patch
|
||||
# version, using the same trigger/guard convention already proven to work
|
||||
# for Dependabot PRs in sync-requirements-for-python-dep-upgrade-pr.yml
|
||||
# (plain `pull_request` gets a working, write-capable GITHUB_TOKEN here
|
||||
# because Dependabot pushes branches directly to this repo, not a fork).
|
||||
#
|
||||
# This does NOT auto-merge anything - repo-wide auto-merge is disabled
|
||||
# (Settings > General > Pull Requests > "Allow auto-merge" is off), and
|
||||
# flipping that is a separate, repo-wide decision this workflow doesn't
|
||||
# make on its own. Branch protection also still requires 1 approving
|
||||
# review; this just means that review can already exist by the time a
|
||||
# human looks at the PR, for the (large majority of) ecosystems whose
|
||||
# files aren't matched by any CODEOWNERS pattern. One ecosystem - the npm
|
||||
# bump under .github/actions - matches the /.github/ CODEOWNERS entry, so
|
||||
# those PRs will still need a human owner's approval regardless of this
|
||||
# workflow; it posts a review there too, but that alone won't satisfy the
|
||||
# code-owner requirement.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
# Cancel a superseded run if Dependabot pushes to the same PR again before
|
||||
# the previous run finished (matches the pattern used elsewhere in
|
||||
# superset-docs-verify.yml).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-patch-bump:
|
||||
name: Approve patch-level bump
|
||||
# Mirrors the guard in sync-requirements-for-python-dep-upgrade-pr.yml:
|
||||
# limited to (1) PRs authored by Dependabot and (2) the upstream repo,
|
||||
# since forked PRs don't get a write-capable token here anyway.
|
||||
if: >
|
||||
github.repository == 'apache/superset' &&
|
||||
github.event.pull_request.user.login == 'dependabot[bot]' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # to post the approving review via `gh pr review`
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
# This exact SHA is on ASF Infra's action allowlist
|
||||
# (apache/infrastructure-actions approved_patterns.yml) as of this
|
||||
# writing. Do not bump without opening an Infra ticket to allow
|
||||
# the new SHA first!
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
|
||||
|
||||
- name: Approve patch-level bump
|
||||
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
|
||||
run: |
|
||||
gh pr review --approve "$PR_URL" \
|
||||
--body "Auto-approved: patch-level bump only ($DEPENDENCY_NAMES)."
|
||||
134
.github/workflows/frontend-bundle-size-nightly.yml
vendored
Normal file
134
.github/workflows/frontend-bundle-size-nightly.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Frontend bundle size (nightly baseline + analyzer)
|
||||
|
||||
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
|
||||
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
|
||||
# report of the same build. Deliberately NOT triggered on every push to
|
||||
# master: a day-old baseline/report is fine for catching relative
|
||||
# regressions on PRs and for browsing what's actually in the bundle, and
|
||||
# building the production bundle on every one of the many pushes master
|
||||
# gets per day would burn CI time for no benefit a nightly refresh doesn't
|
||||
# already cover.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh-baseline:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout master"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t $TAG \
|
||||
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
|
||||
--target superset-node-ci \
|
||||
.
|
||||
|
||||
# Same cache the PR-time bundle-size job restores/writes -- webpack's
|
||||
# persistent filesystem cache turns a warm production build into ~20s
|
||||
# instead of several minutes. See superset-frontend.yml for the
|
||||
# matching restore step and why it's keyed this way.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry. Absent on the very first run ever; benchmark-action starts a
|
||||
# fresh history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
|
||||
# they're independent env-gated additions in webpack.config.js (one
|
||||
# sets `config.stats`, the other pushes plugins), so one production
|
||||
# build produces both the numeric stats.json and the analyzer's
|
||||
# report.html. Only report.html is mounted out, not
|
||||
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
|
||||
# documented in webpack.config.js as routinely exceeding 100MB for
|
||||
# this app (it's .gitignore'd for exactly that reason), too large to
|
||||
# publish as a static site page.
|
||||
- name: Build production bundle with stats and analyzer report
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
mkdir -p ${{ github.workspace }}/superset/static/assets
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# No PR to comment on here, so comment-on-alert is off -- the job
|
||||
# summary (summary-always) is the only surface for this run.
|
||||
- name: Update bundle size baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
- name: Save bundle size history
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
|
||||
# Publishes the treemap to Netlify (the same host already used for
|
||||
# superset-storybook.netlify.app and docs previews, reusing the
|
||||
# existing NETLIFY_AUTH_TOKEN). Skipped until
|
||||
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
|
||||
# Netlify site named superset-bundle-analyzer and add its site ID as
|
||||
# that secret to turn this on; nothing else in this workflow depends
|
||||
# on it.
|
||||
- name: Publish bundle analyzer report to Netlify
|
||||
if: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID != '' }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
|
||||
run: |
|
||||
mkdir -p netlify-publish
|
||||
cp superset/static/assets/report.html netlify-publish/index.html
|
||||
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
|
||||
# tool, not an application dependency; a global/npx install has no
|
||||
# lockfile context. Version pinned above the floor set by other
|
||||
# ad-hoc installs in this repo (bump deliberately when upgrading).
|
||||
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish
|
||||
88
.github/workflows/superset-frontend.yml
vendored
88
.github/workflows/superset-frontend.yml
vendored
@@ -195,3 +195,91 @@ jobs:
|
||||
run: |
|
||||
docker run --rm $TAG bash -c \
|
||||
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"
|
||||
|
||||
# Compares a PR's own bundle size against the last nightly-recorded
|
||||
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
|
||||
# persisting new baselines). PR-only: a push to master doesn't need this
|
||||
# check re-run against itself, and re-persisting the baseline on every
|
||||
# push to master -- which happens many times a day -- would burn a full
|
||||
# production build for no benefit nightly refresh doesn't already cover.
|
||||
bundle-size:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
- name: Load Docker Image
|
||||
run: |
|
||||
zstd -d < docker-image.tar.zst | docker load
|
||||
|
||||
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
|
||||
# turns a warm production build into ~20s instead of several minutes,
|
||||
# but GH-hosted runners are fresh VMs with nothing carried over between
|
||||
# jobs -- without restoring it explicitly, every single PR would pay
|
||||
# the full cold-build cost. Keyed on the same files webpack's own
|
||||
# `buildDependencies` invalidates on, so a stale cache is never used.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry, which is always the latest nightly run. Absent before the
|
||||
# first nightly run ever happens; benchmark-action starts a fresh
|
||||
# history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
- name: Build production bundle with stats
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# Comparison + alert only -- this job never persists. See
|
||||
# frontend-bundle-size-nightly.yml for why.
|
||||
- name: Compare bundle size against nightly baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-on-alert: true
|
||||
alert-threshold: "110%"
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
@@ -31,6 +31,8 @@ under the License.
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](http://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
[](https://superset-storybook.netlify.app)
|
||||
[](https://superset-bundle-analyzer.netlify.app)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
|
||||
85
superset-frontend/scripts/bundle-size-summary.js
Normal file
85
superset-frontend/scripts/bundle-size-summary.js
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Reduces a webpack `--json` stats file down to the handful of headline
|
||||
// numbers worth tracking over time, in the flat array format
|
||||
// benchmark-action/github-action-benchmark expects for its
|
||||
// "customSmallerIsBetter" tool. The full stats file also includes a
|
||||
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
|
||||
// useful for this purpose, so we only ever read `entrypoints`.
|
||||
//
|
||||
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
|
||||
// `preamble`, `theme`, and `service-worker` are small, low-variance
|
||||
// infrastructure chunks, not where bundle bloat actually shows up.
|
||||
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
|
||||
|
||||
function entrypointSizeByExt(entrypoint, ext) {
|
||||
return (entrypoint.assets || [])
|
||||
.filter(asset => asset.name.endsWith(ext))
|
||||
.reduce((total, asset) => total + asset.size, 0);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const statsPath = process.argv[2];
|
||||
if (!statsPath) {
|
||||
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
||||
const { entrypoints } = stats;
|
||||
if (!entrypoints) {
|
||||
console.error(
|
||||
'stats.json has no `entrypoints` key -- was it generated with ' +
|
||||
'`--stats=normal` (or richer)? `minimal`/`errors-only` stats omit it.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
TRACKED_ENTRYPOINTS.forEach(name => {
|
||||
const entrypoint = entrypoints[name];
|
||||
if (!entrypoint) {
|
||||
console.error(`stats.json is missing the "${name}" entrypoint`);
|
||||
process.exit(1);
|
||||
}
|
||||
results.push({
|
||||
name: `${name} entrypoint (JS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.js'),
|
||||
});
|
||||
results.push({
|
||||
name: `${name} entrypoint (CSS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.css'),
|
||||
});
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS };
|
||||
107
superset-frontend/spec/scripts/bundle-size-summary.test.js
Normal file
107
superset-frontend/spec/scripts/bundle-size-summary.test.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const {
|
||||
entrypointSizeByExt,
|
||||
main,
|
||||
} = require('../../scripts/bundle-size-summary');
|
||||
|
||||
function mockStats(entrypoints) {
|
||||
jest
|
||||
.spyOn(fs, 'readFileSync')
|
||||
.mockReturnValue(JSON.stringify({ entrypoints }));
|
||||
}
|
||||
|
||||
function mockExit() {
|
||||
return jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt sums only assets matching the given extension', () => {
|
||||
const entrypoint = {
|
||||
assets: [
|
||||
{ name: 'spa.entry.js', size: 100 },
|
||||
{ name: 'spa.entry.js.map', size: 500 },
|
||||
{ name: 'spa.entry.css', size: 20 },
|
||||
],
|
||||
};
|
||||
expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100);
|
||||
expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20);
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => {
|
||||
expect(entrypointSizeByExt({}, '.js')).toBe(0);
|
||||
});
|
||||
|
||||
test('main prints byte totals for every tracked entrypoint', () => {
|
||||
mockStats({
|
||||
spa: { assets: [{ name: 'spa.js', size: 100 }] },
|
||||
embedded: { assets: [{ name: 'embedded.js', size: 50 }] },
|
||||
});
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
main();
|
||||
|
||||
const printed = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(printed).toEqual([
|
||||
{ name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 },
|
||||
{ name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
{ name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 },
|
||||
{ name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('main exits with an error when a tracked entrypoint is missing from stats.json', () => {
|
||||
mockStats({ spa: { assets: [] } });
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('missing the "embedded" entrypoint'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main exits with an error when stats.json has no `entrypoints` key', () => {
|
||||
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({}));
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no `entrypoints` key'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main prints a usage message and exits when no stats path is given', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
||||
});
|
||||
@@ -738,4 +738,14 @@ const smp = new SpeedMeasurePlugin({
|
||||
disable: !measure,
|
||||
});
|
||||
|
||||
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
|
||||
// above omits both). Not `normal`/`detailed` stats: those also serialize the
|
||||
// full ~15k-module dependency graph, which is hundreds of MB for this app --
|
||||
// large enough to exceed Node's max string length when read back with
|
||||
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
|
||||
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
|
||||
if (process.env.BUNDLE_SIZE_STATS) {
|
||||
config.stats = { all: false, assets: true, entrypoints: true };
|
||||
}
|
||||
|
||||
module.exports = smp.wrap(config);
|
||||
|
||||
@@ -397,7 +397,8 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
md["color_namespace"] = data.get("color_namespace")
|
||||
|
||||
md["expanded_slices"] = data.get("expanded_slices", {})
|
||||
md["refresh_frequency"] = data.get("refresh_frequency", 0)
|
||||
if "refresh_frequency" in data:
|
||||
md["refresh_frequency"] = data["refresh_frequency"]
|
||||
md["color_scheme"] = data.get("color_scheme", "")
|
||||
md["label_colors"] = data.get("label_colors", {})
|
||||
md["shared_label_colors"] = data.get("shared_label_colors", [])
|
||||
|
||||
@@ -24,6 +24,7 @@ from superset.connectors.sqla.models import Database, SqlaTable
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.utils import json
|
||||
from tests.unit_tests.conftest import with_feature_flags
|
||||
|
||||
|
||||
@@ -117,3 +118,53 @@ def test_set_dash_metadata_preserves_soft_deleted_members(
|
||||
)
|
||||
# And the position slot kept its UUID rather than being nulled.
|
||||
assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
|
||||
|
||||
|
||||
def test_set_dash_metadata_preserves_refresh_frequency(session: Session) -> None:
|
||||
"""set_dash_metadata must not reset refresh_frequency when absent from data.
|
||||
|
||||
Regression test for #42116: ``data.get("refresh_frequency", 0)`` would
|
||||
unconditionally overwrite the existing value with 0 whenever the caller
|
||||
did not include ``refresh_frequency`` in the data dict.
|
||||
"""
|
||||
Dashboard.metadata.create_all(session.get_bind())
|
||||
|
||||
dashboard = Dashboard(
|
||||
dashboard_title="refresh_test_dash",
|
||||
json_metadata=json.dumps({"refresh_frequency": 30}),
|
||||
)
|
||||
db.session.add(dashboard)
|
||||
db.session.flush()
|
||||
|
||||
# Simulate a save that does NOT include refresh_frequency
|
||||
# (e.g. changing only the title via the PropertiesModal).
|
||||
DashboardDAO.set_dash_metadata(dashboard, {"color_scheme": "superset"})
|
||||
|
||||
md = json.loads(dashboard.json_metadata)
|
||||
assert md["refresh_frequency"] == 30, (
|
||||
"refresh_frequency should be preserved when not present in data"
|
||||
)
|
||||
|
||||
|
||||
def test_set_dash_metadata_updates_refresh_frequency_when_present(
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""set_dash_metadata must update refresh_frequency when it IS in data."""
|
||||
Dashboard.metadata.create_all(session.get_bind())
|
||||
|
||||
dashboard = Dashboard(
|
||||
dashboard_title="refresh_test_dash_2",
|
||||
json_metadata=json.dumps({"refresh_frequency": 30}),
|
||||
)
|
||||
db.session.add(dashboard)
|
||||
db.session.flush()
|
||||
|
||||
# Simulate a save that explicitly sets refresh_frequency to 0.
|
||||
DashboardDAO.set_dash_metadata(
|
||||
dashboard, {"refresh_frequency": 0, "color_scheme": "superset"}
|
||||
)
|
||||
|
||||
md = json.loads(dashboard.json_metadata)
|
||||
assert md["refresh_frequency"] == 0, (
|
||||
"refresh_frequency should be updated when present in data"
|
||||
)
|
||||
|
||||
@@ -571,3 +571,53 @@ def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
|
||||
# Must not raise — falls back to str()
|
||||
result = stringify_values(data)
|
||||
assert result[0] == str({"key": _Unserializable()})
|
||||
|
||||
|
||||
def test_empty_result_set_preserves_column_metadata() -> None:
|
||||
"""
|
||||
Test that column metadata is preserved when query returns zero rows.
|
||||
|
||||
When a query returns no data but has a valid cursor description, the
|
||||
column names and types from cursor_description should be preserved
|
||||
in the result set. This allows downstream consumers (like the UI)
|
||||
to display column headers even for empty result sets.
|
||||
"""
|
||||
data: DbapiResult = []
|
||||
description = [
|
||||
("id", "int", None, None, None, None, True),
|
||||
("name", "varchar", None, None, None, None, True),
|
||||
("created_at", "timestamp", None, None, None, None, True),
|
||||
]
|
||||
|
||||
result_set = SupersetResultSet(
|
||||
data,
|
||||
description, # type: ignore
|
||||
BaseEngineSpec,
|
||||
)
|
||||
|
||||
# Verify column count
|
||||
assert len(result_set.columns) == 3
|
||||
|
||||
# Verify column names are preserved
|
||||
column_names = [col["column_name"] for col in result_set.columns]
|
||||
assert column_names == ["id", "name", "created_at"]
|
||||
|
||||
assert result_set.columns[0]["type"] == BaseEngineSpec.get_datatype(
|
||||
description[0][1]
|
||||
)
|
||||
assert result_set.columns[1]["type"] == BaseEngineSpec.get_datatype(
|
||||
description[1][1]
|
||||
)
|
||||
assert result_set.columns[2]["type"] == BaseEngineSpec.get_datatype(
|
||||
description[2][1]
|
||||
)
|
||||
|
||||
# Verify the PyArrow table has the correct schema
|
||||
assert result_set.table.num_rows == 0
|
||||
assert len(result_set.table.column_names) == 3
|
||||
assert list(result_set.table.column_names) == ["id", "name", "created_at"]
|
||||
|
||||
# Verify DataFrame conversion works
|
||||
df = result_set.to_pandas_df()
|
||||
assert len(df) == 0
|
||||
assert list(map(str, df.columns)) == ["id", "name", "created_at"]
|
||||
|
||||
Reference in New Issue
Block a user