test: add deterministic resilience matrix
This commit is contained in:
@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/browser',
|
testDir: './tests/browser',
|
||||||
|
globalSetup: './tests/helpers/test-build-auth.ts',
|
||||||
workers: 4,
|
workers: 4,
|
||||||
use: {
|
use: {
|
||||||
serviceWorkers: 'block',
|
serviceWorkers: 'block',
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const canonicalSectionIds = [
|
|||||||
];
|
];
|
||||||
const prohibitedSurfaces = [
|
const prohibitedSurfaces = [
|
||||||
/__PORTFOLIO_INPUTS__/iu,
|
/__PORTFOLIO_INPUTS__/iu,
|
||||||
|
/__PORTFOLIO_TEST_HOOKS__/iu,
|
||||||
|
/PORTFOLIO_TEST_TOKEN/iu,
|
||||||
/__PORTFOLIO_(?:TEST|INSTRUMENTATION)__/iu,
|
/__PORTFOLIO_(?:TEST|INSTRUMENTATION)__/iu,
|
||||||
/__portfolio(?:Journey)?Instrumentation/iu,
|
/__portfolio(?:Journey)?Instrumentation/iu,
|
||||||
/(?:evidenceId|sourcePath|sourceFingerprint|approvalOwner|privacyClassification|denylist|originalFilename)/iu,
|
/(?:evidenceId|sourcePath|sourceFingerprint|approvalOwner|privacyClassification|denylist|originalFilename)/iu,
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
if (!graph) return;
|
if (!graph) return;
|
||||||
try {
|
try {
|
||||||
await graph.resume();
|
await graph.resume();
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (firstStart) await fail();
|
if (firstStart || (error instanceof Error && error.name === 'InvalidStateError')) await fail();
|
||||||
else {
|
else {
|
||||||
desired = 'suspended';
|
desired = 'suspended';
|
||||||
resumeIntent = 'user-required';
|
resumeIntent = 'user-required';
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types';
|
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;
|
type AudioContextFactory = () => AudioContext;
|
||||||
|
|
||||||
export function createBrowserSynthEngine(): AudioGraph {
|
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
|
const AudioContextConstructor = window.AudioContext
|
||||||
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
|
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 {
|
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
|
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
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 {
|
export interface AccessibleVideoSource {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -64,6 +68,11 @@ const markUnavailable = (): void => {
|
|||||||
|
|
||||||
const play = async (): Promise<void> => {
|
const play = async (): Promise<void> => {
|
||||||
if (unavailable.value) return;
|
if (unavailable.value) return;
|
||||||
|
if (TEST_BUILD && isTestFault('F8-video-failure')) {
|
||||||
|
recordTestFaultEvent('video-failure');
|
||||||
|
markUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
lazy.requestExplicit();
|
lazy.requestExplicit();
|
||||||
status.value = 'Loading video.';
|
status.value = 'Loading video.';
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const statusText = computed(() => {
|
|||||||
if (props.policy.reason === 'data-saver') return 'Motion: static — data saver is active';
|
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 === 'visitor-pause') return 'Motion: paused';
|
||||||
if (props.policy.reason === 'saved-static') return 'Motion: static';
|
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';
|
if (props.sceneStatus === 'loading' || props.sceneStatus === 'idle') return 'Motion: preparing';
|
||||||
return 'Motion: active';
|
return 'Motion: active';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { computed, onMounted, ref, toRef, watch } from 'vue';
|
|||||||
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
|
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
|
||||||
import type { JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
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<{
|
const props = defineProps<{
|
||||||
journey: JourneySnapshot;
|
journey: JourneySnapshot;
|
||||||
@@ -18,6 +22,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||||
const clientReady = ref(false);
|
const clientReady = ref(false);
|
||||||
|
const posterFailed = ref(TEST_BUILD && isTestFault('F7-poster-failure'));
|
||||||
const journey = toRef(props, 'journey');
|
const journey = toRef(props, 'journey');
|
||||||
const policy = toRef(props, 'policy');
|
const policy = toRef(props, 'policy');
|
||||||
const quality = toRef(props, 'quality');
|
const quality = toRef(props, 'quality');
|
||||||
@@ -35,6 +40,7 @@ const showCanvas = computed(() => (
|
|||||||
|
|
||||||
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
|
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
|
||||||
onMounted(() => { clientReady.value = true; });
|
onMounted(() => { clientReady.value = true; });
|
||||||
|
if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,8 +50,10 @@ onMounted(() => { clientReady.value = true; });
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
:data-scene-state="sceneState"
|
:data-scene-state="sceneState"
|
||||||
:data-scene-generation="sceneGeneration"
|
: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" />
|
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { createAudioController } from '@/audio/create-audio-controller';
|
|||||||
import { createBrowserSynthEngine } from '@/audio/synth-engine';
|
import { createBrowserSynthEngine } from '@/audio/synth-engine';
|
||||||
import type { AudioController, AudioUiState } from '@/audio/types';
|
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 {
|
export interface AudioEngineSources {
|
||||||
intensity: Readonly<Ref<number>>;
|
intensity: Readonly<Ref<number>>;
|
||||||
volume: Readonly<Ref<number>>;
|
volume: Readonly<Ref<number>>;
|
||||||
@@ -57,10 +60,13 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding
|
|||||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
window.addEventListener('pagehide', onPageHide);
|
window.addEventListener('pagehide', onPageHide);
|
||||||
window.addEventListener('pageshow', onPageShow);
|
window.addEventListener('pageshow', onPageShow);
|
||||||
|
const onTestInterruption = (): void => { void controller?.noteInterruption(); };
|
||||||
|
if (TEST_BUILD) window.addEventListener('portfolio-test-audio-interruption', onTestInterruption);
|
||||||
removePageListeners = () => {
|
removePageListeners = () => {
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
window.removeEventListener('pagehide', onPageHide);
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
window.removeEventListener('pageshow', onPageShow);
|
window.removeEventListener('pageshow', onPageShow);
|
||||||
|
if (TEST_BUILD) window.removeEventListener('portfolio-test-audio-interruption', onTestInterruption);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { createSceneController } from '@/scene/create-scene-controller';
|
|||||||
import type { JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||||
import type { SceneController, SceneControllerSnapshot } from '@/scene/types';
|
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';
|
export type SceneHostState = 'idle' | 'loading' | 'running' | 'paused' | 'static' | 'failed' | 'disposed';
|
||||||
|
|
||||||
@@ -72,7 +76,16 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canvas === null) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +105,10 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
error.value = false;
|
error.value = false;
|
||||||
state.value = 'loading';
|
state.value = 'loading';
|
||||||
try {
|
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');
|
const { createProceduralSceneGeneration } = await import('@/scene/world/environment');
|
||||||
if (
|
if (
|
||||||
abort.signal.aborted
|
abort.signal.aborted
|
||||||
@@ -102,12 +119,20 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
|| sources.canvas.value !== canvas
|
|| sources.canvas.value !== canvas
|
||||||
) return;
|
) return;
|
||||||
|
|
||||||
const next = createSceneController({
|
let next!: SceneController;
|
||||||
|
next = createSceneController({
|
||||||
canvas,
|
canvas,
|
||||||
quality: sources.quality.value,
|
quality: sources.quality.value,
|
||||||
build: createProceduralSceneGeneration,
|
build: createProceduralSceneGeneration,
|
||||||
motionAllowed: () => sources.policy.value.allowSceneRequest,
|
motionAllowed: () => sources.policy.value.allowSceneRequest,
|
||||||
onFrameRendered: sources.sampleFrame,
|
onFrameRendered: sources.sampleFrame,
|
||||||
|
onStateChange: (nextState) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (controller !== next) return;
|
||||||
|
error.value = nextState.kind === 'static';
|
||||||
|
publish();
|
||||||
|
});
|
||||||
|
},
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
controller = next;
|
controller = next;
|
||||||
@@ -120,10 +145,20 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
const kind = next.snapshot().state.kind;
|
const kind = next.snapshot().state.kind;
|
||||||
error.value = kind === 'static';
|
error.value = kind === 'static';
|
||||||
publish();
|
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 {
|
} catch {
|
||||||
if (!abort.signal.aborted && currentGeneration === generation) {
|
if (!abort.signal.aborted && currentGeneration === generation) {
|
||||||
error.value = true;
|
error.value = true;
|
||||||
disposeActive('failed');
|
disposeActive(TEST_BUILD && isTestFault('F1-scene-chunk') ? 'static' : 'failed');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (importAbort === abort) importAbort = null;
|
if (importAbort === abort) importAbort = null;
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { createApp } from './app';
|
|||||||
import { validateAndFreezeProductionInputSnapshot } from './content/production-input-snapshot';
|
import { validateAndFreezeProductionInputSnapshot } from './content/production-input-snapshot';
|
||||||
import type { ProductionInputs } from './content/types';
|
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> => {
|
export const readProductionInputsSnapshot = (source: Document): Readonly<ProductionInputs> => {
|
||||||
const snapshot = source.getElementById('portfolio-inputs');
|
const snapshot = source.getElementById('portfolio-inputs');
|
||||||
if (
|
if (
|
||||||
@@ -21,6 +32,8 @@ export const readProductionInputsSnapshot = (source: Document): Readonly<Product
|
|||||||
};
|
};
|
||||||
|
|
||||||
const appRoot = document.getElementById('app');
|
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);
|
createApp(readProductionInputsSnapshot(document)).mount(appRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
|
|
||||||
const transition = (next: SceneLifecycleState): void => {
|
const transition = (next: SceneLifecycleState): void => {
|
||||||
machine.transition(next);
|
machine.transition(next);
|
||||||
|
options.onStateChange?.(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopRaf = (): void => rootResources.cancelAnimationFrame();
|
const stopRaf = (): void => rootResources.cancelAnimationFrame();
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface SceneControllerOptions {
|
|||||||
page?: ScenePageEnvironment;
|
page?: ScenePageEnvironment;
|
||||||
motionAllowed?: () => boolean;
|
motionAllowed?: () => boolean;
|
||||||
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
|
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
|
||||||
|
onStateChange?: ((state: SceneLifecycleState) => unknown) | undefined;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { createSun } from '@/scene/world/sun';
|
|||||||
import { createCar } from '@/scene/world/car';
|
import { createCar } from '@/scene/world/car';
|
||||||
import { createSign } from '@/scene/world/sign';
|
import { createSign } from '@/scene/world/sign';
|
||||||
import { createParticles } from '@/scene/world/particles';
|
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>> = {
|
const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
|
||||||
'deep-night': 0x040713,
|
'deep-night': 0x040713,
|
||||||
@@ -29,6 +33,18 @@ export function createChaseCamera(aspect = 1) {
|
|||||||
export async function createProceduralSceneGeneration(
|
export async function createProceduralSceneGeneration(
|
||||||
context: SceneBuildContext,
|
context: SceneBuildContext,
|
||||||
): Promise<SceneGeneration> {
|
): 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');
|
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||||
const canvas = context.canvas as HTMLCanvasElement;
|
const canvas = context.canvas as HTMLCanvasElement;
|
||||||
if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required');
|
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 driveDistance = 0;
|
||||||
let lastNow: number | null = null;
|
let lastNow: number | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let renderedFrames = 0;
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||||
@@ -128,6 +145,14 @@ export async function createProceduralSceneGeneration(
|
|||||||
return {
|
return {
|
||||||
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
||||||
if (disposed) return;
|
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();
|
resize();
|
||||||
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||||
lastNow = now;
|
lastNow = now;
|
||||||
@@ -152,6 +177,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
} else {
|
} else {
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
|
renderedFrames += 1;
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|||||||
126
src/testing/fault-hooks.ts
Normal file
126
src/testing/fault-hooks.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
|
declare const __PORTFOLIO_TEST_TOKEN_DIGEST__: string;
|
||||||
|
|
||||||
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||||
|
const TEST_TOKEN_DIGEST = typeof __PORTFOLIO_TEST_TOKEN_DIGEST__ === 'undefined'
|
||||||
|
? ''
|
||||||
|
: __PORTFOLIO_TEST_TOKEN_DIGEST__;
|
||||||
|
|
||||||
|
export const faultIds = [
|
||||||
|
'F1-scene-chunk',
|
||||||
|
'F1-shader',
|
||||||
|
'F1-car',
|
||||||
|
'F1-texture',
|
||||||
|
'F2-first-frame-timeout',
|
||||||
|
'F3-post-first-frame-exception',
|
||||||
|
'F4-context-loss-restore',
|
||||||
|
'F5-hidden-context-loss',
|
||||||
|
'F6-repeated-context-loss',
|
||||||
|
'F7-poster-failure',
|
||||||
|
'F8-video-failure',
|
||||||
|
'F9-hydration-failure',
|
||||||
|
'F10-audio-start-rejection',
|
||||||
|
'F11-audio-resume-rejection',
|
||||||
|
'F12-audio-graph-invalid',
|
||||||
|
'F13-audio-suspend-rejection',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PortfolioFaultId = typeof faultIds[number];
|
||||||
|
export type PortfolioFaultAction = 'context-loss' | 'context-restore' | 'audio-interruption';
|
||||||
|
|
||||||
|
interface TestHookSnapshot {
|
||||||
|
readonly fault: PortfolioFaultId;
|
||||||
|
readonly events: readonly string[];
|
||||||
|
readonly sceneState: string | null;
|
||||||
|
readonly canvasCount: number;
|
||||||
|
readonly posterCount: number;
|
||||||
|
readonly staticFallbackCount: number;
|
||||||
|
readonly audioStatus: string | null;
|
||||||
|
readonly videoStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortfolioTestHooks {
|
||||||
|
readonly version: 'portfolio-fault-hooks/v1';
|
||||||
|
readonly fault: PortfolioFaultId;
|
||||||
|
trigger(action: PortfolioFaultAction): void;
|
||||||
|
snapshot(): TestHookSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__PORTFOLIO_TEST_HOOKS__?: PortfolioTestHooks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const faultSet = new Set<string>(faultIds);
|
||||||
|
const allowedActions: Readonly<Partial<Record<PortfolioFaultId, readonly PortfolioFaultAction[]>>> = {
|
||||||
|
'F4-context-loss-restore': ['context-loss', 'context-restore'],
|
||||||
|
'F5-hidden-context-loss': ['context-loss', 'context-restore'],
|
||||||
|
'F6-repeated-context-loss': ['context-loss', 'context-restore'],
|
||||||
|
'F11-audio-resume-rejection': ['audio-interruption'],
|
||||||
|
'F12-audio-graph-invalid': ['audio-interruption'],
|
||||||
|
};
|
||||||
|
let activeFault: PortfolioFaultId | null = null;
|
||||||
|
const events: string[] = [];
|
||||||
|
|
||||||
|
const digest = async (value: string): Promise<string> => {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
const output = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return [...new Uint8Array(output)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const configuredFault = (): PortfolioFaultId | null => {
|
||||||
|
const value = new URL(location.href).searchParams.get('__portfolio_fault');
|
||||||
|
return value !== null && faultSet.has(value) ? value as PortfolioFaultId : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchCanvasEvent = (type: 'webglcontextlost' | 'webglcontextrestored'): void => {
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>('.journey-canvas');
|
||||||
|
if (canvas === null) return;
|
||||||
|
canvas.dispatchEvent(new Event(type, { cancelable: type === 'webglcontextlost' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (action: PortfolioFaultAction): void => {
|
||||||
|
if (activeFault === null || !(allowedActions[activeFault]?.includes(action) ?? false)) {
|
||||||
|
throw new TypeError('The requested action is not valid for the configured fault.');
|
||||||
|
}
|
||||||
|
if (action === 'context-loss') dispatchCanvasEvent('webglcontextlost');
|
||||||
|
else if (action === 'context-restore') dispatchCanvasEvent('webglcontextrestored');
|
||||||
|
else window.dispatchEvent(new Event('portfolio-test-audio-interruption'));
|
||||||
|
events.push(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = (): TestHookSnapshot => Object.freeze({
|
||||||
|
fault: activeFault!,
|
||||||
|
events: Object.freeze([...events]),
|
||||||
|
sceneState: document.querySelector('.journey-world')?.getAttribute('data-scene-state') ?? null,
|
||||||
|
canvasCount: document.querySelectorAll('.journey-canvas').length,
|
||||||
|
posterCount: document.querySelectorAll('.journey-poster').length,
|
||||||
|
staticFallbackCount: document.querySelectorAll('.journey-world[data-static-fallback="true"]').length,
|
||||||
|
audioStatus: document.querySelector('.drive-controls__audio-status')?.textContent?.trim() ?? null,
|
||||||
|
videoStatus: document.querySelector('[data-testid="video-status"]')?.textContent?.trim() ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installAuthorizedTestHooks = async (): Promise<void> => {
|
||||||
|
if (!TEST_BUILD) return;
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const token = url.searchParams.get('__portfolio_test_token');
|
||||||
|
const fault = configuredFault();
|
||||||
|
if (token === null || fault === null || await digest(token) !== TEST_TOKEN_DIGEST) return;
|
||||||
|
activeFault = fault;
|
||||||
|
events.length = 0;
|
||||||
|
Object.defineProperty(window, '__PORTFOLIO_TEST_HOOKS__', {
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
writable: false,
|
||||||
|
value: Object.freeze({ version: 'portfolio-fault-hooks/v1', fault, trigger, snapshot }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTestFault = (fault: PortfolioFaultId): boolean => (
|
||||||
|
TEST_BUILD && activeFault === fault
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recordTestFaultEvent = (event: string): void => {
|
||||||
|
if (TEST_BUILD && activeFault !== null) events.push(event);
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
# Browser characterization tests
|
# Browser characterization tests
|
||||||
|
|
||||||
These Playwright tests lock the observable portfolio behavior at the browser
|
These Playwright tests lock the observable portfolio behavior at the browser
|
||||||
boundary. They exercise the built `dist/` output through the local preview
|
boundary. Ordinary suites exercise the built `dist/` output through the local
|
||||||
server rather than replacing Vue, Three.js, WebGL, or Web Audio with production
|
preview server. The resilience suite additionally creates an authenticated,
|
||||||
test hooks.
|
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
|
## 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.
|
2. Use literal inputs and expected browser-visible results.
|
||||||
3. Install `scene-instrumentation.ts` before navigation when resource counts or
|
3. Install `scene-instrumentation.ts` before navigation when resource counts or
|
||||||
platform calls are the observable boundary.
|
platform calls are the observable boundary.
|
||||||
4. Keep failure injection in `page.addInitScript()` or Playwright routing; do
|
4. Prefer browser-owned `page.addInitScript()` or routing. A lifecycle fault
|
||||||
not add a test API to the application bundle.
|
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
|
5. Run the new case against both configured Chromium profiles, then run the
|
||||||
full suite.
|
full suite.
|
||||||
|
|
||||||
Never copy credentials, private project identifiers, or private URLs into a
|
Never copy credentials, private project identifiers, or private URLs into a
|
||||||
fixture. Use clearly fake values when a credential-shaped input is required.
|
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.
|
||||||
|
|||||||
89
tests/browser/audio-interruption.spec.ts
Normal file
89
tests/browser/audio-interruption.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
76
tests/browser/context-loss.spec.ts
Normal file
76
tests/browser/context-loss.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
112
tests/browser/fault-matrix.spec.ts
Normal file
112
tests/browser/fault-matrix.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
tests/browser/media-failure.spec.ts
Normal file
50
tests/browser/media-failure.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
167
tests/helpers/test-build-auth.ts
Normal file
167
tests/helpers/test-build-auth.ts
Normal 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)
|
||||||
|
);
|
||||||
@@ -2,12 +2,27 @@ import { fileURLToPath, URL } from 'node:url';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
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()],
|
plugins: [vue()],
|
||||||
|
define: {
|
||||||
|
__PORTFOLIO_TEST_BUILD__: JSON.stringify(testBuild),
|
||||||
|
__PORTFOLIO_TEST_TOKEN_DIGEST__: JSON.stringify(testBuild ? testTokenDigest : ''),
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user