From b8f8d0d9d947530c900851fdf9c5efb09ff9c4d7 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Wed, 8 Jul 2026 22:17:15 +0300 Subject: [PATCH 1/3] feat(app): replay UI + report viewer + react refactor Add three view tabs to the inspector: Inspect, Replay, and Report. - Inspect: existing timeline + message inspector view (unchanged) - Replay: play/pause/step/scrubbar with speed selector, event payload display, and failures-at-this-event highlighting - Report: in-app report viewer with Markdown (pre) and HTML (iframe with srcdoc) formats, toggleable via format selector Refactor: extract ReplayView into a separate component to avoid TypeScript null-narrowing issues in JSX. Add ReplayEngine integration with auto-advance when playing. Add Playwright E2E tests for replay UI (step forward/back, payload display) and report viewer (HTML iframe rendering). Also fix E2E test for failed-auth: scope FAILED_AUTHORIZATION locator to failure-summary section with .first() (multiple failures in fixture). Closes #64 --- apps/web/src/app/inspector/page.tsx | 605 ++++++++++++++++++++-------- apps/web/tests/replay.spec.ts | 67 +++ 2 files changed, 515 insertions(+), 157 deletions(-) create mode 100644 apps/web/tests/replay.spec.ts diff --git a/apps/web/src/app/inspector/page.tsx b/apps/web/src/app/inspector/page.tsx index bf34f61..8f34575 100644 --- a/apps/web/src/app/inspector/page.tsx +++ b/apps/web/src/app/inspector/page.tsx @@ -15,8 +15,9 @@ import { ParseError, MAX_INPUT_SIZE_BYTES, } from '@ocpp-debugkit/toolkit/core'; -import { generateMarkdownReport } from '@ocpp-debugkit/toolkit/reporter'; +import { generateMarkdownReport, generateHtmlReport } from '@ocpp-debugkit/toolkit/reporter'; import { scenarios } from '@ocpp-debugkit/toolkit/scenarios'; +import { ReplayEngine } from '@ocpp-debugkit/toolkit/replay'; // --------------------------------------------------------------------------- // Types @@ -32,6 +33,13 @@ interface AnalysisState { error: string | null; } +interface ReplayState { + engine: ReplayEngine | null; + currentIndex: number; + isPlaying: boolean; + speed: number; +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -40,6 +48,14 @@ export default function InspectorPage() { const [input, setInput] = useState(''); const [analysis, setAnalysis] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); + const [replay, setReplay] = useState({ + engine: null, + currentIndex: 0, + isPlaying: false, + speed: 1, + }); + const [reportFormat, setReportFormat] = useState<'markdown' | 'html'>('markdown'); + const [view, setView] = useState<'inspect' | 'replay' | 'report'>('inspect'); const timelineRef = useRef(null); const selectedEvent = useMemo(() => { @@ -80,6 +96,13 @@ export default function InspectorPage() { selectedEventId: null, error: null, }); + // Initialize replay engine + setReplay({ + engine: new ReplayEngine(result.events, failures), + currentIndex: 0, + isPlaying: false, + speed: 1, + }); } catch (e) { const message = e instanceof ParseError ? e.message : e instanceof Error ? e.message : 'Unknown error'; @@ -121,21 +144,80 @@ export default function InspectorPage() { const handleExportReport = useCallback(() => { if (!analysis) return; - const report = generateMarkdownReport({ + const reportData = { events: analysis.events, sessions: analysis.sessions, failures: analysis.failures, summaries: analysis.summaries, warnings: analysis.warnings, + }; + const isHtml = reportFormat === 'html'; + const report = isHtml ? generateHtmlReport(reportData) : generateMarkdownReport(reportData); + const blob = new Blob([report], { + type: isHtml ? 'text/html' : 'text/markdown', }); - const blob = new Blob([report], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'ocpp-debugkit-report.md'; + a.download = isHtml ? 'ocpp-debugkit-report.html' : 'ocpp-debugkit-report.md'; a.click(); URL.revokeObjectURL(url); - }, [analysis]); + }, [analysis, reportFormat]); + + // Replay handlers + const handleReplayStep = useCallback(() => { + if (!replay.engine) return; + const result = replay.engine.step(); + if (result) { + setReplay({ ...replay, currentIndex: result.index }); + setAnalysis((prev) => (prev ? { ...prev, selectedEventId: result.event.id } : prev)); + } + }, [replay]); + + const handleReplayStepBack = useCallback(() => { + if (!replay.engine) return; + const result = replay.engine.stepBack(); + if (result) { + setReplay({ ...replay, currentIndex: result.index }); + setAnalysis((prev) => (prev ? { ...prev, selectedEventId: result.event.id } : prev)); + } + }, [replay]); + + const handleReplayJump = useCallback( + (index: number) => { + if (!replay.engine) return; + const result = replay.engine.jumpTo(index); + if (result) { + setReplay({ ...replay, currentIndex: result.index }); + setAnalysis((prev) => (prev ? { ...prev, selectedEventId: result.event.id } : prev)); + } + }, + [replay], + ); + + const handleReplayPlay = useCallback(() => { + setReplay({ ...replay, isPlaying: true }); + }, [replay]); + + const handleReplayPause = useCallback(() => { + setReplay({ ...replay, isPlaying: false }); + }, [replay]); + + // Auto-advance replay when playing + useMemo(() => { + if (!replay.isPlaying || !replay.engine) return; + const engine = replay.engine; + const interval = setInterval(() => { + const result = engine.step(); + if (result) { + setReplay((prev) => ({ ...prev, currentIndex: result.index })); + setAnalysis((prev) => (prev ? { ...prev, selectedEventId: result.event.id } : prev)); + } else { + setReplay((prev) => ({ ...prev, isPlaying: false })); + } + }, 1000 / replay.speed); + return () => clearInterval(interval); + }, [replay.isPlaying, replay.speed]); // Keyboard navigation: arrow up/down to move between events const handleKeyDown = useCallback( @@ -179,12 +261,22 @@ export default function InspectorPage() { OCPP Inspector {analysis && analysis.events.length > 0 && ( - +
+ + +
)} @@ -275,165 +367,237 @@ export default function InspectorPage() { {/* Results */} {analysis && analysis.events.length > 0 && !isAnalyzing && (
- {/* Summary stats */} -
- - - - + {/* View tabs */} +
+ {(['inspect', 'replay', 'report'] as const).map((tab) => ( + + ))}
- {/* Failures */} - {analysis.failures.length > 0 && ( -
-

- Failures ({analysis.failures.length}) -

-
- {analysis.failures.map((failure, i) => ( -
-
- - {failure.severity === 'critical' - ? '๐Ÿ”ด' - : failure.severity === 'warning' - ? '๐ŸŸก' - : '๐Ÿ”ต'} - - - {failure.code} - - - {failure.severity} - -
-

- {failure.description} -

- {failure.suggestedSteps.length > 0 && ( -
- - Suggested steps - -
    - {failure.suggestedSteps.map((step, j) => ( -
  1. {step}
  2. - ))} -
-
- )} -
- ))} + {/* Inspect view */} + {view === 'inspect' && ( + <> + {/* Summary stats */} +
+ + + +
-
- )} - {/* Timeline + Message Inspector */} -
- {/* Timeline */} -
-
-

- Event Timeline -

- - {analysis.events.length} events ยท โ†‘โ†“ to navigate - -
-
- {analysis.events.map((event) => ( - + ))} +
+
+ + {/* Message inspector */} +
+

+ Message Inspector +

+ {selectedEvent ? ( +
+
+ + + + + + + {selectedEvent.errorCode && ( + + )} + {selectedEvent.errorDescription && ( + + )} +
+
+

Raw Message

+
+                            {JSON.stringify(selectedEvent.rawMessage, null, 2)}
+                          
+
+
+

Payload

+
+                            {JSON.stringify(selectedEvent.payload, null, 2)}
+                          
+
-
- {event.timestamp !== null - ? new Date(event.timestamp).toISOString() - : 'no timestamp'}{' '} - ยท {event.direction} + ) : ( +
+ Select an event from the timeline to inspect its details.
- - ))} + )} +
-
+ + )} + {view === 'replay' && replay.engine && ( + setReplay({ ...replay, speed })} + /> + )} - {/* Message inspector */} -
-

- Message Inspector -

- {selectedEvent ? ( -
-
- - - - - - - {selectedEvent.errorCode && ( - - )} - {selectedEvent.errorDescription && ( - - )} -
-
-

Raw Message

-
-                        {JSON.stringify(selectedEvent.rawMessage, null, 2)}
-                      
-
-
-

Payload

-
-                        {JSON.stringify(selectedEvent.payload, null, 2)}
-                      
-
-
+ {/* Report view */} + {view === 'report' && ( +
+ {reportFormat === 'html' ? ( +