perf: enforce journey budgets and reference traces
This commit is contained in:
@@ -97,6 +97,16 @@ export const wrongTokenFaultUrl = (origin: string, fault: PortfolioFaultId): str
|
||||
fault,
|
||||
);
|
||||
|
||||
export const authorizedPerformanceUrl = (origin: string, tier: 'high' | 'low'): string => {
|
||||
const token = process.env.PORTFOLIO_TEST_TOKEN;
|
||||
if (token === undefined) throw new Error('The per-run portfolio test token is unavailable.');
|
||||
const url = new URL(origin);
|
||||
url.searchParams.set('__portfolio_performance', 'drive-trace.v1');
|
||||
url.searchParams.set('__portfolio_test_token', token);
|
||||
url.searchParams.set('__portfolio_tier', tier);
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export interface BrowserErrorCollector {
|
||||
readonly pageErrors: string[];
|
||||
}
|
||||
|
||||
30
tests/performance/aggregation.test.ts
Normal file
30
tests/performance/aggregation.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregateFiveDriveTraces } from '../../scripts/lib/performance-aggregation.mjs';
|
||||
|
||||
const run = (lcp: number, tbt: number, gatingReferenceResult = true) => ({
|
||||
schema: 'drive-trace.v1',
|
||||
profile: 'desktop-high',
|
||||
gatingReferenceResult,
|
||||
validReferenceCadence: true,
|
||||
metrics: { lcp, tbt },
|
||||
budgetResults: { cls: true, scene: true },
|
||||
});
|
||||
|
||||
describe('five-run drive trace aggregation', () => {
|
||||
it('uses worst and median gates across exactly five reference runs', () => {
|
||||
expect(aggregateFiveDriveTraces([
|
||||
run(2_000, 100), run(2_100, 110), run(2_500, 200), run(2_900, 250), run(3_000, 300),
|
||||
])).toMatchObject({
|
||||
runCount: 5,
|
||||
passes: true,
|
||||
summary: { lcpWorst: 3_000, lcpMedian: 2_500, tbtWorst: 300, tbtMedian: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-reference developer evidence and wrong run counts', () => {
|
||||
expect(aggregateFiveDriveTraces([
|
||||
run(2_000, 100, false), run(2_000, 100), run(2_000, 100), run(2_000, 100), run(2_000, 100),
|
||||
]).passes).toBe(false);
|
||||
expect(() => aggregateFiveDriveTraces([run(2_000, 100)])).toThrow(/five/u);
|
||||
});
|
||||
});
|
||||
41
tests/performance/budgets.test.ts
Normal file
41
tests/performance/budgets.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from '../../scripts/lib/bundle-budgets.mjs';
|
||||
|
||||
describe('release budgets', () => {
|
||||
it('pins every exact section 14 threshold', () => {
|
||||
expect(PERFORMANCE_BUDGETS).toMatchObject({
|
||||
initialHtmlCriticalCss: 100 * 1024,
|
||||
initialApplicationJs: 120 * 1024,
|
||||
heroPoster: 220 * 1024,
|
||||
initialFonts: 120 * 1024,
|
||||
firstLiveScene: 650 * 1024,
|
||||
projectVideo: 2.5 * 1024 * 1024,
|
||||
projectVideoDurationSeconds: 10,
|
||||
projectVideoWidth: 1280,
|
||||
projectVideoHeight: 720,
|
||||
projectStill: 400 * 1024,
|
||||
chart: 100 * 1024,
|
||||
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
||||
highTierExtras: 750 * 1024,
|
||||
deferredNonVideo: 3.5 * 1024 * 1024,
|
||||
imageSceneConcurrency: 2,
|
||||
videoConcurrency: 1,
|
||||
mobileTextureDimension: 2048,
|
||||
lowTierGpuEstimate: 96 * 1024 * 1024,
|
||||
highTierGpuEstimate: 192 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies every artifact once and keeps the scene chunk deferred', () => {
|
||||
expect(classifyArtifact('index.html', '<script src="/assets/index-a.js"></script>')).toBe('initial-html');
|
||||
expect(classifyArtifact('assets/index-a.css', '')).toBe('critical-css');
|
||||
expect(classifyArtifact('assets/index-a.js', 'import("./environment-b.js")')).toBe('initial-application-js');
|
||||
expect(classifyArtifact('assets/environment-b.js', 'three scene')).toBe('first-live-scene');
|
||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||
});
|
||||
|
||||
it('fails exact byte boundaries and accepts equality', () => {
|
||||
expect(evaluateBudgetTotals({ initialHtmlCriticalCss: 100 * 1024 }).violations).toEqual([]);
|
||||
expect(evaluateBudgetTotals({ initialHtmlCriticalCss: 100 * 1024 + 1 }).violations).toContainEqual(expect.objectContaining({ budget: 'initialHtmlCriticalCss' }));
|
||||
});
|
||||
});
|
||||
209
tests/performance/drive-trace.spec.ts
Normal file
209
tests/performance/drive-trace.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { performance as nodePerformance } from 'node:perf_hooks';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
import { authorizedPerformanceUrl } from '../helpers/test-build-auth';
|
||||
import {
|
||||
calculateCls,
|
||||
calculateLabTbt,
|
||||
calculateLostPresentationShare,
|
||||
evaluateEventToNextPaint,
|
||||
evaluateSceneContinuity,
|
||||
latestLcpBeforeCutoff,
|
||||
nearestRankP95,
|
||||
postRunningLongTasks,
|
||||
unexpectedLateShifts,
|
||||
type ScriptedActionSample,
|
||||
} from '../../src/testing/performance-instrumentation';
|
||||
|
||||
type NamedProfile = 'desktop-high' | 'mobile-low';
|
||||
|
||||
const profileForProject = (name: string): {
|
||||
name: NamedProfile;
|
||||
tier: 'high' | 'low';
|
||||
viewport: { width: number; height: number };
|
||||
latency: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
sceneCpuP95: number;
|
||||
lostShare: number;
|
||||
} => name === 'performance-mobile'
|
||||
? { name: 'mobile-low', tier: 'low', viewport: { width: 390, height: 844 }, latency: 150, download: 200_000, upload: 93_750, sceneCpuP95: 24, lostShare: 0.1 }
|
||||
: { name: 'desktop-high', tier: 'high', viewport: { width: 1_440, height: 900 }, latency: 40, download: 1_250_000, upload: 1_250_000, sceneCpuP95: 12, lostShare: 0.05 };
|
||||
|
||||
const sleep = (duration: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, duration));
|
||||
|
||||
const stableTargetCenter = async (page: Page, selector: string) => {
|
||||
const locator = page.locator(selector).filter({ visible: true });
|
||||
await expect(locator).toHaveCount(1);
|
||||
await expect(locator).toBeEnabled();
|
||||
const box = await locator.boundingBox();
|
||||
if (box === null) throw new Error(`Trace target is not visible: ${selector}`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
};
|
||||
|
||||
const dispatchPointerAction = async (
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
at: (offset: number) => Promise<void>,
|
||||
offset: number,
|
||||
id: string,
|
||||
selector: string,
|
||||
) => {
|
||||
await at(offset - 50);
|
||||
const center = await stableTargetCenter(page, selector);
|
||||
const markerSelector = `[data-drive-target="${id}"]`;
|
||||
await page.locator(selector).filter({ visible: true }).evaluate((element, actionId) => {
|
||||
element.setAttribute('data-drive-target', actionId);
|
||||
}, id);
|
||||
await at(offset - 20);
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...center });
|
||||
await at(offset);
|
||||
await page.evaluate(({ actionId, target }) => window.__PORTFOLIO_PERFORMANCE__?.beginAction(actionId, target), { actionId: id, target: markerSelector });
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...center });
|
||||
await at(offset + 40);
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...center });
|
||||
};
|
||||
|
||||
const dispatchWheelBurst = async (page: Page, cdp: CDPSession, at: (offset: number) => Promise<void>, offset: number, deltaY: number) => {
|
||||
const center = await page.evaluate(() => {
|
||||
const viewport = window.visualViewport;
|
||||
return {
|
||||
x: (viewport?.offsetLeft ?? 0) + (viewport?.width ?? innerWidth) / 2,
|
||||
y: (viewport?.offsetTop ?? 0) + (viewport?.height ?? innerHeight) / 2,
|
||||
};
|
||||
});
|
||||
for (const deltaOffset of [0, 40, 80, 120, 160, 200]) {
|
||||
await at(offset + deltaOffset);
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', deltaX: 0, deltaY, ...center });
|
||||
}
|
||||
};
|
||||
|
||||
test('drive-trace.v1 runs on current Chromium as explicitly non-reference evidence', async ({ page, distUrl }, testInfo) => {
|
||||
test.setTimeout(75_000);
|
||||
const profile = profileForProject(testInfo.project.name);
|
||||
await page.setViewportSize(profile.viewport);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Network.enable');
|
||||
await cdp.send('Network.clearBrowserCache');
|
||||
await cdp.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: profile.latency,
|
||||
downloadThroughput: profile.download,
|
||||
uploadThroughput: profile.upload,
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.goto('about:blank');
|
||||
const loadStarted = nodePerformance.now();
|
||||
await page.goto(authorizedPerformanceUrl(distUrl, profile.tier), { waitUntil: 'load', timeout: 10_000 });
|
||||
const loadDuration = nodePerformance.now() - loadStarted;
|
||||
expect(loadDuration).toBeLessThanOrEqual(10_000);
|
||||
await expect.poll(() => page.evaluate(() => window.__PORTFOLIO_PERFORMANCE__?.snapshot().lcp.length ?? 0), { timeout: 10_000 }).toBeGreaterThan(0);
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running', { timeout: 5_000 });
|
||||
await expect(page.locator('.journey-hud__quality')).toHaveText(profile.tier === 'high' ? 'High' : 'Low');
|
||||
|
||||
const warmupRafs = await page.evaluate(() => new Promise<number[]>((resolve) => {
|
||||
const samples: number[] = [];
|
||||
const frame = (now: number): void => {
|
||||
samples.push(now);
|
||||
if (samples.length === 121) resolve(samples);
|
||||
else requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
}));
|
||||
const warmupIntervals = warmupRafs.slice(1).map((stamp, index) => stamp - warmupRafs[index]!);
|
||||
const warmupMedian = [...warmupIntervals].sort((a, b) => a - b)[Math.floor(warmupIntervals.length / 2)]!;
|
||||
const validReferenceCadence = Math.abs(warmupMedian - 16.667) <= 1;
|
||||
if (process.env.PORTFOLIO_REFERENCE_LAB === '1') expect(validReferenceCadence).toBe(true);
|
||||
|
||||
await page.evaluate(() => window.__PORTFOLIO_PERFORMANCE__?.startTrace());
|
||||
const nodeTraceStart = nodePerformance.now();
|
||||
const at = async (offset: number): Promise<void> => {
|
||||
const remaining = nodeTraceStart + offset - nodePerformance.now();
|
||||
if (remaining > 0) await sleep(remaining);
|
||||
};
|
||||
|
||||
await dispatchPointerAction(page, cdp, at, 1_000, 'start', 'button:has-text("START THE DRIVE")');
|
||||
await dispatchWheelBurst(page, cdp, at, 1_500, 100);
|
||||
await at(2_350);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
||||
await at(2_370);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
||||
await expect(page.getByRole('slider', { name: 'Sound volume' })).toBeFocused();
|
||||
await at(2_400);
|
||||
await page.evaluate(() => window.__PORTFOLIO_PERFORMANCE__?.beginAction('volume', 'input[aria-label="Sound volume"]'));
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await at(2_440);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await expect(page.getByRole('slider', { name: 'Sound volume' })).toHaveValue('0.35');
|
||||
await dispatchWheelBurst(page, cdp, at, 2_800, 100);
|
||||
await dispatchPointerAction(page, cdp, at, 3_700, 'mute', 'button:has-text("Mute sound")');
|
||||
await expect(page.getByRole('button', { name: 'Unmute sound' })).toBeVisible();
|
||||
await dispatchWheelBurst(page, cdp, at, 4_100, 100);
|
||||
await dispatchPointerAction(page, cdp, at, 5_000, 'unmute', 'button:has-text("Unmute sound")');
|
||||
await dispatchWheelBurst(page, cdp, at, 5_400, 100);
|
||||
await dispatchPointerAction(page, cdp, at, 6_300, 'pause', 'button:has-text("Pause motion")');
|
||||
await dispatchWheelBurst(page, cdp, at, 6_700, 100);
|
||||
await dispatchPointerAction(page, cdp, at, 7_600, 'resume', 'button:has-text("Resume motion")');
|
||||
await dispatchWheelBurst(page, cdp, at, 8_000, 100);
|
||||
await at(8_800);
|
||||
await page.evaluate(() => window.__PORTFOLIO_PERFORMANCE__?.armNavigationSettlementBarrier());
|
||||
await dispatchPointerAction(page, cdp, at, 8_900, 'toolkit', 'nav a[href="#toolkit"]');
|
||||
await dispatchWheelBurst(page, cdp, at, 9_300, -100);
|
||||
await dispatchWheelBurst(page, cdp, at, 10_600, -100);
|
||||
await dispatchWheelBurst(page, cdp, at, 11_900, 100);
|
||||
await dispatchWheelBurst(page, cdp, at, 13_200, 100);
|
||||
await dispatchPointerAction(page, cdp, at, 14_500, 'contact', 'nav a[href="#contact"]');
|
||||
await at(20_000);
|
||||
await page.evaluate(() => window.__PORTFOLIO_PERFORMANCE__?.endTrace());
|
||||
|
||||
await expect(page).toHaveURL(/#contact$/u);
|
||||
await expect(page.locator('#contact > h2')).toBeFocused();
|
||||
await expect(page.locator('.journey-hud__quality')).toHaveText(profile.tier === 'high' ? 'High' : 'Low');
|
||||
const snapshot = await page.evaluate(() => window.__PORTFOLIO_PERFORMANCE__?.snapshot());
|
||||
if (snapshot === undefined || snapshot.traceStart === null || snapshot.traceEnd === null || snapshot.firstSceneFrame === null) {
|
||||
throw new Error('The authenticated performance snapshot is incomplete.');
|
||||
}
|
||||
const expectedPeriod = warmupMedian;
|
||||
const fcp = await page.evaluate(() => performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? null);
|
||||
const lcp = latestLcpBeforeCutoff(snapshot.lcp, snapshot.cutoff);
|
||||
const cls = calculateCls(snapshot.layoutShifts);
|
||||
const tbt = calculateLabTbt(snapshot.longTasks, fcp, snapshot.traceStart);
|
||||
const postRunning = postRunningLongTasks(snapshot.longTasks, snapshot.traceStart, snapshot.traceEnd);
|
||||
const eventToPaint = evaluateEventToNextPaint(snapshot.actions as ScriptedActionSample[]);
|
||||
const continuity = evaluateSceneContinuity(snapshot.sceneIntervals, snapshot.sceneFrames, expectedPeriod);
|
||||
const lost = calculateLostPresentationShare(snapshot.pageRafs, expectedPeriod, Math.floor(20_000 / expectedPeriod) - 3);
|
||||
const lateShifts = unexpectedLateShifts(snapshot.layoutShifts, snapshot.firstSceneFrame, snapshot.traceEnd);
|
||||
const sceneCpuP95 = nearestRankP95(snapshot.sceneFrames.map((frame) => frame.end - frame.start));
|
||||
|
||||
const budgetResults = {
|
||||
lcp: lcp <= 3_000,
|
||||
cls: cls <= 0.1,
|
||||
tbt: tbt <= 300,
|
||||
postRunningLongTasks: postRunning.length === 0,
|
||||
eventToNextPaint: eventToPaint.p95 <= 200,
|
||||
sceneCpu: sceneCpuP95 <= profile.sceneCpuP95,
|
||||
continuity: continuity.passes,
|
||||
lostPresentation: lost.hasSufficientCoverage && lost.share <= profile.lostShare,
|
||||
lateShifts: lateShifts.length === 0,
|
||||
};
|
||||
if (process.env.PORTFOLIO_REFERENCE_LAB === '1') {
|
||||
expect(Object.values(budgetResults).every(Boolean)).toBe(true);
|
||||
}
|
||||
expect(snapshot.navigationBarrier.cancelledEpoch).not.toBeNull();
|
||||
|
||||
const report = {
|
||||
schema: 'drive-trace.v1',
|
||||
evidenceKind: 'current-chromium-developer-machine-non-reference',
|
||||
gatingReferenceResult: false,
|
||||
profile: profile.name,
|
||||
tier: profile.tier,
|
||||
browser: testInfo.project.name,
|
||||
metrics: { warmupMedian, lcp, cls, tbt, postRunningLongTaskCount: postRunning.length, eventToNextPaintP95: eventToPaint.p95, sceneCpuP95, continuity, lostPresentationShare: lost.share, lateShiftCount: lateShifts.length },
|
||||
budgetResults,
|
||||
referenceBudgetsPass: validReferenceCadence && Object.values(budgetResults).every(Boolean),
|
||||
validReferenceCadence,
|
||||
pendingPhysicalReferenceRuns: 5,
|
||||
};
|
||||
await writeFile(testInfo.outputPath('drive-trace.v1.json'), `${JSON.stringify(report, null, 2)}\n`);
|
||||
});
|
||||
111
tests/performance/metrics.test.ts
Normal file
111
tests/performance/metrics.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateCls,
|
||||
calculateLabTbt,
|
||||
calculateLostPresentationShare,
|
||||
evaluateEventToNextPaint,
|
||||
evaluateSceneContinuity,
|
||||
latestLcpBeforeCutoff,
|
||||
nearestRankP95,
|
||||
postRunningLongTasks,
|
||||
unexpectedLateShifts,
|
||||
type ScriptedActionSample,
|
||||
} from '@/testing/performance-instrumentation';
|
||||
|
||||
describe('performance metric math', () => {
|
||||
it('uses exact nearest-rank p95 without interpolation', () => {
|
||||
expect(nearestRankP95([100, 1, 3, 2, 4])).toBe(100);
|
||||
expect(nearestRankP95(Array.from({ length: 20 }, (_, index) => index + 1))).toBe(19);
|
||||
expect(() => nearestRankP95([])).toThrow(/sample/u);
|
||||
});
|
||||
|
||||
it('cuts LCP off at the first trusted input or hidden transition', () => {
|
||||
expect(latestLcpBeforeCutoff([100, 200, 450], 400)).toBe(200);
|
||||
expect(() => latestLcpBeforeCutoff([], 400)).toThrow(/LCP/u);
|
||||
});
|
||||
|
||||
it('calculates CLS with exact one-second and five-second session windows', () => {
|
||||
expect(calculateCls([
|
||||
{ startTime: 0, value: 0.01, hadRecentInput: false },
|
||||
{ startTime: 1_000, value: 0.01, hadRecentInput: false },
|
||||
{ startTime: 2_000, value: 0.01, hadRecentInput: false },
|
||||
{ startTime: 3_000, value: 0.01, hadRecentInput: false },
|
||||
{ startTime: 4_000, value: 0.01, hadRecentInput: false },
|
||||
{ startTime: 5_000, value: 0.01, hadRecentInput: false },
|
||||
{ startTime: 5_001, value: 0.2, hadRecentInput: false },
|
||||
{ startTime: 5_100, value: 1, hadRecentInput: true },
|
||||
])).toBeCloseTo(0.2);
|
||||
});
|
||||
|
||||
it('clips lab TBT to the post-50ms portion before running', () => {
|
||||
expect(calculateLabTbt([
|
||||
{ startTime: 0, duration: 100 },
|
||||
{ startTime: 180, duration: 100 },
|
||||
{ startTime: 250, duration: 100 },
|
||||
], 20, 300)).toBe(100);
|
||||
expect(() => calculateLabTbt([], null, 300)).toThrow(/FCP/u);
|
||||
});
|
||||
|
||||
it('detects every long task intersecting the post-running trace', () => {
|
||||
expect(postRunningLongTasks([
|
||||
{ startTime: 900, duration: 200 },
|
||||
{ startTime: 20_999, duration: 2 },
|
||||
{ startTime: 21_000, duration: 100 },
|
||||
], 1_000, 21_000)).toEqual([
|
||||
{ startTime: 900, duration: 200 },
|
||||
{ startTime: 20_999, duration: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires the exact eight action confirmations and applies the conservative 16ms bound', () => {
|
||||
const ids = ['start', 'volume', 'mute', 'unmute', 'pause', 'resume', 'toolkit', 'contact'] as const;
|
||||
const actions: ScriptedActionSample[] = ids.map((id, index) => ({
|
||||
id,
|
||||
target: `target-${id}`,
|
||||
dispatchTime: index * 1_000,
|
||||
acknowledgementCount: 1,
|
||||
mutationTime: index * 1_000 + 20,
|
||||
doubleRafTime: index * 1_000 + 40,
|
||||
eventTimingSupported: true,
|
||||
eventEntries: index === 7 ? [{ target: `target-${id}`, startTime: index * 1_000, duration: 80, interactionId: 7 }] : [],
|
||||
}));
|
||||
expect(evaluateEventToNextPaint(actions)).toEqual({ values: [16, 16, 16, 16, 16, 16, 16, 80], p95: 80 });
|
||||
expect(() => evaluateEventToNextPaint(actions.slice(0, 7))).toThrow(/eight/u);
|
||||
expect(() => evaluateEventToNextPaint([{ ...actions[0]!, acknowledgementCount: 2 }, ...actions.slice(1)])).toThrow(/acknowledgement/u);
|
||||
});
|
||||
|
||||
it('evaluates scene continuity at interval boundaries and callback coverage', () => {
|
||||
const result = evaluateSceneContinuity(
|
||||
[{ start: 0, end: 100 }],
|
||||
[
|
||||
{ start: 10, end: 12 },
|
||||
{ start: 28, end: 30 },
|
||||
{ start: 46, end: 48 },
|
||||
{ start: 64, end: 66 },
|
||||
{ start: 82, end: 84 },
|
||||
],
|
||||
16.667,
|
||||
);
|
||||
expect(result.passes).toBe(true);
|
||||
expect(result.expectedCallbacks).toBe(5);
|
||||
expect(result.actualCallbacks).toBe(5);
|
||||
expect(evaluateSceneContinuity([{ start: 0, end: 100 }], [{ start: 60, end: 61 }], 16.667).passes).toBe(false);
|
||||
});
|
||||
|
||||
it('counts lost presentation opportunities from page-level RAF intervals', () => {
|
||||
const result = calculateLostPresentationShare([0, 16.667, 50.001, 66.668], 16.667, 3);
|
||||
expect(result.opportunities).toBe(4);
|
||||
expect(result.lost).toBe(1);
|
||||
expect(result.share).toBeCloseTo(0.25);
|
||||
expect(result.hasSufficientCoverage).toBe(true);
|
||||
});
|
||||
|
||||
it('allows no unexpected late shifts', () => {
|
||||
expect(unexpectedLateShifts([
|
||||
{ startTime: 99, value: 1, hadRecentInput: false },
|
||||
{ startTime: 101, value: 1, hadRecentInput: true },
|
||||
{ startTime: 102, value: 0.1, hadRecentInput: false },
|
||||
{ startTime: 201, value: 0.1, hadRecentInput: false },
|
||||
], 100, 200)).toEqual([{ startTime: 102, value: 0.1, hadRecentInput: false }]);
|
||||
});
|
||||
});
|
||||
167
tests/performance/physical-drive.protocol.ts
Normal file
167
tests/performance/physical-drive.protocol.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export type CycleDirection = 'forward' | 'reverse';
|
||||
|
||||
export interface SafeViewport { safeX: number; safeY: number; width: number; height: number }
|
||||
export interface ViewportGeometry { offsetLeft: number; offsetTop: number; width: number; height: number }
|
||||
export interface SafeInsets { left: number; right: number; top: number; bottom: number }
|
||||
export interface TargetBox { left: number; top: number; right: number; bottom: number }
|
||||
|
||||
type PointerAction =
|
||||
| { type: 'pointerMove'; duration: number; origin: 'viewport'; x: number; y: number }
|
||||
| { type: 'pointerDown'; button: 0 }
|
||||
| { type: 'pointerUp'; button: 0 }
|
||||
| { type: 'pause'; duration: number };
|
||||
|
||||
type KeyAction =
|
||||
| { type: 'keyDown'; value: string }
|
||||
| { type: 'keyUp'; value: string }
|
||||
| { type: 'pause'; duration: number };
|
||||
|
||||
export const roundHalfUp = (value: number): number => Math.floor(value + 0.5);
|
||||
|
||||
export const computeSafeViewport = (viewport: ViewportGeometry, insets: SafeInsets): SafeViewport => {
|
||||
const width = viewport.width - insets.left - insets.right;
|
||||
const height = viewport.height - insets.top - insets.bottom;
|
||||
if (!(width > 0) || !(height > 0)) throw new Error('Safe viewport dimensions must be positive.');
|
||||
return {
|
||||
safeX: viewport.offsetLeft + insets.left,
|
||||
safeY: viewport.offsetTop + insets.top,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSwipeCycle = (direction: CycleDirection, safe: SafeViewport) => {
|
||||
const startFraction = direction === 'forward' ? 0.8 : 0.2;
|
||||
const delta = direction === 'forward' ? -0.7 : 0.7;
|
||||
const x = roundHalfUp(safe.safeX + 0.5 * safe.width);
|
||||
const actions: PointerAction[] = [
|
||||
{ type: 'pointerMove', duration: 0, origin: 'viewport', x, y: roundHalfUp(safe.safeY + safe.height * startFraction) },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
];
|
||||
for (let index = 1; index <= 15; index += 1) {
|
||||
actions.push({
|
||||
type: 'pointerMove',
|
||||
duration: 40,
|
||||
origin: 'viewport',
|
||||
x,
|
||||
y: roundHalfUp(safe.safeY + safe.height * (startFraction + delta * index / 15)),
|
||||
});
|
||||
}
|
||||
actions.push({ type: 'pointerUp', button: 0 }, { type: 'pause', duration: 400 });
|
||||
return {
|
||||
pointer: { type: 'pointer' as const, id: 'driveTouch' as const, parameters: { pointerType: 'touch' as const }, actions },
|
||||
keyboard: { type: 'key' as const, id: 'driveKeys' as const, actions: [{ type: 'pause' as const, duration: 1_000 }] },
|
||||
};
|
||||
};
|
||||
|
||||
const driveSources = (pointerActions: PointerAction[], keyboardActions: KeyAction[]) => ({
|
||||
pointer: {
|
||||
type: 'pointer' as const,
|
||||
id: 'driveTouch' as const,
|
||||
parameters: { pointerType: 'touch' as const },
|
||||
actions: pointerActions,
|
||||
},
|
||||
keyboard: { type: 'key' as const, id: 'driveKeys' as const, actions: keyboardActions },
|
||||
});
|
||||
|
||||
export const buildTargetActivation = (first: TargetBox, second: TargetBox, safe: SafeViewport) => {
|
||||
for (const edge of ['left', 'top', 'right', 'bottom'] as const) {
|
||||
if (Math.abs(first[edge] - second[edge]) >= 1) throw new Error('Activation target box must remain stable across both samples.');
|
||||
}
|
||||
const left = Math.max(safe.safeX, second.left);
|
||||
const top = Math.max(safe.safeY, second.top);
|
||||
const right = Math.min(safe.safeX + safe.width, second.right);
|
||||
const bottom = Math.min(safe.safeY + safe.height, second.bottom);
|
||||
if (!(right > left) || !(bottom > top)) throw new Error('Activation target must intersect the safe viewport.');
|
||||
return driveSources([
|
||||
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: roundHalfUp((left + right) / 2), y: roundHalfUp((top + bottom) / 2) },
|
||||
{ type: 'pause', duration: 60 },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pause', duration: 60 },
|
||||
{ type: 'pointerUp', button: 0 },
|
||||
], [{ type: 'pause', duration: 120 }]);
|
||||
};
|
||||
|
||||
export const buildVolumeKeySequence = () => driveSources(
|
||||
[{ type: 'pause', duration: 16_040 }],
|
||||
[
|
||||
{ type: 'pause', duration: 15_900 },
|
||||
{ type: 'keyDown', value: '\uE004' },
|
||||
{ type: 'pause', duration: 20 },
|
||||
{ type: 'keyUp', value: '\uE004' },
|
||||
{ type: 'pause', duration: 80 },
|
||||
{ type: 'keyDown', value: '\uE012' },
|
||||
{ type: 'pause', duration: 40 },
|
||||
{ type: 'keyUp', value: '\uE012' },
|
||||
],
|
||||
);
|
||||
|
||||
export const chooseCycleDirection = (
|
||||
direction: CycleDirection,
|
||||
rawScrollY: number,
|
||||
rawMaxScrollY: number,
|
||||
): CycleDirection => {
|
||||
const maxScrollY = Math.max(0, rawMaxScrollY);
|
||||
const scrollY = Math.min(maxScrollY, Math.max(0, rawScrollY));
|
||||
if (direction === 'forward' && scrollY >= maxScrollY - 2) return 'reverse';
|
||||
if (direction === 'reverse' && scrollY <= 2) return 'forward';
|
||||
return direction;
|
||||
};
|
||||
|
||||
const integerRange = (start: number, endExclusive: number): number[] => Array.from(
|
||||
{ length: endExclusive - start },
|
||||
(_, index) => start + index,
|
||||
);
|
||||
|
||||
export const physicalCycleOffsets = (): number[] => [
|
||||
...integerRange(30, 120),
|
||||
...integerRange(125, 180),
|
||||
...integerRange(275, 510).filter((offset) => offset !== 389 && offset !== 390),
|
||||
];
|
||||
|
||||
interface ProtocolEvent { offsetMs: number; action: string; assertions: string[] }
|
||||
interface ProtocolBranch { offsetMs: number; action: string; assertions: string[] }
|
||||
|
||||
export const buildPhysicalDriveProtocol = () => {
|
||||
const events: ProtocolEvent[] = [
|
||||
{ offsetMs: 0, action: 'begin-idle-portrait', assertions: ['one-canvas-loop', 'geometry'] },
|
||||
{ offsetMs: 15_000, action: 'activate-start-the-drive', assertions: ['state-by-15500'] },
|
||||
{ offsetMs: 15_900, action: 'volume-tab-keydown', assertions: [] },
|
||||
{ offsetMs: 15_920, action: 'volume-tab-keyup', assertions: ['slider-focused'] },
|
||||
{ offsetMs: 16_000, action: 'volume-arrow-left-keydown', assertions: [] },
|
||||
{ offsetMs: 16_040, action: 'volume-arrow-left-keyup', assertions: ['volume-35-by-16500'] },
|
||||
...physicalCycleOffsets().map((offset) => ({
|
||||
offsetMs: offset * 1_000,
|
||||
action: 'scroll-cycle',
|
||||
assertions: offset === 391
|
||||
? ['sample-direction-and-safe-viewport', 'cancel-exact-pending-navigation-epoch', 'release-barrier']
|
||||
: ['sample-direction-and-safe-viewport'],
|
||||
})),
|
||||
{ offsetMs: 120_000, action: 'rotate-landscape', assertions: ['geometry-by-125000', 'one-canvas-loop', 'tier-at-most-maximum'] },
|
||||
{ offsetMs: 180_000, action: 'background', assertions: ['no-page-input-for-30s'] },
|
||||
{ offsetMs: 210_000, action: 'foreground', assertions: ['lifecycle-counts-by-215000'] },
|
||||
{ offsetMs: 225_000, action: 'audio-interruption', assertions: ['suspended-and-resume-sound-by-227000'] },
|
||||
{ offsetMs: 230_000, action: 'activate-resume-sound', assertions: ['state-by-230500'] },
|
||||
{ offsetMs: 250_000, action: 'context-loss', assertions: [] },
|
||||
{ offsetMs: 252_000, action: 'context-restore', assertions: ['one-running-generation-or-static-by-257000'] },
|
||||
{ offsetMs: 270_000, action: 'rotate-portrait', assertions: ['geometry-by-275000'] },
|
||||
{ offsetMs: 390_000, action: 'arm-navigation-settlement-barrier', assertions: [] },
|
||||
{ offsetMs: 390_000, action: 'navigate-infrastructure', assertions: ['one-navigation-epoch'] },
|
||||
{ offsetMs: 510_000, action: 'background', assertions: ['no-page-input-for-30s'] },
|
||||
{ offsetMs: 540_000, action: 'foreground', assertions: ['lifecycle-counts-by-545000'] },
|
||||
{ offsetMs: 575_000, action: 'navigate-contact', assertions: ['state-by-575500'] },
|
||||
{ offsetMs: 580_000, action: 'settle-contact', assertions: ['contact-usable', 'final-geometry'] },
|
||||
{ offsetMs: 600_000, action: 'finish', assertions: ['no-crash', 'no-duplicates', 'monotonic-quality'] },
|
||||
].sort((left, right) => left.offsetMs - right.offsetMs);
|
||||
const branches: { live: ProtocolBranch[]; static: ProtocolBranch[] } = {
|
||||
live: [
|
||||
{ offsetMs: 550_000, action: 'pause-motion', assertions: ['zero-scene-raf', 'retained-frame', 'audio-independent'] },
|
||||
{ offsetMs: 565_000, action: 'resume-motion', assertions: ['one-canvas-loop-by-570000'] },
|
||||
],
|
||||
static: [
|
||||
{ offsetMs: 550_000, action: 'assert-static-without-input', assertions: ['poster', 'zero-raf', 'motion-unavailable', 'audio-independent'] },
|
||||
{ offsetMs: 565_000, action: 'assert-static-without-input', assertions: ['unchanged-poster', 'zero-raf', 'controls-usable'] },
|
||||
],
|
||||
};
|
||||
return { version: 'physical-drive.v1' as const, durationMs: 600_000, events, branches };
|
||||
};
|
||||
101
tests/performance/physical-drive.test.ts
Normal file
101
tests/performance/physical-drive.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPhysicalDriveProtocol,
|
||||
buildTargetActivation,
|
||||
buildVolumeKeySequence,
|
||||
buildSwipeCycle,
|
||||
chooseCycleDirection,
|
||||
computeSafeViewport,
|
||||
physicalCycleOffsets,
|
||||
roundHalfUp,
|
||||
} from './physical-drive.protocol';
|
||||
|
||||
describe('physical-drive.v1 protocol', () => {
|
||||
it('uses round-half-up safe viewport coordinates', () => {
|
||||
expect(roundHalfUp(1.5)).toBe(2);
|
||||
expect(computeSafeViewport({ offsetLeft: 2, offsetTop: 3, width: 390, height: 844 }, { left: 10, right: 12, top: 20, bottom: 22 })).toEqual({
|
||||
safeX: 12,
|
||||
safeY: 23,
|
||||
width: 368,
|
||||
height: 802,
|
||||
});
|
||||
expect(() => computeSafeViewport({ offsetLeft: 0, offsetTop: 0, width: 10, height: 10 }, { left: 5, right: 5, top: 0, bottom: 0 })).toThrow(/positive/u);
|
||||
});
|
||||
|
||||
it('generates the literal 15-step synchronized touch cycle', () => {
|
||||
const cycle = buildSwipeCycle('forward', { safeX: 0, safeY: 0, width: 100, height: 200 });
|
||||
expect(cycle.pointer.id).toBe('driveTouch');
|
||||
expect(cycle.keyboard.id).toBe('driveKeys');
|
||||
expect(cycle.pointer.actions).toHaveLength(19);
|
||||
expect(cycle.pointer.actions[0]).toEqual({ type: 'pointerMove', duration: 0, origin: 'viewport', x: 50, y: 160 });
|
||||
expect(cycle.pointer.actions[2]).toEqual({ type: 'pointerMove', duration: 40, origin: 'viewport', x: 50, y: 151 });
|
||||
expect(cycle.pointer.actions[16]).toEqual({ type: 'pointerMove', duration: 40, origin: 'viewport', x: 50, y: 20 });
|
||||
expect(cycle.pointer.actions.at(-1)).toEqual({ type: 'pause', duration: 400 });
|
||||
expect(cycle.keyboard.actions).toEqual([{ type: 'pause', duration: 1_000 }]);
|
||||
});
|
||||
|
||||
it('switches direction only at the two-pixel boundaries', () => {
|
||||
expect(chooseCycleDirection('forward', 98, 100)).toBe('reverse');
|
||||
expect(chooseCycleDirection('forward', 97.99, 100)).toBe('forward');
|
||||
expect(chooseCycleDirection('reverse', 2, 100)).toBe('forward');
|
||||
expect(chooseCycleDirection('reverse', 2.01, 100)).toBe('reverse');
|
||||
});
|
||||
|
||||
it('builds exact stable-box activation and synchronized volume input sources', () => {
|
||||
const activation = buildTargetActivation(
|
||||
{ left: 10, top: 20, right: 110, bottom: 70 },
|
||||
{ left: 10.5, top: 20.5, right: 110.5, bottom: 70.5 },
|
||||
{ safeX: 0, safeY: 0, width: 100, height: 100 },
|
||||
);
|
||||
expect(activation.pointer.actions).toEqual([
|
||||
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: 55, y: 46 },
|
||||
{ type: 'pause', duration: 60 },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pause', duration: 60 },
|
||||
{ type: 'pointerUp', button: 0 },
|
||||
]);
|
||||
expect(activation.keyboard.actions).toEqual([{ type: 'pause', duration: 120 }]);
|
||||
expect(() => buildTargetActivation(
|
||||
{ left: 10, top: 20, right: 110, bottom: 70 },
|
||||
{ left: 11, top: 20, right: 110, bottom: 70 },
|
||||
{ safeX: 0, safeY: 0, width: 100, height: 100 },
|
||||
)).toThrow(/stable/u);
|
||||
|
||||
const volume = buildVolumeKeySequence();
|
||||
expect(volume.keyboard.actions).toEqual([
|
||||
{ type: 'pause', duration: 15_900 },
|
||||
{ type: 'keyDown', value: '\uE004' },
|
||||
{ type: 'pause', duration: 20 },
|
||||
{ type: 'keyUp', value: '\uE004' },
|
||||
{ type: 'pause', duration: 80 },
|
||||
{ type: 'keyDown', value: '\uE012' },
|
||||
{ type: 'pause', duration: 40 },
|
||||
{ type: 'keyUp', value: '\uE012' },
|
||||
]);
|
||||
expect(volume.pointer.actions).toEqual([{ type: 'pause', duration: 16_040 }]);
|
||||
});
|
||||
|
||||
it('uses every specified cycle except the excluded 389/390-second cycles', () => {
|
||||
const offsets = physicalCycleOffsets();
|
||||
expect(offsets).toContain(388);
|
||||
expect(offsets).not.toContain(389);
|
||||
expect(offsets).not.toContain(390);
|
||||
expect(offsets).toContain(391);
|
||||
expect(offsets).toContain(509);
|
||||
expect(offsets).not.toContain(510);
|
||||
});
|
||||
|
||||
it('preserves normative event order and terminal-Static branches', () => {
|
||||
const protocol = buildPhysicalDriveProtocol();
|
||||
expect(protocol.version).toBe('physical-drive.v1');
|
||||
expect(protocol.durationMs).toBe(600_000);
|
||||
expect(protocol.events.filter((event) => event.offsetMs === 390_000).map((event) => event.action)).toEqual([
|
||||
'arm-navigation-settlement-barrier',
|
||||
'navigate-infrastructure',
|
||||
]);
|
||||
expect(protocol.events.find((event) => event.offsetMs === 391_000)?.assertions).toContain('cancel-exact-pending-navigation-epoch');
|
||||
expect(protocol.branches.live.find((event) => event.offsetMs === 550_000)?.action).toBe('pause-motion');
|
||||
expect(protocol.branches.static.find((event) => event.offsetMs === 550_000)?.action).toBe('assert-static-without-input');
|
||||
expect(protocol.events.at(-1)).toMatchObject({ offsetMs: 600_000, action: 'finish' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user