Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# πŸ›‘οΈ Audit: `ekaramet/bugfix-A-281-277-230-batch-4`
## 🏁 Verdict: PASS

---

## 🎯 Scope & Compliance
- **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 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 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

---

## πŸ” Audit Findings & Blockers
### 🚨 Critical (Blockers)
1. None
### ⚠️ High/Medium/Low
1. None

---

## πŸ“‹ Requirements, Audit & Drift
- **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)

---

## πŸ› οΈ Automated Gate Summary
- PASS: `npm run policy -- --require-approval=false` (exit=0)

---

## βœ… 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-08-04
- **READY_FOR_MAIN**: YES
5 changes: 3 additions & 2 deletions docs/implementation/audit-traceability-matrix.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Audit Traceability Matrix
<!-- Last modified: 2026-06-09 (stagger tests updates) -->
<!-- Last modified: 2026-07-18 (frame rate & memory verification) -->

This document is the single source of truth for requirement-to-audit-to-ticket-to-test coverage.

Expand Down Expand Up @@ -123,10 +123,11 @@ 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. |
| 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. |
| Playwright config constraints (#276, #274, #273) | A-07 | `tests/e2e/audit/audit.e2e.test.js` | Enforces workers capped to 1 in CI, fullyParallel set to false, and browser projects configured (Chromium, Firefox, WebKit, and preview server). |
| Production CSP & frame busting (#273) | A-07 | `tests/e2e/production-csp.spec.js` | Verifies production CSP meta tags, clickjacking HTTP headers (`X-Frame-Options`), and frame-busting breakout redirect behavior. |


## Completion Criteria For This Matrix

1. Every requirement row must map to ticket owners, audit IDs, and a verification anchor.
Expand Down
60 changes: 57 additions & 3 deletions src/main.ecs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)) {
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -618,6 +671,7 @@ export function createGameRuntime({
if (targetWindow) {
targetWindow[FRAME_PROBE_KEY] = {
getStats: frameProbe.getStats,
reset: frameProbe.reset,
};
targetWindow[RUNTIME_HOOK_KEY] = controls;
}
Expand Down
80 changes: 76 additions & 4 deletions tests/e2e/audit/audit.browser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,22 @@ 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,
},
)
.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 ({
Expand Down Expand Up @@ -306,10 +309,74 @@ 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 waitForFrameSamples(page, thresholds.minFrameSamples, 8_000, {
slowFrameThresholdMs: 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);

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 }) => {
Expand Down Expand Up @@ -423,6 +490,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);
}
}
});

Expand Down
Loading
Loading