verify-browser.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  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 out = path.resolve('docs/report-quality-html-20261111');
  6. const raw = JSON.parse(await fs.readFile(path.join(out, 'browser-fixture.json'), 'utf8'));
  7. const html = await fs.readFile(path.join(out, '报告版式预览.html'), 'utf8');
  8. const task = JSON.parse(await fs.readFile(path.join(out, 'preview-task.json'), 'utf8'));
  9. const origin = 'http://127.0.0.1:4181';
  10. const { chromium } = await import(pathToFileURL(path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
  11. const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
  12. const checks = [], errors = [], requests = [], sizes = [];
  13. await fs.mkdir(path.join(out, 'screenshots'), { recursive: true });
  14. try {
  15. const context = await browser.newContext({ viewport: { width: 1440, height: 1050 } });
  16. let filesEnabled = true;
  17. await context.route('**/*', async route => {
  18. const request = route.request(), url = new URL(request.url());
  19. if (request.method() === 'GET' && url.pathname === '/api/functions' && url.searchParams.get('path') === '/ksf/file') {
  20. assert.equal(url.searchParams.get('artifactId'), 'argument-preview');
  21. return route.fulfill({ contentType: 'text/html', body: html, headers: {
  22. 'content-security-policy': "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; sandbox allow-popups allow-popups-to-escape-sandbox",
  23. } });
  24. }
  25. if (url.pathname === '/api/functions' || url.pathname === '/__dev/member-groups') {
  26. const body = request.postDataJSON(); requests.push(body?.path);
  27. const data = body?.path === '/ksfGetPublicBoard' ? structuredClone(raw)
  28. : body?.path === '/ksfGetPublicMemberGroups' ? { schema: 'ksf.workshop-member-groups.v1', groups: [], members: [] } : null;
  29. if (!data) { errors.push('Unexpected API ' + body?.path); return route.abort(); }
  30. if (!filesEnabled && data.artifact) delete data.artifact[0].report.attachments;
  31. return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ result: { ok: true, data } }) });
  32. }
  33. if (url.origin === origin && request.method() === 'GET') return route.continue();
  34. // Source link navigation is intercepted to prove it can open from the sandbox.
  35. if (url.hostname === 'sh.jd.com') return route.fulfill({ contentType: 'text/html', body: '<h1>原始来源链接打开验证(未访问线上来源)</h1>' });
  36. errors.push('Unexpected request ' + request.method() + ' ' + url.origin + url.pathname); return route.abort();
  37. });
  38. const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message));
  39. for (const width of [1440, 390, 320]) {
  40. await page.setViewportSize({ width, height: 1050 });
  41. await page.goto(origin + '/docs/report-quality-html-20261111/' + encodeURIComponent('报告版式预览.html'));
  42. const measure = await page.evaluate(() => ({
  43. width: innerWidth, scrollWidth: document.documentElement.scrollWidth,
  44. quotes: [...document.querySelectorAll('.source-group .quote-card')].map(el => ({ height: el.getBoundingClientRect().height, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight })),
  45. tableScrolls: [...document.querySelectorAll('.table-wrap')].map(el => ({ client: el.clientWidth, scroll: el.scrollWidth })),
  46. text: document.body.innerText,
  47. dividers: [...document.querySelectorAll('.quote-card')].every(el => getComputedStyle(el.querySelector('figcaption')).borderBottomWidth === '1px' && getComputedStyle(el.querySelector('.quote-ref')).borderTopWidth === '1px'),
  48. }));
  49. assert.ok(measure.scrollWidth <= width, JSON.stringify(measure));
  50. assert.equal(measure.dividers, true);
  51. assert.equal(measure.quotes.length, 3);
  52. assert.ok(measure.quotes.every(q => q.scrollHeight <= q.clientHeight + 1));
  53. assert.ok(measure.quotes[2].height > measure.quotes[0].height + 30);
  54. for (const entry of Object.values(task.report.evidence)) assert.ok(measure.text.includes(entry.quotes[0].text));
  55. await page.screenshot({ path: path.join(out, `screenshots/html-cover-${width}.png`) });
  56. await page.locator('.finding').first().scrollIntoViewIfNeeded();
  57. await page.screenshot({ path: path.join(out, `screenshots/html-evidence-${width}.png`) });
  58. await page.locator('#chapter-3').scrollIntoViewIfNeeded();
  59. await page.screenshot({ path: path.join(out, `screenshots/html-comparison-${width}.png`) });
  60. sizes.push({ type: 'html', width, ...measure, text: undefined });
  61. checks.push(`${width}px 独立HTML:摘要/正文/原话保留、卡片自适应、来源分段、表格局部滚动、整页无溢出`);
  62. await page.goto(origin + '/report/argument-preview?mode=sample');
  63. await page.locator('[data-html-report]').waitFor();
  64. const frame = page.frameLocator('view-report iframe.html-report-preview');
  65. await frame.locator('h1').waitFor();
  66. assert.equal(await frame.locator('h1').textContent(), task.title);
  67. assert.equal(await page.locator('[data-chapter]:visible').count(), 0, '默认读HTML,避免正文重复堆叠');
  68. assert.equal(await page.locator('.reading-formats button[aria-pressed="true"]').textContent(), 'HTML 排版');
  69. const screen = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
  70. assert.ok(screen.scrollWidth <= width, JSON.stringify(screen));
  71. await page.locator('[data-html-report]').scrollIntoViewIfNeeded();
  72. await page.screenshot({ path: path.join(out, `screenshots/board-html-${width}.png`) });
  73. await page.getByRole('button', { name: '看板正文与证据', exact: true }).click();
  74. await page.locator('[data-evidence-reading]').waitFor();
  75. assert.equal(await page.locator('[data-chapter]:visible').count(), 3);
  76. assert.equal(await page.locator('.html-report-preview').count(), 0);
  77. assert.deepEqual(await page.locator('[data-evidence-reading] blockquote').allTextContents(), Object.values(task.report.evidence).map(e => e.quotes[0].text));
  78. checks.push(`${width}px 看板:默认HTML、切换正文/证据、保留章节与原话、无溢出`);
  79. }
  80. await page.setViewportSize({ width: 1440, height: 1050 });
  81. await page.getByRole('button', { name: 'HTML 排版', exact: true }).click();
  82. const frame = page.frameLocator('view-report iframe.html-report-preview');
  83. const popupWait = page.waitForEvent('popup');
  84. await frame.locator('.quote-ref a').first().click();
  85. const popup = await popupWait; await popup.waitForLoadState();
  86. assert.equal(new URL(popup.url()).hostname, 'sh.jd.com');
  87. await popup.close();
  88. checks.push('HTML内来源链接可从受限预览打开;仅测试拦截,未访问外部来源');
  89. const sandbox = await page.locator('.html-report-preview').getAttribute('sandbox');
  90. assert.ok(!sandbox.includes('allow-scripts') && !sandbox.includes('allow-same-origin'));
  91. assert.ok((await page.locator('.html-open').getAttribute('href')).includes('preview=1'));
  92. assert.ok((await page.getByRole('link', { name: '下载 HTML', exact: true }).getAttribute('href')).includes('artifactId=argument-preview'));
  93. checks.push('独立阅读、下载关联同报告文件;预览不授予脚本或同源权限');
  94. await page.emulateMedia({ media: 'print' });
  95. assert.equal(await page.locator('[data-html-report]:visible').count(), 0);
  96. assert.equal(await page.locator('[data-chapter]:visible').count(), 3);
  97. assert.equal(await page.locator('[data-evidence-reading]:visible').count(), 1);
  98. await page.emulateMedia({ media: 'screen' });
  99. await page.goto(origin + '/report/argument-preview?mode=sample&ch=next-test');
  100. await page.locator('#ch-next-test').waitFor();
  101. assert.equal(await page.locator('.reading-formats button[aria-pressed="true"]').textContent(), '看板正文与证据');
  102. checks.push('带章节深链接自动切到看板正文,保留原章节阅读');
  103. filesEnabled = false;
  104. await page.goto(origin + '/report/argument-preview?mode=sample');
  105. await page.locator('[data-evidence-reading]').waitFor();
  106. assert.equal(await page.locator('[data-html-report]').count(), 0);
  107. assert.equal(await page.locator('[data-chapter]:visible').count(), 3);
  108. checks.push('没有HTML附件的历史报告保留可读正文,不造文件或链接');
  109. await page.goto(origin + '/docs/workshop-quality-r13-20261110/' + encodeURIComponent('final-康师傅工作坊操作手册.html'));
  110. assert.ok(await page.locator('body').innerText());
  111. const manual = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA);
  112. assert.equal(manual.steps.length, 14);
  113. const bank = JSON.parse(await fs.readFile('docs/workshop-quality-r13-20261110/manual/workshop-dialogue-prompts.json', 'utf8'));
  114. assert.match(bank.shared.delivery, /不合格先补研究或修正文/);
  115. checks.push('final手册可读取,课表14页、42轮提示词结构保留,含内容自检与默认HTML规则');
  116. assert.deepEqual(errors, []);
  117. assert.ok(requests.every(x => ['/ksfGetPublicBoard','/ksfGetPublicMemberGroups'].includes(x)));
  118. const result = { ok: true, checks, sizes, errors, fixtureOnly: true, externalCollectionRequests: 0, productionWrites: 0 };
  119. await fs.writeFile(path.join(out, 'browser-verification.json'), JSON.stringify(result, null, 2) + '\n');
  120. console.log(JSON.stringify({ ok: true, checks: checks.length, errors, productionWrites: 0 }));
  121. } finally { await browser.close(); }