203 lines
7.2 KiB
TypeScript
203 lines
7.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[];
|
|
signFrames: SignFrameInstrumentation[];
|
|
}
|
|
|
|
export interface SignFrameInstrumentation {
|
|
now: number;
|
|
visible: boolean;
|
|
targetId: string;
|
|
phase: string;
|
|
navigationArrival?: true;
|
|
progress: number;
|
|
opacity: number;
|
|
activeSectionId: string;
|
|
direction: number;
|
|
corridor: string;
|
|
viewportProfile: string;
|
|
bounds: { left: number; top: number; right: number; bottom: number; width: number; height: number };
|
|
texture: {
|
|
width: number;
|
|
height: number;
|
|
maximumSize: number;
|
|
anisotropy: number;
|
|
fontReadyWhenDrawn: boolean;
|
|
};
|
|
rendererPixelRatio: number;
|
|
viewport: { width: number; height: number };
|
|
}
|
|
|
|
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: [],
|
|
signFrames: [],
|
|
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));
|
|
});
|
|
window.addEventListener('portfolio-sign-frame', (event) => {
|
|
const detail = (event as CustomEvent<SignFrameInstrumentation>).detail;
|
|
if (!detail || state.signFrames.length >= 600) return;
|
|
state.signFrames.push(structuredClone(detail));
|
|
});
|
|
});
|
|
}
|
|
|
|
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],
|
|
signFrames: state.signFrames.map((frame) => structuredClone(frame)),
|
|
};
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|