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

@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/browser',
globalSetup: './tests/helpers/test-build-auth.ts',
workers: 4,
use: {
serviceWorkers: 'block',

View File

@@ -15,6 +15,8 @@ const canonicalSectionIds = [
];
const prohibitedSurfaces = [
/__PORTFOLIO_INPUTS__/iu,
/__PORTFOLIO_TEST_HOOKS__/iu,
/PORTFOLIO_TEST_TOKEN/iu,
/__PORTFOLIO_(?:TEST|INSTRUMENTATION)__/iu,
/__portfolio(?:Journey)?Instrumentation/iu,
/(?:evidenceId|sourcePath|sourceFingerprint|approvalOwner|privacyClassification|denylist|originalFilename)/iu,

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);
};

View File

@@ -1,9 +1,11 @@
# Browser characterization tests
These Playwright tests lock the observable portfolio behavior at the browser
boundary. They exercise the built `dist/` output through the local preview
server rather than replacing Vue, Three.js, WebGL, or Web Audio with production
test hooks.
boundary. Ordinary suites exercise the built `dist/` output through the local
preview server. The resilience suite additionally creates an authenticated,
ephemeral test client: Playwright owns the raw per-run token, while only its
SHA-256 digest enters that temporary bundle. Global fault hooks are absent from
and rejected in production artifacts.
## Run
@@ -26,10 +28,26 @@ npm run test:browser -- tests/browser/journey-integration.spec.ts tests/browser/
2. Use literal inputs and expected browser-visible results.
3. Install `scene-instrumentation.ts` before navigation when resource counts or
platform calls are the observable boundary.
4. Keep failure injection in `page.addInitScript()` or Playwright routing; do
not add a test API to the application bundle.
4. Prefer browser-owned `page.addInitScript()` or routing. A lifecycle fault
that requires an application seam must use one of the closed F1–F13 hook
names and the authenticated helper.
5. Run the new case against both configured Chromium profiles, then run the
full suite.
Never copy credentials, private project identifiers, or private URLs into a
fixture. Use clearly fake values when a credential-shaped input is required.
## Browser evidence status
- Executable here: current Playwright Chromium 151, desktop and a simulated
mobile viewport. The simulated viewport is responsive evidence only and is
not described as a physical Pixel result.
- Pending because no runnable binary/channel is installed: current Firefox,
WebKit/Safari, Edge, and the actual previous-stable desktop majors. Installed
Chromium 148/145 builds are older retained binaries, not the required
previous-stable 150 evidence, so they are not mislabeled or substituted.
- Pending physical release evidence: current iOS Safari on iPhone 13 and
Android Chromium on Pixel 6, including `physical-drive.v1`.
These pending cells remain release checkpoints; this repository does not turn
missing hardware or browser binaries into passing emulation results.

View File

