// Small CDP client and retry policy shared with the harness regression test. export function nextAttempt(index, position, attempt, maxRetries) { if (position > index) return { index: Math.max(index + 1, position), attempt: 0 }; if (attempt >= maxRetries) return { index: index + 1, attempt: 0, gaveUp: true }; // Never rewind to a lagging follower after giving up on an earlier event. return { index, attempt: attempt + 1, retry: true }; } // A retry is scheduled after the bounded wait, but a slow follower can commit // during the beat of grace before that retry is emitted. Re-read the tracker // immediately before dispatch so a stale retry cannot re-strike an event that // has already advanced. export function retryAlreadyCommitted(index, position) { return Number.isFinite(position) && position > index; } export function notePilotTarget(targets) { return targets.find(t => t.type === 'page' && /^http:\/\/(localhost|127\.0\.0\.1):8000\//.test(t.url)); } export function summariseRun(trace) { const attempts = trace.events.filter(e => e.type === 'input.testEvent'); const commits = trace.events.filter(e => e.type === 'follow.advance' && e.accepted !== false); const indices = new Set(attempts.map(e => e.index)); const onTime = new Set(), late = new Set(); const unmatched = []; const latencies = []; for (const c of commits) { const previous = attempts.filter(a => a.index === c.toIndex && a.t <= c.t); const a = previous.at(-1); if (!a) { unmatched.push({index:c.toIndex,t:c.t,reason:'no prior attempt for this score event'}); continue; } const lag = c.t - a.t; if (lag <= 450) { onTime.add(c.toIndex); latencies.push(lag); } else late.add(c.toIndex); } return { attemptedEvents:indices.size, attempts:attempts.length, detectedWithin450ms:onTime.size, detectedOnlyLate:[...late].filter(i => !onTime.has(i)).length, undetected:[...indices].filter(i => !onTime.has(i) && !late.has(i)), commitsWithoutPriorAttempt:unmatched, retries:attempts.filter(a => a.attempt > 0).length, autoSkips:trace.events.filter(e => e.type === 'skip' && e.reason === 'auto') .map(e => ({index:e.fromIndex,heldMs:e.heldMs,dueHeldMs:e.dueHeldMs})), medianDetectionMs:latencies.length ? latencies.sort((a,b)=>a-b)[Math.floor(latencies.length/2)] : null, invalid:trace.events.filter(e => e.type === 'test.invalid').map(e => e.reason), }; } export async function connectNotePilot(port = 9222) { const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`, {signal:AbortSignal.timeout(5000)})).json(); const page = notePilotTarget(targets); if (!page) throw Error('No NotePilot tab found on localhost:8000'); const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { const timer = setTimeout(() => { ws.close(); reject(Error('CDP connection timed out')); }, 5000); ws.onopen = () => { clearTimeout(timer); resolve(); }; ws.onerror = () => { clearTimeout(timer); reject(Error('CDP connection failed')); }; }); let id = 0; const pending = new Map(); ws.onmessage = e => { const msg = JSON.parse(e.data), call = pending.get(msg.id); if (!call) return; pending.delete(msg.id); clearTimeout(call.timer); if (msg.error) call.reject(Error(JSON.stringify(msg.error))); else call.resolve(msg.result); }; ws.onclose = () => { for (const call of pending.values()) { clearTimeout(call.timer); call.reject(Error('CDP closed')); } pending.clear(); }; const send = (method, params = {}, timeout = 15000) => new Promise((resolve, reject) => { const requestId = ++id; const timer = setTimeout(() => { pending.delete(requestId); reject(Error(`CDP timed out: ${method}`)); }, timeout); pending.set(requestId, {resolve, reject, timer}); ws.send(JSON.stringify({id:requestId, method, params})); }); const evaluate = async (expression, timeout = 15000) => { const r = await send('Runtime.evaluate', {expression, awaitPromise:true, returnByValue:true, userGesture:true}, timeout); if (r.exceptionDetails) throw Error(r.exceptionDetails.exception?.description || JSON.stringify(r.exceptionDetails)); return r.result.value; }; return {send, evaluate, close: () => ws.close()}; }