test: add deterministic resilience matrix

This commit is contained in:
2026-09-02 07:24:48 +03:00
parent 5abd1c19ae
commit 8089d061ec
21 changed files with 809 additions and 22 deletions

View File

@@ -103,8 +103,8 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
if (!graph) return;
try {
await graph.resume();
} catch {
if (firstStart) await fail();
} catch (error) {
if (firstStart || (error instanceof Error && error.name === 'InvalidStateError')) await fail();
else {
desired = 'suspended';
resumeIntent = 'user-required';

View File

@@ -1,12 +1,44 @@
import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
type AudioContextFactory = () => AudioContext;
export function createBrowserSynthEngine(): AudioGraph {
if (TEST_BUILD && isTestFault('F10-audio-start-rejection')) {
recordTestFaultEvent('audio-start-rejected');
throw new DOMException('Injected audio start rejection', 'NotAllowedError');
}
const AudioContextConstructor = window.AudioContext
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
return createSynthEngine(() => new AudioContextConstructor());
const graph = createSynthEngine(() => new AudioContextConstructor());
if (!TEST_BUILD) return graph;
let resumeCount = 0;
return {
...graph,
async resume() {
resumeCount += 1;
if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) {
recordTestFaultEvent('audio-resume-rejected');
throw new DOMException('Injected audio resume rejection', 'NotAllowedError');
}
if (isTestFault('F12-audio-graph-invalid') && resumeCount > 1) {
recordTestFaultEvent('audio-graph-invalid');
throw new DOMException('Injected invalid audio graph', 'InvalidStateError');
}
await graph.resume();
},
async suspend() {
if (isTestFault('F13-audio-suspend-rejection')) {
recordTestFaultEvent('audio-suspend-rejected');
throw new DOMException('Injected audio suspend rejection', 'InvalidStateError');
}
await graph.suspend();
},
};
}
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
import { useLazyMedia } from '@/composables/useLazyMedia';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export interface AccessibleVideoSource {
src: string;
@@ -64,6 +68,11 @@ const markUnavailable = (): void => {
const play = async (): Promise<void> => {
if (unavailable.value) return;
if (TEST_BUILD && isTestFault('F8-video-failure')) {
recordTestFaultEvent('video-failure');
markUnavailable();
return;
}
lazy.requestExplicit();
status.value = 'Loading video.';
await nextTick();

View File

@@ -39,7 +39,7 @@ const statusText = computed(() => {
if (props.policy.reason === 'data-saver') return 'Motion: static — data saver is active';
if (props.policy.reason === 'visitor-pause') return 'Motion: paused';
if (props.policy.reason === 'saved-static') return 'Motion: static';
if (props.sceneStatus === 'failed' || props.sceneStatus === 'static') return 'Motion: static — scene unavailable';
if (props.sceneStatus === 'failed' || props.sceneStatus === 'static') return 'Visuals unavailable';
if (props.sceneStatus === 'loading' || props.sceneStatus === 'idle') return 'Motion: preparing';
return 'Motion: active';
});

View File

@@ -3,6 +3,10 @@ import { computed, onMounted, ref, toRef, watch } from 'vue';
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
const props = defineProps<{
journey: JourneySnapshot;
@@ -18,6 +22,7 @@ const emit = defineEmits<{
const canvas = ref<HTMLCanvasElement | null>(null);
const clientReady = ref(false);
const posterFailed = ref(TEST_BUILD && isTestFault('F7-poster-failure'));
const journey = toRef(props, 'journey');
const policy = toRef(props, 'policy');
const quality = toRef(props, 'quality');
@@ -35,6 +40,7 @@ const showCanvas = computed(() => (
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
onMounted(() => { clientReady.value = true; });
if (posterFailed.value) recordTestFaultEvent('poster-failure');
</script>
<template>
@@ -44,8 +50,10 @@ onMounted(() => { clientReady.value = true; });
aria-hidden="true"
:data-scene-state="sceneState"
:data-scene-generation="sceneGeneration"
:data-static-fallback="!showCanvas"
:data-poster-failed="posterFailed"
>
<img class="journey-poster" alt="" width="1600" height="900">
<img v-if="!posterFailed" class="journey-poster" alt="" width="1600" height="900" @error="posterFailed = true">
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
</div>
<slot />

View File

@@ -10,6 +10,9 @@ import { createAudioController } from '@/audio/create-audio-controller';
import { createBrowserSynthEngine } from '@/audio/synth-engine';
import type { AudioController, AudioUiState } from '@/audio/types';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export interface AudioEngineSources {
intensity: Readonly<Ref<number>>;
volume: Readonly<Ref<number>>;
@@ -57,10 +60,13 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
const onTestInterruption = (): void => { void controller?.noteInterruption(); };
if (TEST_BUILD) window.addEventListener('portfolio-test-audio-interruption', onTestInterruption);
removePageListeners = () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
if (TEST_BUILD) window.removeEventListener('portfolio-test-audio-interruption', onTestInterruption);
};
});

View File

@@ -11,6 +11,10 @@ import { createSceneController } from '@/scene/create-scene-controller';
import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { SceneController, SceneControllerSnapshot } from '@/scene/types';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export type SceneHostState = 'idle' | 'loading' | 'running' | 'paused' | 'static' | 'failed' | 'disposed';
@@ -72,7 +76,16 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
return;
}
if (canvas === null) {
disposeActive(error.value ? 'failed' : 'idle');
const injectedStatic = TEST_BUILD && (
isTestFault('F1-scene-chunk')
|| isTestFault('F1-shader')
|| isTestFault('F1-car')
|| isTestFault('F1-texture')
|| isTestFault('F2-first-frame-timeout')
|| isTestFault('F3-post-first-frame-exception')
|| isTestFault('F6-repeated-context-loss')
);
disposeActive(error.value ? (injectedStatic ? 'static' : 'failed') : 'idle');
return;
}
@@ -92,6 +105,10 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
error.value = false;
state.value = 'loading';
try {
if (TEST_BUILD && isTestFault('F1-scene-chunk')) {
recordTestFaultEvent('scene-chunk-rejected');
throw new Error('Injected scene chunk failure');
}
const { createProceduralSceneGeneration } = await import('@/scene/world/environment');
if (
abort.signal.aborted
@@ -102,12 +119,20 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|| sources.canvas.value !== canvas
) return;
const next = createSceneController({
let next!: SceneController;
next = createSceneController({
canvas,
quality: sources.quality.value,
build: createProceduralSceneGeneration,
motionAllowed: () => sources.policy.value.allowSceneRequest,
onFrameRendered: sources.sampleFrame,
onStateChange: (nextState) => {
queueMicrotask(() => {
if (controller !== next) return;
error.value = nextState.kind === 'static';
publish();
});
},
signal: abort.signal,
});
controller = next;
@@ -120,10 +145,20 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
const kind = next.snapshot().state.kind;
error.value = kind === 'static';
publish();
if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) {
window.setTimeout(() => {
if (controller === next && next.snapshot().state.kind === 'running') {
recordTestFaultEvent('first-frame-deadline');
next.setStatic('first-frame-timeout');
error.value = true;
publish();
}
}, 5_000);
}
} catch {
if (!abort.signal.aborted && currentGeneration === generation) {
error.value = true;
disposeActive('failed');
disposeActive(TEST_BUILD && isTestFault('F1-scene-chunk') ? 'static' : 'failed');
}
} finally {
if (importAbort === abort) importAbort = null;

View File

@@ -2,6 +2,17 @@ import { createApp } from './app';
import { validateAndFreezeProductionInputSnapshot } from './content/production-input-snapshot';
import type { ProductionInputs } from './content/types';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
const requestsTestFault = TEST_BUILD
&& new URL(location.href).searchParams.has('__portfolio_fault')
&& new URL(location.href).searchParams.has('__portfolio_test_token');
if (requestsTestFault) {
const { installAuthorizedTestHooks } = await import('./testing/fault-hooks');
await installAuthorizedTestHooks();
}
export const readProductionInputsSnapshot = (source: Document): Readonly<ProductionInputs> => {
const snapshot = source.getElementById('portfolio-inputs');
if (
@@ -21,6 +32,8 @@ export const readProductionInputsSnapshot = (source: Document): Readonly<Product
};
const appRoot = document.getElementById('app');
if (appRoot !== null) {
const hydrationFault = TEST_BUILD
&& window.__PORTFOLIO_TEST_HOOKS__?.fault === 'F9-hydration-failure';
if (appRoot !== null && !hydrationFault) {
createApp(readProductionInputsSnapshot(document)).mount(appRoot);
}

View File

@@ -73,6 +73,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
const transition = (next: SceneLifecycleState): void => {
machine.transition(next);
options.onStateChange?.(next);
};
const stopRaf = (): void => rootResources.cancelAnimationFrame();

View File

@@ -53,6 +53,7 @@ export interface SceneControllerOptions {
page?: ScenePageEnvironment;
motionAllowed?: () => boolean;
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
onStateChange?: ((state: SceneLifecycleState) => unknown) | undefined;
signal?: AbortSignal;
}

View File

@@ -9,6 +9,10 @@ import { createSun } from '@/scene/world/sun';
import { createCar } from '@/scene/world/car';
import { createSign } from '@/scene/world/sign';
import { createParticles } from '@/scene/world/particles';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
'deep-night': 0x040713,
@@ -29,6 +33,18 @@ export function createChaseCamera(aspect = 1) {
export async function createProceduralSceneGeneration(
context: SceneBuildContext,
): Promise<SceneGeneration> {
if (TEST_BUILD && isTestFault('F1-shader')) {
recordTestFaultEvent('shader-rejected');
throw new Error('Injected shader failure');
}
if (TEST_BUILD && isTestFault('F1-texture')) {
recordTestFaultEvent('texture-rejected');
throw new Error('Injected texture failure');
}
if (TEST_BUILD && isTestFault('F1-car')) {
recordTestFaultEvent('car-rejected');
throw new Error('Injected car failure');
}
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
const canvas = context.canvas as HTMLCanvasElement;
if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required');
@@ -104,6 +120,7 @@ export async function createProceduralSceneGeneration(
let driveDistance = 0;
let lastNow: number | null = null;
let disposed = false;
let renderedFrames = 0;
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -128,6 +145,14 @@ export async function createProceduralSceneGeneration(
return {
render(snapshot: Readonly<JourneySnapshot>, now: number) {
if (disposed) return;
if (
TEST_BUILD
&& isTestFault('F3-post-first-frame-exception')
&& renderedFrames > 0
) {
recordTestFaultEvent('post-first-frame-exception');
throw new Error('Injected post-first-frame exception');
}
resize();
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
lastNow = now;
@@ -152,6 +177,7 @@ export async function createProceduralSceneGeneration(
} else {
renderer.render(scene, camera);
}
renderedFrames += 1;
},
dispose() {
if (disposed) return;

126
src/testing/fault-hooks.ts Normal file
View 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);
};