test: lock journey lifecycle and static fallback

This commit is contained in:
2026-09-02 05:36:59 +03:00
parent 130ff2d8bb
commit a86767cdc6
11 changed files with 478 additions and 5 deletions

35
tests/browser/README.md Normal file
View File

@@ -0,0 +1,35 @@
# 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.
## Run
Build the preview artifacts, then run the whole browser suite:
```bash
npm run build:preview
npm run test:browser
```
Run only the Phase 2 journey gate:
```bash
npm run test:browser -- tests/browser/journey-integration.spec.ts tests/browser/reduced-motion.spec.ts tests/browser/static-fallback.spec.ts tests/browser/bfcache-lifecycle.spec.ts
```
## Add a case
1. Name the concrete regression the case catches.
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.
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.

View File

@@ -0,0 +1,57 @@
import { expect, test } from '../helpers/serve-dist';
import {
dispatchPersistedPageTransition,
installSceneInstrumentation,
readSceneInstrumentation,
} from '../helpers/scene-instrumentation';
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
test('synthetic persisted freeze and restore keeps at most one canvas, RAF loop, and audio context', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
for (let cycle = 0; cycle < 3; cycle += 1) {
await dispatchPersistedPageTransition(page, 'pagehide');
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
await dispatchPersistedPageTransition(page, 'pageshow');
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBeLessThanOrEqual(1);
}
const state = await readSceneInstrumentation(page);
expect(state.maximumCanvasCount).toBe(1);
expect(await page.locator('.journey-canvas').count()).toBe(1);
expect(state.audioContextsConstructed).toBe(1);
expect(state.liveAudioContexts).toBeLessThanOrEqual(1);
});
test('persisted lifecycle events preserve paused and Static states without duplicate resources', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await page.getByRole('button', { name: 'Pause motion' }).click();
await dispatchPersistedPageTransition(page, 'pagehide');
await dispatchPersistedPageTransition(page, 'pageshow');
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
await page.getByLabel('Quality control').selectOption('static');
await dispatchPersistedPageTransition(page, 'pagehide');
await dispatchPersistedPageTransition(page, 'pageshow');
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
const state = await readSceneInstrumentation(page);
expect(state.maximumCanvasCount).toBeLessThanOrEqual(1);
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
expect(state.liveAudioContexts).toBe(0);
});

View File

@@ -0,0 +1,101 @@
import { expect, test } from '../helpers/serve-dist';
import {
installSceneInstrumentation,
readSceneInstrumentation,
waitForOneAnimationFrame,
} from '../helpers/scene-instrumentation';
const sections = [
{ id: 'start', label: 'Start' },
{ id: 'profile', label: 'Profile' },
{ id: 'experience', label: 'Experience' },
{ id: 'infrastructure', label: 'Infrastructure' },
{ id: 'selected-builds', label: 'Selected builds' },
{ id: 'additional-work', label: 'Additional work' },
{ id: 'toolkit', label: 'Toolkit' },
{ id: 'contact', label: 'Contact' },
] as const;
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
test('keeps navigation, HUD, progress, chapter, and arrival on the same next-frame section snapshot', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', /running|failed|static/u);
const chapterColors: string[] = [];
for (const section of sections) {
await page.evaluate((id) => {
const heading = document.getElementById(`${id}-heading`);
if (!(heading instanceof HTMLElement)) throw new Error(`Missing heading: ${id}`);
const top = heading.getBoundingClientRect().top + window.scrollY;
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const destination = id === 'contact'
? document.documentElement.scrollHeight
: Math.max(0, top - viewportHeight * 0.35 + 1);
window.scrollTo({ top: destination, behavior: 'instant' });
}, section.id);
await waitForOneAnimationFrame(page);
await expect(page.locator(`nav a[href="#${section.id}"]`)).toHaveAttribute('aria-current', 'page');
await expect(page.locator('.journey-hud__section')).toHaveText(section.label);
const routeText = await page.locator('.journey-hud__route').textContent();
expect(routeText).toMatch(/^ROUTE (?:100|[0-9]{1,2})%$/u);
if (section.id === 'contact') {
await expect(page.locator('.journey-hud__arrival')).toHaveText('ARRIVED');
await expect(page.locator('.journey-hud__route')).toHaveText('ROUTE 100%');
} else {
await expect(page.locator('.journey-hud__arrival')).toHaveCount(0);
}
// The section snapshot is emitted in the scroll RAF above; the scene's
// already-running loop must consume it no later than its next RAF.
await waitForOneAnimationFrame(page);
const clearColors = (await readSceneInstrumentation(page)).clearColors.filter((color) => color !== '#000000');
const latestChapterColor = clearColors.at(-1);
if (latestChapterColor === undefined) throw new Error(`Missing rendered chapter color for ${section.id}`);
chapterColors.push(latestChapterColor);
}
const instrumentation = await readSceneInstrumentation(page);
expect(instrumentation.maximumCanvasCount).toBeLessThanOrEqual(1);
const expectedChapterColors = (page.viewportSize()?.width ?? 0) < 768
? [
'#040713', '#040713', '#060918', '#080b1d',
'#0d1025', '#0d1025', '#18152c', '#2b2035',
]
: [
'#000102', '#000102', '#000102', '#010103',
'#010105', '#010105', '#020206', '#060409',
];
expect(chapterColors).toEqual(expectedChapterColors);
});
test('draws only the independently eligible next roadside sign during passive forward travel', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const oracle = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8));
return { destination, viewportHeight, expectedLabel: '01 / PROFILE' };
});
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
await waitForOneAnimationFrame(page);
}
await page.evaluate((destination) => window.scrollTo(0, destination), oracle.destination);
await waitForOneAnimationFrame(page);
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn).toContain(oracle.expectedLabel);
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
expect([...new Set(labels)]).toEqual([oracle.expectedLabel]);
});

View File

@@ -0,0 +1,40 @@
import { expect, test } from '../helpers/serve-dist';
import {
environmentChunkPattern,
installSceneInstrumentation,
readSceneInstrumentation,
} from '../helpers/scene-instrumentation';
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
test('boots reduced motion without requesting the live scene or automatic media', async ({ page, distUrl }) => {
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-poster')).toHaveCount(1);
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('.journey-hud')).toBeVisible();
await expect(page.locator('nav[aria-label="Portfolio sections"]')).toBeVisible();
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
await expect(page.getByRole('status')).toContainText('reduced motion requested');
expect(requests.some((url) => environmentChunkPattern.test(url))).toBe(false);
await expect(page.locator('video[src], video source[src]')).toHaveCount(0);
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
});
test('requires explicit Enable motion after the system stops requesting reduction', async ({ page, distUrl }) => {
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await page.emulateMedia({ reducedMotion: 'no-preference' });
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
expect(requests.some((url) => environmentChunkPattern.test(url))).toBe(false);
await page.getByRole('button', { name: 'Enable motion' }).click();
await expect.poll(() => requests.some((url) => environmentChunkPattern.test(url))).toBe(true);
});

View File

@@ -0,0 +1,68 @@
import { expect, test } from '../helpers/serve-dist';
import {
environmentChunkPattern,
installSceneInstrumentation,
readSceneInstrumentation,
} from '../helpers/scene-instrumentation';
test.beforeEach(async ({ page }) => installSceneInstrumentation(page));
test('falls back to one poster and no scene loop when the deferred scene chunk is unavailable', async ({
page,
distUrl,
}) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.route(environmentChunkPattern, (route) => route.abort('failed'));
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'failed');
await expect(page.locator('.journey-poster')).toHaveCount(1);
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('main > section')).toHaveCount(8);
await expect(page.locator('nav a[href="#contact"]')).toBeVisible();
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual([]);
expect(pageErrors).toEqual([]);
});
test('contains a WebGL initialization failure without hiding the portfolio', async ({ page, distUrl }) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.addInitScript(() => {
const nativeGetContext = HTMLCanvasElement.prototype.getContext;
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value(this: HTMLCanvasElement, contextId: string, options?: unknown): RenderingContext | null {
if (contextId === 'webgl' || contextId === 'webgl2' || contextId === 'experimental-webgl') return null;
return Reflect.apply(nativeGetContext, this, [contextId, options]) as RenderingContext | null;
},
});
});
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'failed');
await expect(page.locator('.journey-poster')).toHaveCount(1);
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.locator('#portfolio-content')).toBeVisible();
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
await expect.poll(async () => (await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
expect((await readSceneInstrumentation(page)).unhandledRejections).toEqual([]);
expect(pageErrors).toEqual([]);
});
test('allows an explicit Static visitor to re-enable motion when the system permits it', async ({ page, distUrl }) => {
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Quality control').selectOption('static');
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await page.getByRole('button', { name: 'Enable motion' }).click();
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await expect(page.locator('.journey-canvas')).toHaveCount(1);
expect(requests.some((url) => environmentChunkPattern.test(url))).toBe(true);
});