diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index cdc7a9025584..dac997cb7a39 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1212,6 +1212,10 @@ 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 + - name: Prepare JS + working-directory: apps/demos + run: pnpm run prepare-js + - name: Start CSP Server run: node apps/demos/utils/server/csp-server.js 8080 & diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index 480b96a8c91b..547e0d892349 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -1,7 +1,8 @@ -const { execFile, execFileSync } = require('child_process'); +const { execFileSync, spawn } = require('child_process'); const { join } = require('path'); +const os = require('os'); const { - readdirSync, existsSync, writeFileSync, mkdirSync, + readdirSync, existsSync, readFileSync, writeFileSync, mkdirSync, } = require('fs'); const http = require('http'); @@ -9,7 +10,30 @@ 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) || 10; + +// Concurrency must stay moderate: even jQuery viz demos (charts/gauges) render on the +// CPU, and framework demos additionally transpile TS/SFC/Angular in the browser on every +// load — over-subscribing starves rendering and turns into load timeouts. Cap by core +// count (with a fallback for containers where os.cpus() is empty). +// const cpuCount = os.cpus().length || 4; +// const DEFAULT_CONCURRENCY = Math.min(cpuCount, FRAMEWORK === 'jQuery' ? 15 : 8); +// const parsedConcurrency = parseInt(process.env.CSP_CONCURRENCY, 10); +// Guard against NaN/0/undefined: a bad value would make runPool spawn zero workers, +// visit no demos, and report a vacuous "no violations" pass. +const CONCURRENCY = 20; // parsedConcurrency > 0 ? parsedConcurrency : DEFAULT_CONCURRENCY; + +// Injected into every page before navigation; records securitypolicyviolation events +// into window.__cspViolations synchronously as they fire (no async report round-trip). +const CSP_LISTENER_SOURCE = readFileSync( + join(__dirname, '..', 'visual-tests', 'inject', 'csp-listener.js'), + 'utf8', +); + +// DOM render is the correct completion signal (framework demos render after network +// idle, via in-browser compilation), bounded so non-rendering demos don't hang. +const RENDER_DEADLINE_MS = 20000; +// Short tail for violations that fire just after first render (e.g. async chart draw). +const SETTLE_MS = 300; function findChrome() { const candidates = [ @@ -40,6 +64,81 @@ function findChrome() { const CHROME_PATH = findChrome(); +const DEBUG_PORT = 20222; +const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +let browserChild = null; +let browserCdp = null; + +// Minimal CDP-over-WebSocket connection: awaitable commands (correlated by id), +// plus error/close handling. Used for both the browser-level connection +// (Target.* commands) and per-tab page connections. +function openCdp(wsUrl) { + const ws = new WebSocket(wsUrl); + const pending = new Map(); + let msgId = 0; + + ws.addEventListener('message', ({ data }) => { + const msg = JSON.parse(data); + const cb = pending.get(msg.id); + if (cb) { + pending.delete(msg.id); + cb(msg.result); + } + }); + + return { + ready: new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', reject); + }), + send(method, params) { + msgId += 1; + const id = msgId; + return new Promise((resolve) => { + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params: params ?? {} })); + }); + }, + onError(fn) { ws.addEventListener('error', fn); }, + close() { try { ws.close(); } catch { /* already closed */ } }, + }; +} + +async function startBrowser() { + browserChild = spawn(CHROME_PATH, [ + '--headless=new', + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-software-rasterizer', + '--no-first-run', + '--no-default-browser-check', + `--remote-debugging-port=${DEBUG_PORT}`, + `--user-data-dir=${join(os.tmpdir(), 'csp-chrome-shared')}`, + ], { stdio: 'ignore' }); + const info = await waitForDebugger(DEBUG_PORT); + browserCdp = openCdp(info.webSocketDebuggerUrl); + await browserCdp.ready; +} + +function stopBrowser() { + if (browserCdp) browserCdp.close(); + try { browserChild.kill('SIGKILL'); } catch { /* already dead */ } +} + +async function waitForDebugger(port, maxWaitMs = 15000) { + const deadline = Date.now() + maxWaitMs; + while (Date.now() < deadline) { + try { + return await httpRequest(`http://127.0.0.1:${port}/json/version`); + } catch { + await delay(200); + } + } + throw new Error(`Chrome debugger did not start on port ${port}`); +} + function findDemos() { const demosDir = join(DEMO_ROOT, 'Demos'); const result = []; @@ -75,31 +174,69 @@ function findDemos() { 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=2000', - '--window-size=100,100', - url, - ], { timeout: 50000, killSignal: 'SIGKILL' }, (error) => { - if (error && error.killed) { - reject(new Error(`Chrome timed out for ${url}`)); - } else { - resolve(); - } - }); - child.on('error', (err) => { - reject(new Error(`Failed to launch Chrome at "${CHROME_PATH}": ${err.message}`)); +// Navigate, wait until the demo has actually rendered, then read the CSP violations +// the page recorded for itself. networkIdle is not enough: framework demos transpile +// their TS/SFC in-browser and render *after* the network goes quiet (CPU work, no +// network), so DOM render — not network idle — is the completion signal. Bounded so +// demos that never render don't hang. Violations are collected in-page (via the +// injected securitypolicyviolation listener), so no async report round-trip and no +// cross-demo bleed under concurrency. +async function collectViolations(tab, url) { + await tab.ready; + await tab.send('Page.enable'); + await tab.send('Runtime.enable'); + await tab.send('Page.addScriptToEvaluateOnNewDocument', { source: CSP_LISTENER_SOURCE }); + await tab.send('Page.navigate', { url }); + + const deadline = Date.now() + RENDER_DEADLINE_MS; + while (Date.now() < deadline) { + const evalRes = await tab.send('Runtime.evaluate', { + expression: "!!document.querySelector('.dx-widget')", + returnByValue: true, }); + if (evalRes && evalRes.result && evalRes.result.value) break; + await delay(250); + } + + await delay(SETTLE_MS); + + const res = await tab.send('Runtime.evaluate', { + expression: 'JSON.stringify(window.__cspViolations || [])', + returnByValue: true, + }); + const raw = res && res.result && res.result.value; + const all = raw ? JSON.parse(raw) : []; + + // A single page can emit thousands of identical events (e.g. eval() called in a loop), + // which would bloat the report without adding information. Collapse to unique + // (directive, blocked, source) tuples — matches how the report summary groups them. + const seen = new Set(); + return all.filter((v) => { + const key = `${v.effectiveDirective || v.violatedDirective}|${v.blockedURI}|${v.sourceFile}|${v.lineNumber}|${v.columnNumber}`; + if (seen.has(key)) return false; + seen.add(key); + return true; }); } +async function visitPage(url) { + const { targetId } = await browserCdp.send('Target.createTarget', { url: 'about:blank' }); + const tab = openCdp(`ws://127.0.0.1:${DEBUG_PORT}/devtools/page/${targetId}`); + + try { + return await Promise.race([ + collectViolations(tab, url), + new Promise((resolve, reject) => { + tab.onError(() => reject(new Error(`Chrome WebSocket error for ${url}`))); + }), + delay(RENDER_DEADLINE_MS + 10000).then(() => { throw new Error(`Timed out loading ${url}`); }), + ]); + } finally { + tab.close(); + await browserCdp.send('Target.closeTarget', { targetId }); + } +} + function httpRequest(url, method) { return new Promise((resolve, reject) => { const req = http.request(url, { method: method || 'GET' }, (res) => { @@ -127,8 +264,9 @@ async function runPool(items, concurrency, fn) { await fn(items[i], i); } } + const workerCount = Math.max(1, Math.min(concurrency || 1, items.length)); const workers = []; - for (let w = 0; w < Math.min(concurrency, items.length); w += 1) { + for (let w = 0; w < workerCount; w += 1) { workers.push(worker()); } await Promise.all(workers); @@ -150,42 +288,42 @@ async function main() { let totalViolations = 0; let demosWithViolations = 0; + let demosVisited = 0; const allViolations = []; - await httpRequest(`${SERVER_URL}/csp-violations`, 'DELETE'); + await startBrowser(); - await runPool(demos, CONCURRENCY, async (demo, i) => { - const idx = i + 1; - const snapshot = await httpRequest(`${SERVER_URL}/csp-violations`); - const since = snapshot.lastId || 0; + try { + await runPool(demos, CONCURRENCY, async (demo, i) => { + const idx = i + 1; + demosVisited += 1; - try { - await visitPage(demo.url); - } catch (err) { - console.log(` ⚠️ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework} — ${err.message}`); - return; - } + let violations; + try { + violations = await visitPage(demo.url); + } catch (err) { + console.log(` ⚠️ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework} — ${err.message}`); + return; + } + + if (violations.length > 0) { + demosWithViolations += 1; + totalViolations += violations.length; - const result = await httpRequest(`${SERVER_URL}/csp-violations?since=${since}`); - const violations = (result.violations || []).filter( - (v) => v.documentUri === demo.url || v.documentUri === `${demo.url}index.html`, - ); - - if (violations.length > 0) { - demosWithViolations += 1; - totalViolations += violations.length; - - console.log(` ❌ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework} — ${violations.length} violation(s)`); - for (const v of violations) { - const blocked = v.blockedUri || 'N/A'; - const directive = v.effectiveDirective || v.violatedDirective || '?'; - console.log(` ${directive}: ${blocked}`); - allViolations.push({ ...v, framework: FRAMEWORK }); + console.log(` ❌ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework} — ${violations.length} violation(s)`); + for (const v of violations) { + 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}`); } - } else { - console.log(` ✅ [${idx}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework}`); - } - }); + }); + } finally { + stopBrowser(); + } const reportFile = join(REPORT_DIR, `csp-violations-${FRAMEWORK.toLowerCase()}.jsonl`); @@ -197,10 +335,18 @@ async function main() { console.log(`\n${'='.repeat(60)}`); console.log(`Framework: ${FRAMEWORK}`); - console.log(`Demos checked: ${demos.length}`); + console.log(`Demos checked: ${demosVisited}/${demos.length}`); console.log(`Demos with violations: ${demosWithViolations}`); console.log(`Total violations: ${totalViolations}`); + // Defend against a vacuous pass: if no demo was actually visited (e.g. a bad + // concurrency value spawning zero workers), "no violations" is meaningless — fail loudly. + if (demosVisited < demos.length) { + console.log(`\n❌ Only ${demosVisited}/${demos.length} demos were visited — the run is incomplete.`); + process.exitCode = 1; + return; + } + if (totalViolations > 0) { console.log(`\n⚠️ ${totalViolations} CSP violation(s) detected in ${demosWithViolations} demo(s)`); console.log(`Report: ${reportFile}`); diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 79e3b800f4ec..c516221e4fac 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -2,9 +2,8 @@ const crypto = require('crypto'); const express = require('express'); -const cookieParser = require('cookie-parser'); const { join, resolve } = require('path'); -const { readFileSync, readdirSync } = require('fs'); +const { readFileSync } = require('fs'); const RateLimit = require('express-rate-limit'); const root = join(__dirname, '..', '..', '..', '..'); @@ -34,14 +33,13 @@ const CSP_DEMO_ALLOWLIST = { 'Button/Icons': { 'font-src': ['https://maxcdn.bootstrapcdn.com'], }, - 'CardView/WebAPIService': { - 'img-src': ['data:'], - }, + // '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:'], @@ -65,68 +63,63 @@ const CSP_DEMO_ALLOWLIST = { 'Scheduler/SignalRService': { 'connect-src': ['wss://js.devexpress.com'], }, - 'DataGrid/Cell': { - 'img-src': ['data:'], - }, - // AI demo: inline