diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml
index edad31a95aa7..9fe341f26cf1 100644
--- a/.github/workflows/visual-tests-demos.yml
+++ b/.github/workflows/visual-tests-demos.yml
@@ -632,7 +632,6 @@ jobs:
uses: ./.github/actions/setup-chrome
with:
chrome-version: '145.0.7632.67'
- runner-type: 'github-hosted'
- name: Use Node.js
uses: actions/setup-node@v4
@@ -787,7 +786,6 @@ jobs:
uses: ./.github/actions/setup-chrome
with:
chrome-version: '145.0.7632.67'
- runner-type: 'github-hosted'
- name: Use Node.js
uses: actions/setup-node@v4
@@ -916,7 +914,6 @@ jobs:
uses: ./.github/actions/setup-chrome
with:
chrome-version: '145.0.7632.67'
- runner-type: 'github-hosted'
- name: Use Node.js
uses: actions/setup-node@v4
@@ -1069,3 +1066,221 @@ jobs:
pattern: accessibility-reports-*
delete-merged: true
+ csp-check-jquery:
+ name: CSP check (jQuery)
+ needs: [check-should-run, build-devextreme]
+ if: |
+ always() &&
+ needs.check-should-run.outputs.should-run == 'true' &&
+ needs.build-devextreme.result == 'success'
+ runs-on: devextreme-shr2
+ timeout-minutes: 60
+
+ steps:
+ - name: Get sources
+ uses: actions/checkout@v4
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: devextreme-artifacts-jquery
+ path: ./packages/devextreme
+
+ - name: Unpack artifacts
+ working-directory: ./packages/devextreme
+ run: 7z x artifacts.zip -aoa
+
+ - name: Setup Chrome
+ uses: ./.github/actions/setup-chrome
+ with:
+ chrome-version: '145.0.7632.67'
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - uses: actions/cache/restore@v4
+ name: Restore pnpm cache
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-cache
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Start CSP Server
+ run: node apps/demos/utils/server/csp-server.js 8080 &
+
+ - name: Run CSP Check
+ working-directory: apps/demos
+ env:
+ CSP_FRAMEWORKS: jQuery
+ CHROME_PATH: google-chrome-stable
+ run: node utils/server/csp-check.js
+
+ - name: Upload CSP report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: csp-violations-jquery
+ path: apps/demos/csp-reports/
+ if-no-files-found: ignore
+
+ csp-check-frameworks:
+ name: CSP check (${{ matrix.FRAMEWORK }})
+ needs: [check-should-run, determine-framework-tests-scope, build-devextreme]
+ if: |
+ always() &&
+ needs.check-should-run.outputs.should-run == 'true' &&
+ needs.determine-framework-tests-scope.result == 'success' &&
+ needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' &&
+ needs.build-devextreme.result == 'success'
+ strategy:
+ fail-fast: false
+ matrix:
+ FRAMEWORK: [React, Vue, Angular]
+ runs-on: devextreme-shr2
+ timeout-minutes: 60
+
+ steps:
+ - name: Get sources
+ uses: actions/checkout@v4
+
+ - name: Download devextreme sources
+ uses: actions/download-artifact@v4
+ with:
+ name: devextreme-sources
+
+ - name: Setup Chrome
+ uses: ./.github/actions/setup-chrome
+ with:
+ chrome-version: '145.0.7632.67'
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - uses: actions/cache/restore@v4
+ name: Restore pnpm cache
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-cache
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install tgz
+ 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
+
+ - name: Start CSP Server
+ run: node apps/demos/utils/server/csp-server.js 8080 &
+
+ - name: Run CSP Check
+ working-directory: apps/demos
+ env:
+ CSP_FRAMEWORKS: ${{ matrix.FRAMEWORK }}
+ CHROME_PATH: google-chrome-stable
+ run: node utils/server/csp-check.js
+
+ - name: Upload CSP report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: csp-violations-${{ matrix.FRAMEWORK }}
+ path: apps/demos/csp-reports/
+ if-no-files-found: ignore
+
+ csp-report-summary:
+ name: CSP Violations Summary
+ runs-on: devextreme-shr2
+ needs: [check-should-run, csp-check-jquery, csp-check-frameworks]
+ if: always() && needs.check-should-run.outputs.should-run == 'true'
+ timeout-minutes: 5
+
+ steps:
+ - name: Get sources
+ uses: actions/checkout@v4
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Download all CSP reports
+ uses: actions/download-artifact@v4
+ with:
+ pattern: csp-violations-*
+ path: csp-reports-all
+ merge-multiple: true
+ continue-on-error: true
+
+ - name: Summarize CSP violations
+ run: |
+ mkdir -p apps/demos/csp-reports
+
+ echo "## CSP Violations Report" >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+
+ GRAND_TOTAL=0
+ for report in csp-reports-all/csp-violations-*.jsonl; do
+ [ -f "$report" ] || continue
+ FRAMEWORK=$(basename "$report" | sed 's/csp-violations-//;s/\.jsonl//')
+ cp "$report" "apps/demos/csp-reports/"
+
+ if [ -s "$report" ]; then
+ COUNT=$(wc -l < "$report" | tr -d ' ')
+ GRAND_TOTAL=$((GRAND_TOTAL + COUNT))
+ echo "### ⚠️ ${FRAMEWORK}: ${COUNT} violation(s)" >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+ echo 'Show detailed report
' >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ CSP_REPORT_FILE="$report" node apps/demos/utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+ echo ' ' >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+ else
+ echo "### ✅ ${FRAMEWORK}: No violations" >> $GITHUB_STEP_SUMMARY
+ echo '' >> $GITHUB_STEP_SUMMARY
+ fi
+ done
+
+ if [ "$GRAND_TOTAL" -eq 0 ]; then
+ echo "✅ No CSP violations detected across all frameworks."
+ else
+ echo "⚠️ Total: $GRAND_TOTAL CSP violation(s)"
+ fi
+
+ - name: Upload merged CSP reports
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: csp-violations-report
+ path: apps/demos/csp-reports/
+ if-no-files-found: ignore
+
diff --git a/apps/demos/.gitignore b/apps/demos/.gitignore
index 045269d9a84b..82d6e6478557 100644
--- a/apps/demos/.gitignore
+++ b/apps/demos/.gitignore
@@ -33,5 +33,7 @@ Demos/**/tsconfig.json
**/.DS_Store
publish-demos
+csp-reports
+
.angular
angular.json
diff --git a/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html b/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html
index ec99855d024d..d3428ce8d006 100644
--- a/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html
+++ b/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html
@@ -11,10 +11,6 @@
integrity="sha512-+EeCylkt9WHJk5tGJxYdecHOcXFRME7qnbsfeMsdQL6NUPYm2+uGFmyleEqsmVoap/f3dN/sc3BX9t9kHXkHHg=="
crossorigin="anonymous"
>
-
-
diff --git a/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html b/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html
index c99a50ef7e3b..26b87f672084 100644
--- a/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html
+++ b/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html
@@ -15,9 +15,6 @@
integrity="sha512-+EeCylkt9WHJk5tGJxYdecHOcXFRME7qnbsfeMsdQL6NUPYm2+uGFmyleEqsmVoap/f3dN/sc3BX9t9kHXkHHg=="
crossorigin="anonymous"
>
-
diff --git a/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html b/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html
index 808e13261fc6..7658b170789d 100644
--- a/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html
+++ b/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html
@@ -7,7 +7,7 @@
-
+
diff --git a/apps/demos/package.json b/apps/demos/package.json
index 001dd0183254..7be95763bc38 100644
--- a/apps/demos/package.json
+++ b/apps/demos/package.json
@@ -177,7 +177,10 @@
"lint-non-demos": "pnpx nx run-many -t lint-js lint-css lint-html -p devextreme-demos",
"lint": "pnpm run lint-non-demos && pnpm run lint-demos",
"test-testcafe": "ts-node utils/visual-tests/testcafe-runner.ts",
+ "test-testcafe:csp": "cross-env CSP_REPORT=true ts-node utils/visual-tests/testcafe-runner.ts",
"test-testcafe:accessibility": "cross-env STRATEGY=accessibility CONSTEL=jquery node utils/visual-tests/testcafe-runner.ts",
+ "csp-server": "node utils/server/csp-server.js 8080",
+ "csp-check": "node utils/server/csp-check.js",
"fix-lint": "prettier --write . && eslint --fix . && stylelint **/*.{css,vue} --fix",
"prettier": "prettier",
"build-bundles": "gulp bundles",
diff --git a/apps/demos/testing/common.test.ts b/apps/demos/testing/common.test.ts
index 22650163014e..346cca76faae 100644
--- a/apps/demos/testing/common.test.ts
+++ b/apps/demos/testing/common.test.ts
@@ -1,8 +1,9 @@
import { glob } from 'glob';
import { join } from 'path';
-import { existsSync } from 'fs';
+import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { createScreenshotsComparer } from 'devextreme-screenshot-comparer';
import { axeCheck, createReport } from '@testcafe-community/axe';
+import { ClientFunction } from 'testcafe';
import {
runTestAtPage,
shouldRunFramework,
@@ -32,6 +33,28 @@ const execTestCafeCode = (t, code) => {
return testCafeFunction(t);
};
+const getClientCspViolations = ClientFunction(() => (window as any).__cspViolations || []);
+
+const isCspEnabled = () => process.env.CSP_REPORT === 'true';
+
+const cspReportDir = join(__dirname, '..', 'csp-reports');
+const cspReportFile = join(cspReportDir, 'csp-violations.jsonl');
+
+const writeCspReport = (testName: string, framework: string, violations: any[]) => {
+ if (!violations.length) return;
+ if (!existsSync(cspReportDir)) {
+ mkdirSync(cspReportDir, { recursive: true });
+ }
+ for (const v of violations) {
+ const entry = {
+ test: testName,
+ framework,
+ ...v,
+ };
+ appendFileSync(cspReportFile, `${JSON.stringify(entry)}\n`);
+ }
+};
+
const getIgnoredRules = (testName) => {
const ignoredRules = [];
@@ -103,6 +126,13 @@ const getClientScripts = () => {
scripts.push({ module: 'axe-core/axe.min.js' });
}
+ if (isCspEnabled()) {
+ scripts.push(
+ // @ts-expect-error
+ join(__dirname, '../utils/visual-tests/inject/csp-listener.js'),
+ );
+ }
+
scripts.push(
// @ts-expect-error
join(__dirname, '../utils/visual-tests/inject/test-utils.js'),
@@ -225,9 +255,15 @@ Object.values(FRAMEWORKS).forEach((approach) => {
} else {
const consoleMessages = await t.getBrowserConsoleMessages();
const errors = [...consoleMessages.error, ...consoleMessages.warn]
- .filter((e) => !knownWarnings.some((kw) => e.startsWith(kw)));
+ .filter((e) => !knownWarnings.some((kw) => e.startsWith(kw)))
+ .filter((e) => !e.startsWith('[CSP Violation]'));
await t.expect(errors).eql([]);
+ if (isCspEnabled()) {
+ const cspViolations = await getClientCspViolations();
+ writeCspReport(testName, approach, cspViolations);
+ }
+
const { takeScreenshot, compareResults } = createScreenshotsComparer(t);
await testScreenshot(t, takeScreenshot, `${testName}.png`, undefined, comparisonOptions);
diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js
new file mode 100644
index 000000000000..d0928a8fb506
--- /dev/null
+++ b/apps/demos/utils/server/csp-check.js
@@ -0,0 +1,202 @@
+const { execFile, execFileSync } = require('child_process');
+const { join } = require('path');
+const {
+ readdirSync, existsSync, writeFileSync, mkdirSync,
+} = require('fs');
+const http = require('http');
+
+const DEMO_ROOT = join(__dirname, '..', '..');
+const REPORT_DIR = join(DEMO_ROOT, 'csp-reports');
+const SERVER_URL = process.env.CSP_SERVER_URL || 'http://localhost:8080';
+const FRAMEWORK = (process.env.CSP_FRAMEWORKS || 'jQuery').trim();
+const CONCURRENCY = parseInt(process.env.CSP_CONCURRENCY, 10) || 8;
+
+function findChrome() {
+ const candidates = [
+ process.env.CHROME_PATH,
+ 'google-chrome-stable',
+ 'google-chrome',
+ 'chromium-browser',
+ 'chromium',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/usr/bin/google-chrome-stable',
+ '/usr/bin/google-chrome',
+ ].filter(Boolean);
+
+ for (const candidate of candidates) {
+ try {
+ if (candidate.startsWith('/')) {
+ execFileSync('test', ['-x', candidate], { stdio: 'ignore' });
+ } else {
+ execFileSync('which', [candidate], { stdio: 'ignore' });
+ }
+ return candidate;
+ } catch {
+ // try next candidate
+ }
+ }
+ throw new Error('Chrome not found. Set CHROME_PATH environment variable.');
+}
+
+const CHROME_PATH = findChrome();
+
+function findDemos() {
+ const demosDir = join(DEMO_ROOT, 'Demos');
+ const result = [];
+
+ if (!existsSync(demosDir)) {
+ console.error(`Demos directory not found: ${demosDir}`);
+ return result;
+ }
+
+ const widgets = readdirSync(demosDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory());
+
+ for (const widget of widgets) {
+ const widgetDir = join(demosDir, widget.name);
+ let demos = [];
+
+ demos = readdirSync(widgetDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory());
+
+ for (const demo of demos) {
+ const fwDir = join(widgetDir, demo.name, FRAMEWORK);
+ if (existsSync(join(fwDir, 'index.html'))) {
+ result.push({
+ url: `${SERVER_URL}/apps/demos/Demos/${widget.name}/${demo.name}/${FRAMEWORK}/`,
+ widget: widget.name,
+ demo: demo.name,
+ framework: FRAMEWORK,
+ });
+ }
+ }
+ }
+
+ return result;
+}
+
+function visitPage(url) {
+ return new Promise((resolve, reject) => {
+ const child = execFile(CHROME_PATH, [
+ '--headless=new',
+ '--no-sandbox',
+ '--disable-gpu',
+ '--disable-software-rasterizer',
+ '--disable-dev-shm-usage',
+ '--dump-dom',
+ '--virtual-time-budget=5000',
+ '--window-size=100,100',
+ url,
+ ], { timeout: 30000 }, () => resolve());
+ child.on('error', (err) => {
+ reject(new Error(`Failed to launch Chrome at "${CHROME_PATH}": ${err.message}`));
+ });
+ });
+}
+
+function httpRequest(url, method) {
+ return new Promise((resolve, reject) => {
+ const req = http.request(url, { method: method || 'GET' }, (res) => {
+ let data = '';
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => {
+ try {
+ resolve(JSON.parse(data));
+ } catch {
+ resolve(data);
+ }
+ });
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+async function main() {
+ console.log(`Chrome: ${CHROME_PATH}`);
+ console.log(`Server: ${SERVER_URL}`);
+ console.log(`Framework: ${FRAMEWORK}`);
+ console.log(`Concurrency: ${CONCURRENCY}\n`);
+
+ const demos = findDemos();
+ console.log(`Found ${demos.length} demo page(s) to check\n`);
+
+ if (demos.length === 0) {
+ console.log('No demos found. Exiting.');
+ return;
+ }
+
+ let totalViolations = 0;
+ let demosWithViolations = 0;
+ const allViolations = [];
+
+ for (let batchStart = 0; batchStart < demos.length; batchStart += CONCURRENCY) {
+ const batch = demos.slice(batchStart, batchStart + CONCURRENCY);
+
+ await httpRequest(`${SERVER_URL}/csp-violations`, 'DELETE');
+
+ await Promise.all(batch.map((demo) => visitPage(demo.url)));
+
+ await new Promise((resolve) => { setTimeout(resolve, 500); });
+
+ const result = await httpRequest(`${SERVER_URL}/csp-violations`);
+ const violations = result.violations || [];
+
+ const violationsByUrl = {};
+ for (const v of violations) {
+ const uri = v.documentUri || '';
+ if (!violationsByUrl[uri]) violationsByUrl[uri] = [];
+ violationsByUrl[uri].push(v);
+ }
+
+ for (let j = 0; j < batch.length; j += 1) {
+ const demo = batch[j];
+ const idx = batchStart + j + 1;
+ const demoViolations = violationsByUrl[demo.url]
+ || violationsByUrl[`${demo.url}index.html`]
+ || [];
+
+ if (demoViolations.length > 0) {
+ demosWithViolations += 1;
+ totalViolations += demoViolations.length;
+
+ console.log(` ❌ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework} — ${demoViolations.length} violation(s)`);
+ for (const v of demoViolations) {
+ const blocked = v.blockedUri || 'N/A';
+ const directive = v.effectiveDirective || v.violatedDirective || '?';
+ console.log(` ${directive}: ${blocked}`);
+ allViolations.push({ ...v, framework: FRAMEWORK });
+ }
+ } else {
+ console.log(` ✅ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework}`);
+ }
+ }
+ }
+
+ const reportFile = join(REPORT_DIR, `csp-violations-${FRAMEWORK.toLowerCase()}.jsonl`);
+
+ if (allViolations.length > 0) {
+ mkdirSync(REPORT_DIR, { recursive: true });
+ const lines = allViolations.map((v) => JSON.stringify(v)).join('\n');
+ writeFileSync(reportFile, `${lines}\n`);
+ }
+
+ console.log(`\n${'='.repeat(60)}`);
+ console.log(`Framework: ${FRAMEWORK}`);
+ console.log(`Demos checked: ${demos.length}`);
+ console.log(`Demos with violations: ${demosWithViolations}`);
+ console.log(`Total violations: ${totalViolations}`);
+
+ if (totalViolations > 0) {
+ console.log(`\n⚠️ ${totalViolations} CSP violation(s) detected in ${demosWithViolations} demo(s)`);
+ console.log(`Report: ${reportFile}`);
+ process.exitCode = 1;
+ } else {
+ console.log('\n✅ No CSP violations detected');
+ }
+}
+
+main().catch((err) => {
+ console.error('CSP check failed:', err.message);
+ process.exit(1);
+});
diff --git a/apps/demos/utils/server/csp-report-summary.js b/apps/demos/utils/server/csp-report-summary.js
new file mode 100644
index 000000000000..5963db40093e
--- /dev/null
+++ b/apps/demos/utils/server/csp-report-summary.js
@@ -0,0 +1,77 @@
+const { readFileSync, existsSync } = require('fs');
+const { join } = require('path');
+
+const reportFile = process.env.CSP_REPORT_FILE || join(__dirname, '..', '..', 'csp-reports', 'csp-violations.jsonl');
+
+if (!existsSync(reportFile)) {
+ console.log('No CSP violations report found.');
+ console.log(`Expected at: ${reportFile}`);
+ console.log('Run tests with CSP_REPORT=true to generate a report.');
+ process.exit(0);
+}
+
+const lines = readFileSync(reportFile, 'utf-8')
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+
+if (lines.length === 0) {
+ console.log('✅ No CSP violations detected!');
+ process.exit(0);
+}
+
+console.log(`\n⚠️ CSP Violations Report: ${lines.length} violation(s) found\n`);
+console.log('='.repeat(80));
+
+const byDirective = {};
+for (const v of lines) {
+ const key = v.violatedDirective || v.effectiveDirective || 'unknown';
+ if (!byDirective[key]) {
+ byDirective[key] = [];
+ }
+ byDirective[key].push(v);
+}
+
+for (const [directive, violations] of Object.entries(byDirective)) {
+ console.log(`\n📋 ${directive} (${violations.length} violation(s)):`);
+ console.log('-'.repeat(60));
+
+ const uniqueViolations = new Map();
+ for (const v of violations) {
+ const key = `${v.blockedURI}|${v.sourceFile}|${v.lineNumber}`;
+ if (!uniqueViolations.has(key)) {
+ uniqueViolations.set(key, { ...v, count: 1, tests: [v.test] });
+ } else {
+ const existing = uniqueViolations.get(key);
+ existing.count += 1;
+ if (!existing.tests.includes(v.test)) {
+ existing.tests.push(v.test);
+ }
+ }
+ }
+
+ for (const [, v] of uniqueViolations) {
+ console.log(` blocked: ${v.blockedURI || 'N/A'}`);
+ console.log(` source: ${v.sourceFile || 'N/A'}:${v.lineNumber || '?'}`);
+ console.log(` count: ${v.count} occurrence(s) in ${v.tests.length} test(s)`);
+ if (v.tests.length <= 5) {
+ console.log(` tests: ${v.tests.join(', ')}`);
+ } else {
+ console.log(` tests: ${v.tests.slice(0, 5).join(', ')} ... and ${v.tests.length - 5} more`);
+ }
+ console.log();
+ }
+}
+
+const byFramework = {};
+for (const v of lines) {
+ const fw = v.framework || 'unknown';
+ byFramework[fw] = (byFramework[fw] || 0) + 1;
+}
+
+console.log('='.repeat(80));
+console.log('\n📊 Summary by framework:');
+for (const [fw, count] of Object.entries(byFramework)) {
+ console.log(` ${fw}: ${count} violation(s)`);
+}
+console.log(`\n Total: ${lines.length} violation(s)\n`);
diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js
new file mode 100644
index 000000000000..48ae19c2cedf
--- /dev/null
+++ b/apps/demos/utils/server/csp-server.js
@@ -0,0 +1,410 @@
+/* eslint-disable import/no-extraneous-dependencies */
+
+const crypto = require('crypto');
+const express = require('express');
+const serveStatic = require('serve-static');
+const cookieParser = require('cookie-parser');
+const { join, resolve } = require('path');
+const { readFileSync, readdirSync } = require('fs');
+
+const root = join(__dirname, '..', '..', '..', '..');
+const indexFileName = 'index.html';
+const port = process.argv[2] ?? 8080;
+const host = process.env.CSP_SERVER_HOST || '127.0.0.1';
+
+const cspViolations = [];
+let cspViolationIdCounter = 0;
+
+const CSP_BASE_DIRECTIVES = {
+ 'default-src': ["'none'"],
+ 'script-src': ["'self'", 'https://esm.sh', 'https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'],
+ 'style-src': ["'self'", 'https://maxcdn.bootstrapcdn.com'],
+ 'img-src': ["'self'"],
+ 'font-src': ["'self'"],
+ 'connect-src': ["'self'", 'https://js.devexpress.com', 'https://demos.devexpress.com'],
+ 'worker-src': ["'self'"],
+ 'frame-src': ["'self'"],
+ 'object-src': ["'none'"],
+ 'base-uri': ["'none'"],
+ 'form-action': ["'self'"],
+ 'frame-ancestors': ["'none'"],
+};
+
+const CSP_DEMO_ALLOWLIST = {
+ 'Button/Icons': {
+ 'font-src': ['https://maxcdn.bootstrapcdn.com'],
+ },
+ 'CardView/WebAPIService': {
+ 'img-src': ['data:'],
+ },
+ // Azure Maps SDK: inline styles, blob workers, data: images,
+ // and font glyphs from atlas.microsoft.com
+ Map: {
+ 'script-src': ['https://atlas.microsoft.com'],
+ 'style-src': ["'unsafe-inline'"],
+ 'connect-src': ['https://atlas.microsoft.com'],
+ 'worker-src': ['blob:'],
+ 'img-src': ['data:'],
+ 'font-src': ['https://atlas.microsoft.com'],
+ },
+ 'DataGrid/CollaborativeEditing': {
+ 'connect-src': ['wss://js.devexpress.com'],
+ },
+ 'Charts/SignalRService': {
+ 'connect-src': ['wss://js.devexpress.com'],
+ },
+ 'Charts/SpiderWeb': {
+ 'font-src': ['https://fonts.gstatic.com'],
+ },
+ 'Common/ListsOverview': {
+ 'img-src': ['data:'],
+ },
+ 'DataGrid/SignalRService': {
+ 'connect-src': ['wss://js.devexpress.com'],
+ },
+ 'Scheduler/SignalRService': {
+ 'connect-src': ['wss://js.devexpress.com'],
+ },
+ 'DataGrid/Cell': {
+ 'img-src': ['data:'],
+ },
+ // AI demo: inline