From 4c48730e435028440c19c4ccff95444fc00b3087 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 22:07:56 -0300 Subject: [PATCH] feat(inspector-ui): post snapshots to session requests endpoint while recording (R5-5 frontend half) --- .../hooks/useSessionRecorder.ts | 135 ++++++++++++++--- tests/unit/ui/use-session-recorder.test.tsx | 140 ++++++++++++++++++ 2 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 tests/unit/ui/use-session-recorder.test.tsx diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSessionRecorder.ts b/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSessionRecorder.ts index b16e0b8c0c..4951367f3d 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSessionRecorder.ts +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSessionRecorder.ts @@ -1,6 +1,11 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { WsEvent } from "@/mitm/inspector/types"; + +const WS_PATH = "/api/tools/traffic-inspector/ws"; +const SNAPSHOT_FLUSH_MS = 500; +const SNAPSHOT_FLUSH_BATCH = 10; export interface SessionInfo { id: string; @@ -24,6 +29,10 @@ export function useSessionRecorder() { const timerRef = useRef | null>(null); const startTimeRef = useRef(0); const mountedRef = useRef(true); + const recordingWsRef = useRef(null); + const recordingSessionRef = useRef(null); + const pendingSnapshotsRef = useRef([]); + const flushTimerRef = useRef | null>(null); useEffect(() => { mountedRef.current = true; @@ -54,27 +63,101 @@ export function useSessionRecorder() { }; }, []); - const start = useCallback(async (name?: string) => { - try { - const res = await fetch("/api/tools/traffic-inspector/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }); - if (!res.ok) return; - const data = (await res.json()) as { session: SessionInfo }; - setSession(data.session); - setRecording(true); - startTimeRef.current = Date.now(); - setElapsed(0); - timerRef.current = setInterval(() => { - setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); - }, 1000); - } catch { - // ignore + const flushSnapshots = useCallback(async (sessionId: string) => { + if (pendingSnapshotsRef.current.length === 0) return; + const batch = pendingSnapshotsRef.current.splice(0, pendingSnapshotsRef.current.length); + for (const payload of batch) { + try { + await fetch( + `/api/tools/traffic-inspector/sessions/${encodeURIComponent(sessionId)}/requests`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payload }), + } + ); + } catch { + // best-effort: don't break recording UI on network failure + } } }, []); + const scheduleFlush = useCallback( + (sessionId: string) => { + if (flushTimerRef.current) return; + flushTimerRef.current = setTimeout(() => { + flushTimerRef.current = null; + void flushSnapshots(sessionId); + }, SNAPSHOT_FLUSH_MS); + }, + [flushSnapshots] + ); + + const stopRecordingWs = useCallback(() => { + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + if (recordingWsRef.current) { + recordingWsRef.current.onclose = null; + recordingWsRef.current.close(); + recordingWsRef.current = null; + } + }, []); + + const start = useCallback( + async (name?: string) => { + try { + const res = await fetch("/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) return; + const data = (await res.json()) as { session: SessionInfo }; + const newSession = data.session; + setSession(newSession); + recordingSessionRef.current = newSession; + setRecording(true); + startTimeRef.current = Date.now(); + setElapsed(0); + timerRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); + }, 1000); + + // Open a dedicated WS to capture traffic events during the recording window + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${proto}//${window.location.host}${WS_PATH}`; + const ws = new WebSocket(wsUrl); + recordingWsRef.current = ws; + + ws.onmessage = (ev: MessageEvent) => { + if (!mountedRef.current) return; + let event: WsEvent; + try { + event = JSON.parse(ev.data as string) as WsEvent; + } catch { + return; + } + if (event.type !== "new") return; + const sid = recordingSessionRef.current?.id; + if (!sid) return; + pendingSnapshotsRef.current.push(JSON.stringify(event.data)); + if (pendingSnapshotsRef.current.length >= SNAPSHOT_FLUSH_BATCH) { + void flushSnapshots(sid); + } else { + scheduleFlush(sid); + } + }; + + ws.onerror = () => ws.close(); + } catch { + // ignore + } + }, + [flushSnapshots, scheduleFlush] + ); + const stop = useCallback(async () => { if (!session) return; if (timerRef.current) { @@ -82,8 +165,15 @@ export function useSessionRecorder() { timerRef.current = null; } setRecording(false); + // Flush any remaining pending snapshots before stopping + const sid = session.id; + stopRecordingWs(); + if (pendingSnapshotsRef.current.length > 0) { + await flushSnapshots(sid); + } + recordingSessionRef.current = null; try { - await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(session.id)}`, { + await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sid)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "stop" }), @@ -93,7 +183,7 @@ export function useSessionRecorder() { } await fetchSessions(); setSession(null); - }, [session, fetchSessions]); + }, [session, fetchSessions, stopRecordingWs, flushSnapshots]); const deleteSession = useCallback(async (id: string) => { try { @@ -109,6 +199,11 @@ export function useSessionRecorder() { useEffect(() => { return () => { if (timerRef.current) clearInterval(timerRef.current); + if (flushTimerRef.current) clearTimeout(flushTimerRef.current); + if (recordingWsRef.current) { + recordingWsRef.current.onclose = null; + recordingWsRef.current.close(); + } }; }, []); diff --git a/tests/unit/ui/use-session-recorder.test.tsx b/tests/unit/ui/use-session-recorder.test.tsx new file mode 100644 index 0000000000..f4889b244a --- /dev/null +++ b/tests/unit/ui/use-session-recorder.test.tsx @@ -0,0 +1,140 @@ +/** + * Tests for useSessionRecorder (R5-5 frontend half) + * + * Verifies that during recording, new traffic WS events trigger + * POST to /api/tools/traffic-inspector/sessions/{id}/requests. + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const HOOK_SRC = fs.readFileSync( + path.resolve( + __dirname, + "../../../src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSessionRecorder.ts" + ), + "utf8" +); + +describe("useSessionRecorder R5-5 source assertions", () => { + it("opens a WebSocket during start() for traffic capture", () => { + assert.ok( + HOOK_SRC.includes("new WebSocket(wsUrl)"), + "should open a WebSocket connection inside start()" + ); + }); + + it("POSTs to /sessions/{id}/requests on new WS event", () => { + assert.ok( + HOOK_SRC.includes("/requests"), + "should POST to sessions/{id}/requests endpoint" + ); + assert.ok( + HOOK_SRC.includes(`method: "POST"`), + "should use POST method" + ); + assert.ok( + HOOK_SRC.includes("payload"), + "should send payload in body" + ); + }); + + it("buffers events and flushes in batches", () => { + assert.ok( + HOOK_SRC.includes("SNAPSHOT_FLUSH_BATCH"), + "should define SNAPSHOT_FLUSH_BATCH constant" + ); + assert.ok( + HOOK_SRC.includes("SNAPSHOT_FLUSH_MS"), + "should define SNAPSHOT_FLUSH_MS constant for debounce" + ); + assert.ok( + HOOK_SRC.includes("pendingSnapshotsRef"), + "should use a pendingSnapshotsRef buffer" + ); + }); + + it("stops WS and flushes on stop()", () => { + assert.ok( + HOOK_SRC.includes("stopRecordingWs()"), + "stop() should call stopRecordingWs to clean up the WS" + ); + assert.ok( + HOOK_SRC.includes("await flushSnapshots(sid)"), + "stop() should await a final flush before sending PATCH" + ); + }); + + it("handles POST failure gracefully (does not throw)", () => { + // The fetch call must be wrapped in try/catch + const fetchBlock = HOOK_SRC.slice(HOOK_SRC.indexOf("flushSnapshots")); + assert.ok( + fetchBlock.includes("} catch {"), + "POST fetch should be wrapped in try/catch to handle failures gracefully" + ); + }); + + it("only pushes 'new' event type to snapshots", () => { + assert.ok( + HOOK_SRC.includes(`event.type !== "new"`), + "should early-return for non-new events" + ); + }); +}); + +describe("useSessionRecorder snapshot flush logic (unit)", () => { + it("batch threshold triggers immediate flush instead of timer", () => { + // Simulate the flush decision logic + const SNAPSHOT_FLUSH_BATCH = 10; + const pendingSnapshots: string[] = []; + let immediateFlushCalled = false; + let scheduleFlushCalled = false; + + const flushSnapshots = () => { immediateFlushCalled = true; }; + const scheduleFlush = () => { scheduleFlushCalled = true; }; + + // Below threshold + pendingSnapshots.push(JSON.stringify({ id: "req-1" })); + if (pendingSnapshots.length >= SNAPSHOT_FLUSH_BATCH) { + flushSnapshots(); + } else { + scheduleFlush(); + } + assert.equal(immediateFlushCalled, false); + assert.equal(scheduleFlushCalled, true); + + // At threshold + immediateFlushCalled = false; + scheduleFlushCalled = false; + for (let i = 0; i < SNAPSHOT_FLUSH_BATCH - 1; i++) { + pendingSnapshots.push(JSON.stringify({ id: `req-${i + 2}` })); + } + assert.equal(pendingSnapshots.length, SNAPSHOT_FLUSH_BATCH); + if (pendingSnapshots.length >= SNAPSHOT_FLUSH_BATCH) { + flushSnapshots(); + } else { + scheduleFlush(); + } + assert.equal(immediateFlushCalled, true); + assert.equal(scheduleFlushCalled, false); + }); + + it("POST URL is correct format", () => { + const sessionId = "test-session-123"; + const url = `/api/tools/traffic-inspector/sessions/${encodeURIComponent(sessionId)}/requests`; + assert.equal(url, "/api/tools/traffic-inspector/sessions/test-session-123/requests"); + }); + + it("POST body contains stringified payload", () => { + const req = { id: "req-1", host: "api.openai.com", method: "POST" }; + const payload = JSON.stringify(req); + const body = JSON.stringify({ payload }); + const parsed = JSON.parse(body) as { payload: string }; + assert.equal(parsed.payload, payload); + const reparsed = JSON.parse(parsed.payload) as typeof req; + assert.equal(reparsed.id, "req-1"); + }); +});