import http from 'node:http'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const folder = path.join(root, 'docs/workshop-manual-20261109'); const standalonePath = path.join(root, 'docs/workshop-manual-20261110-r1/ksf-workshop-manual-r10.html'); const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); const { chromium } = await import(pathToFileURL(path.join(root, 'work/workshop-manual-tools/node_modules/playwright/index.mjs')).href); const allowed = new Map([['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']], ['/manual.css', ['manual.css', 'text/css']], ['/content.js', ['content.js', 'application/javascript']], ['/manual.js', ['manual.js', 'application/javascript']]]); const server = http.createServer(async (req, res) => { if (req.url === '/standalone.html') { try { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(await fs.readFile(standalonePath)); } catch { res.writeHead(404); res.end(); } return; } const item = allowed.get(new URL(req.url, 'http://127.0.0.1').pathname); if (!item) { res.writeHead(404); res.end(); return; } try { res.writeHead(200, { 'content-type': item[1] + '; charset=utf-8' }); res.end(await fs.readFile(path.join(folder, item[0]))); } catch { res.writeHead(500); res.end(); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const base = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' }); const errors = []; const checks = []; try { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, permissions: ['clipboard-read', 'clipboard-write'] }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); await page.goto(base, { waitUntil: 'networkidle' }); await page.locator('#chapter-title').waitFor(); assert.equal(await page.locator('#chapter-title').textContent(), '安装与初始化'); const steps = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA.steps); assert.equal(steps.length, 15); assert.equal(await page.locator('.nav-item').count(), steps.length); assert.equal(await page.locator('.hero, .overview, .roadmap').count(), 0); assert.equal(await page.locator('#prompt-text').isVisible(), true); assert.ok((await page.locator('#prompt-text').boundingBox()).y < 520); checks.push('Compact task view starts directly with chapter content and expanded prompt'); await page.locator('#copy-prompt').click(); let copied = await page.evaluate(() => navigator.clipboard.readText()); const release = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA.release); assert.ok(copied.includes(release.guide) && copied.includes(release.version)); assert.ok((await page.locator('.version-note').textContent()).includes(release.version)); if (release.localDraftPublished === false) assert.ok((await page.locator('.version-note').textContent()).includes('待联合发布')); checks.push('Installation prompt copies the current fixed release and preserves migration status distinction'); await page.locator('#completed').check(); await page.reload({ waitUntil: 'networkidle' }); assert.equal(await page.locator('#completed').isChecked(), true); await page.locator('#completed').uncheck(); checks.push('Personal progress survives reload without any backend request'); const overflow = []; for (const width of [1440, 390]) { await page.setViewportSize({ width, height: 960 }); for (const step of steps) { await page.evaluate(id => { location.hash = id; }, step.id); await page.waitForFunction(title => document.getElementById('chapter-title')?.textContent === title, step.title); assert.equal(await page.locator('.nav-item[aria-current="step"]').count(), 1); assert.equal(await page.locator('#prompt-text').isVisible(), true); for (const output of step.deliverables) assert.ok((await page.locator('.outputs').textContent()).includes(output)); const prompt = await page.locator('#prompt-text').textContent(); assert.equal(/49\s*天|四十九天/.test(prompt), false); if (step.task) { assert.ok(prompt.includes(step.question) && prompt.includes(step.inputs) && prompt.includes(step.task)); assert.ok(prompt.includes(step.skillReference)); const reference = path.resolve(root, 'skills/ksf-workshop-runtime', step.skillReference); assert.ok(reference.startsWith(path.join(root, 'skills/ksf-workshop-runtime/references') + path.sep)); assert.ok((await fs.stat(reference)).isFile()); for (const output of step.deliverables) assert.ok(prompt.includes(output)); } const dimensions = await page.evaluate(() => ({ client: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth })); if (dimensions.scroll > dimensions.client) overflow.push({ width, id: step.id, ...dimensions }); } } assert.deepEqual(overflow, []); checks.push('All 15 task panels render their outputs with no page overflow at 1440px and 390px'); checks.push('All 13 research prompts include their question, reusable inputs, existing Skill reference, task and deliverables'); await page.evaluate(() => { location.hash = 'competitors'; }); await page.waitForFunction(() => document.getElementById('chapter-title').textContent === '比较竞品与产品取舍'); await page.locator('#copy-prompt').click(); copied = await page.evaluate(() => navigator.clipboard.readText()); const competitor = steps.find(step => step.id === 'competitors'); assert.ok(copied.includes(competitor.question) && copied.includes(competitor.inputs) && copied.includes(competitor.skillReference) && copied.includes(competitor.task)); assert.ok(copied.includes('接续摘要') && copied.includes(competitor.deliverables[0])); checks.push('Research prompt copies shared evidence rules, chapter-specific work and deliverables together'); await page.evaluate(() => { location.hash = 'proposal'; }); const proposal = steps.find(step => step.id === 'proposal'); await page.waitForFunction(title => document.getElementById('chapter-title').textContent === title, proposal.title); await page.locator('#copy-prompt').click(); copied = await page.evaluate(() => navigator.clipboard.readText()); assert.ok(copied.includes(proposal.task) && copied.includes('决定性待验证项')); assert.equal(/49\s*天|四十九天/.test(await page.locator('#chapter').textContent()), false); assert.equal(await page.locator('#next').isDisabled(), true); checks.push('Workshop ends with concept review and evidence gaps; final prompt excludes the post-workshop 49-day plan'); await page.locator('.menu-button').click(); assert.equal(await page.locator('.menu-button').getAttribute('aria-expanded'), 'true'); await page.locator('.nav-item[href="#topic"]').click(); assert.equal(await page.locator('.menu-button').getAttribute('aria-expanded'), 'false'); await page.waitForFunction(() => document.getElementById('chapter-title').textContent === '确定本组研究课题'); checks.push('Mobile menu navigates to the distinct topic-start step'); await page.evaluate(() => { location.hash = 'install'; }); await page.waitForFunction(() => document.getElementById('chapter-title').textContent === '安装与初始化'); await page.waitForFunction(() => !document.getElementById('toast').classList.contains('show')); await page.evaluate(() => { document.documentElement.style.scrollBehavior = 'auto'; window.scrollTo(0, 0); }); await page.screenshot({ path: path.join(folder, 'mobile.png'), fullPage: true, animations: 'disabled' }); await page.setViewportSize({ width: 1440, height: 1000 }); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(folder, 'desktop.png'), fullPage: true, animations: 'disabled' }); assert.deepEqual(errors, []); checks.push('No browser execution errors'); const standalone = await context.newPage(); standalone.on('pageerror', error => errors.push(error.message)); await standalone.goto(base + '/standalone.html', { waitUntil: 'networkidle' }); await standalone.locator('#chapter-title').waitFor(); assert.equal(await standalone.locator('#chapter-title').textContent(), '安装与初始化'); assert.equal(await standalone.locator('.nav-item').count(), steps.length); await standalone.locator('#copy-prompt').click(); const standalonePrompt = await standalone.evaluate(() => navigator.clipboard.readText()); // Windows clipboard normalizes line endings; compare the complete text after that normalization. assert.equal(standalonePrompt.replaceAll('\r\n', '\n'), steps[0].prompt.replaceAll('\r\n', '\n')); await standalone.evaluate(() => { location.hash = 'proposal'; }); await standalone.waitForFunction(() => document.getElementById('chapter-title').textContent === '完成概念提案与评审汇总'); assert.deepEqual(errors, []); checks.push('Standalone HTML opens, copies the same complete prompt and navigates to the final chapter'); const sourceFiles = Object.fromEntries(await Promise.all(['index.html', 'content.js', 'manual.js', 'manual.css'].map(async name => [name, digest(await fs.readFile(path.join(folder, name)))]))); const receipt = { ok: true, panels: steps.length, widths: [1440, 390], checks, browserErrors: errors, sourceFiles, standaloneVerified: true, standaloneSha256: digest(await fs.readFile(standalonePath)), businessDataWritten: false, employeeWorkshopTrialCompleted: false, manualPublished: false }; await fs.writeFile(path.join(folder, 'browser-verification.json'), JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }