test: add deterministic resilience matrix
This commit is contained in:
@@ -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.
|
||||
|
||||
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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user