127 lines
4.8 KiB
TypeScript
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);
|
|
};
|