test: add deterministic resilience matrix
This commit is contained in:
126
src/testing/fault-hooks.ts
Normal file
126
src/testing/fault-hooks.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user