import { chromium } from 'playwright'; import { writeFile, readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import assert from 'node:assert/strict'; const cache = resolve(process.env.LOCALAPPDATA, 'ms-playwright'); const installed = (await readdir(cache)).filter(x => /^chromium-\d+$/.test(x)).sort().reverse(); const browser = await chromium.launch({ executablePath: process.env.DEMO_CHROME || resolve(cache, installed[0], 'chrome-win64', 'chrome.exe') }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'no-preference' }); page.setDefaultTimeout(6000); const results = []; const errors = []; page.on('pageerror', error => errors.push(error.message)); async function test(name, action) { try { await action(); results.push({ name, passed: true }); } catch (error) { results.push({ name, passed: false, error: error.message }); process.stdout.write(`FAIL ${name}: ${error.message}\n`); } } async function clickFrames(selector, target = '.page') { return page.evaluate(async ({ selector, target }) => { document.querySelector(selector).click(); await new Promise(requestAnimationFrame); return document.querySelector(target)?.getAnimations().map(a => ({ frames: a.effect.getKeyframes(), duration: a.effect.getTiming().duration })) || []; }, { selector, target }); } await test('navigation preserves sidebar, desktop fade has no displacement', async () => { await page.goto('http://localhost:4300/desktop/contacts'); await page.evaluate(() => { window.testSidebar = document.querySelector('.sidebar'); }); const animation = await clickFrames('.nav-link[href="/desktop/projects"]'); assert.equal(animation.length, 1); assert.equal(animation[0].duration, 160); assert.ok(animation[0].frames.every(f => !f.transform)); assert.ok(await page.evaluate(() => window.testSidebar === document.querySelector('.sidebar'))); }); await test('search preserves input node, focus and does not replay route animation', async () => { await page.goto('http://localhost:4300/desktop/contacts'); await page.evaluate(() => { window.testSearch = document.querySelector('#search-contacts'); }); await page.locator('#search-contacts').fill('知'); assert.ok(await page.evaluate(() => window.testSearch === document.activeElement && window.testSearch === document.querySelector('#search-contacts'))); assert.equal(await page.locator('.page').evaluate(el => el.getAnimations().length), 0); assert.equal(await page.locator('.contact-row').count(), 1); }); await test('rapid route changes do not stack animations', async () => { const animation = await page.evaluate(async () => { for (const name of ['projects', 'events', 'workbench']) document.querySelector(`.nav-link[href="/desktop/${name}"]`).click(); await new Promise(requestAnimationFrame); return document.querySelector('.page').getAnimations().length; }); assert.ok(animation <= 1, `${animation} animations overlap`); assert.ok(page.url().endsWith('/workbench')); }); await test('mobile detail enters forward and returns backward', async () => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('http://localhost:4300/mobile/projects'); const forward = await clickFrames('.project-row'); assert.equal(forward.length, 1); assert.match(forward[0].frames[0].transform, /translateX\(16px\)/); assert.equal(forward[0].duration, 200); const back = await clickFrames('.breadcrumbs [data-action="navigate"]'); assert.equal(back.length, 1); assert.match(back[0].frames[0].transform, /translateX\(-16px\)/); }); await test('bottom drawer enters vertically and completes its exit', async () => { await page.goto('http://localhost:4300/mobile/chat/zhixia'); const frames = await clickFrames('[data-action="results-drawer"]', '.drawer'); assert.match(frames[0].frames[0].transform, /translateY\(100%\)/); assert.ok(await page.locator('#app').evaluate(el => el.inert)); const closing = await page.evaluate(() => { document.querySelector('.dialog [data-action="close"]').click(); return document.querySelector('.dialog')?.getAnimations().map(a => a.effect.getTiming().duration); }); assert.deepEqual(closing, [160]); await page.locator('.dialog').waitFor({ state: 'detached' }); assert.equal(await page.locator('#app').evaluate(el => el.inert), false); assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'results-drawer'); }); await test('desktop drawer enters horizontally', async () => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('http://localhost:4300/desktop/chat'); const frames = await clickFrames('[data-action="lab"]', '.drawer'); assert.match(frames[0].frames[0].transform, /translateX\(100%\)/); await page.keyboard.press('Escape'); await page.locator('.dialog').waitFor({ state: 'detached' }); }); await test('modal keyboard focus never reaches background navigation', async () => { await page.goto('http://localhost:4300/desktop/mine'); await page.locator('[data-action="logout"]').click(); for (let i = 0; i < 8; i++) await page.keyboard.press('Tab'); assert.ok(await page.evaluate(() => !!document.activeElement.closest('.dialog'))); await page.keyboard.press('Escape'); await page.locator('.dialog').waitFor({ state: 'detached' }); }); await test('reduced motion disables route and drawer transitions', async () => { await page.emulateMedia({ reducedMotion: 'reduce' }); const frames = await clickFrames('.nav-link[href="/desktop/contacts"]'); assert.equal(frames.length, 0); const drawerFrames = await clickFrames('[data-action="lab"]', '.drawer'); assert.equal(drawerFrames.length, 0); await page.locator('.dialog [data-action="close"]').click(); assert.equal(await page.locator('.dialog').count(), 0); }); await test('no motion runtime errors', async () => assert.deepEqual(errors, [])); await writeFile('test-results/motion-report.json', JSON.stringify({ passed: results.filter(r => r.passed).length, total: results.length, results, errors }, null, 2)); await browser.close(); process.stdout.write(`${results.filter(r => r.passed).length}/${results.length} motion checks passed.\n`); if (results.some(r => !r.passed)) process.exitCode = 1;