Files
memind/scripts/verify-public-page-interaction.mjs
john ae9090948a fix(mindspace): relax published page CSP so agent HTML interactions work
Switch default MindSpace HTML delivery to a hybrid inline CSP so onclick handlers and addEventListener pages both run under real HTTP headers, and add Playwright interaction verify beyond HTTP 200 checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:39:39 +08:00

281 lines
10 KiB
JavaScript

#!/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 = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>onclick fixture</title>
</head>
<body>
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
<div id="status">idle</div>
<script>
document.getElementById('btn').addEventListener('click', function () {
document.getElementById('status').textContent = 'listener-ok';
});
</script>
</body>
</html>`;
const ADD_EVENT_LISTENER_FIXTURE = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>listener fixture</title></head>
<body>
<button id="btn">点我</button>
<div id="status">idle</div>
<script>
document.getElementById('btn').addEventListener('click', function () {
document.body.dataset.clicked = '1';
document.getElementById('status').textContent = 'ok';
});
</script>
</body>
</html>`;
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;
});