verify-workshop-manual.mjs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import http from 'node:http';
  2. import fs from 'node:fs/promises';
  3. import path from 'node:path';
  4. import { fileURLToPath, pathToFileURL } from 'node:url';
  5. import assert from 'node:assert/strict';
  6. import crypto from 'node:crypto';
  7. const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  8. const folder = path.join(root, 'docs/workshop-manual-20261109');
  9. const standalonePath = path.join(root, 'docs/workshop-manual-20261110-r1/ksf-workshop-manual-r10.html');
  10. const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
  11. const { chromium } = await import(pathToFileURL(path.join(root, 'work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
  12. 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']]]);
  13. const server = http.createServer(async (req, res) => {
  14. if (req.url === '/standalone.html') {
  15. try { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(await fs.readFile(standalonePath)); }
  16. catch { res.writeHead(404); res.end(); }
  17. return;
  18. }
  19. const item = allowed.get(new URL(req.url, 'http://127.0.0.1').pathname);
  20. if (!item) { res.writeHead(404); res.end(); return; }
  21. try { res.writeHead(200, { 'content-type': item[1] + '; charset=utf-8' }); res.end(await fs.readFile(path.join(folder, item[0]))); }
  22. catch { res.writeHead(500); res.end(); }
  23. });
  24. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  25. const base = `http://127.0.0.1:${server.address().port}`;
  26. const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
  27. const errors = [];
  28. const checks = [];
  29. try {
  30. const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, permissions: ['clipboard-read', 'clipboard-write'] });
  31. const page = await context.newPage();
  32. page.on('pageerror', error => errors.push(error.message));
  33. await page.goto(base, { waitUntil: 'networkidle' });
  34. await page.locator('#chapter-title').waitFor();
  35. assert.equal(await page.locator('#chapter-title').textContent(), '安装与初始化');
  36. const steps = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA.steps);
  37. assert.equal(steps.length, 15);
  38. assert.equal(await page.locator('.nav-item').count(), steps.length);
  39. assert.equal(await page.locator('.hero, .overview, .roadmap').count(), 0);
  40. assert.equal(await page.locator('#prompt-text').isVisible(), true);
  41. assert.ok((await page.locator('#prompt-text').boundingBox()).y < 520);
  42. checks.push('Compact task view starts directly with chapter content and expanded prompt');
  43. await page.locator('#copy-prompt').click();
  44. let copied = await page.evaluate(() => navigator.clipboard.readText());
  45. const release = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA.release);
  46. assert.ok(copied.includes(release.guide) && copied.includes(release.version));
  47. assert.ok((await page.locator('.version-note').textContent()).includes(release.version));
  48. if (release.localDraftPublished === false) assert.ok((await page.locator('.version-note').textContent()).includes('待联合发布'));
  49. checks.push('Installation prompt copies the current fixed release and preserves migration status distinction');
  50. await page.locator('#completed').check();
  51. await page.reload({ waitUntil: 'networkidle' });
  52. assert.equal(await page.locator('#completed').isChecked(), true);
  53. await page.locator('#completed').uncheck();
  54. checks.push('Personal progress survives reload without any backend request');
  55. const overflow = [];
  56. for (const width of [1440, 390]) {
  57. await page.setViewportSize({ width, height: 960 });
  58. for (const step of steps) {
  59. await page.evaluate(id => { location.hash = id; }, step.id);
  60. await page.waitForFunction(title => document.getElementById('chapter-title')?.textContent === title, step.title);
  61. assert.equal(await page.locator('.nav-item[aria-current="step"]').count(), 1);
  62. assert.equal(await page.locator('#prompt-text').isVisible(), true);
  63. for (const output of step.deliverables) assert.ok((await page.locator('.outputs').textContent()).includes(output));
  64. const prompt = await page.locator('#prompt-text').textContent();
  65. assert.equal(/49\s*天|四十九天/.test(prompt), false);
  66. if (step.task) {
  67. assert.ok(prompt.includes(step.question) && prompt.includes(step.inputs) && prompt.includes(step.task));
  68. assert.ok(prompt.includes(step.skillReference));
  69. const reference = path.resolve(root, 'skills/ksf-workshop-runtime', step.skillReference);
  70. assert.ok(reference.startsWith(path.join(root, 'skills/ksf-workshop-runtime/references') + path.sep));
  71. assert.ok((await fs.stat(reference)).isFile());
  72. for (const output of step.deliverables) assert.ok(prompt.includes(output));
  73. }
  74. const dimensions = await page.evaluate(() => ({ client: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
  75. if (dimensions.scroll > dimensions.client) overflow.push({ width, id: step.id, ...dimensions });
  76. }
  77. }
  78. assert.deepEqual(overflow, []);
  79. checks.push('All 15 task panels render their outputs with no page overflow at 1440px and 390px');
  80. checks.push('All 13 research prompts include their question, reusable inputs, existing Skill reference, task and deliverables');
  81. await page.evaluate(() => { location.hash = 'competitors'; });
  82. await page.waitForFunction(() => document.getElementById('chapter-title').textContent === '比较竞品与产品取舍');
  83. await page.locator('#copy-prompt').click();
  84. copied = await page.evaluate(() => navigator.clipboard.readText());
  85. const competitor = steps.find(step => step.id === 'competitors');
  86. assert.ok(copied.includes(competitor.question) && copied.includes(competitor.inputs) && copied.includes(competitor.skillReference) && copied.includes(competitor.task));
  87. assert.ok(copied.includes('接续摘要') && copied.includes(competitor.deliverables[0]));
  88. checks.push('Research prompt copies shared evidence rules, chapter-specific work and deliverables together');
  89. await page.evaluate(() => { location.hash = 'proposal'; });
  90. const proposal = steps.find(step => step.id === 'proposal');
  91. await page.waitForFunction(title => document.getElementById('chapter-title').textContent === title, proposal.title);
  92. await page.locator('#copy-prompt').click();
  93. copied = await page.evaluate(() => navigator.clipboard.readText());
  94. assert.ok(copied.includes(proposal.task) && copied.includes('决定性待验证项'));
  95. assert.equal(/49\s*天|四十九天/.test(await page.locator('#chapter').textContent()), false);
  96. assert.equal(await page.locator('#next').isDisabled(), true);
  97. checks.push('Workshop ends with concept review and evidence gaps; final prompt excludes the post-workshop 49-day plan');
  98. await page.locator('.menu-button').click();
  99. assert.equal(await page.locator('.menu-button').getAttribute('aria-expanded'), 'true');
  100. await page.locator('.nav-item[href="#topic"]').click();
  101. assert.equal(await page.locator('.menu-button').getAttribute('aria-expanded'), 'false');
  102. await page.waitForFunction(() => document.getElementById('chapter-title').textContent === '确定本组研究课题');
  103. checks.push('Mobile menu navigates to the distinct topic-start step');
  104. await page.evaluate(() => { location.hash = 'install'; });
  105. await page.waitForFunction(() => document.getElementById('chapter-title').textContent === '安装与初始化');
  106. await page.waitForFunction(() => !document.getElementById('toast').classList.contains('show'));
  107. await page.evaluate(() => { document.documentElement.style.scrollBehavior = 'auto'; window.scrollTo(0, 0); });
  108. await page.screenshot({ path: path.join(folder, 'mobile.png'), fullPage: true, animations: 'disabled' });
  109. await page.setViewportSize({ width: 1440, height: 1000 });
  110. await page.evaluate(() => window.scrollTo(0, 0));
  111. await page.screenshot({ path: path.join(folder, 'desktop.png'), fullPage: true, animations: 'disabled' });
  112. assert.deepEqual(errors, []);
  113. checks.push('No browser execution errors');
  114. const standalone = await context.newPage();
  115. standalone.on('pageerror', error => errors.push(error.message));
  116. await standalone.goto(base + '/standalone.html', { waitUntil: 'networkidle' });
  117. await standalone.locator('#chapter-title').waitFor();
  118. assert.equal(await standalone.locator('#chapter-title').textContent(), '安装与初始化');
  119. assert.equal(await standalone.locator('.nav-item').count(), steps.length);
  120. await standalone.locator('#copy-prompt').click();
  121. const standalonePrompt = await standalone.evaluate(() => navigator.clipboard.readText());
  122. // Windows clipboard normalizes line endings; compare the complete text after that normalization.
  123. assert.equal(standalonePrompt.replaceAll('\r\n', '\n'), steps[0].prompt.replaceAll('\r\n', '\n'));
  124. await standalone.evaluate(() => { location.hash = 'proposal'; });
  125. await standalone.waitForFunction(() => document.getElementById('chapter-title').textContent === '完成概念提案与评审汇总');
  126. assert.deepEqual(errors, []);
  127. checks.push('Standalone HTML opens, copies the same complete prompt and navigates to the final chapter');
  128. 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)))])));
  129. const receipt = { ok: true, panels: steps.length, widths: [1440, 390], checks, browserErrors: errors,
  130. sourceFiles, standaloneVerified: true, standaloneSha256: digest(await fs.readFile(standalonePath)),
  131. businessDataWritten: false, employeeWorkshopTrialCompleted: false, manualPublished: false };
  132. await fs.writeFile(path.join(folder, 'browser-verification.json'), JSON.stringify(receipt, null, 2) + '\n');
  133. console.log(JSON.stringify(receipt));
  134. } finally {
  135. await browser.close();
  136. await new Promise(resolve => server.close(resolve));
  137. }