Skip to content
Draft

Test CI #33728

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/visual-tests-demos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 &

Expand Down
258 changes: 202 additions & 56 deletions apps/demos/utils/server/csp-check.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
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');

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 = [
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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`);

Expand All @@ -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}`);
Expand Down
Loading
Loading