import { expect, type Page, test } from '@playwright/test'; async function installLifecycleProbe(page: Page) { await page.addInitScript(() => { const activeAnimationFrames = new Set(); const contexts: WebGLRenderingContext[] = []; const listeners: Array<{ listener: EventListenerOrEventListenerObject | null; target: EventTarget; type: string; }> = []; const requestAnimationFrameOriginal = window.requestAnimationFrame.bind(window); const cancelAnimationFrameOriginal = window.cancelAnimationFrame.bind(window); window.requestAnimationFrame = (callback) => { let id = 0; id = requestAnimationFrameOriginal((time) => { activeAnimationFrames.delete(id); callback(time); }); activeAnimationFrames.add(id); return id; }; window.cancelAnimationFrame = (id) => { activeAnimationFrames.delete(id); cancelAnimationFrameOriginal(id); }; const addEventListenerOriginal = EventTarget.prototype.addEventListener; const removeEventListenerOriginal = EventTarget.prototype.removeEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { listeners.push({ listener, target: this, type }); return addEventListenerOriginal.call(this, type, listener, options); }; EventTarget.prototype.removeEventListener = function (type, listener, options) { const index = listeners.findIndex( (entry) => entry.target === this && entry.type === type && entry.listener === listener, ); if (index >= 0) listeners.splice(index, 1); return removeEventListenerOriginal.call(this, type, listener, options); }; const getContextOriginal = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function (...args) { const context = getContextOriginal.apply(this, args as never); if ( context && (args[0] === 'webgl' || args[0] === 'webgl2' || args[0] === 'experimental-webgl') && !contexts.includes(context as WebGLRenderingContext) ) { contexts.push(context as WebGLRenderingContext); } return context; } as typeof HTMLCanvasElement.prototype.getContext; Object.defineProperty(window, '__lifecycleProbe', { value: { snapshot: () => ({ activeAnimationFrames: activeAnimationFrames.size, canvasListeners: listeners.filter( (entry) => entry.target instanceof HTMLCanvasElement, ).length, contexts: contexts.length, contextsLost: contexts.length > 0 && contexts.every((context) => context.isContextLost()), keydownListeners: listeners.filter( (entry) => entry.target === window && entry.type === 'keydown', ).length, resizeListeners: listeners.filter( (entry) => entry.target === window && entry.type === 'resize', ).length, }), }, }); }); } test('viewer2d releases its canvas when disposed', async ({ page }) => { await installLifecycleProbe(page); await page.goto('/'); await page.getByRole('button', { name: 'Sample DXF' }).click(); await expect(page.locator('#status')).toContainText('✓ simple.dxf'); await expect(page.locator('canvas')).toHaveCount(1); const lifecycle = await page.evaluate(() => { const viewer = ( window as Window & { __viewer2d?: { dispose?: () => void }; } ).__viewer2d; return { exposed: Boolean(viewer), disposable: typeof viewer?.dispose === 'function', }; }); expect(lifecycle).toEqual({ exposed: true, disposable: true }); await page.evaluate(() => { ( window as Window & { __viewer2d?: { dispose?: () => void }; } ).__viewer2d?.dispose?.(); }); await page.evaluate(() => { ( window as Window & { __viewer2d?: { dispose?: () => void }; } ).__viewer2d?.dispose?.(); }); await expect(page.locator('canvas')).toHaveCount(0); const resources = await page.evaluate(() => ( window as Window & { __lifecycleProbe: { snapshot: () => Record }; } ).__lifecycleProbe.snapshot(), ); expect(resources).toEqual({ activeAnimationFrames: 0, canvasListeners: 0, contexts: 1, contextsLost: true, keydownListeners: 0, resizeListeners: 0, }); }); test('viewer3d releases its canvas on pagehide and remains disposable', async ({ page }) => { await installLifecycleProbe(page); await page.goto('http://127.0.0.1:3333/?model=/samples/Box.glb'); await page.waitForFunction( () => Boolean( ( window as Window & { __viewer?: { current?: object }; } ).__viewer?.current, ), ); const result = await page.evaluate(() => { window.dispatchEvent(new PageTransitionEvent('pagehide')); const exposed = (window as Window & { __viewer?: { dispose: () => void } }).__viewer; exposed?.dispose(); const resources = ( window as Window & { __lifecycleProbe: { snapshot: () => Record }; } ).__lifecycleProbe.snapshot(); return { canvases: document.querySelectorAll('canvas').length, exposed: Boolean(exposed), resources, }; }); expect(result).toEqual({ canvases: 0, exposed: false, resources: { activeAnimationFrames: 0, canvasListeners: 0, contexts: 1, contextsLost: true, keydownListeners: 0, resizeListeners: 0, }, }); }); test('viewer2d and viewer3d survive ten mount transitions without duplicate canvases', async ({ page, }) => { test.setTimeout(180_000); const runtimeErrors: string[] = []; page.on('pageerror', (error) => runtimeErrors.push(error.message)); for (let transition = 0; transition < 10; transition += 1) { await page.goto('http://127.0.0.1:5173/'); await page.getByRole('button', { name: 'Sample DWG' }).click(); await expect(page.locator('#status')).toContainText('7309 entities', { timeout: 20_000 }); await expect(page.locator('canvas')).toHaveCount(1); await page.goto('http://127.0.0.1:3333/?model=/samples/Box.glb'); await page.waitForFunction( () => Boolean( ( window as Window & { __viewer?: { current?: object }; } ).__viewer?.current, ), undefined, { timeout: 20_000 }, ); await expect(page.locator('canvas')).toHaveCount(1); } expect(runtimeErrors).toEqual([]); });