Files
Portfolio/src/testing/fault-hooks.ts

127 lines
4.8 KiB
TypeScript

declare const __PORTFOLIO_TEST_BUILD__: boolean;
declare const __PORTFOLIO_TEST_TOKEN_DIGEST__: string;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
const TEST_TOKEN_DIGEST = typeof __PORTFOLIO_TEST_TOKEN_DIGEST__ === 'undefined'
? ''
: __PORTFOLIO_TEST_TOKEN_DIGEST__;
export const faultIds = [
'F1-scene-chunk',
'F1-shader',
'F1-car',
'F1-texture',
'F2-first-frame-timeout',
'F3-post-first-frame-exception',
'F4-context-loss-restore',
'F5-hidden-context-loss',
'F6-repeated-context-loss',
'F7-poster-failure',
'F8-video-failure',
'F9-hydration-failure',
'F10-audio-start-rejection',
'F11-audio-resume-rejection',
'F12-audio-graph-invalid',
'F13-audio-suspend-rejection',
] as const;
export type PortfolioFaultId = typeof faultIds[number];
export type PortfolioFaultAction = 'context-loss' | 'context-restore' | 'audio-interruption';
interface TestHookSnapshot {
readonly fault: PortfolioFaultId;
readonly events: readonly string[];
readonly sceneState: string | null;
readonly canvasCount: number;
readonly posterCount: number;
readonly staticFallbackCount: number;
readonly audioStatus: string | null;
readonly videoStatus: string | null;
}
interface PortfolioTestHooks {
readonly version: 'portfolio-fault-hooks/v1';
readonly fault: PortfolioFaultId;
trigger(action: PortfolioFaultAction): void;
snapshot(): TestHookSnapshot;
}
declare global {
interface Window {
__PORTFOLIO_TEST_HOOKS__?: PortfolioTestHooks;
}
}
const faultSet = new Set<string>(faultIds);
const allowedActions: Readonly<Partial<Record<PortfolioFaultId, readonly PortfolioFaultAction[]>>> = {
'F4-context-loss-restore': ['context-loss', 'context-restore'],
'F5-hidden-context-loss': ['context-loss', 'context-restore'],
'F6-repeated-context-loss': ['context-loss', 'context-restore'],
'F11-audio-resume-rejection': ['audio-interruption'],
'F12-audio-graph-invalid': ['audio-interruption'],
};
let activeFault: PortfolioFaultId | null = null;
const events: string[] = [];
const digest = async (value: string): Promise<string> => {
const bytes = new TextEncoder().encode(value);
const output = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(output)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
};
const configuredFault = (): PortfolioFaultId | null => {
const value = new URL(location.href).searchParams.get('__portfolio_fault');
return value !== null && faultSet.has(value) ? value as PortfolioFaultId : null;
};
const dispatchCanvasEvent = (type: 'webglcontextlost' | 'webglcontextrestored'): void => {
const canvas = document.querySelector<HTMLCanvasElement>('.journey-canvas');
if (canvas === null) return;
canvas.dispatchEvent(new Event(type, { cancelable: type === 'webglcontextlost' }));
};
const trigger = (action: PortfolioFaultAction): void => {
if (activeFault === null || !(allowedActions[activeFault]?.includes(action) ?? false)) {
throw new TypeError('The requested action is not valid for the configured fault.');
}
if (action === 'context-loss') dispatchCanvasEvent('webglcontextlost');
else if (action === 'context-restore') dispatchCanvasEvent('webglcontextrestored');
else window.dispatchEvent(new Event('portfolio-test-audio-interruption'));
events.push(action);
};
const snapshot = (): TestHookSnapshot => Object.freeze({
fault: activeFault!,
events: Object.freeze([...events]),
sceneState: document.querySelector('.journey-world')?.getAttribute('data-scene-state') ?? null,
canvasCount: document.querySelectorAll('.journey-canvas').length,
posterCount: document.querySelectorAll('.journey-poster').length,
staticFallbackCount: document.querySelectorAll('.journey-world[data-static-fallback="true"]').length,
audioStatus: document.querySelector('.drive-controls__audio-status')?.textContent?.trim() ?? null,
videoStatus: document.querySelector('[data-testid="video-status"]')?.textContent?.trim() ?? null,
});
export const installAuthorizedTestHooks = async (): Promise<void> => {
if (!TEST_BUILD) return;
const url = new URL(location.href);
const token = url.searchParams.get('__portfolio_test_token');
const fault = configuredFault();
if (token === null || fault === null || await digest(token) !== TEST_TOKEN_DIGEST) return;
activeFault = fault;
events.length = 0;
Object.defineProperty(window, '__PORTFOLIO_TEST_HOOKS__', {
configurable: false,
enumerable: false,
writable: false,
value: Object.freeze({ version: 'portfolio-fault-hooks/v1', fault, trigger, snapshot }),
});
};
export const isTestFault = (fault: PortfolioFaultId): boolean => (
TEST_BUILD && activeFault === fault
);
export const recordTestFaultEvent = (event: string): void => {
if (TEST_BUILD && activeFault !== null) events.push(event);
};