verify-board-v3-browser.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import assert from 'node:assert/strict';
  4. import { pathToFileURL } from 'node:url';
  5. const production = process.argv.includes('--production');
  6. const output = path.resolve(process.env.KSF_BROWSER_OUTPUT || 'work/board-v3-release-20261112/browser');
  7. await fs.mkdir(output, { recursive: true });
  8. const manifest = JSON.parse(await fs.readFile('deploy/board-v3-20261112.json'));
  9. const files = new Set(manifest.files.map(file => file.path));
  10. const modulePath = process.env.KSF_PLAYWRIGHT_MODULE || path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs');
  11. const { chromium } = await import(pathToFileURL(modulePath).href);
  12. const browser = await chromium.launch({ headless: true, ...(process.env.KSF_BROWSER_EXECUTABLE
  13. ? { executablePath: process.env.KSF_BROWSER_EXECUTABLE }
  14. : process.platform === 'win32' ? { executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' } : {}) });
  15. const origin = 'https://kangshifu.fmode.cn', checks = [], errors = [], blockedWrites = [];
  16. const receipt = { release: manifest.release, mode: production ? 'production-read-only' : 'local-source-with-public-production-reads',
  17. productionWrites: 0, deployedByThisCheck: false, checks, errors, blockedWrites };
  18. try {
  19. const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
  20. await context.route('**/*', async route => {
  21. const request = route.request(), url = new URL(request.url());
  22. if (!production && url.origin === origin && files.has(url.pathname.slice(1))) return route.fulfill({
  23. contentType: /\.m?js$/.test(url.pathname) ? 'application/javascript' : url.pathname.endsWith('.json') ? 'application/json' : 'font/woff2',
  24. body: await fs.readFile(url.pathname.slice(1)) });
  25. if (url.searchParams.get('fileId') === 'browser-only-file') return route.fulfill({ contentType: 'text/html',
  26. body: '<!doctype html><meta charset="utf-8"><h1>浏览器夹具:HTML排版展示</h1><p>仅在浏览器内存中验证,未保存为真实成果。</p>',
  27. headers: { 'content-security-policy': "default-src 'none'; sandbox" } });
  28. if (!['GET', 'HEAD'].includes(request.method())) {
  29. const body = request.postDataJSON();
  30. if (url.origin !== origin || url.pathname !== '/api/functions'
  31. || !['/ksfGetPublicBoard', '/ksfGetPublicMemberGroups'].includes(body?.path)) {
  32. blockedWrites.push(url.origin + url.pathname); return route.abort();
  33. }
  34. }
  35. return route.continue();
  36. });
  37. const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
  38. let reportPath;
  39. for (const width of [1440, 390, 320]) {
  40. await page.setViewportSize({ width, height: 1000 });
  41. await page.goto(origin + '/workshop'); await page.locator('[data-output-member]').first().waitFor();
  42. const columns = await page.locator('thead').first().locator('[data-course-column]').count(); assert.equal(columns, 13);
  43. const memberIds = await page.locator('[data-output-member]').evaluateAll(rows => rows.map(row => row.dataset.outputMember));
  44. assert.equal(new Set(memberIds).size, memberIds.length);
  45. const overflow = await page.evaluate(() => Math.max(0, document.documentElement.scrollWidth - innerWidth)); assert.equal(overflow, 0);
  46. reportPath ||= await page.locator('[data-workshop-artifact]').first().getAttribute('href');
  47. await page.screenshot({ path: path.join(output, 'workshop-' + width + '.png') });
  48. checks.push({ page: 'workshop', width, columns, members: memberIds.length, overflow, actualPublicData: true });
  49. await page.goto(new URL(reportPath, origin).href); await page.locator('view-report h1').first().waitFor();
  50. const report = await page.locator('view-report').evaluate(view => ({ artifactId: view.artifactId,
  51. attachments: view.db.artifactById[view.artifactId]?.report?.attachments?.length || 0,
  52. statisticsCards: view.renderRoot.querySelectorAll('.statistics-card').length,
  53. chapters: view.renderRoot.querySelectorAll('[data-chapter]').length }));
  54. assert.ok(report.chapters > 0); const reportOverflow = await page.evaluate(() => Math.max(0, document.documentElement.scrollWidth - innerWidth));
  55. assert.equal(reportOverflow, 0); await page.screenshot({ path: path.join(output, 'real-report-' + width + '.png') });
  56. checks.push({ page: 'real-report', width, ...report, overflow: reportOverflow, actualPublicData: true });
  57. await page.goto(origin + '/reports'); await page.locator('[data-type-card]').first().waitFor();
  58. assert.equal(await page.locator('[data-type-card]').count(), 11);
  59. await page.goto(origin + '/pool'); await page.locator('view-pool .course-note').waitFor();
  60. assert.equal(await page.evaluate(() => Math.max(0, document.documentElement.scrollWidth - innerWidth)), 0);
  61. assert.equal(await page.locator('view-pool button[aria-pressed="true"]').filter({ hasText: '最新' }).count(), 2);
  62. checks.push({ page: 'reports-and-pool', width, reportCategories: 11, defaultSort: 'newest', actualPublicData: true });
  63. }
  64. // Separate in-memory fixtures exercise cards even when public reports have not saved these sections yet.
  65. for (const width of [1440, 390, 320]) {
  66. await page.setViewportSize({ width, height: 1000 }); await page.goto(new URL(reportPath, origin).href);
  67. await page.locator('view-report h1').first().waitFor();
  68. await page.locator('view-report').evaluate(async view => {
  69. const db = view.db, original = db.artifactById[view.artifactId];
  70. const report = { title: '浏览器内存夹具:统计和课程排序', executive: ['仅用于排版验证,未写入业务数据。'],
  71. chapters: [{ id: 'browser-verification', title: '浏览器夹具', sections: [
  72. { id: 'statistics-summary', kind: 'table', data: { cols: ['维度/主题', '提及数 n', '有效样本 N', '统计口径与范围', '数据解释与限制', '产品含义', '证据'], rows: [
  73. ['密封便利', 4, 10, '夹具样本10条,不代表真实调研', '多主题可重叠', '用于验证密封结构卡片', ''],
  74. ['待核材料', null, null, '夹具分母尚未核实', '不计算比例', '先补原件再判断', '']] } },
  75. { id: 'course-priority', kind: 'table', data: { cols: ['机会', '证据强度', '可做性', '商业价值', '依据一句话', '排序'], rows: [
  76. ['浏览器夹具候选', 3, 2, null, '商业价值尚未核实,仅验证未知项展示', '待补资料']] } },
  77. ] }], evidence: {}, attachments: [{ id: 'browser-only-file', name: '浏览器夹具.html', contentType: 'text/html', sha256: 'a'.repeat(64),
  78. url: location.origin + '/api/functions?path=%2Fksf%2Ffile&artifactId=' + encodeURIComponent(view.artifactId) + '&fileId=browser-only-file' }] };
  79. const artifact = { ...original, report };
  80. view.db = { ...db, artifacts: db.artifacts.map(row => row.id === artifact.id ? artifact : row), artifactById: { ...db.artifactById, [artifact.id]: artifact } };
  81. view._readingFormat = 'html'; await view.updateComplete;
  82. });
  83. await page.locator('[data-html-report] iframe').waitFor();
  84. await page.frameLocator('[data-html-report] iframe').locator('h1').waitFor();
  85. await page.getByRole('button', { name: '看板正文与证据', exact: true }).click();
  86. assert.equal(await page.locator('[data-html-report] iframe').count(), 0);
  87. assert.equal(await page.locator('.statistics-card').count(), 2); assert.equal(await page.locator('.course-assessment').count(), 1);
  88. assert.ok((await page.locator('.statistics-card').first().textContent()).includes('40.0%'));
  89. assert.ok((await page.locator('.statistics-card').nth(1).textContent()).includes('不计算占比'));
  90. assert.ok((await page.locator('.course-assessment').textContent()).includes('未知项待补'));
  91. assert.equal(await page.evaluate(() => Math.max(0, document.documentElement.scrollWidth - innerWidth)), 0);
  92. await page.locator('[data-statistics-summary]').scrollIntoViewIfNeeded();
  93. await page.screenshot({ path: path.join(output, 'fixture-statistics-' + width + '.png') });
  94. await page.getByRole('button', { name: 'HTML 排版', exact: true }).click();
  95. await page.locator('[data-html-report] iframe').waitFor();
  96. checks.push({ page: 'in-memory-fixture', width, statisticsCards: 2, courseAssessmentCards: 1, htmlToggle: '1→0→1', actualPublicData: false, saved: false });
  97. }
  98. assert.deepEqual(blockedWrites, []); assert.deepEqual(errors, []); receipt.ok = true;
  99. } catch (error) { receipt.ok = false; receipt.failure = error.stack; process.exitCode = 1; }
  100. finally { await browser.close(); await fs.writeFile(path.join(output, 'browser-check.json'), JSON.stringify(receipt, null, 2) + '\n'); }
  101. console.log(JSON.stringify(receipt, null, 2));