The editor follows the Flatsome UX Builder: dark top bar with the device switch, one sidebar with tabs for elements, structure and page, an element view with breadcrumbs, property tabs and actions, element cards, a context menu in the preview and keyboard shortcuts that also work inside the preview. Entrance and hover effects play for every visitor unless the website chooses to respect the browser's reduced-motion preference (setting "motion", class "motion-respect" on <html>). Waiting blocks stay hidden until they scroll into view and are shown at once when the reader jumps past them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
189 lines
14 KiB
JavaScript
189 lines
14 KiB
JavaScript
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('#kontakt');
|
|
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'kontakt')), 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 === 'kontakt'));
|
|
const calls = await page.evaluate(() => motionCalls);
|
|
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].transform, 'translateY(40px)');
|
|
assert.equal(calls.find(call => call.id === 'kontakt').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 === '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(-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 === 'kontakt'), 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": <html class="motion-respect">. 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('#kontakt'), 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 === 'kontakt'), 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 === 'kontakt').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 === 'kontakt').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 === 'kontakt'), 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('#kontakt').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 <html>; 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();
|
|
}
|