verify-layout.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { chromium } from 'playwright';
  2. import { mkdir, writeFile, readdir } from 'node:fs/promises';
  3. import { resolve, dirname } from 'node:path';
  4. import { fileURLToPath } from 'node:url';
  5. import assert from 'node:assert/strict';
  6. const root = dirname(fileURLToPath(import.meta.url));
  7. const out = resolve(root, 'test-results', 'layout');
  8. await mkdir(out, { recursive: true });
  9. const cache = resolve(process.env.LOCALAPPDATA, 'ms-playwright');
  10. const installed = (await readdir(cache)).filter(x => /^chromium-\d+$/.test(x)).sort().reverse();
  11. const browser = await chromium.launch({ executablePath: process.env.DEMO_CHROME || resolve(cache, installed[0], 'chrome-win64', 'chrome.exe') });
  12. const page = await browser.newPage({ reducedMotion: 'reduce' });
  13. page.setDefaultTimeout(6000);
  14. const results = [];
  15. const errors = [];
  16. page.on('pageerror', error => errors.push(error.message));
  17. const routes = ['chat/zhixia', 'contacts', 'projects', 'projects/autumn', 'events', 'workbench', 'tasks', 'tasks/T-106', 'deliverables', 'agent/zhixia', 'mine'];
  18. async function test(name, action) {
  19. try { await action(); results.push({ name, passed: true }); }
  20. catch (error) { results.push({ name, passed: false, error: error.message }); process.stdout.write(`FAIL ${name}: ${error.message}\n`); }
  21. }
  22. async function geometry() {
  23. return page.evaluate(() => {
  24. const main = document.querySelector('#main');
  25. const root = document.documentElement;
  26. const overlaps = [];
  27. for (const selector of ['.settings-content .setting-row', '.settings-appearance', '.settings-layout', '.account-panel']) {
  28. for (const group of document.querySelectorAll(selector)) {
  29. const children = [...group.children].map(el => ({ name: el.className || el.tagName, r: el.getBoundingClientRect() })).filter(({ r }) => r.width && r.height);
  30. for (let i = 0; i < children.length; i++) {
  31. for (let j = i + 1; j < children.length; j++) {
  32. const a = children[i], b = children[j];
  33. const width = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
  34. const height = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
  35. if (width > 1 && height > 1) overlaps.push({ selector, a: a.name, b: b.name, width, height });
  36. }
  37. }
  38. }
  39. }
  40. const overflows = [...document.querySelectorAll('.settings-section, .account-panel, .page, .task-detail, .project-detail, .agent-card')].filter(el => el.scrollWidth > el.clientWidth + 1).map(el => ({ element: el.className, width: el.clientWidth, scroll: el.scrollWidth }));
  41. const content = main?.querySelector(':scope > .page');
  42. let margins = null;
  43. if (content && document.body.dataset.mode === 'desktop') {
  44. // Compare against the usable workspace, excluding the navigation and scrollbar.
  45. const viewportLeft = main.getBoundingClientRect().left + main.clientLeft;
  46. const rect = content.getBoundingClientRect();
  47. margins = { left: rect.left - viewportLeft, right: viewportLeft + main.clientWidth - rect.right };
  48. }
  49. return { width: innerWidth, docWidth: root.scrollWidth, mainWidth: main?.clientWidth, mainScroll: main?.scrollWidth, overlaps, overflows, margins };
  50. });
  51. }
  52. for (const mode of ['desktop', 'mobile']) {
  53. for (const width of mode === 'desktop' ? [2560, 1920, 1440, 1280, 1150, 1024, 940, 768] : [360, 390, 430]) {
  54. await page.setViewportSize({ width, height: mode === 'desktop' ? 900 : 844 });
  55. for (const route of mode === 'desktop' ? routes : ['home', 'messages', ...routes]) {
  56. await test(`${mode} ${width}px ${route}`, async () => {
  57. await page.goto(`http://localhost:4300/${mode}/${route}`);
  58. const layout = await geometry();
  59. assert.ok(layout.docWidth <= width + 1, JSON.stringify(layout));
  60. assert.ok(layout.mainScroll <= layout.mainWidth + 1, JSON.stringify(layout));
  61. assert.deepEqual(layout.overlaps, []);
  62. assert.deepEqual(layout.overflows, []);
  63. if (layout.margins) {
  64. assert.ok(Math.abs(layout.margins.left - layout.margins.right) <= 1, `Content is not centered: ${JSON.stringify(layout.margins)}`);
  65. }
  66. if (width === 1440 || width === 390) {
  67. await page.screenshot({ path: resolve(out, `${mode}-${route.replaceAll('/', '-')}.png`), scale: 'css' });
  68. }
  69. if (width === 1920 && ['contacts', 'events', 'workbench', 'mine'].includes(route)) {
  70. await page.screenshot({ path: resolve(out, `desktop-wide-${route}.png`), scale: 'css' });
  71. }
  72. });
  73. }
  74. }
  75. }
  76. await test('desktop settings fit at 1440x900', async () => {
  77. await page.setViewportSize({ width: 1440, height: 900 });
  78. await page.goto('http://localhost:4300/desktop/mine');
  79. const geometry = await page.evaluate(() => {
  80. const account = document.querySelector('.account-panel').getBoundingClientRect();
  81. const content = document.querySelector('.settings-content').getBoundingClientRect();
  82. return { gap: content.left - account.right, topDifference: content.top - account.top, bottom: content.bottom };
  83. });
  84. assert.ok(geometry.gap >= 20);
  85. assert.equal(geometry.topDifference, 0);
  86. assert.ok(geometry.bottom <= 900, JSON.stringify(geometry));
  87. });
  88. await test('dark settings contrast surfaces remain distinct', async () => {
  89. await page.locator('[data-action="theme"][data-value="dark"]').click();
  90. const surfaces = await page.evaluate(() => ['.sidebar', '.workspace', '.settings-content'].map(s => getComputedStyle(document.querySelector(s)).backgroundColor));
  91. assert.equal(new Set(surfaces).size, 3);
  92. await page.screenshot({ path: resolve(out, 'desktop-mine-dark.png') });
  93. });
  94. await test('desktop sidebar remains reachable at 768x600', async () => {
  95. await page.setViewportSize({ width: 768, height: 600 });
  96. await page.goto('http://localhost:4300/desktop/mine');
  97. await page.locator('.profile-button').scrollIntoViewIfNeeded();
  98. const box = await page.locator('.profile-button').boundingBox();
  99. assert.ok(box.y + box.height <= 600);
  100. });
  101. await test('no runtime errors', async () => assert.deepEqual(errors, []));
  102. await writeFile(resolve(out, 'report.json'), JSON.stringify({ passed: results.filter(r => r.passed).length, total: results.length, results, errors }, null, 2));
  103. await browser.close();
  104. process.stdout.write(`${results.filter(r => r.passed).length}/${results.length} layout checks passed.\n`);
  105. if (results.some(r => !r.passed)) process.exitCode = 1;