diff --git a/packages/toolkit/src/cli/commands/report.ts b/packages/toolkit/src/cli/commands/report.ts
index f5c4128..bf0b883 100644
--- a/packages/toolkit/src/cli/commands/report.ts
+++ b/packages/toolkit/src/cli/commands/report.ts
@@ -10,7 +10,7 @@ import {
ParseError,
type Trace,
} from '../../core/index.js';
-import { generateMarkdownReport } from '../../reporter/index.js';
+import { generateMarkdownReport, generateHtmlReport } from '../../reporter/index.js';
import { CliError, readTraceFile } from '../utils.js';
export interface ReportOptions {
@@ -58,14 +58,19 @@ export async function reportCommand(file: string, options: ReportOptions): Promi
const failures = detectFailures(result.events, sessions);
const summaries = summarizeSessions(sessions, failures);
- const report = generateMarkdownReport({
+ const analysisResult = {
events: result.events,
sessions,
failures,
summaries,
warnings: result.warnings,
metadata,
- });
+ };
+
+ const report =
+ options.format === 'html'
+ ? generateHtmlReport(analysisResult)
+ : generateMarkdownReport(analysisResult);
if (options.output) {
const { writeFileSync } = await import('node:fs');
diff --git a/packages/toolkit/src/reporter/html.test.ts b/packages/toolkit/src/reporter/html.test.ts
new file mode 100644
index 0000000..0253eab
--- /dev/null
+++ b/packages/toolkit/src/reporter/html.test.ts
@@ -0,0 +1,298 @@
+import { describe, it, expect } from 'vitest';
+import { generateHtmlReport } from './html.js';
+import type { AnalysisResult } from './types.js';
+import type { Event, Failure, Session, SessionSummary, RawOcppMessage } from '../core/index.js';
+
+// Helpers
+function makeEvent(
+ id: string,
+ messageId: string,
+ messageType: 'Call' | 'CallResult' | 'CallError',
+ action: string | null,
+ timestamp: number | null = null,
+): Event {
+ let rawMessage: RawOcppMessage;
+ if (messageType === 'Call') {
+ rawMessage = [2, messageId, action as string, {}];
+ } else if (messageType === 'CallResult') {
+ rawMessage = [3, messageId, {}];
+ } else {
+ rawMessage = [4, messageId, 'Error', 'desc', {}];
+ }
+ return {
+ id,
+ messageId,
+ timestamp,
+ direction: 'CS_TO_CSMS',
+ messageType,
+ action,
+ payload: {},
+ errorCode: messageType === 'CallError' ? 'Error' : null,
+ errorDescription: messageType === 'CallError' ? 'desc' : null,
+ rawMessage,
+ };
+}
+
+function makeSession(
+ sessionId: string,
+ events: Event[],
+ transactionId: number | null = 100001,
+): Session {
+ return {
+ sessionId,
+ stationId: 'CS-001',
+ connectorId: 1,
+ transactionId,
+ startTime: events[0]?.timestamp ?? null,
+ endTime: events[events.length - 1]?.timestamp ?? null,
+ events,
+ status: 'completed',
+ };
+}
+
+function makeFailure(): Failure {
+ return {
+ code: 'FAILED_AUTHORIZATION',
+ description: 'Authorization rejected: idTag returned "Invalid" status',
+ severity: 'warning',
+ eventIds: ['evt-0001', 'evt-0002'],
+ suggestedSteps: ['Verify the idTag is valid', 'Check the CSMS local authorization list'],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('generateHtmlReport', () => {
+ it('generates a non-empty HTML string', () => {
+ const result: AnalysisResult = {
+ events: [makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000)],
+ sessions: [],
+ failures: [],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(typeof report).toBe('string');
+ expect(report.length).toBeGreaterThan(0);
+ expect(report.toLowerCase()).toContain('');
+ });
+
+ it('contains the main title', () => {
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('OCPP DebugKit — Trace Analysis Report');
+ });
+
+ it('contains section headers (Session Overview, Failures, etc.)', () => {
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('Session Overview');
+ expect(report).toContain('Timeline Summary');
+ expect(report).toContain('Failures');
+ expect(report).toContain('Suggested Next Steps');
+ expect(report).toContain('Event Appendix');
+ });
+
+ it('shows no-failures message when clean', () => {
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('No failures detected');
+ });
+
+ it('includes failure details when failures are present', () => {
+ const failure = makeFailure();
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [failure],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('FAILED_AUTHORIZATION');
+ expect(report).toContain('Authorization rejected');
+ expect(report).toContain('Verify the idTag is valid');
+ expect(report).toContain('Check the CSMS local authorization list');
+ });
+
+ it('includes session overview with session details', () => {
+ const events = [
+ makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000),
+ makeEvent('evt-0002', 'msg-002', 'Call', 'StartTransaction', 2000),
+ ];
+ const session = makeSession('session-0', events);
+ const result: AnalysisResult = {
+ events,
+ sessions: [session],
+ failures: [],
+ summaries: [
+ {
+ sessionId: 'session-0',
+ stationId: 'CS-001',
+ connectorId: 1,
+ transactionId: 100001,
+ status: 'completed',
+ eventCount: 2,
+ durationMs: 1000,
+ failureCount: 0,
+ actionSequence: ['BootNotification', 'StartTransaction'],
+ },
+ ],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('CS-001');
+ expect(report).toContain('session-0');
+ });
+
+ it('includes timeline summary with action sequence', () => {
+ const summary: SessionSummary = {
+ sessionId: 'session-0',
+ stationId: 'CS-001',
+ connectorId: 1,
+ transactionId: 100001,
+ status: 'completed',
+ eventCount: 5,
+ durationMs: 30000,
+ failureCount: 0,
+ actionSequence: ['BootNotification', 'Authorize', 'StartTransaction'],
+ };
+
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [],
+ summaries: [summary],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('BootNotification → Authorize → StartTransaction');
+ });
+
+ it('includes event appendix with event details', () => {
+ const events = [
+ makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000),
+ makeEvent('evt-0002', 'msg-001', 'CallResult', null, 1500),
+ ];
+ const result: AnalysisResult = {
+ events,
+ sessions: [],
+ failures: [],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).toContain('evt-0001');
+ expect(report).toContain('BootNotification');
+ });
+
+ it('escapes HTML entities in failure descriptions to prevent XSS', () => {
+ const failure: Failure = {
+ code: 'FAILED_AUTHORIZATION',
+ description: 'Payload contained ',
+ severity: 'warning',
+ eventIds: ['evt-0001'],
+ suggestedSteps: ['Fix the config'],
+ };
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [failure],
+ summaries: [],
+ warnings: [],
+ };
+
+ const report = generateHtmlReport(result);
+ // The literal ');
+ // It must be escaped
+ expect(report).toContain('<script>');
+ expect(report).toContain('<b>config</b>');
+ });
+
+ it('escapes HTML entities in metadata', () => {
+ const result: AnalysisResult = {
+ events: [],
+ sessions: [],
+ failures: [],
+ summaries: [],
+ warnings: [],
+ metadata: {
+ stationId: 'CS-',
+ description: '',
+ },
+ };
+
+ const report = generateHtmlReport(result);
+ expect(report).not.toContain('');
+ expect(report).toContain('<img src=x>');
+ expect(report).toContain('<script>evil()</script>');
+ });
+
+ it('produces a self-contained document with no external src/href links', () => {
+ const events = [makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000)];
+ const result: AnalysisResult = {
+ events,
+ sessions: [makeSession('session-0', events)],
+ failures: [makeFailure()],
+ summaries: [
+ {
+ sessionId: 'session-0',
+ stationId: 'CS-001',
+ connectorId: 1,
+ transactionId: 100001,
+ status: 'completed',
+ eventCount: 1,
+ durationMs: 1000,
+ failureCount: 1,
+ actionSequence: ['BootNotification'],
+ },
+ ],
+ warnings: [{ index: 0, message: 'Line 1: test warning' }],
+ metadata: {
+ stationId: 'CS-001',
+ ocppVersion: '1.6',
+ source: 'csms-log',
+ description: 'Test trace',
+ },
+ };
+
+ const report = generateHtmlReport(result);
+ // No external resource links: src="http or href="http
+ expect(report).not.toMatch(/src\s*=\s*["']https?:\/\//i);
+ expect(report).not.toMatch(/href\s*=\s*["']https?:\/\//i);
+ // No tags to external stylesheets
+ expect(report).not.toMatch(/ external imports
+ expect(report).not.toMatch(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/** Format a duration in milliseconds as a human-readable string. */
+function formatDuration(ms: number | null): string {
+ if (ms === null) return 'Unknown';
+ if (ms < 1000) return `${ms}ms`;
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+ return `${hours}h ${remainingMinutes}m`;
+}
+
+/** Format an epoch millisecond timestamp as ISO 8601. */
+function formatTimestamp(ms: number | null): string {
+ if (ms === null) return 'Unknown';
+ return new Date(ms).toISOString();
+}
+
+/** Get severity badge class for a failure. */
+function severityClass(severity: string): string {
+ switch (severity) {
+ case 'critical':
+ return 'severity-critical';
+ case 'warning':
+ return 'severity-warning';
+ case 'info':
+ return 'severity-info';
+ default:
+ return 'severity-default';
+ }
+}
+
+/** Get severity emoji for a failure. */
+function severityEmoji(severity: string): string {
+ switch (severity) {
+ case 'critical':
+ return '🔴';
+ case 'warning':
+ return '🟡';
+ case 'info':
+ return '🔵';
+ default:
+ return '⚪';
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CSS theme (dark)
+// ---------------------------------------------------------------------------
+
+const CSS = `
+ :root {
+ color-scheme: dark;
+ }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ background: #0d1117;
+ color: #c9d1d9;
+ margin: 0;
+ padding: 2rem 1rem;
+ line-height: 1.6;
+ }
+ .container {
+ max-width: 960px;
+ margin: 0 auto;
+ }
+ h1 {
+ color: #f0f6fc;
+ border-bottom: 1px solid #30363d;
+ padding-bottom: 0.5rem;
+ }
+ h2 {
+ color: #58a6ff;
+ margin-top: 2.5rem;
+ border-bottom: 1px solid #21262d;
+ padding-bottom: 0.3rem;
+ }
+ h3 {
+ color: #d2a8ff;
+ margin-top: 1.5rem;
+ }
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 1rem 0;
+ font-size: 0.875rem;
+ }
+ th, td {
+ border: 1px solid #30363d;
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ }
+ th {
+ background: #161b22;
+ color: #f0f6fc;
+ }
+ tr:nth-child(even) {
+ background: #161b22;
+ }
+ code {
+ background: #161b22;
+ padding: 0.15rem 0.4rem;
+ border-radius: 4px;
+ font-size: 0.85em;
+ }
+ .header-meta {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.25rem 1rem;
+ margin: 1rem 0;
+ }
+ .header-meta dt {
+ color: #8b949e;
+ font-weight: 600;
+ }
+ .stats {
+ display: flex;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+ margin: 1rem 0;
+ }
+ .stat {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 0.75rem 1.25rem;
+ text-align: center;
+ }
+ .stat .stat-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #f0f6fc;
+ }
+ .stat .stat-label {
+ color: #8b949e;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+ .failure {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 1rem 1.25rem;
+ margin: 1rem 0;
+ }
+ .severity-badge {
+ display: inline-block;
+ padding: 0.15rem 0.5rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+ .severity-critical { background: #da3633; color: #fff; }
+ .severity-warning { background: #d29922; color: #1a1a1a; }
+ .severity-info { background: #1f6feb; color: #fff; }
+ .severity-default { background: #30363d; color: #c9d1d9; }
+ .ok-message {
+ color: #3fb950;
+ font-style: italic;
+ }
+ ol, ul { padding-left: 1.5rem; }
+ .event-id { color: #8b949e; font-family: monospace; }
+ footer {
+ color: #8b949e;
+ font-size: 0.8rem;
+ margin-top: 3rem;
+ padding-top: 1rem;
+ border-top: 1px solid #21262d;
+ }
+`;
+
+// ---------------------------------------------------------------------------
+// Section generators
+// ---------------------------------------------------------------------------
+
+function generateHead(): string {
+ return `
No sessions detected.
'; + } + + const rows = result.sessions + .map((session) => { + const summary = result.summaries.find((s) => s.sessionId === session.sessionId); + const duration = summary?.durationMs ?? null; + return `| Session | Station | Connector | Transaction | Start | End | Duration | Status |
|---|
No sessions to summarize.
'; + } + + const blocks = result.summaries + .map((summary) => { + const actionSequence = escapeHtml(summary.actionSequence.join(' → ')) || 'None'; + return `${escapeHtml(failure.severity)}
+Description: ${escapeHtml(failure.description)}
+Events: ${eventIds}
+Suggested Steps:
+No events to display.
'; + } + + function formatEventCompact(event: Event): string { + const time = formatTimestamp(event.timestamp); + const action = event.action ?? '-'; + return `${escapeHtml(event.messageId)}| ID | Timestamp | Direction | Type | Action | MessageId |
|---|