test: add deterministic resilience matrix

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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