@@ -0,0 +1,89 @@
import { expect, test } from '../helpers/serve-dist';
import {
assertSemanticFaultOracle,
authorizedFaultUrl,
collectBrowserErrors,
readUnhandledRejections,
triggerFaultAction,
} from '../helpers/test-build-auth';
import { installSceneInstrumentation, readSceneInstrumentation } from '../helpers/scene-instrumentation';
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Audio fault matrix runs once on current desktop Chromium.');
await installSceneInstrumentation(page);
});
const startAudio = async (page: import('@playwright/test').Page): Promise<void> => {
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
};
const assertAudioContained = async (
page: import('@playwright/test').Page,
pageErrors: readonly string[],
expectedLiveContexts: number,
): Promise<void> => {
await assertSemanticFaultOracle(page);
expect(pageErrors).toEqual([]);
expect(await readUnhandledRejections(page)).toEqual([]);
const resources = await readSceneInstrumentation(page);
expect(resources.unhandledRejections).toEqual([]);
expect(resources.audioContextsConstructed).toBe(1);
expect(resources.liveAudioContexts).toBe(expectedLiveContexts);
};
test('F10 contains initial Web Audio start rejection and creates no reusable graph', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F10-audio-start-rejection'), { waitUntil: 'domcontentloaded' });
await startAudio(page);
await expect(page.getByText('Audio unavailable. Continue without sound.')).toBeVisible();
await expect(page.getByRole('button', { name: 'START THE DRIVE' })).toHaveCount(0);
await assertSemanticFaultOracle(page);
expect(errors.pageErrors).toEqual([]);
expect(await readUnhandledRejections(page)).toEqual([]);
expect((await readSceneInstrumentation(page)).audioContextsConstructed).toBe(0);
});
test('F11 keeps an interrupted intact graph suspended after resume rejection', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F11-audio-resume-rejection'), { waitUntil: 'domcontentloaded' });
await startAudio(page);
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
await triggerFaultAction(page, 'audio-interruption');
await expect(page.getByRole('button', { name: 'Resume sound' })).toBeVisible();
await page.getByRole('button', { name: 'Resume sound' }).click();
await expect(page.getByRole('button', { name: 'Resume sound' })).toBeVisible();
await expect(page.getByText('Audio unavailable. Continue without sound.')).toHaveCount(0);
await assertAudioContained(page, errors.pageErrors, 1);
});
test('F12 tears down a graph that becomes invalid during resume', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F12-audio-graph-invalid'), { waitUntil: 'domcontentloaded' });
await startAudio(page);
await triggerFaultAction(page, 'audio-interruption');
await expect(page.getByRole('button', { name: 'Resume sound' })).toBeVisible();
await page.getByRole('button', { name: 'Resume sound' }).click();
await expect(page.getByText('Audio unavailable. Continue without sound.')).toBeVisible();
await expect.poll(async () => (await readSceneInstrumentation(page)).liveAudioContexts).toBe(0);
await assertAudioContained(page, errors.pageErrors, 0);
});
test('F13 synchronously silences and closes the graph when hide-time suspend rejects', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F13-audio-suspend-rejection'), { waitUntil: 'domcontentloaded' });
await startAudio(page);
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
await expect(page.getByText('Audio unavailable. Continue without sound.')).toBeVisible();
await expect.poll(async () => (await readSceneInstrumentation(page)).liveAudioContexts).toBe(0);
await assertAudioContained(page, errors.pageErrors, 0);
});

View File

@@ -0,0 +1,76 @@
import { expect, test } from '../helpers/serve-dist';
import {
assertSemanticFaultOracle,
authorizedFaultUrl,
collectBrowserErrors,
readUnhandledRejections,
triggerFaultAction,
} from '../helpers/test-build-auth';
import { installSceneInstrumentation, readSceneInstrumentation } from '../helpers/scene-instrumentation';
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Lifecycle faults run once on current desktop Chromium.');
await installSceneInstrumentation(page);
});
const assertContained = async (page: import('@playwright/test').Page, pageErrors: readonly string[]): Promise<void> => {
expect(pageErrors).toEqual([]);
expect(await readUnhandledRejections(page)).toEqual([]);
const resources = await readSceneInstrumentation(page);
expect(resources.unhandledRejections).toEqual([]);
expect(resources.maximumCanvasCount).toBeLessThanOrEqual(1);
expect(resources.activeAnimationFrames).toBeLessThanOrEqual(1);
};
test('F4 restores one visible lost context before the five-second cutoff', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F4-context-loss-restore'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const firstGeneration = await page.locator('.journey-world').getAttribute('data-scene-generation');
await triggerFaultAction(page, 'context-loss');
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'loading');
await triggerFaultAction(page, 'context-restore');
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await expect(page.locator('.journey-world')).not.toHaveAttribute('data-scene-generation', firstGeneration ?? '');
await expect(page.locator('.journey-canvas')).toHaveCount(1);
await assertSemanticFaultOracle(page);
await assertContained(page, errors.pageErrors);
});
test('F5 defers a hidden restoration until the first visible lifecycle task', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F5-hidden-context-loss'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'paused');
await triggerFaultAction(page, 'context-loss');
await triggerFaultAction(page, 'context-restore');
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'loading');
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await expect(page.locator('.journey-canvas')).toHaveCount(1);
await assertSemanticFaultOracle(page);
await assertContained(page, errors.pageErrors);
});
test('F6 makes a later context loss terminal for the session', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F6-repeated-context-loss'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await triggerFaultAction(page, 'context-loss');
await triggerFaultAction(page, 'context-restore');
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await triggerFaultAction(page, 'context-loss');
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'static');
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('.drive-controls__status')).toHaveText('Visuals unavailable');
await assertSemanticFaultOracle(page);
await assertContained(page, errors.pageErrors);
});

