diff --git a/playwright.config.ts b/playwright.config.ts
index ff133fc..3876f12 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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',
diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs
index 5c84a21..92a22ec 100644
--- a/scripts/verify-artifacts.mjs
+++ b/scripts/verify-artifacts.mjs
@@ -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,
diff --git a/src/audio/create-audio-controller.ts b/src/audio/create-audio-controller.ts
index 98d8339..71ed933 100644
--- a/src/audio/create-audio-controller.ts
+++ b/src/audio/create-audio-controller.ts
@@ -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';
diff --git a/src/audio/synth-engine.ts b/src/audio/synth-engine.ts
index ccbaa20..72df00d 100644
--- a/src/audio/synth-engine.ts
+++ b/src/audio/synth-engine.ts
@@ -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 {
diff --git a/src/components/AccessibleVideo.vue b/src/components/AccessibleVideo.vue
index 0806560..53258f3 100644
--- a/src/components/AccessibleVideo.vue
+++ b/src/components/AccessibleVideo.vue
@@ -1,6 +1,10 @@
@@ -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"
>
-
+
diff --git a/src/composables/useAudioEngine.ts b/src/composables/useAudioEngine.ts
index 77467a5..37f3e5d 100644
--- a/src/composables/useAudioEngine.ts
+++ b/src/composables/useAudioEngine.ts
@@ -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[>;
volume: Readonly][>;
@@ -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);
};
});
diff --git a/src/composables/useSceneController.ts b/src/composables/useSceneController.ts
index 51a6abc..4f6c31c 100644
--- a/src/composables/useSceneController.ts
+++ b/src/composables/useSceneController.ts
@@ -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;
diff --git a/src/entry-client.ts b/src/entry-client.ts
index 8f16a2e..71ee443 100644
--- a/src/entry-client.ts
+++ b/src/entry-client.ts
@@ -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 => {
const snapshot = source.getElementById('portfolio-inputs');
if (
@@ -21,6 +32,8 @@ export const readProductionInputsSnapshot = (source: Document): Readonly {
machine.transition(next);
+ options.onStateChange?.(next);
};
const stopRaf = (): void => rootResources.cancelAnimationFrame();
diff --git a/src/scene/types.ts b/src/scene/types.ts
index 6c6d66e..c26acf5 100644
--- a/src/scene/types.ts
+++ b/src/scene/types.ts
@@ -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;
}
diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts
index e7a6712..0dea51f 100644
--- a/src/scene/world/environment.ts
+++ b/src/scene/world/environment.ts
@@ -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> = {
'deep-night': 0x040713,
@@ -29,6 +33,18 @@ export function createChaseCamera(aspect = 1) {
export async function createProceduralSceneGeneration(
context: SceneBuildContext,
): Promise {
+ 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, 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;
diff --git a/src/testing/fault-hooks.ts b/src/testing/fault-hooks.ts
new file mode 100644
index 0000000..48d6458
--- /dev/null
+++ b/src/testing/fault-hooks.ts
@@ -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(faultIds);
+const allowedActions: Readonly>> = {
+ '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 => {
+ 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('.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 => {
+ 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);
+};
diff --git a/tests/browser/README.md b/tests/browser/README.md
index 33824c2..ae6b883 100644
--- a/tests/browser/README.md
+++ b/tests/browser/README.md
@@ -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.
diff --git a/tests/browser/audio-interruption.spec.ts b/tests/browser/audio-interruption.spec.ts
new file mode 100644
index 0000000..b92bf75
--- /dev/null
+++ b/tests/browser/audio-interruption.spec.ts
@@ -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 => {
+ await page.getByRole('button', { name: 'START THE DRIVE' }).click();
+};
+
+const assertAudioContained = async (
+ page: import('@playwright/test').Page,
+ pageErrors: readonly string[],
+ expectedLiveContexts: number,
+): Promise => {
+ 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);
+});
diff --git a/tests/browser/context-loss.spec.ts b/tests/browser/context-loss.spec.ts
new file mode 100644
index 0000000..a87fa5e
--- /dev/null
+++ b/tests/browser/context-loss.spec.ts
@@ -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 => {
+ 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);
+});
diff --git a/tests/browser/fault-matrix.spec.ts b/tests/browser/fault-matrix.spec.ts
new file mode 100644
index 0000000..090dbef
--- /dev/null
+++ b/tests/browser/fault-matrix.spec.ts
@@ -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[0],
+ pageErrors: readonly string[],
+): Promise => {
+ 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);
+ });
+});
diff --git a/tests/browser/media-failure.spec.ts b/tests/browser/media-failure.spec.ts
new file mode 100644
index 0000000..9cde82f
--- /dev/null
+++ b/tests/browser/media-failure.spec.ts
@@ -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([]);
+});
diff --git a/tests/helpers/test-build-auth.ts b/tests/helpers/test-build-auth.ts
new file mode 100644
index 0000000..d274a22
--- /dev/null
+++ b/tests/helpers/test-build-auth.ts
@@ -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 => {
+ 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 => {
+ 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, /]