Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ jobs:
node test-issue-1509-detect-preset.js
node test-live.js
node test-issue-124-rx-coverage-viewport.js
node test-rx-coverage-escape.js
node test-issue-125-live-toggles-wiring.js
node test-coverage-gate.js
node test-node-reach-coverage.js
Expand Down Expand Up @@ -540,6 +541,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-fullscreen-1572-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1599-replay-freeze-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-pr-1490-live-map-gpu-animations-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m1-icons-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m2-icons-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m3-icons-e2e.js 2>&1 | tee -a e2e-output.txt
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ node test-issue-1890-og-url.js
node test-traces.js
node test-live-multibyte-filter.js
node test-issue-124-rx-coverage-viewport.js
node test-rx-coverage-escape.js
node test-issue-125-live-toggles-wiring.js
node test-issue-2052-touch-target-css.js
node test-channel-proposals.js
Expand Down
221 changes: 149 additions & 72 deletions test-pr-1490-live-map-gpu-animations-e2e.js
Original file line number Diff line number Diff line change
@@ -1,86 +1,163 @@
const { test, expect } = require('@playwright/test');
/**
* E2E (PR #1490 / #1514 / #1520, ported for #170): the Live map's canvas
* animation engine queues, drains and goes back to sleep.
*
* live.js draws in-flight packets on a <canvas> inside a dedicated Leaflet
* pane (`animationsPane`, z=625) and runs a requestAnimationFrame loop only
* while there is something to animate. When an animation finishes it hands
* off to a fading Leaflet polyline on the same pane and records it in
* `recentPaths`, which is capped at 5.
*
* Per viewport (desktop and mobile) the test loads /#/live, waits until init
* has finished (window._liveWSHandler() returns a handler) and the engine is
* idle, then fires a burst of 20 synthetic lines through the real
* window._liveDrawAnimatedLine seam and checks, via window._liveTestSeams:
* 1. the animation canvas lives on the animations pane;
* 2. all 20 animations are queued and the engine wakes;
* 3. they drain to 0, and in the frame they drain recentPaths is <= 5
* (20 > 5, so the cap actually runs);
* 4. the engine goes back to sleep (isAnimating false);
* 5. the fading polylines are drawn on the animations pane: with
* preferCanvas:true Leaflet adds a renderer canvas to that pane.
*
* Headless Chromium may throttle requestAnimationFrame, so the drain is
* driven by awaiting rAF from inside the page (as in
* test-issue-1599-replay-freeze-e2e.js), which also lets step 3 read the
* path count in the same frame the queue empties.
*
* Originally written against @playwright/test, which is not a dependency;
* this port uses the plain `playwright` API like the other *-e2e.js files.
*
* Usage: BASE_URL=http://localhost:13581 node test-pr-1490-live-map-gpu-animations-e2e.js
*/
'use strict';
const { chromium } = require('playwright');

