verify-motion.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. import { chromium } from 'playwright';
  2. import { writeFile, readdir } from 'node:fs/promises';
  3. import { resolve } from 'node:path';
  4. import assert from 'node:assert/strict';
  5. const cache = resolve(process.env.LOCALAPPDATA, 'ms-playwright');
  6. const installed = (await readdir(cache)).filter(x => /^chromium-\d+$/.test(x)).sort().reverse();
  7. const browser = await chromium.launch({ executablePath: process.env.DEMO_CHROME || resolve(cache, installed[0], 'chrome-win64', 'chrome.exe') });
  8. const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'no-preference' });
  9. page.setDefaultTimeout(6000);
  10. const results = [];
  11. const errors = [];
  12. page.on('pageerror', error => errors.push(error.message));
  13. async function test(name, action) {
  14. try { await action(); results.push({ name, passed: true }); }
  15. catch (error) { results.push({ name, passed: false, error: error.message }); process.stdout.write(`FAIL ${name}: ${error.message}\n`); }
  16. }
  17. async function clickFrames(selector, target = '.page') {
  18. return page.evaluate(async ({ selector, target }) => {
  19. document.querySelector(selector).click();
  20. await new Promise(requestAnimationFrame);
  21. return document.querySelector(target)?.getAnimations().map(a => ({ frames: a.effect.getKeyframes(), duration: a.effect.getTiming().duration })) || [];
  22. }, { selector, target });
  23. }
  24. await test('navigation preserves sidebar, desktop fade has no displacement', async () => {
  25. await page.goto('http://localhost:4300/desktop/contacts');
  26. await page.evaluate(() => { window.testSidebar = document.querySelector('.sidebar'); });
  27. const animation = await clickFrames('.nav-link[href="/desktop/projects"]');
  28. assert.equal(animation.length, 1);
  29. assert.equal(animation[0].duration, 160);
  30. assert.ok(animation[0].frames.every(f => !f.transform));
  31. assert.ok(await page.evaluate(() => window.testSidebar === document.querySelector('.sidebar')));
  32. });
  33. await test('search preserves input node, focus and does not replay route animation', async () => {
  34. await page.goto('http://localhost:4300/desktop/contacts');
  35. await page.evaluate(() => { window.testSearch = document.querySelector('#search-contacts'); });
  36. await page.locator('#search-contacts').fill('知');
  37. assert.ok(await page.evaluate(() => window.testSearch === document.activeElement && window.testSearch === document.querySelector('#search-contacts')));
  38. assert.equal(await page.locator('.page').evaluate(el => el.getAnimations().length), 0);
  39. assert.equal(await page.locator('.contact-row').count(), 1);
  40. });
  41. await test('rapid route changes do not stack animations', async () => {
  42. const animation = await page.evaluate(async () => {
  43. for (const name of ['projects', 'events', 'workbench']) document.querySelector(`.nav-link[href="/desktop/${name}"]`).click();
  44. await new Promise(requestAnimationFrame);
  45. return document.querySelector('.page').getAnimations().length;
  46. });
  47. assert.ok(animation <= 1, `${animation} animations overlap`);
  48. assert.ok(page.url().endsWith('/workbench'));
  49. });
  50. await test('mobile detail enters forward and returns backward', async () => {
  51. await page.setViewportSize({ width: 390, height: 844 });
  52. await page.goto('http://localhost:4300/mobile/projects');
  53. const forward = await clickFrames('.project-row');
  54. assert.equal(forward.length, 1);
  55. assert.match(forward[0].frames[0].transform, /translateX\(16px\)/);
  56. assert.equal(forward[0].duration, 200);
  57. const back = await clickFrames('.breadcrumbs [data-action="navigate"]');
  58. assert.equal(back.length, 1);
  59. assert.match(back[0].frames[0].transform, /translateX\(-16px\)/);
  60. });
  61. await test('bottom drawer enters vertically and completes its exit', async () => {
  62. await page.goto('http://localhost:4300/mobile/chat/zhixia');
  63. const frames = await clickFrames('[data-action="results-drawer"]', '.drawer');
  64. assert.match(frames[0].frames[0].transform, /translateY\(100%\)/);
  65. assert.ok(await page.locator('#app').evaluate(el => el.inert));
  66. const closing = await page.evaluate(() => {
  67. document.querySelector('.dialog [data-action="close"]').click();
  68. return document.querySelector('.dialog')?.getAnimations().map(a => a.effect.getTiming().duration);
  69. });
  70. assert.deepEqual(closing, [160]);
  71. await page.locator('.dialog').waitFor({ state: 'detached' });
  72. assert.equal(await page.locator('#app').evaluate(el => el.inert), false);
  73. assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'results-drawer');
  74. });
  75. await test('desktop drawer enters horizontally', async () => {
  76. await page.setViewportSize({ width: 1440, height: 900 });
  77. await page.goto('http://localhost:4300/desktop/chat');
  78. const frames = await clickFrames('[data-action="lab"]', '.drawer');
  79. assert.match(frames[0].frames[0].transform, /translateX\(100%\)/);
  80. await page.keyboard.press('Escape');
  81. await page.locator('.dialog').waitFor({ state: 'detached' });
  82. });
  83. await test('modal keyboard focus never reaches background navigation', async () => {
  84. await page.goto('http://localhost:4300/desktop/mine');
  85. await page.locator('[data-action="logout"]').click();
  86. for (let i = 0; i < 8; i++) await page.keyboard.press('Tab');
  87. assert.ok(await page.evaluate(() => !!document.activeElement.closest('.dialog')));
  88. await page.keyboard.press('Escape');
  89. await page.locator('.dialog').waitFor({ state: 'detached' });
  90. });
  91. await test('reduced motion disables route and drawer transitions', async () => {
  92. await page.emulateMedia({ reducedMotion: 'reduce' });
  93. const frames = await clickFrames('.nav-link[href="/desktop/contacts"]');
  94. assert.equal(frames.length, 0);
  95. const drawerFrames = await clickFrames('[data-action="lab"]', '.drawer');
  96. assert.equal(drawerFrames.length, 0);
  97. await page.locator('.dialog [data-action="close"]').click();
  98. assert.equal(await page.locator('.dialog').count(), 0);
  99. });
  100. await test('no motion runtime errors', async () => assert.deepEqual(errors, []));
  101. await writeFile('test-results/motion-report.json', JSON.stringify({ passed: results.filter(r => r.passed).length, total: results.length, results, errors }, null, 2));
  102. await browser.close();
  103. process.stdout.write(`${results.filter(r => r.passed).length}/${results.length} motion checks passed.\n`);
  104. if (results.some(r => !r.passed)) process.exitCode = 1;