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 }; 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(); 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 target = page.locator('#kontakt'); assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'kontakt')), false, 'Below-fold section must wait for scrolling'); await target.scrollIntoViewIfNeeded(); await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt')); const calls = await page.evaluate(() => motionCalls); assert.equal(calls.find(call => call.id === 'kontakt').frames[0].transform, 'translateY(28px)'); 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 === 'kontakt').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('kontakt'); 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 === 'kontakt')); 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(-36px)' : 'translateX(36px)'); 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()); } await page.emulateMedia({ reducedMotion: 'reduce' }); await page.reload(); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(800); assert.equal(await page.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations'); assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1'); results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'left/right computed movement', 'reduced motion'] }); await context.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('#kontakt').evaluate(node => getComputedStyle(node).opacity), '1'); await noJs.close(); assert.deepEqual(errors, []); assert.equal(await hash(), before, 'Original database unchanged'); await writeFile(`${report}/result.json`, JSON.stringify({ results, errors, 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(); }