import type { Page } from '@playwright/test'; export const environmentChunkPattern = /\/assets\/environment-[^/]+\.js(?:[?#].*)?$/u; export interface SceneInstrumentationSnapshot { activeAnimationFrames: number; maximumCanvasCount: number; audioContextsConstructed: number; liveAudioContexts: number; routeLabelsDrawn: string[]; clearColors: string[]; unhandledRejections: string[]; } type InstrumentedWindow = Window & { __portfolioJourneyInstrumentation?: SceneInstrumentationSnapshot & { animationFrameIds: Set; audioContexts: AudioContext[]; }; }; /** * Installs browser-owned observers before application code runs. The helper * patches platform boundaries only; no test API is compiled into the site. */ export async function installSceneInstrumentation(page: Page): Promise { await page.addInitScript(() => { const instrumentedWindow = window as InstrumentedWindow; const state: NonNullable = { activeAnimationFrames: 0, maximumCanvasCount: 0, audioContextsConstructed: 0, liveAudioContexts: 0, routeLabelsDrawn: [], clearColors: [], unhandledRejections: [], animationFrameIds: new Set(), audioContexts: [], }; Object.defineProperty(instrumentedWindow, '__portfolioJourneyInstrumentation', { configurable: false, enumerable: false, value: state, writable: false, }); const nativeRequestAnimationFrame = window.requestAnimationFrame.bind(window); const nativeCancelAnimationFrame = window.cancelAnimationFrame.bind(window); window.requestAnimationFrame = (callback: FrameRequestCallback): number => { let id = 0; id = nativeRequestAnimationFrame((now) => { state.animationFrameIds.delete(id); state.activeAnimationFrames = state.animationFrameIds.size; callback(now); }); state.animationFrameIds.add(id); state.activeAnimationFrames = state.animationFrameIds.size; return id; }; window.cancelAnimationFrame = (id: number): void => { state.animationFrameIds.delete(id); state.activeAnimationFrames = state.animationFrameIds.size; nativeCancelAnimationFrame(id); }; const updateCanvasCount = (): void => { state.maximumCanvasCount = Math.max( state.maximumCanvasCount, document.querySelectorAll('.journey-canvas').length, ); }; document.addEventListener('DOMContentLoaded', () => { updateCanvasCount(); new MutationObserver(updateCanvasCount).observe(document.documentElement, { childList: true, subtree: true, }); }, { once: true }); const nativeFillText = CanvasRenderingContext2D.prototype.fillText; const routeLabels = new Set([ 'Start', 'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact', ]); CanvasRenderingContext2D.prototype.fillText = function fillText( text: string, x: number, y: number, maxWidth?: number, ): void { if (routeLabels.has(text)) state.routeLabelsDrawn.push(text); if (maxWidth === undefined) nativeFillText.call(this, text, x, y); else nativeFillText.call(this, text, x, y, maxWidth); }; const colorChannel = (value: number): string => Math.round(value * 255) .toString(16) .padStart(2, '0'); const patchClearColor = (prototype: WebGLRenderingContext | WebGL2RenderingContext): void => { const nativeClearColor = prototype.clearColor; prototype.clearColor = function clearColor(red, green, blue, alpha): void { state.clearColors.push(`#${colorChannel(red)}${colorChannel(green)}${colorChannel(blue)}`); nativeClearColor.call(this, red, green, blue, alpha); }; }; if (typeof WebGLRenderingContext !== 'undefined') patchClearColor(WebGLRenderingContext.prototype); if (typeof WebGL2RenderingContext !== 'undefined') patchClearColor(WebGL2RenderingContext.prototype); const NativeAudioContext = window.AudioContext; if (NativeAudioContext) { class InstrumentedAudioContext extends NativeAudioContext { constructor(options?: AudioContextOptions) { super(options); state.audioContextsConstructed += 1; state.audioContexts.push(this); state.liveAudioContexts = state.audioContexts.filter((context) => context.state !== 'closed').length; this.addEventListener('statechange', () => { state.liveAudioContexts = state.audioContexts.filter((context) => context.state !== 'closed').length; }); } } Object.defineProperty(window, 'AudioContext', { configurable: true, value: InstrumentedAudioContext, }); } window.addEventListener('unhandledrejection', (event) => { const reason = event.reason; state.unhandledRejections.push(reason instanceof Error ? reason.message : String(reason)); }); }); } export async function readSceneInstrumentation(page: Page): Promise { return page.evaluate(() => { const state = (window as InstrumentedWindow).__portfolioJourneyInstrumentation; if (!state) throw new Error('Scene instrumentation was not installed before navigation.'); return { activeAnimationFrames: state.animationFrameIds.size, maximumCanvasCount: state.maximumCanvasCount, audioContextsConstructed: state.audioContextsConstructed, liveAudioContexts: state.audioContexts.filter((context) => context.state !== 'closed').length, routeLabelsDrawn: [...state.routeLabelsDrawn], clearColors: [...state.clearColors], unhandledRejections: [...state.unhandledRejections], }; }); } export async function waitForOneAnimationFrame(page: Page): Promise { await page.evaluate(() => new Promise((resolve) => { window.requestAnimationFrame(() => queueMicrotask(resolve)); })); } export async function dispatchPersistedPageTransition( page: Page, type: 'pagehide' | 'pageshow', ): Promise { await page.evaluate((eventType) => { window.dispatchEvent(new PageTransitionEvent(eventType, { persisted: true })); }, type); }