test.describe('Live Map Canvas Animation Engine', () => {
test('canvas initializes and animations drain correctly', async ({ page }) => {
// 1. Load the map route
await page.goto('/#/live');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const BURST = 20; // > the recentPaths cap of 5
const PATH_CAP = 5;
// One animation takes ~660ms at 1x. The bound is generous for slow CI
// runners and the instrumented frontend; the measured time is logged.
const DRAIN_TIMEOUT_MS = 5000;
const VIEWPORTS = [
{ name: 'desktop', viewport: { width: 1400, height: 900 } },
{ name: 'mobile', viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true },
];

// Ensure the map container has loaded
const mapContainer = page.locator('#liveMap');
await expect(mapContainer).toBeVisible();
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }

// 2. Assert the <canvas> element exists
// The animation canvas is appended to the dedicated `animationsPane`
// (#1514 S6 — disambiguate from Leaflet's own preferCanvas:true renderer
// that lives on overlayPane and would otherwise be matched by `canvas`.first()).
const animCanvas = mapContainer.locator('.leaflet-pane.leaflet-animations-pane canvas');
await expect(animCanvas).toBeAttached();
async function runViewport(browser, vp) {
console.log('\n--- ' + vp.name + ' ' + vp.viewport.width + 'x' + vp.viewport.height + ' ---');
const ctx = await browser.newContext({ viewport: vp.viewport, isMobile: !!vp.isMobile, hasTouch: !!vp.hasTouch });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
try {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveMap', { state: 'visible' });
await page.waitForFunction(() => typeof window._liveWSHandler === 'function' && !!window._liveWSHandler() &&
!!window._liveTestSeams && typeof window._liveDrawAnimatedLine === 'function');
// Start from an idle engine in LIVE mode at 1x.
await page.evaluate(() => { if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE'); });
await page.waitForFunction(() => window._liveTestSeams.getAnimCount() === 0 && !window._liveTestSeams.isAnimating(),
null, { polling: 'raf' });

// 3. Fire synthetic packets
// #1514 S5 — bumped from 5 to 20 so the `recentPaths.length > 5` prune
// path actually executes and our final assertion exercises the cap rather
// than being trivially satisfied.
const packetCount = 20;
await page.evaluate((count) => {
// Ensure the VCR speed is at standard 1x for predictable timing
if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE');
await step(vp.name + ': the animation canvas is on the animations pane', async () => {
const n = await page.locator('#liveMap .leaflet-pane.leaflet-animations-pane > canvas').count();
assert(n >= 1, 'no <canvas> on .leaflet-animations-pane (got ' + n + ')');
const z = await page.evaluate(() => getComputedStyle(document.querySelector('#liveMap .leaflet-animations-pane')).zIndex);
assert(z === '625', 'animations pane z-index is ' + z + ', want 625');
});

const queued = await page.evaluate((count) => {
for (let i = 0; i < count; i++) {
window._liveDrawAnimatedLine(
[37.4, -122.0],
[37.5, -122.1],
'#00ff00',
null,
null,
'00AA',
'test-hash-' + i
);
window._liveDrawAnimatedLine([37.4, -122.0], [37.5, -122.1], '#00ff00', null, null, '00AA', 'test-1490-' + i);
}
return { count: window._liveTestSeams.getAnimCount(), awake: window._liveTestSeams.isAnimating() };
}, BURST);

await step(vp.name + ': a burst of ' + BURST + ' animations is queued and wakes the engine', async () => {
assert(queued.count === BURST, 'queued ' + queued.count + ', want ' + BURST);
assert(queued.awake === true, 'engine did not wake (isAnimating=' + queued.awake + ')');
});

const drained = await page.evaluate(async (timeoutMs) => {
const seam = window._liveTestSeams;
const t0 = performance.now();
while (seam.getAnimCount() > 0 && performance.now() - t0 < timeoutMs) {
await new Promise((r) => requestAnimationFrame(r));
}
}, packetCount);
// Read the path count in the frame the queue emptied, before the
// fades (~0.4s) remove the paths on their own.
return { count: seam.getAnimCount(), paths: seam.getPathCount(), ms: Math.round(performance.now() - t0) };
}, DRAIN_TIMEOUT_MS);

// Verify the packets successfully pushed to the active array
let initialAnimCount = await page.evaluate(() => window._liveTestSeams.getAnimCount());
expect(initialAnimCount).toBe(packetCount);
await step(vp.name + ': the animations drain to 0 (' + drained.ms + 'ms)', async () => {
assert(drained.count === 0, 'activeAnimations did not drain within ' + DRAIN_TIMEOUT_MS + 'ms (count=' + drained.count + ')');
});

// Verify the engine woke up
let isAwake = await page.evaluate(() => window._liveTestSeams.isAnimating());
expect(isAwake).toBe(true);
await step(vp.name + ': recentPaths is capped at ' + PATH_CAP + ' when the burst lands', async () => {
assert(drained.paths >= 1, 'no fading paths were recorded (got ' + drained.paths + ')');
assert(drained.paths <= PATH_CAP, 'recentPaths grew to ' + drained.paths + ', cap is ' + PATH_CAP);
});

// 4. Assert activeAnimations drains within 2x duration
// Base duration is 660ms at 1x speed. 2x duration = 1320ms.
// We add a tiny buffer (1500ms total) for Playwright/Browser overhead.
await expect.poll(async () => {
return await page.evaluate(() => window._liveTestSeams.getAnimCount());
}, {
message: 'activeAnimations did not drain to 0 within 2x duration',
timeout: 1500,
}).toBe(0);
await step(vp.name + ': the engine goes back to sleep', async () => {
// One rAF tick separates the queue emptying from renderAnimations
// flipping isAnimating off.
const asleep = await page.evaluate(async () => {
for (let i = 0; i < 10 && window._liveTestSeams.isAnimating(); i++) {
await new Promise((r) => requestAnimationFrame(r));
}
return !window._liveTestSeams.isAnimating();
});
assert(asleep, 'isAnimating stayed true after the queue drained');
});

// 5. Assert the engine gracefully went back to sleep.
// (#1514 — there is one rAF tick between activeAnimations going to 0 and
// the next renderAnimations frame flipping isAnimating=false. Poll for a
// small jitter window instead of a one-shot read so the test isn't racy
// against that single-frame settling delay.)
await expect.poll(async () => {
return await page.evaluate(() => window._liveTestSeams.isAnimating());
}, { timeout: 200 }).toBe(false);
await step(vp.name + ': the fading polylines render on the animations pane', async () => {
const n = await page.locator('#liveMap .leaflet-animations-pane > canvas.leaflet-zoom-animated').count();
assert(n >= 1, 'no Leaflet renderer canvas on .leaflet-animations-pane — the fades went to another pane');
});

// 6. Assert recent paths didn't blow past the limit
let recentPathsCount = await page.evaluate(() => window._liveTestSeams.getPathCount());
expect(recentPathsCount).toBeLessThanOrEqual(5);
await step(vp.name + ': no page errors', async () => {
assert(errors.length === 0, errors.join(' | '));
});
} finally {
await ctx.close();
}
}

// 7. #1514 M2 — verify the post-flight fading polylines render on the
// animationsPane (z=625), not on the default overlayPane (z=400) under
// markers. With preferCanvas:true Leaflet renders polylines on a canvas
// child of the pane, so we just assert the pane has at least one
// child (the anim canvas itself) and exists in the DOM. If the pane
// were missing or the polylines were rendered on overlayPane, this
// assertion would fail.
const fadePaneChildren = await page.evaluate(() => {
const pane = document.querySelector('.leaflet-pane.leaflet-animations-pane');
if (!pane) return -1;
return pane.querySelectorAll('svg path, canvas').length;
(async () => {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
expect(fadePaneChildren).toBeGreaterThanOrEqual(1);
});
});
} catch (err) {
if (requireChromium) {
console.error('test-pr-1490-live-map-gpu-animations-e2e.js: FAIL — Chromium required but unavailable: ' + err.message);
process.exit(1);
}
console.log('test-pr-1490-live-map-gpu-animations-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')');
process.exit(0);
}
console.log('=== PR #1490 live map canvas animation engine E2E — ' + BASE + ' ===');
try {
for (const vp of VIEWPORTS) {
try { await runViewport(browser, vp); }
catch (e) { failed++; console.error(' ✗ ' + vp.name + ': ' + e.message); }
}
} finally {
await browser.close();
}
console.log('\ntest-pr-1490-live-map-gpu-animations-e2e.js: ' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);
})();
Loading
Loading