diff --git a/config/performance-budget-evidence.json b/config/performance-budget-evidence.json new file mode 100644 index 0000000..f116c82 --- /dev/null +++ b/config/performance-budget-evidence.json @@ -0,0 +1,27 @@ +{ + "schema": "portfolio-runtime-budget-evidence/v1", + "peakRequests": { "imageScene": 0, "video": 0 }, + "tiers": [ + { + "tier": "low", + "textures": [ + { "id": "route-sign-canvas", "width": 768, "height": 192, "bytesPerPixel": 4, "layers": 1, "mipmapped": true } + ], + "geometryBytes": 1048576, + "renderTargets": [ + { "id": "default-framebuffer", "width": 488, "height": 1055, "bytesPerPixel": 4, "attachments": 2, "samples": 1 } + ] + }, + { + "tier": "high", + "textures": [ + { "id": "route-sign-canvas", "width": 768, "height": 192, "bytesPerPixel": 4, "layers": 1, "mipmapped": true } + ], + "geometryBytes": 2097152, + "renderTargets": [ + { "id": "default-framebuffer", "width": 2160, "height": 1350, "bytesPerPixel": 4, "attachments": 2, "samples": 4 }, + { "id": "post-processing-target", "width": 2160, "height": 1350, "bytesPerPixel": 4, "attachments": 2, "samples": 1 } + ] + } + ] +} diff --git a/docs/performance/lab-runbook.md b/docs/performance/lab-runbook.md index 73e6323..05025f2 100644 --- a/docs/performance/lab-runbook.md +++ b/docs/performance/lab-runbook.md @@ -4,7 +4,9 @@ This runbook separates executable developer evidence from the two physical relea ## Automated build and transfer gate -Run `npm run build:preview`. The build ends with `npm run check:budgets`, which classifies every shipped file exactly once, measures Brotli transfer bytes (or raw bytes for already-compressed formats), verifies that the live Three.js scene remains behind a dynamic import, and applies the exact section 14 thresholds. Unknown files fail closed. +Run `npm run build:preview`. The build ends with `npm run check:budgets`, which classifies every shipped file exactly once, measures Brotli transfer bytes (or raw bytes for already-compressed formats), verifies that the live Three.js scene remains behind a dynamic import, and applies the exact section 14 thresholds. Unknown files fail closed. Every non-empty manifest media file is hash-checked and deterministically decoded: Sharp verifies image dimensions and `ffprobe` verifies video dimensions and duration against the manifest. Missing probe evidence, disagreement, or an unprobeable asset fails the gate. + +`config/performance-budget-evidence.json` is the auditable allocation declaration. The checker derives texture bytes as `width × height × bytes-per-pixel × layers × 4/3` for mipmaps, render-target bytes as `width × height × bytes-per-pixel × attachments × samples`, adds declared geometry buffer bytes and the fixed 32 MB allowance, and enforces the Low/High GPU caps. It also enforces peak request concurrency and the 2048-pixel mobile texture bound. High-tier-only chunks use the `high-tier-*`/`environment-high-*` bucket and are capped separately. Any scene allocation or request-scheduling change must update this declaration in the same change; incomplete allocations fail closed. The current empty-media preview measures approximately 7.8 KB for HTML plus critical CSS, 61.8 KB for initial application JavaScript, and 114.2 KB for the deferred first live scene. These values are build observations, not permanent allowances. @@ -32,7 +34,19 @@ They prove that the harness and interactions execute. They do not make a release Use only the Pixel 6 8 GB / Chromium Mobile Low and M1 MacBook Air 8 GB / Chromium Desktop High reference classes, or hardware with the same CPU/GPU and RAM. Fix both displays at 60 Hz; require battery above 50%, power saving off, a cool start, no foreground competition, page zoom 100%, full motion, Save-Data off, and the exact network shaping from section 14. Record OS/browser/display versions in the private lab evidence, not in shipped artifacts. -For each profile, run five fresh temporary profiles from `about:blank`. Clear service workers, Cache Storage, HTTP cache, cookies, local/session storage, and IndexedDB before every run. Set `PORTFOLIO_REFERENCE_LAB=1` so cadence and all per-run budgets become assertions. A warm-up median outside 16.667 ±1 ms invalidates the run. +For each profile, run five fresh temporary profiles from `about:blank`. Clear service workers, Cache Storage, HTTP cache, cookies, local/session storage, and IndexedDB before every run. + +Reference mode is fail-closed. Create one private `portfolio-reference-run-attestation/v1` JSON file per run with the exact pinned device, Chromium/OS/display, viewport, network, run index/ID, and every environmental assertion recorded. Keep device metadata outside the public repository. Compute the SHA-256 of the exact file bytes, then run one trace with all three variables set: + +```bash +PORTFOLIO_REFERENCE_LAB=1 \ +PORTFOLIO_REFERENCE_ATTESTATION_PATH=/private/evidence/desktop-run-1.json \ +PORTFOLIO_REFERENCE_ATTESTATION_SHA256=<64-lowercase-hex-digest> \ +npx playwright test tests/performance/drive-trace.spec.ts \ + --project=performance-desktop --workers=1 +``` + +Reference mode rejects missing or extra fields, digest mismatch, wrong hardware/network/profile, failed setup assertions, or disagreement with the browser-observed viewport, visibility, reduced-motion, Save-Data, zoom, and Chromium version. Only a validated attestation can produce `gatingReferenceResult: true`; ordinary developer runs always remain false. A warm-up median outside 16.667 ±1 ms or any per-run budget failure invalidates the run. Aggregate exactly five reviewed JSON paths: @@ -40,7 +54,9 @@ Aggregate exactly five reviewed JSON paths: npm run aggregate:performance -- run-1.json run-2.json run-3.json run-4.json run-5.json ``` -Aggregation fails unless all five are marked reference evidence, every per-run gate passes, worst/median LCP are at most 3.0/2.5 seconds, and worst/median TBT are at most 300/200 ms. Do not commit result files until their metadata has passed privacy review. +Aggregation fails unless the set contains unique authenticated run IDs, digests, and indexes 1–5 for one profile, all five are marked reference evidence, every per-run gate passes, worst/median LCP are at most 3.0/2.5 seconds, and worst/median TBT are at most 300/200 ms. Run and aggregate the other profile separately. Do not commit result files until their metadata has passed privacy review. + +The former High DPR cap of 2 produced a conservative 242 MB estimate at 1440×900 once the antialiased framebuffer, post target, route-sign texture, geometry, and fixed allowance were counted. High is therefore capped at DPR 1.5. The executable declaration now derives 153,078,016 bytes (about 146 MB) for High and 39,508,160 bytes (about 37.7 MB) for Low, below the exact 192/96 MB limits. ## Physical ten-minute protocol diff --git a/scripts/check-bundle-budgets.mjs b/scripts/check-bundle-budgets.mjs index d0dd7fc..5fc02b6 100644 --- a/scripts/check-bundle-budgets.mjs +++ b/scripts/check-bundle-budgets.mjs @@ -1,8 +1,17 @@ #!/usr/bin/env node +import { createHash } from 'node:crypto'; import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; import { readFile, readdir, stat } from 'node:fs/promises'; import { relative, resolve, sep } from 'node:path'; -import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs'; +import sharp from 'sharp'; +import { fileTypeFromBuffer } from 'file-type'; +import { probeVideo } from './lib/video-frames.mjs'; +import { + classifyArtifact, + evaluateBudgetTotals, + evaluateMediaAssetBudget, + evaluateRuntimeBudgetEvidence, +} from './lib/bundle-budgets.mjs'; const root = resolve(process.argv[2] ?? 'dist'); if (!(await stat(root)).isDirectory()) throw new Error('Bundle budget target must be a directory.'); @@ -21,18 +30,74 @@ const listFiles = async (directory) => { const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp4|png|webm|webp|woff2?)$/iu.test(path) ? bytes.byteLength - : brotliCompressSync(bytes, { - params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, - }).byteLength); + : brotliCompressSync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 } }).byteLength); +const readJson = async (path, label) => { + try { return JSON.parse(await readFile(path, 'utf8')); } catch { throw new Error(`${label} must be valid JSON.`); } +}; + +const manifest = await readJson(resolve('content/public/asset-manifest.json'), 'Public asset manifest'); +const content = await readJson(resolve('content/public/portfolio-content.json'), 'Portfolio content'); +const runtimeEvidence = await readJson(resolve('config/performance-budget-evidence.json'), 'Runtime budget evidence'); +if (!Array.isArray(manifest?.assets)) throw new Error('Public asset manifest is invalid.'); + +const assetClassById = new Map(); +for (const project of [...(content.projects?.selected ?? []), ...(content.projects?.additional ?? [])]) { + for (const media of project.media ?? []) { + assetClassById.set(media.assetId, media.kind === 'chart' ? 'chart' : 'project-still'); + if (media.posterAssetId) assetClassById.set(media.posterAssetId, 'project-still'); + if (media.fallbackAssetId) assetClassById.set(media.fallbackAssetId, 'project-still'); + } + for (const assetId of project.assetIds ?? []) if (!assetClassById.has(assetId)) assetClassById.set(assetId, 'project-still'); +} +for (const group of content.toolkit ?? []) for (const technology of group.technologies ?? []) { + if (technology.assetId) assetClassById.set(technology.assetId, 'project-still'); +} +if (content.metadata?.socialPreviewAssetId) assetClassById.set(content.metadata.socialPreviewAssetId, 'project-still'); + +const manifestByPath = new Map(manifest.assets.map((asset) => [`assets/${asset.fileName}`, asset])); const records = []; +const mediaViolations = []; for (const absolute of await listFiles(root)) { const path = relative(root, absolute).split(sep).join('/'); const bytes = await readFile(absolute); const source = /\.(?:css|html|js|json|txt|xml)$/u.test(path) ? bytes.toString('utf8') : ''; - const bucket = classifyArtifact(path, source); + const manifestAsset = manifestByPath.get(path); + let bucket; + if (manifestAsset !== undefined) { + const digest = createHash('sha256').update(bytes).digest('hex'); + if (digest !== manifestAsset.sha256) throw new Error(`Published asset hash mismatch: ${path}`); + const mediaType = manifestAsset.mediaType; + const detected = await fileTypeFromBuffer(bytes); + const detectedMediaType = detected?.mime ?? (/^\s*]/u.test(bytes.toString('utf8', 0, Math.min(bytes.length, 256))) ? 'image/svg+xml' : null); + if (detectedMediaType !== mediaType) throw new Error(`Manifest media type disagrees with deterministic probing: ${manifestAsset.id}`); + let probe; + if (mediaType?.startsWith('video/')) { + const video = await probeVideo(absolute, { allowAudio: false }); + probe = { mediaType, width: video.width, height: video.height, durationMs: Math.round(video.duration * 1_000) }; + bucket = 'project-video'; + } else if (mediaType?.startsWith('image/')) { + const image = await sharp(absolute, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata(); + probe = { mediaType, width: image.width, height: image.height, durationMs: null }; + bucket = assetClassById.get(manifestAsset.id) === 'chart' ? 'chart' : 'project-still'; + } else { + throw new Error(`Manifest asset has unsupported media type: ${manifestAsset.id}`); + } + mediaViolations.push(...evaluateMediaAssetBudget( + manifestAsset, + probe, + compressedSize(path, bytes), + bucket === 'chart' ? 'chart' : 'project-still', + ).violations); + } else { + bucket = classifyArtifact(path, source); + if (['project-video', 'project-still', 'chart'].includes(bucket)) { + throw new Error(`Project media must be present in the public asset manifest: ${path}`); + } + } records.push({ path, bucket, bytes: bytes.byteLength, transferBytes: compressedSize(path, bytes) }); } +for (const path of manifestByPath.keys()) if (!records.some((record) => record.path === path)) throw new Error(`Manifest asset is missing from the build: ${path}`); const sum = (bucket) => records.filter((record) => record.bucket === bucket).reduce((total, record) => total + record.transferBytes, 0); const totals = { @@ -42,25 +107,10 @@ const totals = { initialFonts: sum('initial-fonts'), firstLiveScene: sum('first-live-scene'), deferredProjectVisuals: sum('project-still') + sum('chart'), - deferredNonVideo: sum('first-live-scene') + sum('project-still') + sum('chart'), + highTierExtras: sum('high-tier-extras'), + deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart'), }; -for (const record of records.filter((item) => item.bucket === 'project-video')) { - if (record.transferBytes > PERFORMANCE_BUDGETS.projectVideo) { - throw new Error(`Project video exceeds its transfer budget: ${record.path}`); - } -} -for (const record of records.filter((item) => item.bucket === 'project-still')) { - if (record.transferBytes > PERFORMANCE_BUDGETS.projectStill) { - throw new Error(`Project still exceeds its transfer budget: ${record.path}`); - } -} -for (const record of records.filter((item) => item.bucket === 'chart')) { - if (record.transferBytes > PERFORMANCE_BUDGETS.chart) { - throw new Error(`Chart exceeds its transfer budget: ${record.path}`); - } -} - const entry = records.find((record) => record.bucket === 'initial-application-js'); const scene = records.find((record) => record.bucket === 'first-live-scene'); if (entry === undefined || scene === undefined) throw new Error('Both initial application and deferred live-scene chunks are required.'); @@ -68,13 +118,15 @@ const entrySource = await readFile(resolve(root, entry.path), 'utf8'); if (!/import\([^)]*environment-/u.test(entrySource)) throw new Error('The live scene is not separated behind a dynamic import.'); if (/WebGLRenderer|WebGLProgram|ShaderMaterial/u.test(entrySource)) throw new Error('Three.js/live-scene code leaked into the reduced-motion initial chunk.'); -const result = evaluateBudgetTotals(totals); +const runtime = evaluateRuntimeBudgetEvidence(runtimeEvidence); +const violations = [...evaluateBudgetTotals(totals).violations, ...mediaViolations, ...runtime.violations]; const report = { schema: 'portfolio-bundle-budget/v1', compression: 'brotli-quality-11-or-raw-already-compressed', totals, + runtime: { peakRequests: runtimeEvidence.peakRequests, tiers: runtime.tiers }, records: records.sort((left, right) => left.path.localeCompare(right.path)), - violations: result.violations, + violations, }; process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); -if (result.violations.length > 0) process.exitCode = 1; +if (violations.length > 0) process.exitCode = 1; diff --git a/scripts/lib/bundle-budgets.d.mts b/scripts/lib/bundle-budgets.d.mts index db31b35..63ed810 100644 --- a/scripts/lib/bundle-budgets.d.mts +++ b/scripts/lib/bundle-budgets.d.mts @@ -3,3 +3,13 @@ export function classifyArtifact(path: string, source?: string): string; export function evaluateBudgetTotals(totals: Readonly>): { violations: Array<{ budget: string; actual: number; threshold: number }>; }; +export function evaluateMediaAssetBudget( + asset: Readonly>, + probe: Readonly> | null, + transferBytes: number, + assetClass?: 'chart' | 'project-still', +): { violations: Array<{ budget: string; actual: number; threshold: number; id?: string }> }; +export function evaluateRuntimeBudgetEvidence(evidence: Readonly>): { + tiers: Array<{ tier: string; textureBytes: number; geometryBytes: number; renderTargetBytes: number; runtimeAllowanceBytes: number; totalBytes: number }>; + violations: Array<{ budget: string; actual: number; threshold: number; id?: string }>; +}; diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index 4a207d7..559b5f9 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -24,6 +24,7 @@ export const classifyArtifact = (path, source = '') => { if (path === 'index.html') return 'initial-html'; if (/^assets\/index-[^/]+\.css$/u.test(path)) return 'critical-css'; if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js'; + if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras'; if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene'; if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts'; if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster'; @@ -42,6 +43,7 @@ const thresholdByTotal = Object.freeze({ firstLiveScene: PERFORMANCE_BUDGETS.firstLiveScene, deferredProjectVisuals: PERFORMANCE_BUDGETS.deferredProjectVisuals, deferredNonVideo: PERFORMANCE_BUDGETS.deferredNonVideo, + highTierExtras: PERFORMANCE_BUDGETS.highTierExtras, }); export const evaluateBudgetTotals = (totals) => ({ @@ -49,3 +51,85 @@ export const evaluateBudgetTotals = (totals) => ({ .filter(([budget, threshold]) => (totals[budget] ?? 0) > threshold) .map(([budget, threshold]) => ({ budget, actual: totals[budget], threshold })), }); + +const violation = (budget, actual, threshold, id) => ({ budget, actual, threshold, ...(id === undefined ? {} : { id }) }); +const finitePositiveInteger = (value) => Number.isInteger(value) && value > 0; + +export const evaluateMediaAssetBudget = (asset, probe, transferBytes, assetClass = undefined) => { + if (probe === null || typeof probe !== 'object') throw new Error(`Media probe evidence is required for ${asset?.id ?? 'unknown asset'}.`); + if (!finitePositiveInteger(asset?.width) || !finitePositiveInteger(asset?.height) + || !finitePositiveInteger(probe.width) || !finitePositiveInteger(probe.height)) { + throw new Error(`Verified dimensions are required for ${asset?.id ?? 'unknown asset'}.`); + } + if (asset.width !== probe.width || asset.height !== probe.height || asset.mediaType !== probe.mediaType) { + throw new Error(`Manifest and probe evidence disagree for ${asset.id}.`); + } + const violations = []; + if (asset.mediaType.startsWith('video/')) { + if (!finitePositiveInteger(asset.durationMs) || !finitePositiveInteger(probe.durationMs) + || Math.abs(asset.durationMs - probe.durationMs) > 50) { + throw new Error(`Verified duration is required for ${asset.id}.`); + } + if (transferBytes > PERFORMANCE_BUDGETS.projectVideo) violations.push(violation('projectVideo', transferBytes, PERFORMANCE_BUDGETS.projectVideo, asset.id)); + if (probe.durationMs > PERFORMANCE_BUDGETS.projectVideoDurationSeconds * 1_000) violations.push(violation('projectVideoDurationSeconds', probe.durationMs / 1_000, PERFORMANCE_BUDGETS.projectVideoDurationSeconds, asset.id)); + if (probe.width > PERFORMANCE_BUDGETS.projectVideoWidth) violations.push(violation('projectVideoWidth', probe.width, PERFORMANCE_BUDGETS.projectVideoWidth, asset.id)); + if (probe.height > PERFORMANCE_BUDGETS.projectVideoHeight) violations.push(violation('projectVideoHeight', probe.height, PERFORMANCE_BUDGETS.projectVideoHeight, asset.id)); + } else { + if (asset.durationMs !== null || probe.durationMs !== null) throw new Error(`Still media cannot declare duration for ${asset.id}.`); + const budget = assetClass === 'chart' ? 'chart' : 'projectStill'; + const threshold = PERFORMANCE_BUDGETS[budget]; + if (transferBytes > threshold) violations.push(violation(budget, transferBytes, threshold, asset.id)); + } + return { violations }; +}; + +const allocationBytes = (allocation, fields, label) => { + for (const field of fields) { + if (!finitePositiveInteger(allocation?.[field])) throw new Error(`${label} ${allocation?.id ?? 'unknown'} has invalid ${field}.`); + } + return fields.reduce((total, field) => total * allocation[field], 1); +}; + +export const evaluateRuntimeBudgetEvidence = (evidence) => { + if (evidence?.schema !== 'portfolio-runtime-budget-evidence/v1' || !Array.isArray(evidence.tiers)) { + throw new Error('Runtime budget evidence must use portfolio-runtime-budget-evidence/v1.'); + } + const tierNames = evidence.tiers.map((tier) => tier?.tier).sort(); + if (JSON.stringify(tierNames) !== JSON.stringify(['high', 'low'])) { + throw new Error('Runtime evidence must contain exactly one Low and High tier allocation ledger.'); + } + const imageScene = evidence.peakRequests?.imageScene; + const video = evidence.peakRequests?.video; + if (!Number.isInteger(imageScene) || imageScene < 0 || !Number.isInteger(video) || video < 0) { + throw new Error('Runtime request concurrency evidence is incomplete.'); + } + const violations = []; + if (imageScene > PERFORMANCE_BUDGETS.imageSceneConcurrency) violations.push(violation('imageSceneConcurrency', imageScene, PERFORMANCE_BUDGETS.imageSceneConcurrency)); + if (video > PERFORMANCE_BUDGETS.videoConcurrency) violations.push(violation('videoConcurrency', video, PERFORMANCE_BUDGETS.videoConcurrency)); + const tiers = evidence.tiers.map((tier) => { + if (!['low', 'high'].includes(tier?.tier) || !Array.isArray(tier.textures) || !Array.isArray(tier.renderTargets) + || !Number.isInteger(tier.geometryBytes) || tier.geometryBytes < 0) { + throw new Error('Runtime tier allocation evidence is incomplete.'); + } + let textureBytes = 0; + for (const texture of tier.textures) { + const base = allocationBytes(texture, ['width', 'height', 'bytesPerPixel', 'layers'], 'Texture'); + if (typeof texture.mipmapped !== 'boolean') throw new Error(`Texture ${texture?.id ?? 'unknown'} is missing mipmap evidence.`); + textureBytes += Math.ceil(base * (texture.mipmapped ? 4 / 3 : 1)); + if (tier.tier === 'low' && (texture.width > PERFORMANCE_BUDGETS.mobileTextureDimension || texture.height > PERFORMANCE_BUDGETS.mobileTextureDimension)) { + violations.push(violation('mobileTextureDimension', Math.max(texture.width, texture.height), PERFORMANCE_BUDGETS.mobileTextureDimension, texture.id)); + } + } + let renderTargetBytes = 0; + for (const target of tier.renderTargets) { + renderTargetBytes += allocationBytes(target, ['width', 'height', 'bytesPerPixel', 'attachments', 'samples'], 'Render target'); + } + const runtimeAllowanceBytes = 32 * 1024 * 1024; + const totalBytes = textureBytes + tier.geometryBytes + renderTargetBytes + runtimeAllowanceBytes; + const budget = tier.tier === 'low' ? 'lowTierGpuEstimate' : 'highTierGpuEstimate'; + const threshold = PERFORMANCE_BUDGETS[budget]; + if (totalBytes > threshold) violations.push(violation(budget, totalBytes, threshold)); + return { tier: tier.tier, textureBytes, geometryBytes: tier.geometryBytes, renderTargetBytes, runtimeAllowanceBytes, totalBytes }; + }); + return { tiers, violations }; +}; diff --git a/scripts/lib/performance-aggregation.mjs b/scripts/lib/performance-aggregation.mjs index ebf185a..32120cf 100644 --- a/scripts/lib/performance-aggregation.mjs +++ b/scripts/lib/performance-aggregation.mjs @@ -10,6 +10,16 @@ export const aggregateFiveDriveTraces = (runs) => { if (typeof profile !== 'string' || runs.some((run) => run?.schema !== 'drive-trace.v1' || run.profile !== profile)) { throw new Error('All runs must use one drive-trace.v1 profile.'); } + const identities = runs.map((run) => run.referenceRun); + const indexes = identities.map((identity) => identity?.runIndex).sort((left, right) => left - right); + if (JSON.stringify(indexes) !== JSON.stringify([1, 2, 3, 4, 5])) { + throw new Error('Reference evidence must contain unique run indexes 1 through 5.'); + } + const runIds = new Set(identities.map((identity) => identity?.runId)); + const digests = new Set(identities.map((identity) => identity?.attestationDigest)); + if (runIds.size !== 5 || digests.size !== 5 || identities.some((identity) => !/^[a-f0-9]{64}$/u.test(identity?.attestationDigest ?? ''))) { + throw new Error('Reference evidence must contain five unique authenticated identities.'); + } const lcp = runs.map((run) => run.metrics.lcp); const tbt = runs.map((run) => run.metrics.tbt); const everyPerRunBudgetPass = runs.every((run) => Object.values(run.budgetResults).every(Boolean)); diff --git a/src/scene/render-quality.ts b/src/scene/render-quality.ts index f2a4d70..5c06131 100644 --- a/src/scene/render-quality.ts +++ b/src/scene/render-quality.ts @@ -8,7 +8,7 @@ export interface RenderQualitySettings { } const settings: Readonly & { dprCap: number }>> = { - high: { dprCap: 2, terrainSegments: 96, particleCount: 384, postEffects: true }, + high: { dprCap: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true }, medium: { dprCap: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true }, low: { dprCap: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false }, }; diff --git a/tests/performance/aggregation.test.ts b/tests/performance/aggregation.test.ts index 05345aa..5e7fa7e 100644 --- a/tests/performance/aggregation.test.ts +++ b/tests/performance/aggregation.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { aggregateFiveDriveTraces } from '../../scripts/lib/performance-aggregation.mjs'; -const run = (lcp: number, tbt: number, gatingReferenceResult = true) => ({ +const run = (lcp: number, tbt: number, runIndex: number, gatingReferenceResult = true) => ({ schema: 'drive-trace.v1', profile: 'desktop-high', + referenceRun: { runId: `run-${runIndex}`, runIndex, attestationDigest: `${runIndex}`.repeat(64) }, gatingReferenceResult, validReferenceCadence: true, metrics: { lcp, tbt }, @@ -13,7 +14,7 @@ const run = (lcp: number, tbt: number, gatingReferenceResult = 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), + run(2_000, 100, 1), run(2_100, 110, 2), run(2_500, 200, 3), run(2_900, 250, 4), run(3_000, 300, 5), ])).toMatchObject({ runCount: 5, passes: true, @@ -23,8 +24,13 @@ describe('five-run drive trace aggregation', () => { 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), + run(2_000, 100, 1, false), run(2_000, 100, 2), run(2_000, 100, 3), run(2_000, 100, 4), run(2_000, 100, 5), ]).passes).toBe(false); - expect(() => aggregateFiveDriveTraces([run(2_000, 100)])).toThrow(/five/u); + expect(() => aggregateFiveDriveTraces([run(2_000, 100, 1)])).toThrow(/five/u); + }); + + it('rejects duplicate or incomplete authenticated reference identities', () => { + const duplicate = [1, 2, 3, 4, 4].map((index) => run(2_000, 100, index)); + expect(() => aggregateFiveDriveTraces(duplicate)).toThrow(/run indexes 1 through 5/u); }); }); diff --git a/tests/performance/budgets.test.ts b/tests/performance/budgets.test.ts index 2491744..08c1169 100644 --- a/tests/performance/budgets.test.ts +++ b/tests/performance/budgets.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from '../../scripts/lib/bundle-budgets.mjs'; +import { + classifyArtifact, + evaluateBudgetTotals, + evaluateMediaAssetBudget, + evaluateRuntimeBudgetEvidence, + PERFORMANCE_BUDGETS, +} from '../../scripts/lib/bundle-budgets.mjs'; describe('release budgets', () => { it('pins every exact section 14 threshold', () => { @@ -31,6 +37,7 @@ describe('release budgets', () => { 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/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras'); expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u); }); @@ -38,4 +45,80 @@ describe('release budgets', () => { expect(evaluateBudgetTotals({ initialHtmlCriticalCss: 100 * 1024 }).violations).toEqual([]); expect(evaluateBudgetTotals({ initialHtmlCriticalCss: 100 * 1024 + 1 }).violations).toContainEqual(expect.objectContaining({ budget: 'initialHtmlCriticalCss' })); }); + + it('rejects an undersized transfer when its video delivery rendition is 20s at 1920x1080', () => { + const result = evaluateMediaAssetBudget({ + id: 'project-demo', + mediaType: 'video/webm', + width: 1_920, + height: 1_080, + durationMs: 20_000, + }, { + mediaType: 'video/webm', + width: 1_920, + height: 1_080, + durationMs: 20_000, + }, 1_024); + + expect(result.violations.map((violation) => violation.budget)).toEqual(expect.arrayContaining([ + 'projectVideoDurationSeconds', + 'projectVideoWidth', + 'projectVideoHeight', + ])); + }); + + it('fails closed when non-empty media dimensions cannot be deterministically probed', () => { + expect(() => evaluateMediaAssetBudget({ + id: 'project-still', + mediaType: 'image/webp', + width: 1_280, + height: 720, + }, null, 1_024)).toThrow(/probe evidence/u); + }); + + it('rejects excessive high-tier extras and request concurrency', () => { + expect(evaluateBudgetTotals({ highTierExtras: PERFORMANCE_BUDGETS.highTierExtras! + 1 }).violations) + .toContainEqual(expect.objectContaining({ budget: 'highTierExtras' })); + expect(evaluateRuntimeBudgetEvidence({ + schema: 'portfolio-runtime-budget-evidence/v1', + peakRequests: { imageScene: 3, video: 2 }, + tiers: [ + { tier: 'low', textures: [], geometryBytes: 0, renderTargets: [] }, + { tier: 'high', textures: [], geometryBytes: 0, renderTargets: [] }, + ], + }).violations.map((violation) => violation.budget)).toEqual(expect.arrayContaining([ + 'imageSceneConcurrency', + 'videoConcurrency', + ])); + }); + + it('derives decoded texture and GPU bytes from allocation formulas', () => { + const result = evaluateRuntimeBudgetEvidence({ + schema: 'portfolio-runtime-budget-evidence/v1', + peakRequests: { imageScene: 0, video: 0 }, + tiers: [ + { + tier: 'low', + textures: [{ id: 'oversized', width: 4_096, height: 4_096, bytesPerPixel: 4, layers: 1, mipmapped: true }], + geometryBytes: 40 * 1024 * 1024, + renderTargets: [{ id: 'framebuffer', width: 2_048, height: 2_048, bytesPerPixel: 8, attachments: 2, samples: 1 }], + }, + { tier: 'high', textures: [], geometryBytes: 0, renderTargets: [] }, + ], + }); + + expect(result.tiers[0]?.textureBytes).toBe(Math.ceil(4_096 * 4_096 * 4 * 4 / 3)); + expect(result.violations.map((violation) => violation.budget)).toEqual(expect.arrayContaining([ + 'mobileTextureDimension', + 'lowTierGpuEstimate', + ])); + }); + + it('fails closed when either named tier allocation ledger is absent', () => { + expect(() => evaluateRuntimeBudgetEvidence({ + schema: 'portfolio-runtime-budget-evidence/v1', + peakRequests: { imageScene: 0, video: 0 }, + tiers: [{ tier: 'low', textures: [], geometryBytes: 0, renderTargets: [] }], + })).toThrow(/exactly one Low and High/u); + }); }); diff --git a/tests/performance/drive-trace.spec.ts b/tests/performance/drive-trace.spec.ts index 05035e3..b6beda9 100644 --- a/tests/performance/drive-trace.spec.ts +++ b/tests/performance/drive-trace.spec.ts @@ -3,6 +3,7 @@ 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 { loadAuthenticatedReferenceRun, validateObservedReferenceEnvironment } from './reference-run'; import { calculateCls, calculateLabTbt, @@ -79,9 +80,10 @@ const dispatchWheelBurst = async (page: Page, cdp: CDPSession, at: (offset: numb } }; -test('drive-trace.v1 runs on current Chromium as explicitly non-reference evidence', async ({ page, distUrl }, testInfo) => { +test('drive-trace.v1 runs on current Chromium with fail-closed reference authentication', async ({ browser, page, distUrl }, testInfo) => { test.setTimeout(75_000); const profile = profileForProject(testInfo.project.name); + const referenceRun = await loadAuthenticatedReferenceRun(profile.name); await page.setViewportSize(profile.viewport); const cdp = await page.context().newCDPSession(page); await cdp.send('Network.enable'); @@ -94,6 +96,7 @@ test('drive-trace.v1 runs on current Chromium as explicitly non-reference eviden }); await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.goto('about:blank'); + const startedFromAboutBlank = page.url() === 'about:blank'; const loadStarted = nodePerformance.now(); await page.goto(authorizedPerformanceUrl(distUrl, profile.tier), { waitUntil: 'load', timeout: 10_000 }); const loadDuration = nodePerformance.now() - loadStarted; @@ -101,6 +104,21 @@ test('drive-trace.v1 runs on current Chromium as explicitly non-reference eviden 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'); + if (referenceRun !== null) { + const observed = await page.evaluate(() => ({ + viewport: { width: innerWidth, height: innerHeight }, + visible: document.visibilityState === 'visible', + reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduce' as const : 'no-preference' as const, + saveData: Boolean((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData), + pageZoom: visualViewport?.scale ?? 1, + })); + validateObservedReferenceEnvironment(referenceRun, { + ...observed, + browserName: 'Chromium', + browserVersion: browser.version(), + startedFromAboutBlank, + }); + } const warmupRafs = await page.evaluate(() => new Promise((resolve) => { const samples: number[] = []; @@ -194,8 +212,9 @@ test('drive-trace.v1 runs on current Chromium as explicitly non-reference eviden const report = { schema: 'drive-trace.v1', - evidenceKind: 'current-chromium-developer-machine-non-reference', - gatingReferenceResult: false, + evidenceKind: referenceRun === null ? 'current-chromium-developer-machine-non-reference' : 'authenticated-physical-reference', + gatingReferenceResult: referenceRun !== null, + ...(referenceRun === null ? {} : { referenceRun: { runId: referenceRun.runId, runIndex: referenceRun.runIndex, attestationDigest: referenceRun.attestationDigest } }), profile: profile.name, tier: profile.tier, browser: testInfo.project.name, @@ -203,7 +222,7 @@ test('drive-trace.v1 runs on current Chromium as explicitly non-reference eviden budgetResults, referenceBudgetsPass: validReferenceCadence && Object.values(budgetResults).every(Boolean), validReferenceCadence, - pendingPhysicalReferenceRuns: 5, + pendingPhysicalReferenceRuns: referenceRun === null ? 5 : 0, }; await writeFile(testInfo.outputPath('drive-trace.v1.json'), `${JSON.stringify(report, null, 2)}\n`); }); diff --git a/tests/performance/reference-run.test.ts b/tests/performance/reference-run.test.ts new file mode 100644 index 0000000..38ab325 --- /dev/null +++ b/tests/performance/reference-run.test.ts @@ -0,0 +1,93 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { authenticateReferenceRun, validateObservedReferenceEnvironment } from './reference-run'; + +const attestation = (runIndex = 1) => ({ + schema: 'portfolio-reference-run-attestation/v1', + profile: 'desktop-high', + runId: `desktop-reference-${runIndex}`, + capturedAt: '2026-09-02T05:00:00.000Z', + runIndex, + device: { model: 'Apple M1 MacBook Air', cpu: 'Apple M1', gpu: 'Apple M1 integrated GPU', ramGb: 8 }, + browser: { name: 'Chromium', version: '140.0.0', driverVersion: 'Playwright 1.62.1' }, + osVersion: 'macOS 15.6', + display: { refreshHz: 60 }, + viewport: { width: 1_440, height: 900 }, + network: { downstreamKbps: 10_000, upstreamKbps: 10_000, roundTripLatencyMs: 40 }, + assertions: { + batteryPercent: 80, + powerSaving: false, + coolStart: true, + noOtherForegroundApplications: true, + cpuEmulation: false, + pageZoom: 1, + reducedMotion: 'no-preference', + saveData: false, + audioState: 'idle', + freshBrowserProfile: true, + startedFromAboutBlank: true, + storageCleared: { serviceWorkers: true, cacheStorage: true, httpCache: true, cookies: true, localStorage: true, sessionStorage: true, indexedDb: true, preferences: true }, + physicalReferenceDevice: true, + }, +}); + +describe('authenticated reference runs', () => { + it('cannot mark an ordinary developer trace as gating evidence', () => { + const bytes = Buffer.from(JSON.stringify(attestation())); + expect(() => authenticateReferenceRun({ + referenceMode: false, + expectedProfile: 'desktop-high', + attestationBytes: bytes, + expectedDigest: createHash('sha256').update(bytes).digest('hex'), + })).toThrow(/reference mode/u); + }); + + it('fails closed on digest, device, network, or assertion mismatch', () => { + const invalid = attestation(); + invalid.network.downstreamKbps = 9_999; + const bytes = Buffer.from(JSON.stringify(invalid)); + expect(() => authenticateReferenceRun({ + referenceMode: true, + expectedProfile: 'desktop-high', + attestationBytes: bytes, + expectedDigest: createHash('sha256').update(bytes).digest('hex'), + })).toThrow(/network/u); + expect(() => authenticateReferenceRun({ + referenceMode: true, + expectedProfile: 'desktop-high', + attestationBytes: bytes, + expectedDigest: '0'.repeat(64), + })).toThrow(/digest/u); + }); + + it('authenticates only exact pinned metadata and validates browser-observed assertions', () => { + const bytes = Buffer.from(JSON.stringify(attestation())); + const authenticated = authenticateReferenceRun({ + referenceMode: true, + expectedProfile: 'desktop-high', + attestationBytes: bytes, + expectedDigest: createHash('sha256').update(bytes).digest('hex'), + }); + expect(authenticated).toMatchObject({ runId: 'desktop-reference-1', runIndex: 1, profile: 'desktop-high' }); + expect(() => validateObservedReferenceEnvironment(authenticated, { + viewport: { width: 1_440, height: 900 }, + visible: true, + reducedMotion: 'no-preference', + saveData: false, + pageZoom: 1, + browserName: 'Chromium', + browserVersion: '140.0.0', + startedFromAboutBlank: true, + })).not.toThrow(); + expect(() => validateObservedReferenceEnvironment(authenticated, { + viewport: { width: 1_440, height: 899 }, + visible: true, + reducedMotion: 'no-preference', + saveData: false, + pageZoom: 1, + browserName: 'Chromium', + browserVersion: '140.0.0', + startedFromAboutBlank: true, + })).toThrow(/viewport/u); + }); +}); diff --git a/tests/performance/reference-run.ts b/tests/performance/reference-run.ts new file mode 100644 index 0000000..a7edeff --- /dev/null +++ b/tests/performance/reference-run.ts @@ -0,0 +1,102 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +export type ReferenceProfile = 'desktop-high' | 'mobile-low'; + +export interface AuthenticatedReferenceRun { + profile: ReferenceProfile; + runId: string; + runIndex: number; + attestationDigest: string; + browserVersion: string; + viewport: { width: number; height: number }; +} + +interface AuthenticationInput { + referenceMode: boolean; + expectedProfile: ReferenceProfile; + attestationBytes: Uint8Array; + expectedDigest: string; +} + +interface ObservedEnvironment { + viewport: { width: number; height: number }; + visible: boolean; + reducedMotion: 'no-preference' | 'reduce'; + saveData: boolean; + pageZoom: number; + browserName: string; + browserVersion: string; + startedFromAboutBlank: boolean; +} + +const exactKeys = (value: unknown, keys: readonly string[], label: string): Record => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} is invalid.`); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${label} fields are invalid.`); + return value as Record; +}; + +const pinned = { + 'desktop-high': { viewport: { width: 1_440, height: 900 }, cpu: 'Apple M1', gpu: 'Apple M1 integrated GPU', ramGb: 8, downstreamKbps: 10_000, upstreamKbps: 10_000, roundTripLatencyMs: 40 }, + 'mobile-low': { viewport: { width: 390, height: 844 }, cpu: 'Google Tensor', gpu: 'Mali-G78 MP20', ramGb: 8, downstreamKbps: 1_600, upstreamKbps: 750, roundTripLatencyMs: 150 }, +} as const; + +export const authenticateReferenceRun = (input: AuthenticationInput): AuthenticatedReferenceRun => { + if (!input.referenceMode) throw new Error('Authenticated reference mode is required.'); + if (!/^[a-f0-9]{64}$/u.test(input.expectedDigest)) throw new Error('Reference attestation digest is invalid.'); + const actualDigest = createHash('sha256').update(input.attestationBytes).digest('hex'); + if (actualDigest !== input.expectedDigest) throw new Error('Reference attestation digest mismatch.'); + let decoded: unknown; + try { decoded = JSON.parse(Buffer.from(input.attestationBytes).toString('utf8')); } catch { throw new Error('Reference attestation JSON is invalid.'); } + const root = exactKeys(decoded, ['schema', 'profile', 'runId', 'runIndex', 'capturedAt', 'device', 'browser', 'osVersion', 'display', 'viewport', 'network', 'assertions'], 'Reference attestation'); + if (root.schema !== 'portfolio-reference-run-attestation/v1' || root.profile !== input.expectedProfile + || typeof root.runId !== 'string' || !/^[a-z0-9][a-z0-9-]{2,79}$/u.test(root.runId) + || !Number.isInteger(root.runIndex) || Number(root.runIndex) < 1 || Number(root.runIndex) > 5 + || typeof root.capturedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(root.capturedAt) + || !Number.isFinite(Date.parse(root.capturedAt)) + || typeof root.osVersion !== 'string' || root.osVersion.length < 2) throw new Error('Reference run metadata is invalid.'); + const profile = pinned[input.expectedProfile]; + const device = exactKeys(root.device, ['model', 'cpu', 'gpu', 'ramGb'], 'Reference device'); + if (typeof device.model !== 'string' || device.model.length < 2 || device.cpu !== profile.cpu || device.gpu !== profile.gpu || device.ramGb !== profile.ramGb) throw new Error('Reference device does not match the pinned class.'); + const browser = exactKeys(root.browser, ['name', 'version', 'driverVersion'], 'Reference browser'); + if (browser.name !== 'Chromium' || typeof browser.version !== 'string' || !/^\d+(?:\.\d+){1,3}$/u.test(browser.version) + || typeof browser.driverVersion !== 'string' || browser.driverVersion.length < 2) throw new Error('Reference browser metadata is invalid.'); + const display = exactKeys(root.display, ['refreshHz'], 'Reference display'); + if (display.refreshHz !== 60) throw new Error('Reference display must be fixed at 60 Hz.'); + const viewport = exactKeys(root.viewport, ['width', 'height'], 'Reference viewport'); + if (viewport.width !== profile.viewport.width || viewport.height !== profile.viewport.height) throw new Error('Reference viewport does not match the pinned profile.'); + const network = exactKeys(root.network, ['downstreamKbps', 'upstreamKbps', 'roundTripLatencyMs'], 'Reference network'); + if (network.downstreamKbps !== profile.downstreamKbps || network.upstreamKbps !== profile.upstreamKbps || network.roundTripLatencyMs !== profile.roundTripLatencyMs) throw new Error('Reference network does not match the pinned profile.'); + const assertions = exactKeys(root.assertions, ['batteryPercent', 'powerSaving', 'coolStart', 'noOtherForegroundApplications', 'cpuEmulation', 'pageZoom', 'reducedMotion', 'saveData', 'audioState', 'freshBrowserProfile', 'startedFromAboutBlank', 'storageCleared', 'physicalReferenceDevice'], 'Reference assertions'); + const storage = exactKeys(assertions.storageCleared, ['serviceWorkers', 'cacheStorage', 'httpCache', 'cookies', 'localStorage', 'sessionStorage', 'indexedDb', 'preferences'], 'Reference storage assertions'); + if (typeof assertions.batteryPercent !== 'number' || assertions.batteryPercent <= 50 || assertions.batteryPercent > 100 + || assertions.powerSaving !== false || assertions.coolStart !== true || assertions.noOtherForegroundApplications !== true + || assertions.cpuEmulation !== false || assertions.pageZoom !== 1 || assertions.reducedMotion !== 'no-preference' + || assertions.saveData !== false || assertions.audioState !== 'idle' || assertions.freshBrowserProfile !== true + || assertions.startedFromAboutBlank !== true || assertions.physicalReferenceDevice !== true + || Object.values(storage).some((value) => value !== true)) throw new Error('Reference environment assertions are not all satisfied.'); + return { + profile: input.expectedProfile, + runId: root.runId, + runIndex: Number(root.runIndex), + attestationDigest: actualDigest, + browserVersion: browser.version, + viewport: profile.viewport, + }; +}; + +export const loadAuthenticatedReferenceRun = async (profile: ReferenceProfile): Promise => { + if (process.env.PORTFOLIO_REFERENCE_LAB !== '1') return null; + const path = process.env.PORTFOLIO_REFERENCE_ATTESTATION_PATH; + const digest = process.env.PORTFOLIO_REFERENCE_ATTESTATION_SHA256; + if (path === undefined || digest === undefined) throw new Error('Reference mode requires an attestation path and SHA-256 digest.'); + return authenticateReferenceRun({ referenceMode: true, expectedProfile: profile, attestationBytes: await readFile(path), expectedDigest: digest }); +}; + +export const validateObservedReferenceEnvironment = (reference: AuthenticatedReferenceRun, observed: ObservedEnvironment): void => { + if (observed.viewport.width !== reference.viewport.width || observed.viewport.height !== reference.viewport.height) throw new Error('Observed viewport does not match the attestation.'); + if (!observed.visible || observed.reducedMotion !== 'no-preference' || observed.saveData || observed.pageZoom !== 1 || !observed.startedFromAboutBlank) throw new Error('Observed reference environment assertions failed.'); + if (observed.browserName !== 'Chromium' || observed.browserVersion !== reference.browserVersion) throw new Error('Observed browser does not match the attestation.'); +}; diff --git a/tests/unit/scene-resources.test.ts b/tests/unit/scene-resources.test.ts index 15935a5..ffd5ed5 100644 --- a/tests/unit/scene-resources.test.ts +++ b/tests/unit/scene-resources.test.ts @@ -98,7 +98,7 @@ describe('scene render quality', () => { const medium = renderQualityFor('medium', 3, false); const low = renderQualityFor('low', 3, false); - expect(high).toEqual({ pixelRatio: 2, terrainSegments: 96, particleCount: 384, postEffects: true }); + expect(high).toEqual({ pixelRatio: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true }); expect(medium).toEqual({ pixelRatio: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true }); expect(low).toEqual({ pixelRatio: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false }); });