From 8c899152c3cb71a24dba85f1938f5d31d74be926 Mon Sep 17 00:00:00 2001 From: Ertval Karameta Date: Sat, 18 Jul 2026 19:32:25 +0300 Subject: [PATCH 1/4] fix(perf): verify frame-rate degradation & coverage --- src/main.ecs.js | 60 +++++++++- tests/e2e/audit/audit.browser.spec.js | 72 +++++++++++- tests/unit/main-ecs.test.js | 151 ++++++++++++++++++++++++++ vitest.config.js | 7 ++ 4 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 tests/unit/main-ecs.test.js diff --git a/src/main.ecs.js b/src/main.ecs.js index 160a1c02..a8d90706 100644 --- a/src/main.ecs.js +++ b/src/main.ecs.js @@ -85,6 +85,7 @@ function toMessage(error) { function createFrameProbe( sampleSize = DEFAULT_FRAME_SAMPLE_SIZE, warmupFrames = DEFAULT_FRAME_PROBE_WARMUP_FRAMES, + windowRef = typeof window !== 'undefined' ? window : null, ) { const deltas = new Float64Array(sampleSize); let count = 0; @@ -94,6 +95,9 @@ function createFrameProbe( // Frames remaining in the warmup window. Each valid frame delta consumes one // warmup slot before deltas start accumulating into the sample buffer. let warmupRemaining = Math.max(0, Math.floor(warmupFrames)); + let initialMemory = 0; + let hasRecordedInitialMemory = false; + const perf = windowRef?.performance || (typeof performance !== 'undefined' ? performance : null); function recordFrame(nowMs) { if (!Number.isFinite(nowMs)) { @@ -105,6 +109,12 @@ function createFrameProbe( if (warmupRemaining > 0) { warmupRemaining -= 1; } else { + if (!hasRecordedInitialMemory) { + if (perf?.memory) { + initialMemory = perf.memory.usedJSHeapSize; + } + hasRecordedInitialMemory = true; + } deltas[cursor] = latestDelta; cursor = (cursor + 1) % sampleSize; if (count < sampleSize) { @@ -116,12 +126,42 @@ function createFrameProbe( lastTimestamp = nowMs; } - function getStats() { + function getStats({ slowFrameThresholdMs = 16.7 } = {}) { const values = toSortedNumericArray(deltas, count); const p50FrameTime = percentileFromSorted(values, 50); const p95FrameTime = percentileFromSorted(values, 95); const p99FrameTime = percentileFromSorted(values, 99); + // Calculate maximum duration of contiguous slow frames + const temporalDeltas = []; + if (count < sampleSize) { + for (let i = 0; i < count; i += 1) { + temporalDeltas.push(deltas[i]); + } + } else { + for (let i = 0; i < sampleSize; i += 1) { + temporalDeltas.push(deltas[(cursor + i) % sampleSize]); + } + } + + let maxContiguousSlowDurationMs = 0; + let currentContiguousSlowDurationMs = 0; + for (const delta of temporalDeltas) { + if (delta > slowFrameThresholdMs) { + currentContiguousSlowDurationMs += delta; + if (currentContiguousSlowDurationMs > maxContiguousSlowDurationMs) { + maxContiguousSlowDurationMs = currentContiguousSlowDurationMs; + } + } else { + currentContiguousSlowDurationMs = 0; + } + } + + let memoryAccumulationBytes = 0; + if (hasRecordedInitialMemory && perf?.memory) { + memoryAccumulationBytes = perf.memory.usedJSHeapSize - initialMemory; + } + return { averageFrameTime: values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0, @@ -131,11 +171,20 @@ function createFrameProbe( p95FrameTime, p99FrameTime, sampleCount: values.length, + maxContiguousSlowDurationMs, + memoryAccumulationBytes, }; } - function reset() { + function reset({ clearBuffer = false } = {}) { lastTimestamp = 0; + if (clearBuffer) { + count = 0; + cursor = 0; + hasRecordedInitialMemory = false; + initialMemory = 0; + warmupRemaining = Math.max(0, Math.floor(warmupFrames)); + } } return { @@ -420,7 +469,11 @@ export function createGameRuntime({ } clearTimeout(handle); }; - const frameProbe = createFrameProbe(DEFAULT_FRAME_SAMPLE_SIZE, frameProbeWarmupFrames); + const frameProbe = createFrameProbe( + DEFAULT_FRAME_SAMPLE_SIZE, + frameProbeWarmupFrames, + targetWindow, + ); if (!bootstrap) { throw new Error('createGameRuntime requires a bootstrap object.'); @@ -618,6 +671,7 @@ export function createGameRuntime({ if (targetWindow) { targetWindow[FRAME_PROBE_KEY] = { getStats: frameProbe.getStats, + reset: frameProbe.reset, }; targetWindow[RUNTIME_HOOK_KEY] = controls; } diff --git a/tests/e2e/audit/audit.browser.spec.js b/tests/e2e/audit/audit.browser.spec.js index 8f28adbf..a2a0150a 100644 --- a/tests/e2e/audit/audit.browser.spec.js +++ b/tests/e2e/audit/audit.browser.spec.js @@ -306,10 +306,75 @@ test('AUDIT-F-17 explicit frame-drop threshold assertions', async ({ page }) => await bootRuntime(page); const thresholds = ACTIVE_THRESHOLDS['AUDIT-F-17']; - const stats = await waitForFrameSamples(page, thresholds.minFrameSamples); + const stats = await page.evaluate((threshold) => { + return window.__MS_GHOSTMAN_FRAME_PROBE__.getStats({ slowFrameThresholdMs: threshold }); + }, thresholds.maxP95FrameTimeMs); expect(stats.p95FrameTime).toBeLessThanOrEqual(thresholds.maxP95FrameTimeMs); expect(stats.p99FrameTime).toBeLessThanOrEqual(thresholds.maxP99FrameTimeMs); + expect(stats.maxContiguousSlowDurationMs).toBeLessThanOrEqual(500); +}); + +test('Performance audit: flags sustained frame drops under artificial delay', async ({ page }) => { + await bootRuntime(page); + + // Inject a system into the ECS world that runs a busy loop of 30ms on every frame. + await page.evaluate(() => { + const world = window.__MS_GHOSTMAN_RUNTIME__.getWorld(); + world.registerSystem({ + name: 'mock-delay-system', + phase: 'meta', + update: () => { + const start = performance.now(); + while (performance.now() - start < 30) {} + }, + }); + }); + + // Reset the frame probe to clear pre-delay samples + await page.evaluate(() => { + window.__MS_GHOSTMAN_FRAME_PROBE__.reset({ clearBuffer: true }); + }); + + const thresholds = ACTIVE_THRESHOLDS['AUDIT-F-17']; + const stats = await waitForFrameSamples(page, thresholds.minFrameSamples); + console.log('--- FRAME STATS UNDER DELAY ---', stats); + + expect(stats.maxContiguousSlowDurationMs).toBeGreaterThan(500); +}); + +test('Performance audit: flags memory accumulation delta under mock leak', async ({ page }) => { + await bootRuntime(page); + + await page.evaluate(() => { + let mockHeapSize = 20_000_000; + Object.defineProperty(window.performance, 'memory', { + value: { + get jsHeapSizeLimit() { + return 2_000_000_000; + }, + get totalJSHeapSize() { + return mockHeapSize; + }, + get usedJSHeapSize() { + mockHeapSize += 100_000; // Increment by 100KB on every read + return mockHeapSize; + }, + }, + configurable: true, + enumerable: true, + }); + }); + + // Reset the frame probe to clear pre-leak samples + await page.evaluate(() => { + window.__MS_GHOSTMAN_FRAME_PROBE__.reset({ clearBuffer: true }); + }); + + const thresholds = ACTIVE_THRESHOLDS['AUDIT-F-17']; + const stats = await waitForFrameSamples(page, thresholds.minFrameSamples); + + expect(stats.memoryAccumulationBytes).toBeGreaterThan(100_000); }); test('AUDIT-F-18 explicit FPS threshold assertions', async ({ page }) => { @@ -423,6 +488,11 @@ test('AUDIT-CI-09 explicit DOM element budget and memory allocation assertions', // Allow for some minor GC noise but fail if > 2MB growth in 200ms expect(growth).toBeLessThan(2 * 1024 * 1024); } + + const stats = await page.evaluate(() => window.__MS_GHOSTMAN_FRAME_PROBE__.getStats()); + if (stats && stats.memoryAccumulationBytes !== undefined) { + expect(stats.memoryAccumulationBytes).toBeLessThan(10_000_000); + } } }); diff --git a/tests/unit/main-ecs.test.js b/tests/unit/main-ecs.test.js new file mode 100644 index 00000000..1826321c --- /dev/null +++ b/tests/unit/main-ecs.test.js @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + assertDomElementBudget, + createGameRuntime, + installUnhandledRejectionHandler, + renderCriticalError, +} from '../../src/main.ecs.js'; + +// Access internal createFrameProbe by extracting it via createGameRuntime's setup +// or we can test it through createGameRuntime's window hook if window is stubbed. +// Wait, createGameRuntime does: +// if (targetWindow) { +// targetWindow[FRAME_PROBE_KEY] = { getStats: frameProbe.getStats }; +// } +// Let's test it by stubbing the dependencies of createGameRuntime. + +describe('main.ecs.js createFrameProbe & helpers', () => { + it('createFrameProbe tracks contiguous slow frames and memory accumulation', () => { + const windowStub = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + performance: { + now: vi.fn().mockReturnValue(0), + memory: { + usedJSHeapSize: 10_000_000, + }, + }, + }; + const documentStub = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const clock = { lastFrameTime: 0, isPaused: false, simTimeMs: 0 }; + const bootstrapStub = { + clock, + world: { frame: 0 }, + gameStatus: { currentState: 'PLAYING' }, + stepFrame: vi.fn(), + getInputAdapter: () => null, + setInputAdapter: () => null, + }; + + let frameCallback = null; + const requestFrame = (cb) => { + frameCallback = cb; + return 1; + }; + const cancelFrame = vi.fn(); + + const runtime = createGameRuntime({ + bootstrap: bootstrapStub, + cancelFrame, + documentRef: documentStub, + frameProbeWarmupFrames: 2, // 2 warmup frames + logger: { error: vi.fn(), warn: vi.fn() }, + nowProvider: windowStub.performance.now, + requestFrame, + windowRef: windowStub, + }); + + runtime.start(); + + // Check targetWindow configuration hooks + const probe = windowStub.__MS_GHOSTMAN_FRAME_PROBE__; + expect(probe).toBeDefined(); + + // Warmup frame 1: delta doesn't exist yet (lastTimestamp not set) + frameCallback(1000); + expect(probe.getStats().sampleCount).toBe(0); + + // Warmup frame 2: consumed as a warmup slot (warmupRemaining 2 -> 1) + frameCallback(1016); + expect(probe.getStats().sampleCount).toBe(0); + + // Warmup frame 3: consumed as a warmup slot (warmupRemaining 1 -> 0) + frameCallback(1032); + expect(probe.getStats().sampleCount).toBe(0); + + // Post-warmup frame 1 (gets recorded, initial memory set to 10MB) + windowStub.performance.memory.usedJSHeapSize = 10_000_000; + frameCallback(1048); // delta = 16 + expect(probe.getStats().sampleCount).toBe(1); + expect(probe.getStats().memoryAccumulationBytes).toBe(0); + + // Post-warmup frame 2 (gets recorded, memory increases by 500KB) + windowStub.performance.memory.usedJSHeapSize = 10_500_000; + frameCallback(1078); // delta = 30 (slow frame > 16.7ms) + expect(probe.getStats().sampleCount).toBe(2); + expect(probe.getStats().memoryAccumulationBytes).toBe(500_000); + expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(30); + + // Post-warmup frame 3 (gets recorded, delta = 40 (slow frame > 16.7ms)) + windowStub.performance.memory.usedJSHeapSize = 11_200_000; + frameCallback(1118); // delta = 40 + expect(probe.getStats().sampleCount).toBe(3); + expect(probe.getStats().memoryAccumulationBytes).toBe(1_200_000); + // Two consecutive slow frames: 30 + 40 = 70 ms + expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(70); + + // Post-warmup frame 4 (gets recorded, delta = 10 (fast frame)) + frameCallback(1128); // delta = 10 + // Consecutive sequence broke, max remains 70 + expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(70); + + // Test recordFrame with invalid inputs + frameCallback(NaN); + frameCallback(undefined); + + runtime.stop(); + }); + + it('assertDomElementBudget checks DOM budget limits', () => { + const mockElements = { length: 10 }; + const documentStub = { + querySelectorAll: vi.fn().mockReturnValue(mockElements), + }; + + // Under limit passes + expect(assertDomElementBudget({ documentRef: documentStub, limit: 15 })).toBe(10); + + // Exceeding limit throws + expect(() => assertDomElementBudget({ documentRef: documentStub, limit: 5 })).toThrowError( + /DOM element budget exceeded/, + ); + }); + + it('renderCriticalError formats and appends error overlay', () => { + const errorElement = { + classList: { + remove: vi.fn(), + }, + setAttribute: vi.fn(), + textContent: '', + }; + const overlayRoot = { + querySelector: vi.fn().mockReturnValue(errorElement), + }; + + renderCriticalError(overlayRoot, new Error('Test crash')); + expect(errorElement.textContent).toBe('Critical error: Test crash'); + }); + + it('installUnhandledRejectionHandler avoids duplicates', () => { + const windowStub = { + addEventListener: vi.fn(), + }; + installUnhandledRejectionHandler({ windowRef: windowStub }); + installUnhandledRejectionHandler({ windowRef: windowStub }); + expect(windowStub.addEventListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 67c79e4b..d1c2fb3e 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -22,6 +22,13 @@ export default defineConfig({ functions: 85, lines: 90, statements: 90, + 'src/main.ecs.js': { + branches: 75, + functions: 70, + lines: 80, + statements: 80, + perFile: true, + }, }, }, }, From d7a620dc8ed3a4e0190c6afe56e08a22e0eccbe4 Mon Sep 17 00:00:00 2001 From: Ertval Karameta Date: Sat, 18 Jul 2026 19:32:36 +0300 Subject: [PATCH 2/4] docs: add pr audit report --- ...t-ekaramet-bugfix-A-281-277-230-batch-4.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md diff --git a/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md b/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md new file mode 100644 index 00000000..6a05932b --- /dev/null +++ b/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md @@ -0,0 +1,56 @@ +# 🛡️ Audit: `ekaramet/bugfix-A-281-277-230-batch-4` +## 🏁 Verdict: PASS + +--- + +## 🎯 Scope & Compliance +- **Ticket ID**: CI-14, CI-03, BUG-15 | **Track**: A +- **Audit Mode**: TICKET +- **Base Comparison**: 765103bbc4ef162a545b9f96a88d9448b3d4014d..HEAD + +### 📦 Deliverables & Verification +- PASS: Implement maxContiguousSlowDurationMs and memoryAccumulationBytes performance tracking (satisfied via createFrameProbe enhancements). +- PASS: Add E2E tests for artificial delay and memory leak conditions (satisfied via tests/e2e/audit/audit.browser.spec.js). +- PASS: Configure branch coverage thresholds of 75% override for main.ecs.js (satisfied via vitest.config.js and tests/unit/main-ecs.test.js). +- PASS: Verify progression logic is preserved across level transitions (satisfied via integration tests). +- **Out-of-Scope Findings**: none + +--- + +## 🔍 Audit Findings & Blockers +### 🚨 Critical (Blockers) +1. None +### ⚠️ High/Medium/Low +1. None + +--- + +## 📋 Requirements, Audit & Drift +- **REQ IDs**: CI-14, CI-03, BUG-15 | **AUDIT IDs**: AUDIT-F-17, AUDIT-F-18 +- PASS: Coverage evidence status (all tests pass, main.ecs.js coverage verified above 75% branch) +- PASS: Manual evidence status (not required; automated checks cover the scope) +- PASS: Feature/Technical Drift Assessment (No Drift) + +--- + +## 🛠️ Automated Gate Summary +- PASS: `npm run policy -- --require-approval=false` (exit=0, duration=110s) + +--- + +## ✅ Policy Matrix +- PASS: Ticket/Track Context Valid +- PASS: Ownership & PR Template Respected +- PASS: ECS DOM Boundary & Adapter Injection +- PASS: Forbidden Tech (canvas/WebGL/frameworks) +- PASS: Security Sinks (innerHTML/eval/timers) +- PASS: Timing, Input, & Rendering Invariants +- PASS: New Files Header Comments +- PASS: Audit Traceability Matrix Mapping +- PASS: No Gameplay/Document/Technical Drift + +--- + +## 📄 Final Report Metadata +- **Date**: 2026-07-18 +- **READY_FOR_MAIN**: YES From 826473351062de8b25a0b21b2e5bcb0147f4a6fc Mon Sep 17 00:00:00 2001 From: Ertval Karameta Date: Sat, 18 Jul 2026 19:43:14 +0300 Subject: [PATCH 3/4] docs: update audit traceability matrix --- docs/implementation/audit-traceability-matrix.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/implementation/audit-traceability-matrix.md b/docs/implementation/audit-traceability-matrix.md index 8646e9b7..a4622c31 100644 --- a/docs/implementation/audit-traceability-matrix.md +++ b/docs/implementation/audit-traceability-matrix.md @@ -1,5 +1,5 @@ # Audit Traceability Matrix - + This document is the single source of truth for requirement-to-audit-to-ticket-to-test coverage. @@ -123,6 +123,7 @@ The following tests verify constraints defined in [AGENTS.md](../../AGENTS.md) t | Query Versioned Cache (#265) | A-13 | `tests/unit/world/world.test.js` | Verifies zero-allocation query cache and correctness on mutations. | | Input Relocation & Latch (#264) | A-13 | `tests/integration/gameplay/a03-game-loop.test.js` | Verifies input snapshotting exactly once per step under catch-up and no input loss on pause-to-resume frames. | | Glob Overlaps De-duplication (#262) | A-13 | `tests/unit/policy-gate/policy-utils.test.js` | Verifies Track A/B/C/D glob pattern exclusivity. | +| DOM element budget (AUDIT-CI-09) | #285 | `tests/e2e/audit/audit.browser.spec.js` | Verifies DOM element budget <= 500 total after level load. | ## Completion Criteria For This Matrix From 771e49885c8e31e04f8d36ba3919563a9b0d2856 Mon Sep 17 00:00:00 2001 From: Ertval Karameta Date: Tue, 4 Aug 2026 17:33:39 +0300 Subject: [PATCH 4/4] refactor: consolidate ecs unit tests and update frame probe e2e helper to accept options --- ...t-ekaramet-bugfix-A-281-277-230-batch-4.md | 16 +- .../audit-traceability-matrix.md | 3 +- tests/e2e/audit/audit.browser.spec.js | 16 +- tests/unit/main-ecs.test.js | 151 ------------- tests/unit/main.ecs.test.js | 202 ++++++++++++++++++ .../policy-gate/playwright-no-tests.test.js | 4 +- vitest.config.js | 1 - 7 files changed, 224 insertions(+), 169 deletions(-) delete mode 100644 tests/unit/main-ecs.test.js diff --git a/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md b/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md index 6a05932b..c985f5db 100644 --- a/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md +++ b/docs/audit-reports/pr-audit-ekaramet-bugfix-A-281-277-230-batch-4.md @@ -4,15 +4,17 @@ --- ## 🎯 Scope & Compliance -- **Ticket ID**: CI-14, CI-03, BUG-15 | **Track**: A +- **Ticket ID**: CI-14 (#281), CI-03 (#277) | **Track**: A - **Audit Mode**: TICKET - **Base Comparison**: 765103bbc4ef162a545b9f96a88d9448b3d4014d..HEAD +> Note on BUG-15 (#230): BUG-15 was previously resolved on `main` in `e5537cb`. This branch contains no progression code modifications. + ### 📦 Deliverables & Verification -- PASS: Implement maxContiguousSlowDurationMs and memoryAccumulationBytes performance tracking (satisfied via createFrameProbe enhancements). +- PASS: Implement maxContiguousSlowDurationMs and memoryAccumulationBytes performance tracking (satisfied via createFrameProbe enhancements in main.ecs.js). - PASS: Add E2E tests for artificial delay and memory leak conditions (satisfied via tests/e2e/audit/audit.browser.spec.js). -- PASS: Configure branch coverage thresholds of 75% override for main.ecs.js (satisfied via vitest.config.js and tests/unit/main-ecs.test.js). -- PASS: Verify progression logic is preserved across level transitions (satisfied via integration tests). +- PASS: Configure branch coverage threshold floor for main.ecs.js (satisfied via vitest.config.js and tests/unit/main.ecs.test.js). +- PASS: Canonical traceability matrix updated for CI-14 (#281) and CI-03 (#277) in docs/implementation/audit-traceability-matrix.md. - **Out-of-Scope Findings**: none --- @@ -26,7 +28,7 @@ --- ## 📋 Requirements, Audit & Drift -- **REQ IDs**: CI-14, CI-03, BUG-15 | **AUDIT IDs**: AUDIT-F-17, AUDIT-F-18 +- **REQ IDs**: CI-14, CI-03 | **AUDIT IDs**: AUDIT-F-17, AUDIT-F-18 - PASS: Coverage evidence status (all tests pass, main.ecs.js coverage verified above 75% branch) - PASS: Manual evidence status (not required; automated checks cover the scope) - PASS: Feature/Technical Drift Assessment (No Drift) @@ -34,7 +36,7 @@ --- ## 🛠️ Automated Gate Summary -- PASS: `npm run policy -- --require-approval=false` (exit=0, duration=110s) +- PASS: `npm run policy -- --require-approval=false` (exit=0) --- @@ -52,5 +54,5 @@ --- ## 📄 Final Report Metadata -- **Date**: 2026-07-18 +- **Date**: 2026-08-04 - **READY_FOR_MAIN**: YES diff --git a/docs/implementation/audit-traceability-matrix.md b/docs/implementation/audit-traceability-matrix.md index a4622c31..69fd91aa 100644 --- a/docs/implementation/audit-traceability-matrix.md +++ b/docs/implementation/audit-traceability-matrix.md @@ -123,7 +123,8 @@ The following tests verify constraints defined in [AGENTS.md](../../AGENTS.md) t | Query Versioned Cache (#265) | A-13 | `tests/unit/world/world.test.js` | Verifies zero-allocation query cache and correctness on mutations. | | Input Relocation & Latch (#264) | A-13 | `tests/integration/gameplay/a03-game-loop.test.js` | Verifies input snapshotting exactly once per step under catch-up and no input loss on pause-to-resume frames. | | Glob Overlaps De-duplication (#262) | A-13 | `tests/unit/policy-gate/policy-utils.test.js` | Verifies Track A/B/C/D glob pattern exclusivity. | -| DOM element budget (AUDIT-CI-09) | #285 | `tests/e2e/audit/audit.browser.spec.js` | Verifies DOM element budget <= 500 total after level load. | +| Frame probe contiguous slow & memory tracking (CI-14) | #281 | `tests/unit/main.ecs.test.js` + `tests/e2e/audit/audit.browser.spec.js` | Verifies createFrameProbe tracks contiguous slow frame duration and memory accumulation, with browser E2E assertions for artificial delay and heap leak. | +| main.ecs.js per-file coverage floor (CI-03) | #277 | `vitest.config.js` | Enforces per-file coverage thresholds (75% branch) for src/main.ecs.js. | ## Completion Criteria For This Matrix diff --git a/tests/e2e/audit/audit.browser.spec.js b/tests/e2e/audit/audit.browser.spec.js index a2a0150a..217dd07c 100644 --- a/tests/e2e/audit/audit.browser.spec.js +++ b/tests/e2e/audit/audit.browser.spec.js @@ -57,11 +57,14 @@ const ACTIVE_THRESHOLDS = { 'AUDIT-B-05': SEMI_AUTOMATABLE_THRESHOLDS['AUDIT-B-05'], }; -async function waitForFrameSamples(page, minimumSamples, timeout = 8_000) { +async function waitForFrameSamples(page, minimumSamples, timeout = 8_000, options = {}) { await expect .poll( async () => { - return page.evaluate(() => window.__MS_GHOSTMAN_FRAME_PROBE__.getStats().sampleCount); + return page.evaluate( + (opts) => window.__MS_GHOSTMAN_FRAME_PROBE__.getStats(opts).sampleCount, + options, + ); }, { timeout, @@ -69,7 +72,7 @@ async function waitForFrameSamples(page, minimumSamples, timeout = 8_000) { ) .toBeGreaterThanOrEqual(minimumSamples); - return page.evaluate(() => window.__MS_GHOSTMAN_FRAME_PROBE__.getStats()); + return page.evaluate((opts) => window.__MS_GHOSTMAN_FRAME_PROBE__.getStats(opts), options); } test('AUDIT-F-01/AUDIT-F-02/AUDIT-B-01 runtime boots and rAF sampling is active', async ({ @@ -306,9 +309,9 @@ test('AUDIT-F-17 explicit frame-drop threshold assertions', async ({ page }) => await bootRuntime(page); const thresholds = ACTIVE_THRESHOLDS['AUDIT-F-17']; - const stats = await page.evaluate((threshold) => { - return window.__MS_GHOSTMAN_FRAME_PROBE__.getStats({ slowFrameThresholdMs: threshold }); - }, thresholds.maxP95FrameTimeMs); + const stats = await waitForFrameSamples(page, thresholds.minFrameSamples, 8_000, { + slowFrameThresholdMs: thresholds.maxP95FrameTimeMs, + }); expect(stats.p95FrameTime).toBeLessThanOrEqual(thresholds.maxP95FrameTimeMs); expect(stats.p99FrameTime).toBeLessThanOrEqual(thresholds.maxP99FrameTimeMs); @@ -338,7 +341,6 @@ test('Performance audit: flags sustained frame drops under artificial delay', as const thresholds = ACTIVE_THRESHOLDS['AUDIT-F-17']; const stats = await waitForFrameSamples(page, thresholds.minFrameSamples); - console.log('--- FRAME STATS UNDER DELAY ---', stats); expect(stats.maxContiguousSlowDurationMs).toBeGreaterThan(500); }); diff --git a/tests/unit/main-ecs.test.js b/tests/unit/main-ecs.test.js deleted file mode 100644 index 1826321c..00000000 --- a/tests/unit/main-ecs.test.js +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - assertDomElementBudget, - createGameRuntime, - installUnhandledRejectionHandler, - renderCriticalError, -} from '../../src/main.ecs.js'; - -// Access internal createFrameProbe by extracting it via createGameRuntime's setup -// or we can test it through createGameRuntime's window hook if window is stubbed. -// Wait, createGameRuntime does: -// if (targetWindow) { -// targetWindow[FRAME_PROBE_KEY] = { getStats: frameProbe.getStats }; -// } -// Let's test it by stubbing the dependencies of createGameRuntime. - -describe('main.ecs.js createFrameProbe & helpers', () => { - it('createFrameProbe tracks contiguous slow frames and memory accumulation', () => { - const windowStub = { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - performance: { - now: vi.fn().mockReturnValue(0), - memory: { - usedJSHeapSize: 10_000_000, - }, - }, - }; - const documentStub = { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }; - const clock = { lastFrameTime: 0, isPaused: false, simTimeMs: 0 }; - const bootstrapStub = { - clock, - world: { frame: 0 }, - gameStatus: { currentState: 'PLAYING' }, - stepFrame: vi.fn(), - getInputAdapter: () => null, - setInputAdapter: () => null, - }; - - let frameCallback = null; - const requestFrame = (cb) => { - frameCallback = cb; - return 1; - }; - const cancelFrame = vi.fn(); - - const runtime = createGameRuntime({ - bootstrap: bootstrapStub, - cancelFrame, - documentRef: documentStub, - frameProbeWarmupFrames: 2, // 2 warmup frames - logger: { error: vi.fn(), warn: vi.fn() }, - nowProvider: windowStub.performance.now, - requestFrame, - windowRef: windowStub, - }); - - runtime.start(); - - // Check targetWindow configuration hooks - const probe = windowStub.__MS_GHOSTMAN_FRAME_PROBE__; - expect(probe).toBeDefined(); - - // Warmup frame 1: delta doesn't exist yet (lastTimestamp not set) - frameCallback(1000); - expect(probe.getStats().sampleCount).toBe(0); - - // Warmup frame 2: consumed as a warmup slot (warmupRemaining 2 -> 1) - frameCallback(1016); - expect(probe.getStats().sampleCount).toBe(0); - - // Warmup frame 3: consumed as a warmup slot (warmupRemaining 1 -> 0) - frameCallback(1032); - expect(probe.getStats().sampleCount).toBe(0); - - // Post-warmup frame 1 (gets recorded, initial memory set to 10MB) - windowStub.performance.memory.usedJSHeapSize = 10_000_000; - frameCallback(1048); // delta = 16 - expect(probe.getStats().sampleCount).toBe(1); - expect(probe.getStats().memoryAccumulationBytes).toBe(0); - - // Post-warmup frame 2 (gets recorded, memory increases by 500KB) - windowStub.performance.memory.usedJSHeapSize = 10_500_000; - frameCallback(1078); // delta = 30 (slow frame > 16.7ms) - expect(probe.getStats().sampleCount).toBe(2); - expect(probe.getStats().memoryAccumulationBytes).toBe(500_000); - expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(30); - - // Post-warmup frame 3 (gets recorded, delta = 40 (slow frame > 16.7ms)) - windowStub.performance.memory.usedJSHeapSize = 11_200_000; - frameCallback(1118); // delta = 40 - expect(probe.getStats().sampleCount).toBe(3); - expect(probe.getStats().memoryAccumulationBytes).toBe(1_200_000); - // Two consecutive slow frames: 30 + 40 = 70 ms - expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(70); - - // Post-warmup frame 4 (gets recorded, delta = 10 (fast frame)) - frameCallback(1128); // delta = 10 - // Consecutive sequence broke, max remains 70 - expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(70); - - // Test recordFrame with invalid inputs - frameCallback(NaN); - frameCallback(undefined); - - runtime.stop(); - }); - - it('assertDomElementBudget checks DOM budget limits', () => { - const mockElements = { length: 10 }; - const documentStub = { - querySelectorAll: vi.fn().mockReturnValue(mockElements), - }; - - // Under limit passes - expect(assertDomElementBudget({ documentRef: documentStub, limit: 15 })).toBe(10); - - // Exceeding limit throws - expect(() => assertDomElementBudget({ documentRef: documentStub, limit: 5 })).toThrowError( - /DOM element budget exceeded/, - ); - }); - - it('renderCriticalError formats and appends error overlay', () => { - const errorElement = { - classList: { - remove: vi.fn(), - }, - setAttribute: vi.fn(), - textContent: '', - }; - const overlayRoot = { - querySelector: vi.fn().mockReturnValue(errorElement), - }; - - renderCriticalError(overlayRoot, new Error('Test crash')); - expect(errorElement.textContent).toBe('Critical error: Test crash'); - }); - - it('installUnhandledRejectionHandler avoids duplicates', () => { - const windowStub = { - addEventListener: vi.fn(), - }; - installUnhandledRejectionHandler({ windowRef: windowStub }); - installUnhandledRejectionHandler({ windowRef: windowStub }); - expect(windowStub.addEventListener).toHaveBeenCalledTimes(1); - }); -}); diff --git a/tests/unit/main.ecs.test.js b/tests/unit/main.ecs.test.js index c4b59365..00459138 100644 --- a/tests/unit/main.ecs.test.js +++ b/tests/unit/main.ecs.test.js @@ -780,3 +780,205 @@ describe('assertDomElementBudget (#285 / CI-13)', () => { ); }); }); + +describe('createFrameProbe & error formatting (CI-14 / #281)', () => { + it('tracks contiguous slow frames and memory accumulation over warmup and active windows', () => { + const windowStub = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + performance: { + now: vi.fn().mockReturnValue(0), + memory: { + usedJSHeapSize: 10_000_000, + }, + }, + }; + const documentStub = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const clock = { lastFrameTime: 0, isPaused: false, simTimeMs: 0 }; + const bootstrapStub = { + clock, + world: { frame: 0 }, + gameStatus: { currentState: 'PLAYING' }, + stepFrame: vi.fn(), + getInputAdapter: () => null, + setInputAdapter: () => null, + }; + + let frameCallback = null; + const requestFrame = (cb) => { + frameCallback = cb; + return 1; + }; + const cancelFrame = vi.fn(); + + const runtime = createGameRuntime({ + bootstrap: bootstrapStub, + cancelFrame, + documentRef: documentStub, + frameProbeWarmupFrames: 2, + logger: { error: vi.fn(), warn: vi.fn() }, + nowProvider: windowStub.performance.now, + requestFrame, + windowRef: windowStub, + }); + + runtime.start(); + + const probe = windowStub.__MS_GHOSTMAN_FRAME_PROBE__; + expect(probe).toBeDefined(); + + // Warmup frame 1: delta doesn't exist yet (lastTimestamp not set) + frameCallback(1000); + expect(probe.getStats().sampleCount).toBe(0); + + // Warmup frame 2: consumed as warmup slot 1 + frameCallback(1016); + expect(probe.getStats().sampleCount).toBe(0); + + // Warmup frame 3: consumed as warmup slot 2 + frameCallback(1032); + expect(probe.getStats().sampleCount).toBe(0); + + // Post-warmup frame 1: recorded, initial memory set to 10MB + windowStub.performance.memory.usedJSHeapSize = 10_000_000; + frameCallback(1048); // delta = 16 + expect(probe.getStats().sampleCount).toBe(1); + expect(probe.getStats().memoryAccumulationBytes).toBe(0); + + // Post-warmup frame 2: slow frame (30ms > 16.7ms), memory increases by 500KB + windowStub.performance.memory.usedJSHeapSize = 10_500_000; + frameCallback(1078); // delta = 30 + expect(probe.getStats().sampleCount).toBe(2); + expect(probe.getStats().memoryAccumulationBytes).toBe(500_000); + expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(30); + + // Post-warmup frame 3: second slow frame (40ms > 16.7ms) + windowStub.performance.memory.usedJSHeapSize = 11_200_000; + frameCallback(1118); // delta = 40 + expect(probe.getStats().sampleCount).toBe(3); + expect(probe.getStats().memoryAccumulationBytes).toBe(1_200_000); + // Two consecutive slow frames: 30 + 40 = 70 ms + expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(70); + + // Post-warmup frame 4: fast frame (10ms) breaks contiguous sequence + frameCallback(1128); // delta = 10 + expect(probe.getStats({ slowFrameThresholdMs: 16.7 }).maxContiguousSlowDurationMs).toBe(70); + + expect(probe.getStats().sampleCount).toBe(4); + + runtime.stop(); + }); + + it('handles reset with and without buffer clearing', () => { + const windowStub = { + performance: { now: () => 100 }, + }; + const documentStub = {}; + const bootstrapStub = { + clock: { lastFrameTime: 0, isPaused: false, simTimeMs: 0 }, + world: { frame: 0 }, + gameStatus: { currentState: 'PLAYING' }, + stepFrame: vi.fn(), + getInputAdapter: () => null, + setInputAdapter: () => null, + }; + let frameCb = null; + const runtime = createGameRuntime({ + bootstrap: bootstrapStub, + cancelFrame: vi.fn(), + documentRef: documentStub, + frameProbeWarmupFrames: 0, + requestFrame: (cb) => { + frameCb = cb; + return 1; + }, + windowRef: windowStub, + }); + runtime.start(); + const probe = windowStub.__MS_GHOSTMAN_FRAME_PROBE__; + + frameCb(100); + frameCb(116); + frameCb(132); + expect(probe.getStats().sampleCount).toBe(2); + + probe.reset({ clearBuffer: true }); + expect(probe.getStats().sampleCount).toBe(0); + expect(probe.getStats().p95FrameTime).toBe(0); + + runtime.stop(); + }); + + it('handles environment without performance.memory', () => { + const windowStub = { + performance: { now: () => 100 }, // No memory property + }; + const documentStub = {}; + const bootstrapStub = { + clock: { lastFrameTime: 0, isPaused: false, simTimeMs: 0 }, + world: { frame: 0 }, + gameStatus: { currentState: 'PLAYING' }, + stepFrame: vi.fn(), + getInputAdapter: () => null, + setInputAdapter: () => null, + }; + let frameCb = null; + const runtime = createGameRuntime({ + bootstrap: bootstrapStub, + cancelFrame: vi.fn(), + documentRef: documentStub, + frameProbeWarmupFrames: 0, + requestFrame: (cb) => { + frameCb = cb; + return 1; + }, + windowRef: windowStub, + }); + runtime.start(); + const probe = windowStub.__MS_GHOSTMAN_FRAME_PROBE__; + + frameCb(100); + frameCb(120); + expect(probe.getStats().memoryAccumulationBytes).toBe(0); + + runtime.stop(); + }); + + it('handles non-finite timestamp fallbacks in normalizeNow', () => { + const windowStub = {}; + const documentStub = {}; + const bootstrapStub = { + clock: { lastFrameTime: 50, isPaused: false, simTimeMs: 0 }, + world: { frame: 0 }, + gameStatus: { currentState: 'PLAYING' }, + stepFrame: vi.fn(), + getInputAdapter: () => null, + setInputAdapter: () => null, + }; + let frameCb = null; + const runtime = createGameRuntime({ + bootstrap: bootstrapStub, + cancelFrame: vi.fn(), + documentRef: documentStub, + frameProbeWarmupFrames: 0, + nowProvider: () => NaN, // getNow returns NaN + requestFrame: (cb) => { + frameCb = cb; + return 1; + }, + windowRef: windowStub, + }); + runtime.start(); + frameCb(NaN); + expect(bootstrapStub.stepFrame).toHaveBeenCalledWith(50, expect.any(Object)); + + bootstrapStub.clock.lastFrameTime = NaN; + frameCb(NaN); + expect(bootstrapStub.stepFrame).toHaveBeenCalledWith(0, expect.any(Object)); + + runtime.stop(); + }); +}); diff --git a/tests/unit/policy-gate/playwright-no-tests.test.js b/tests/unit/policy-gate/playwright-no-tests.test.js index 8db3bdc0..c2f651f0 100644 --- a/tests/unit/policy-gate/playwright-no-tests.test.js +++ b/tests/unit/policy-gate/playwright-no-tests.test.js @@ -19,7 +19,7 @@ describe('Playwright E2E script configuration', () => { }); expect(result.status).not.toBe(0); - }); + }, 15_000); it('test:audit:e2e script exits with a non-zero code if no specs are found', () => { const result = spawnSync( @@ -32,5 +32,5 @@ describe('Playwright E2E script configuration', () => { ); expect(result.status).not.toBe(0); - }); + }, 15_000); }); diff --git a/vitest.config.js b/vitest.config.js index d1c2fb3e..300422bb 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -27,7 +27,6 @@ export default defineConfig({ functions: 70, lines: 80, statements: 80, - perFile: true, }, }, },