View File

@@ -0,0 +1,112 @@
import { expect, test } from '../helpers/serve-dist';
import {
assertSemanticFaultOracle,
authorizedFaultUrl,
collectBrowserErrors,
missingTokenFaultUrl,
readFaultSnapshot,
readUnhandledRejections,
wrongTokenFaultUrl,
} from '../helpers/test-build-auth';
import { installSceneInstrumentation, readSceneInstrumentation } from '../helpers/scene-instrumentation';
import type { PortfolioFaultId } from '@/testing/fault-hooks';
const assertNoEscapedErrors = async (
page: Parameters<typeof readUnhandledRejections>[0],
pageErrors: readonly string[],
): Promise<void> => {
expect(pageErrors).toEqual([]);
expect(await readUnhandledRejections(page)).toEqual([]);
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual([]);
};
test.describe('authenticated fault-build boundary', () => {
test.beforeEach(async ({}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'The complete F1–F13 matrix runs once on current desktop Chromium.');
});
test('missing token exposes no fault API and leaves the portfolio operational', async ({ page, distUrl }) => {
await page.goto(missingTokenFaultUrl(distUrl, 'F1-scene-chunk'), { waitUntil: 'domcontentloaded' });
expect(await page.evaluate(() => '__PORTFOLIO_TEST_HOOKS__' in window)).toBe(false);
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await assertSemanticFaultOracle(page);
});
test('wrong token exposes no fault API and leaves the portfolio operational', async ({ page, distUrl }) => {
await page.goto(wrongTokenFaultUrl(distUrl, 'F1-scene-chunk'), { waitUntil: 'domcontentloaded' });
expect(await page.evaluate(() => '__PORTFOLIO_TEST_HOOKS__' in window)).toBe(false);
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await assertSemanticFaultOracle(page);
});
test('correct token exposes only the frozen named fault interface', async ({ page, distUrl }) => {
await page.goto(authorizedFaultUrl(distUrl, 'F9-hydration-failure'), { waitUntil: 'domcontentloaded' });
await expect.poll(() => page.evaluate(() => '__PORTFOLIO_TEST_HOOKS__' in window)).toBe(true);
const shape = await page.evaluate(() => {
const hooks = (window as unknown as { __PORTFOLIO_TEST_HOOKS__: object }).__PORTFOLIO_TEST_HOOKS__;
return { frozen: Object.isFrozen(hooks), keys: Object.keys(hooks).sort() };
});
expect(shape).toEqual({ frozen: true, keys: ['fault', 'snapshot', 'trigger', 'version'] });
expect((await readFaultSnapshot(page)).fault).toBe('F9-hydration-failure');
await expect(page.locator('.drive-controls')).toHaveCount(1);
await assertSemanticFaultOracle(page);
});
});
test.describe('scene failure matrix', () => {
test.beforeEach(async ({}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'The complete F1–F13 matrix runs once on current desktop Chromium.');
});
for (const fault of [
'F1-scene-chunk',
'F1-shader',
'F1-car',
'F1-texture',
] as const satisfies readonly PortfolioFaultId[]) {
test(`${fault} enters one static fallback without escaping an error`, async ({ page, distUrl }) => {
await installSceneInstrumentation(page);
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, fault), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'static');
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
await expect(page.locator('.journey-poster')).toHaveCount(1);
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('.drive-controls__status')).toHaveText('Visuals unavailable');
await assertSemanticFaultOracle(page);
const resources = await readSceneInstrumentation(page);
expect(resources.activeAnimationFrames).toBe(0);
expect(resources.maximumCanvasCount).toBeLessThanOrEqual(1);
expect((await readFaultSnapshot(page)).staticFallbackCount).toBe(1);
await assertNoEscapedErrors(page, errors.pageErrors);
});
}
test('F2 enforces the literal five-second first-frame deadline', async ({ page, distUrl }) => {
test.setTimeout(12_000);
await installSceneInstrumentation(page);
const errors = await collectBrowserErrors(page);
const startedAt = Date.now();
await page.goto(authorizedFaultUrl(distUrl, 'F2-first-frame-timeout'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'static', { timeout: 7_000 });
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4_800);
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await assertSemanticFaultOracle(page);
await assertNoEscapedErrors(page, errors.pageErrors);
});
test('F3 contains a post-first-frame exception on the next frame', async ({ page, distUrl }) => {
await installSceneInstrumentation(page);
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F3-post-first-frame-exception'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'static');
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await assertSemanticFaultOracle(page);
expect((await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
await assertNoEscapedErrors(page, errors.pageErrors);
});
});

