import { chromium } from 'playwright'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import net from 'node:net'; const php = process.env.PHP_BINARY || 'php'; const hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'); const before = await hash(); const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory }; execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { env, windowsHide: true, stdio: 'ignore' }); const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); const origin = `http://127.0.0.1:${port}`; const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } }); const report = 'reports/motion'; await mkdir(report, { recursive: true }); const results = [], errors = []; let browser; try { for (let attempt = 0; attempt < 60; attempt++) { try { if ((await fetch(origin)).ok) break; } catch {} if (attempt === 59) throw new Error('Pilot server did not start'); await new Promise(resolve => setTimeout(resolve, 200)); } browser = await chromium.launch(process.env.MOTION_BROWSER_CHANNEL ? { channel: process.env.MOTION_BROWSER_CHANNEL } : {}); const systemContext = await browser.newContext({ reducedMotion: null }); const systemPage = await systemContext.newPage(); await systemPage.goto(origin); const systemMotion = await systemPage.evaluate(() => ({ reduced: matchMedia('(prefers-reduced-motion: reduce)').matches, userAgent: navigator.userAgent })); console.log('Default browser motion preference:', JSON.stringify(systemMotion)); await systemContext.close(); for (const width of [1600, 390]) { const context = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'no-preference' }); await context.addInitScript(() => { window.motionCalls = []; const animate = Element.prototype.animate; Element.prototype.animate = function (frames, options) { const animation = animate.call(this, frames, options); window.motionCalls.push({ id: this.id, frames, options, scrollY, top: this.getBoundingClientRect().top }); return animation; }; }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); await page.goto(origin); await page.waitForTimeout(1200); const inventory = await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(node => ({ id: node.id, classes: node.className }))); assert.ok(inventory.length > 0, 'Pilot has entrance effects'); assert.equal(await page.locator('script[src*="motion.js"]').count(), 1); const hoverTarget = page.locator('#leistung-app'); await hoverTarget.scrollIntoViewIfNeeded(); await page.waitForTimeout(800); await hoverTarget.hover(); await page.waitForTimeout(350); const hovered = await hoverTarget.evaluate(node => ({ transform: getComputedStyle(node).transform, matches: node.matches(':hover'), classes: node.className })); console.log('Pilot hover:', width, JSON.stringify(hovered)); assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform'); const target = page.locator('#abschluss-cta'); assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'abschluss-cta')), false, 'Below-fold section must wait for scrolling'); assert.equal(await target.evaluate(node => node.style.opacity), '0', 'Below-fold block waits out of sight, as in Flatsome'); await target.scrollIntoViewIfNeeded(); await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta')); const calls = await page.evaluate(() => motionCalls); assert.equal(calls.find(call => call.id === 'abschluss-cta').frames[0].transform, 'translateY(40px)'); assert.equal(calls.find(call => call.id === 'abschluss-cta').frames[0].opacity, 0, 'Entrance fades in from invisible, as in Flatsome'); await page.waitForTimeout(800); await page.evaluate(() => scrollTo(0, 0)); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100); assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 1, 'Entrance runs once'); await page.screenshot({ path: `${report}/pilot-${width}.png` }); // Exercise both existing choices on the real pilot element without editing saved content. for (const direction of ['left', 'right']) { await page.evaluate(async direction => { const node = document.getElementById('abschluss-cta'); node.classList.remove('fx-entrance-rise', 'fx-entrance-left', 'fx-entrance-right'); node.classList.add(`fx-entrance-${direction}`); const { initMotion } = await import(document.querySelector('script[src*="motion.js"]').src); window.stopMotionCheck?.(); window.motionCalls = []; window.stopMotionCheck = initMotion(); }, direction); await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta')); const actual = await target.evaluate(node => { const animation = node.getAnimations()[0]; animation.pause(); animation.currentTime = 150; return { transform: getComputedStyle(node).transform, frames: animation.effect.getKeyframes() }; }); assert.equal(actual.frames[0].transform, direction === 'left' ? 'translateX(-56px)' : 'translateX(56px)'); const offset = Number(actual.transform.split(',')[4]); assert.ok(direction === 'left' ? offset < 0 : offset > 0, 'Computed movement follows selected direction'); await page.screenshot({ path: `${report}/${direction}-${width}.png` }); await page.evaluate(() => window.stopMotionCheck()); } // Default setting: the website plays regardless of the browser preference (Flatsome mode). // Umut's Chrome reports "reduce" without anyone having chosen it; the site must still move. await page.emulateMedia({ reducedMotion: 'reduce' }); await page.reload(); await target.scrollIntoViewIfNeeded(); await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 }); await page.waitForTimeout(900); assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1', 'Block ends fully visible'); await hoverTarget.scrollIntoViewIfNeeded(); await hoverTarget.hover(); await page.waitForTimeout(350); assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Default setting keeps the hover lift under a reduce preference'); await page.emulateMedia({ reducedMotion: 'no-preference' }); await context.close(); // Setting "Browservorgabe beachten": . Simulated by adding the // class before any script runs, exactly where the template would print it. const respect = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' }); await respect.addInitScript(() => { window.motionCalls = []; const animate = Element.prototype.animate; Element.prototype.animate = function (frames, options) { const animation = animate.call(this, frames, options); window.motionCalls.push({ id: this.id, frames, options, scrollY, top: this.getBoundingClientRect().top }); return animation; }; new MutationObserver((_, observer) => { if (!document.documentElement) return; document.documentElement.classList.add('motion-respect'); observer.disconnect(); }).observe(document, { childList: true }); }); const respectPage = await respect.newPage(); respectPage.on('pageerror', error => errors.push(error.message)); const respectTarget = respectPage.locator('#abschluss-cta'), respectHover = respectPage.locator('#leistung-app'); await respectPage.goto(origin); await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(800); assert.equal(await respectPage.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations when respected'); assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1'); await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350); assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'none', 'Reduced motion suppresses hover movement when respected'); await respectTarget.scrollIntoViewIfNeeded(); await respectPage.emulateMedia({ reducedMotion: 'no-preference' }); await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 }); await respectPage.waitForTimeout(900); await respectPage.emulateMedia({ reducedMotion: 'reduce' }); await respectPage.emulateMedia({ reducedMotion: 'no-preference' }); await respectPage.waitForTimeout(100); assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 1, 'Preference changes do not replay an already seen element'); await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350); assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Hover resumes after allowing motion'); // Also cover a page that starts normally, then suppresses motion before reaching its content. await respectPage.reload(); await respectPage.waitForTimeout(800); await respectPage.emulateMedia({ reducedMotion: 'reduce' }); await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(100); assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 0); assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1', 'A waiting block is shown again when motion is reduced'); await respectPage.emulateMedia({ reducedMotion: 'no-preference' }); await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 }); results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'hidden until visible', 'left/right computed movement', 'saved hover lift', 'default plays under reduce preference', 'motion-respect honours reduce', 'resume without reloading after initial and subsequent reduced motion', 'hover resumes after preference change'] }); await respect.close(); } const noJs = await browser.newContext({ javaScriptEnabled: false }); const page = await noJs.newPage(); await page.goto(origin); assert.ok(await page.locator('main').innerText()); assert.equal(await page.locator('#abschluss-cta').evaluate(node => getComputedStyle(node).opacity), '1'); await noJs.close(); const editorContext = await browser.newContext({ reducedMotion: 'reduce', viewport: { width: 1600, height: 1000 } }); const editor = await editorContext.newPage(); editor.on('pageerror', error => errors.push(error.message)); await editor.goto(origin + '/admin/login'); await editor.getByLabel('Benutzername').fill(fixture.username); await editor.getByLabel('Passwort', { exact: true }).fill(fixture.password); await editor.getByRole('button', { name: 'Anmelden', exact: true }).click(); await editor.waitForURL(origin + '/admin'); await editor.goto(`${origin}/admin/pages/${fixture.homeId}`); // With the default setting the note stays away: the site plays anyway, there is nothing to say. await editor.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); await editor.waitForTimeout(300); assert.equal(await editor.locator('#motion-status').isVisible(), false, 'No motion note while the site ignores the preference'); await editor.close(); // The note appears once the site respects the preference - simulated in the preview frame. // The preview frame carries the website's decision as a class on its ; set it there the // way the template would, then let the preference change re-evaluate the note. const editorRespect = await editorContext.newPage(); editorRespect.on('pageerror', error => errors.push(error.message)); await editorRespect.goto(`${origin}/admin/pages/${fixture.homeId}`); await editorRespect.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); await editorRespect.evaluate(() => document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect')); // Two toggles in one breath cancel out before the change event fires; give each its moment. await editorRespect.emulateMedia({ reducedMotion: 'no-preference' }); await editorRespect.waitForTimeout(300); await editorRespect.emulateMedia({ reducedMotion: 'reduce' }); await editorRespect.locator('#motion-status').waitFor({ state: 'visible' }); await editorRespect.screenshot({ path: `${report}/editor-motion-status.png` }); await editorRespect.emulateMedia({ reducedMotion: 'no-preference' }); await editorRespect.locator('#motion-status').waitFor({ state: 'hidden' }); await editorRespect.emulateMedia({ reducedMotion: 'reduce' }); await editorRespect.locator('#motion-status').waitFor({ state: 'visible' }); await editorContext.close(); assert.deepEqual(errors, []); assert.equal(await hash(), before, 'Original database unchanged'); await writeFile(`${report}/result.json`, JSON.stringify({ systemMotion, results, errors, editorMotionStatus: true, noJavaScript: true, originalUnchanged: true }, null, 2)); console.log(JSON.stringify({ passed: true, devices: results.map(result => result.width), effects: results[0].inventory, report })); } finally { await browser?.close(); server.kill(); }