diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index cdc7a9025584..5a42d15998d3 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1212,6 +1212,16 @@ jobs: working-directory: apps/demos run: pnpm add ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz + # All three frameworks now bundle production-style via csp-bundle.js + # (esbuild + per-framework AOT plugin where applicable). Angular uses + # @angular/build/private's createCompilerPlugin under the hood — see + # apps/demos/utils/server/csp-bundle-angular.js. Pages load orders of + # magnitude faster than the old SystemJS dev path and the CSP profile + # matches production (no inline scripts, no 'unsafe-eval'). + - name: Bundle demos for CSP check + working-directory: apps/demos + run: node utils/server/csp-bundle.js --framework=${{ matrix.FRAMEWORK }} + - name: Start CSP Server run: node apps/demos/utils/server/csp-server.js 8080 & @@ -1219,6 +1229,7 @@ jobs: working-directory: apps/demos env: CSP_FRAMEWORKS: ${{ matrix.FRAMEWORK }} + CSP_USE_BUNDLED: '1' CHROME_PATH: google-chrome-stable run: node utils/server/csp-check.js diff --git a/apps/demos/.gitignore b/apps/demos/.gitignore index 82d6e6478557..dcb7efcf5094 100644 --- a/apps/demos/.gitignore +++ b/apps/demos/.gitignore @@ -34,6 +34,13 @@ Demos/**/tsconfig.json publish-demos csp-reports +csp-bundled-demos + +# Scratch artifacts produced by utils/server/csp-bundle-angular.js. The script +# cleans these up after a successful run, but a SIGKILL / power loss may leave +# them behind — ignoring keeps them out of `git status`. +utils/server/.csp-bundle-angular-* +Demos/**/.csp-bundle-angular-patched.*.ts .angular angular.json diff --git a/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.css b/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.css index 35b448882ab8..616478d03e85 100644 --- a/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.css +++ b/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.css @@ -15,3 +15,11 @@ text-align: center; margin-bottom: 20px; } + +::ng-deep .center-label { + font-size: 18px; +} + +::ng-deep .center-label-total { + font-weight: 600; +} diff --git a/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.html b/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.html index 4658d7b7db09..35a12b8f18c1 100644 --- a/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.html +++ b/apps/demos/Demos/Charts/CenterLabelCustomization/Angular/app/app.component.html @@ -38,15 +38,15 @@ height="40" /> {{ country }} - {{ + {{ calculateTotal(pieChart) }} diff --git a/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.css b/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.css index b5c926de9f28..bb465cfc1896 100644 --- a/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.css +++ b/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.css @@ -11,6 +11,10 @@ display: flex; } +::ng-deep .drawer-panel { + width: 200px; +} + ::ng-deep #toolbar { background-color: var(--dx-component-color-bg); box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.12), 0 2px 2px 0 rgba(0, 0, 0, 0.08); diff --git a/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.html b/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.html index d94c686f61d9..7e8d1b1a32ec 100644 --- a/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.html +++ b/apps/demos/Demos/Drawer/LeftOrRightPosition/Angular/app/app.component.html @@ -9,7 +9,7 @@ [height]="400" [closeOnOutsideClick]="true" > -
+
- diff --git a/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html b/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html index 51222cb55272..1ab1fb54a1df 100644 --- a/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html +++ b/apps/demos/Demos/Drawer/TopOrBottomPosition/Angular/index.html @@ -6,7 +6,6 @@ - diff --git a/apps/demos/package.json b/apps/demos/package.json index 47579b7e76db..f540ca2faa4d 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -20,7 +20,7 @@ "@angular/cli": "~21.1.5", "@angular/common": "~21.1.0", "@angular/compiler": "~21.2.0", - "@angular/compiler-cli": "~21.1.0", + "@angular/compiler-cli": "~21.2.0", "@angular/core": "~21.2.4", "@angular/forms": "~21.1.0", "@angular/platform-browser": "~21.1.0", diff --git a/apps/demos/utils/server/csp-bundle-angular.js b/apps/demos/utils/server/csp-bundle-angular.js new file mode 100644 index 000000000000..8b49d89e6577 --- /dev/null +++ b/apps/demos/utils/server/csp-bundle-angular.js @@ -0,0 +1,941 @@ +/* eslint-disable global-require, import/no-dynamic-require */ + +// Bundles every Angular demo into csp-bundled-demos///Angular/. +// +// Why this is a separate script from csp-bundle.js: +// * React/Vue bundling is a single esbuild call per demo with at most one +// framework plugin (`esbuild-plugin-vue3`). Angular needs AOT compilation, +// so we wire up `@angular/build/private`'s `createCompilerPlugin` plus a +// couple of project-specific shims (devextreme path redirect, asset +// symlinks for off-by-one component-CSS url() refs). +// * Keeping the Angular machinery here lets csp-bundle.js stay small and +// framework-neutral; csp-bundle.js delegates to this file when invoked +// with --framework=Angular. +// +// Run directly: +// node apps/demos/utils/server/csp-bundle-angular.js +// or via the unified entry: +// node apps/demos/utils/server/csp-bundle.js --framework=Angular + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const esbuild = require('esbuild'); + +// @angular/build's parallel-TS worker pool defaults to min(4, cores-1) workers +// (see @angular/build .../utils/environment-options.js -> maxWorkers). Every +// batch spins up a fresh pool, and each worker re-initializes TypeScript + the +// Angular compiler and re-parses the shared .d.ts graph (Angular, +// devextreme-angular, devextreme, rxjs). On a many-core CI runner that 4-worker +// cap leaves the box idle, so we widen it to cores-1 (bounded at 16 to keep +// per-batch spawn + memory cost sane). Combined with the larger BATCH_SIZE +// below — which gives each pool enough files to distribute — the extra workers +// actually pay off. Must be set before @angular/build is required (it reads the +// env at module load); resolveAngularBuildPrivate() does the require lazily in +// main(), so setting it here is early enough. An explicit override wins. +if (!process.env.NG_BUILD_MAX_WORKERS) { + const cores = (typeof os.availableParallelism === 'function' + ? os.availableParallelism() + : (os.cpus() || []).length) || 1; + process.env.NG_BUILD_MAX_WORKERS = String(Math.min(16, Math.max(1, cores - 1))); +} + +const DEMOS_APP_ROOT = path.resolve(__dirname, '..', '..'); +const REPO_ROOT = path.resolve(DEMOS_APP_ROOT, '..', '..'); +const SRC_DEMOS_DIR = path.join(DEMOS_APP_ROOT, 'Demos'); +const OUT_ROOT = path.join(DEMOS_APP_ROOT, 'csp-bundled-demos'); +const NODE_MODULES = path.join(DEMOS_APP_ROOT, 'node_modules'); +const FRAMEWORK = 'Angular'; + +const CONCURRENCY = (() => { + const fromEnv = parseInt(process.env.CSP_BUNDLE_CONCURRENCY, 10); + if (fromEnv > 0) return fromEnv; + return Math.max(4, (os.cpus() || []).length - 1); +})(); + +// Demos per esbuild build. Each build creates one fresh Angular +// ParallelCompilation (worker-pool spawn + shared .d.ts parse), so this is the +// amortization knob: fewer, larger batches => that fixed per-batch cost is paid +// far fewer times and the shared Angular/devextreme typings are parsed once per +// program instead of once per batch. 12 keeps ~15 batches (was ~61 at 8) while +// bounding the blast radius of the per-demo retry fallback when one demo in a +// batch fails to compile (a whole batch then rebuilds individually). +const BATCH_SIZE = (() => { + const fromEnv = parseInt(process.env.CSP_BUNDLE_BATCH_SIZE, 10); + if (fromEnv > 0) return fromEnv; + return 12; +})(); + +const BATCH_CONCURRENCY = (() => { + const fromEnv = parseInt(process.env.CSP_BUNDLE_BATCH_CONCURRENCY, 10); + if (fromEnv > 0) return fromEnv; + return 1; +})(); + +// Same env knobs as csp-bundle.js — optional substring filter for local +// smoke tests, e.g. CSP_BUNDLE_FILTER=Common/FormsOverview. +const FILTER = (process.env.CSP_BUNDLE_FILTER || '').trim(); + +const SHARED_TSCONFIG_TEMPLATE = path.join(__dirname, 'tsconfig.csp-bundle-angular.json'); +const GENERATED_TSCONFIG_DIR = path.join(__dirname, '.csp-bundle-angular-tsconfigs'); + +// Demos with real bugs in their templates / component code that Angular AOT's +// template type-checker catches but JIT (SystemJS dev path) silently accepts. +// We can't silence template type errors via `@ts-nocheck` (the .ngtypecheck.ts +// virtual file is a separate compilation unit). These should be fixed at the +// demo-source level; until then they're skipped so the bundle pipeline can +// finish cleanly and the rest of the demos make it into the CSP check. +const KNOWN_BROKEN_DEMOS = new Set([ + // Property access errors in .html bindings (wrong field name / typos): + 'ActionSheet/PopoverMode', // TS2339 Property 'id' does not exist on type 'Contact' + 'Chat/MessageEditing', // TS2551 'alloUpdatingLabel' (typo) → 'allowUpdating' + 'DataGrid/CustomizeKeyboardNavigation', // TS2551 'editOnkeyPress' (typo) → 'editOnKeyPress' + 'Scheduler/DragAndDrop', // TS2339 Property 'id' does not exist on type 'Task' + 'TreeList/CustomizeKeyboardNavigation', // same 'editOnkeyPress' typo as DataGrid + // (click) / (event) bindings passing more args than the handler accepts: + 'Charts/AreaSelectionZooming', // TS2554 Expected 0 arguments, but got 1 + 'FileUploader/ChunkUpload', // TS2554 + 'LoadIndicator/Overview', // TS2554 + 'SpeechToText/Overview', // TS2554 + 'Stepper/FormIntegration', // TS2554 + 'TreeList/MultipleRowSelection', // TS2554 + // Iterating an Object as if it were iterable: + 'Form/Grouping', // TS2488 Type 'Object' has no [Symbol.iterator] + 'Form/ItemCustomization', // TS2488 + // Template references a non-existent component property: + 'Localization/UsingIntl', // TS2339 Property 'auto' (plus JSON-related, see below) + 'Localization/UsingGlobalize', // TS2339 Property 'auto' +]); + +// @angular/build is transitive via @angular-devkit/build-angular (present in +// apps/demos/package.json). Resolve through it to play nicely with pnpm. +function resolveAngularBuildPrivate() { + const buildAngularPkg = require.resolve('@angular-devkit/build-angular/package.json', { + paths: [DEMOS_APP_ROOT], + }); + const buildAngularDir = path.dirname(buildAngularPkg); + return require(require.resolve('@angular/build/private', { paths: [buildAngularDir] })); +} + +// ngc refuses tsconfigs with empty `files` and empty `include` (TS18002). Per +// demo we write a sibling tsconfig that extends the shared template and lists +// the demo entry in `files`. Sanitized slug in the filename keeps concurrent +// workers from stomping on each other. +function writeTsconfig(name, entryPaths) { + fs.mkdirSync(GENERATED_TSCONFIG_DIR, { recursive: true }); + const slug = name.replace(/[\\/]/g, '__').replace(/[^a-zA-Z0-9_.-]/g, '_'); + const dest = path.join(GENERATED_TSCONFIG_DIR, `${slug}.tsconfig.json`); + // Path resolution in `extends` is relative to the file the field is in, so + // we point at the template via a relative ../-walk. + const extendsRel = path + .relative(path.dirname(dest), SHARED_TSCONFIG_TEMPLATE) + .split(path.sep) + .join('/'); + const config = { + extends: extendsRel, + files: entryPaths.map((entryPath) => path.relative(path.dirname(dest), entryPath).split(path.sep).join('/')), + }; + fs.writeFileSync(dest, `${JSON.stringify(config, null, 2)}\n`); + return dest; +} + +function writeDemoTsconfig(entryPath) { + return writeTsconfig(path.relative(REPO_ROOT, entryPath), [entryPath]); +} + +// Bundled demos load all scripts/styles externally — no inline blocks, no +// SystemJS — so csp-server.js (cspMiddleware) keeps them on the strict +// production CSP without a nonce. Component CSS still gets injected as +//