View File

@@ -0,0 +1,50 @@
import { expect, test } from '../helpers/serve-dist';
import {
assertSemanticFaultOracle,
authorizedFaultUrl,
collectBrowserErrors,
readFaultSnapshot,
readUnhandledRejections,
} from '../helpers/test-build-auth';
import { installSceneInstrumentation, readSceneInstrumentation } from '../helpers/scene-instrumentation';
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Media fault matrix runs once on current desktop Chromium.');
await installSceneInstrumentation(page);
});
test('F7 replaces a failed poster with exactly one CSS fallback and keeps its description', async ({ page, distUrl }) => {
const errors = await collectBrowserErrors(page);
await page.addInitScript(() => localStorage.setItem('portfolio.motion.maximum-quality', 'static'));
await page.goto(authorizedFaultUrl(distUrl, 'F7-poster-failure'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
await expect(page.locator('.journey-world')).toHaveAttribute('data-poster-failed', 'true');
await expect(page.locator('.journey-poster')).toHaveCount(0);
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('#journey-scene-description')).toContainText('night drive');
const background = await page.locator('.journey-world').evaluate((element) => getComputedStyle(element).backgroundColor);
expect(background).not.toBe('rgba(0, 0, 0, 0)');
expect((await readFaultSnapshot(page)).staticFallbackCount).toBe(1);
await assertSemanticFaultOracle(page);
expect(errors.pageErrors).toEqual([]);
expect(await readUnhandledRejections(page)).toEqual([]);
});
test('F8 remains authenticated and request-free while the public manifest has no approved video', async ({ page, distUrl }) => {
const mediaRequests: string[] = [];
page.on('request', (request) => {
if (/\.(?:mp4|webm)(?:$|[?#])/iu.test(request.url())) mediaRequests.push(request.url());
});
const errors = await collectBrowserErrors(page);
await page.goto(authorizedFaultUrl(distUrl, 'F8-video-failure'), { waitUntil: 'networkidle' });
expect((await readFaultSnapshot(page)).fault).toBe('F8-video-failure');
await expect(page.locator('[data-project-media]')).toHaveCount(0);
await expect(page.locator('video, video source')).toHaveCount(0);
expect(mediaRequests).toEqual([]);
await assertSemanticFaultOracle(page);
expect(errors.pageErrors).toEqual([]);
expect(await readUnhandledRejections(page)).toEqual([]);
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual([]);
});

View File

@@ -0,0 +1,167 @@
import { createHash, randomBytes } from 'node:crypto';
import { execFile } from 'node:child_process';
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { promisify } from 'node:util';
import { expect, type FullConfig, type Page } from '@playwright/test';
import type { PortfolioFaultId } from '@/testing/fault-hooks';
const execute = promisify(execFile);
const repositoryRoot = process.cwd();
const temporaryClientRoot = resolve(repositoryRoot, '.portfolio-test-client');
const run = async (command: string, args: readonly string[], environment = process.env): Promise<void> => {
await execute(command, [...args], {
cwd: repositoryRoot,
env: environment,
maxBuffer: 16 * 1024 * 1024,
});
};
const firstMatch = (source: string, expression: RegExp, label: string): string => {
const matches = source.match(expression);
if (matches === null || matches.length !== 1) throw new Error(`Test client is missing ${label}.`);
return matches[0];
};
const buildAuthenticatedClient = async (tokenDigest: string): Promise<void> => {
await rm(temporaryClientRoot, { recursive: true, force: true });
await run('npm', ['run', 'build:preview']);
await run(
resolve(repositoryRoot, 'node_modules/.bin/vite'),
['build', '--outDir', temporaryClientRoot, '--manifest'],
{
...process.env,
PORTFOLIO_TEST_BUILD: '1',
PORTFOLIO_TEST_TOKEN_DIGEST: tokenDigest,
},
);
const productionIndexPath = resolve(repositoryRoot, 'dist/index.html');
const testIndexPath = resolve(temporaryClientRoot, 'index.html');
const [productionIndex, testIndex] = await Promise.all([
readFile(productionIndexPath, 'utf8'),
readFile(testIndexPath, 'utf8'),
]);
const testModule = firstMatch(testIndex, /<script type="module"[^>]+><\/script>/u, 'entry module');
const testStyle = firstMatch(testIndex, /<link rel="stylesheet"[^>]+>/u, 'stylesheet');
const authenticatedIndex = productionIndex
.replace(/\s*<link rel="modulepreload"[^>]+>/gu, '')
.replace(/<script type="module"[^>]+><\/script>/u, testModule)
.replace(/<link rel="stylesheet"[^>]+>/u, testStyle);
const destinationAssets = resolve(repositoryRoot, 'dist/assets');
await mkdir(destinationAssets, { recursive: true });
for (const entry of await readdir(resolve(temporaryClientRoot, 'assets'), { withFileTypes: true })) {
if (!entry.isFile()) throw new Error('The authenticated test client emitted an unexpected nested asset.');
await copyFile(
resolve(temporaryClientRoot, 'assets', entry.name),
resolve(destinationAssets, entry.name),
);
}
await writeFile(productionIndexPath, authenticatedIndex);
await rm(temporaryClientRoot, { recursive: true, force: true });
};
export default async function globalSetup(_config: FullConfig): Promise<() => Promise<void>> {
const token = randomBytes(32).toString('hex');
const tokenDigest = createHash('sha256').update(token).digest('hex');
process.env.PORTFOLIO_TEST_TOKEN = token;
await buildAuthenticatedClient(tokenDigest);
return async () => {
await rm(temporaryClientRoot, { recursive: true, force: true });
delete process.env.PORTFOLIO_TEST_BUILD;
delete process.env.PORTFOLIO_TEST_TOKEN_DIGEST;
await run('npm', ['run', 'build:preview']);
};
}
const withFault = (origin: string, token: string | null, fault: PortfolioFaultId): string => {
const url = new URL(origin);
url.searchParams.set('__portfolio_fault', fault);
if (token !== null) url.searchParams.set('__portfolio_test_token', token);
return url.href;
};
export const authorizedFaultUrl = (origin: string, fault: PortfolioFaultId): string => {
const token = process.env.PORTFOLIO_TEST_TOKEN;
if (token === undefined) throw new Error('The per-run portfolio test token is unavailable.');
return withFault(origin, token, fault);
};
export const missingTokenFaultUrl = (origin: string, fault: PortfolioFaultId): string => withFault(origin, null, fault);
export const wrongTokenFaultUrl = (origin: string, fault: PortfolioFaultId): string => withFault(
origin,
'00'.repeat(32),
fault,
);
export interface BrowserErrorCollector {
readonly pageErrors: string[];
}
export const collectBrowserErrors = async (page: Page): Promise<BrowserErrorCollector> => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.addInitScript(() => {
const target = window as unknown as { __faultUnhandledRejections?: string[] };
target.__faultUnhandledRejections = [];
window.addEventListener('unhandledrejection', (event) => {
target.__faultUnhandledRejections!.push(event.reason instanceof Error ? event.reason.message : String(event.reason));
});
});
return { pageErrors };
};
export const readUnhandledRejections = (page: Page): Promise<string[]> => page.evaluate(() => (
[...((window as unknown as { __faultUnhandledRejections?: string[] }).__faultUnhandledRejections ?? [])]
));
export const assertSemanticFaultOracle = async (page: Page): Promise<void> => {
await expect(page.getByRole('heading', { level: 1, name: 'Luca Pîslaru' })).toHaveCount(1);
await expect(page.locator('main > section')).toHaveCount(8);
await expect(page.locator('nav[aria-label="Portfolio sections"] a')).toHaveCount(8);
await expect(page.locator('nav a[href="#contact"]')).toBeVisible();
const email = page.locator('#contact a[href^="mailto:"]');
await expect(email).toBeVisible();
for (const title of [
'Private self-hosted medication tracker',
'Private healthcare platform',
'Private game prototype',
]) {
await expect(page.locator('article', { hasText: title }).locator('a.project-link')).toHaveCount(0);
}
await page.locator('nav a[href="#contact"]').focus();
await expect(page.locator('nav a[href="#contact"]')).toBeFocused();
await expect(page.locator('.journey-world')).toHaveCount(1);
};
export interface TestHookSnapshot {
fault: PortfolioFaultId;
events: string[];
sceneState: string | null;
canvasCount: number;
posterCount: number;
staticFallbackCount: number;
audioStatus: string | null;
videoStatus: string | null;
}
export const readFaultSnapshot = (page: Page): Promise<TestHookSnapshot> => page.evaluate(() => {
const hooks = (window as unknown as {
__PORTFOLIO_TEST_HOOKS__?: { snapshot(): TestHookSnapshot };
}).__PORTFOLIO_TEST_HOOKS__;
if (hooks === undefined) throw new Error('Authenticated portfolio fault hooks were not installed.');
return hooks.snapshot();
});
export const triggerFaultAction = (page: Page, action: 'context-loss' | 'context-restore' | 'audio-interruption') => (
page.evaluate((nextAction) => {
const hooks = (window as unknown as {
__PORTFOLIO_TEST_HOOKS__?: { trigger(action: typeof nextAction): void };
}).__PORTFOLIO_TEST_HOOKS__;
if (hooks === undefined) throw new Error('Authenticated portfolio fault hooks were not installed.');
hooks.trigger(nextAction);
}, action)
);

View File

@@ -2,12 +2,27 @@ import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
export default defineConfig(() => {
const testBuild = process.env.PORTFOLIO_TEST_BUILD === '1';
const testTokenDigest = process.env.PORTFOLIO_TEST_TOKEN_DIGEST ?? '';
if (testBuild && !/^[a-f0-9]{64}$/u.test(testTokenDigest)) {
throw new Error('Authenticated test builds require one SHA-256 token digest.');
}
if (!testBuild && testTokenDigest !== '') {
throw new Error('A test token digest cannot influence a production build.');
}
return {
plugins: [vue()],
define: {
__PORTFOLIO_TEST_BUILD__: JSON.stringify(testBuild),
__PORTFOLIO_TEST_TOKEN_DIGEST__: JSON.stringify(testBuild ? testTokenDigest : ''),
},
build: {
sourcemap: false,
},
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
};
});