#!/usr/bin/env node /** * Verify MindSpace published pages under real HTTP CSP — interactions, not just HTTP 200. * * Requires: local Portal (pnpm dev) + playwright. */ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081'; const userId = process.env.VERIFY_USER_ID || '1e655eff-69ce-4f56-9d74-b02883e4112a'; const checks = []; function record(name, ok, details = {}) { checks.push({ name, ok: Boolean(ok), ...details }); const mark = ok ? 'PASS' : 'FAIL'; console.log(`${mark} ${name}${details.detail ? ` — ${details.detail}` : ''}`); } function pageUrl(relativePath) { return `${base}/${PUBLISH_ROOT_DIR}/${userId}/${relativePath}`; } const ONCLICK_FIXTURE = ` onclick fixture
idle
`; const ADD_EVENT_LISTENER_FIXTURE = ` listener fixture
idle
`; async function writeFixture(relativePath, html) { const diskPath = path.join(repoRoot, PUBLISH_ROOT_DIR, userId, relativePath); await fs.mkdir(path.dirname(diskPath), { recursive: true }); await fs.writeFile(diskPath, html, 'utf8'); } async function launchBrowser() { const { chromium } = await import('playwright'); const browser = await chromium.launch({ headless: true }); return browser; } function attachDiagnostics(page, label) { const cspViolations = []; page.on('console', (message) => { const text = message.text(); if (/Content Security Policy|Refused to execute|blocked by CSP/i.test(text)) { cspViolations.push(`${label}: ${text}`); } }); page.on('pageerror', (error) => { cspViolations.push(`${label}: pageerror ${error.message}`); }); return cspViolations; } async function testFixturePage(browser, relativePath, { clickSelector, evaluateReady }) { const page = await browser.newPage(); const violations = attachDiagnostics(page, relativePath); await page.goto(pageUrl(relativePath), { waitUntil: 'networkidle', timeout: 25_000 }); await page.click(clickSelector); await page.waitForTimeout(250); const ready = await page.evaluate(evaluateReady); const csp = (await page.evaluate(() => document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content)) ?? ''; const headerProbe = await fetch(pageUrl(relativePath)).then((res) => res.headers.get('content-security-policy') ?? ''); await page.close(); return { ready, violations, csp: headerProbe || csp }; } async function testStickyNoteInteractions(browser) { const page = await browser.newPage(); const violations = attachDiagnostics(page, 'sticky-note-reminder.html'); const url = pageUrl('public/sticky-note-reminder.html'); await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 }); await page.click('#prioSelector .priority-option.high'); const priorityActive = await page.evaluate(() => document.querySelector('#prioSelector .priority-option.high')?.classList.contains('active'), ); const uniqueTitle = `E2E-${Date.now()}`; const beforeTotal = Number(await page.textContent('#statTotal')); await page.fill('#inputTitle', uniqueTitle); await page.fill('#inputContent', '自动化交互测试内容,用于验证动态 onclick 展开。'); await page.click('#btnSubmit'); await page.waitForFunction( (title) => [...document.querySelectorAll('.card-title')].some((el) => el.textContent?.includes(title)), uniqueTitle, { timeout: 10_000 }, ); const afterTotal = Number(await page.textContent('#statTotal')); await page.click(`.card-title:text("${uniqueTitle}")`); const cardContent = page.locator('.timeline-item', { hasText: uniqueTitle }).locator('.card-content').first(); await cardContent.click(); await page.waitForTimeout(300); const expanded = await cardContent.evaluate((el) => el.classList.contains('expanded')); const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? ''); await page.close(); return { violations, priorityActive, totalIncreased: afterTotal > beforeTotal, expanded, headerCsp, }; } async function testPlantTreeInteractions(browser) { const page = await browser.newPage(); const violations = attachDiagnostics(page, 'plant-tree.html'); const url = pageUrl('public/plant-tree.html'); await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 }); const username = `E2E-${Date.now()}`; await page.fill('#usernameInput', username); await page.click('#plantBtn'); await page.waitForFunction( () => { const toast = document.querySelector('.toast.show, .toast[style*="opacity: 1"]'); return toast && /种下/.test(toast.textContent || ''); }, { timeout: 10_000 }, ).catch(async () => { await page.waitForTimeout(1500); }); const toastText = await page.evaluate(() => { const nodes = [...document.querySelectorAll('.toast, #toast')]; return nodes.map((node) => node.textContent?.trim()).filter(Boolean).join(' | '); }); await page.waitForFunction( (name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)), username, { timeout: 10_000 }, ).catch(() => {}); const listed = await page.evaluate( (name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)), username, ); const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? ''); await page.close(); return { violations, toastText, listed, headerCsp, }; } async function main() { console.log(`\n=== MindSpace public page interaction verify @ ${base} ===\n`); const health = await fetch(`${base}/auth/status`).then((r) => r.ok).catch(() => false); record('portal_running', health, { detail: health ? 'auth/status OK' : 'server down' }); if (!health) { process.exitCode = 1; return; } await writeFixture('public/csp-onclick-fixture.html', ONCLICK_FIXTURE); await writeFixture('public/csp-listener-fixture.html', ADD_EVENT_LISTENER_FIXTURE); let browser; try { browser = await launchBrowser(); } catch (error) { record('playwright_available', false, { detail: error?.message || String(error) }); process.exitCode = 1; return; } record('playwright_available', true, { detail: 'chromium launched' }); const onclick = await testFixturePage(browser, 'public/csp-onclick-fixture.html', { clickSelector: '#btn', evaluateReady: () => ({ bodyClicked: document.body.dataset.clicked === '1', listenerStatus: document.getElementById('status')?.textContent ?? '', }), }); record('fixture_onclick_attribute_executes', onclick.ready.bodyClicked, { detail: JSON.stringify(onclick.ready), }); record('fixture_onclick_with_listener_both_work', onclick.ready.listenerStatus === 'listener-ok', { detail: onclick.ready.listenerStatus, }); record('fixture_onclick_no_csp_violations', onclick.violations.length === 0, { detail: onclick.violations.join(' | ') || 'none', }); record('fixture_onclick_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(onclick.csp), { detail: onclick.csp.match(/script-src[^;]+/)?.[0] ?? onclick.csp.slice(0, 120), }); const listener = await testFixturePage(browser, 'public/csp-listener-fixture.html', { clickSelector: '#btn', evaluateReady: () => ({ bodyClicked: document.body.dataset.clicked === '1', status: document.getElementById('status')?.textContent ?? '', }), }); record('fixture_add_event_listener_executes', listener.ready.status === 'ok', { detail: JSON.stringify(listener.ready), }); record('fixture_add_event_listener_no_csp_violations', listener.violations.length === 0, { detail: listener.violations.join(' | ') || 'none', }); const sticky = await testStickyNoteInteractions(browser); record('sticky_note_priority_click_works', sticky.priorityActive, { detail: String(sticky.priorityActive) }); record('sticky_note_submit_increases_total', sticky.totalIncreased, { detail: String(sticky.totalIncreased) }); record('sticky_note_dynamic_onclick_expand_works', sticky.expanded, { detail: sticky.expanded ? 'card expanded' : 'onclick expand blocked', }); record('sticky_note_no_csp_violations', sticky.violations.length === 0, { detail: sticky.violations.join(' | ') || 'none', }); record('sticky_note_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(sticky.headerCsp), { detail: sticky.headerCsp.match(/script-src[^;]+/)?.[0] ?? sticky.headerCsp.slice(0, 120), }); const plant = await testPlantTreeInteractions(browser); record('plant_tree_submit_shows_toast', /种下/.test(plant.toastText), { detail: plant.toastText || 'empty' }); record('plant_tree_record_listed', plant.listed, { detail: String(plant.listed) }); record('plant_tree_no_csp_violations', plant.violations.length === 0, { detail: plant.violations.join(' | ') || 'none', }); await browser.close(); const ok = checks.every((item) => item.ok); console.log(`\n=== ${ok ? 'ALL PASS — published pages interact correctly under CSP' : 'SOME FAILED'} (${checks.filter((item) => item.ok).length}/${checks.length}) ===\n`); if (!ok) { console.log('Failed checks:'); for (const item of checks.filter((entry) => !entry.ok)) { console.log(` - ${item.name}${item.detail ? `: ${item.detail}` : ''}`); } console.log('\nTip: restart dev server (pnpm dev) after CSP policy changes.\n'); } process.exitCode = ok ? 0 : 1; } main().catch((error) => { console.error(error); process.exitCode = 1; });