Files
Portfolio/tests/helpers/scene-instrumentation.ts

171 lines
6.2 KiB
TypeScript

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<number>;
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<void> {
await page.addInitScript(() => {
const instrumentedWindow = window as InstrumentedWindow;
const state: NonNullable<InstrumentedWindow['__portfolioJourneyInstrumentation']> = {
activeAnimationFrames: 0,
maximumCanvasCount: 0,
audioContextsConstructed: 0,
liveAudioContexts: 0,
routeLabelsDrawn: [],
clearColors: [],
unhandledRejections: [],
animationFrameIds: new Set<number>(),
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<SceneInstrumentationSnapshot> {
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<void> {
await page.evaluate(() => new Promise<void>((resolve) => {
window.requestAnimationFrame(() => queueMicrotask(resolve));
}));
}
export async function dispatchPersistedPageTransition(
page: Page,
type: 'pagehide' | 'pageshow',
): Promise<void> {
await page.evaluate((eventType) => {
window.dispatchEvent(new PageTransitionEvent(eventType, { persisted: true }));
}, type);
}