From c99176302cbb11b7f4807f1d09a625cc918774b2 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Wed, 8 Jul 2026 20:37:50 +0300 Subject: [PATCH] feat(reporter): HTML report format + CLI --format html Closes #62 --- packages/toolkit/src/cli/commands/report.ts | 11 +- packages/toolkit/src/reporter/html.test.ts | 298 ++++++++++++++ packages/toolkit/src/reporter/html.ts | 431 ++++++++++++++++++++ packages/toolkit/src/reporter/index.ts | 1 + 4 files changed, 738 insertions(+), 3 deletions(-) create mode 100644 packages/toolkit/src/reporter/html.test.ts create mode 100644 packages/toolkit/src/reporter/html.ts 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 ` + + + OCPP DebugKit — Trace Analysis Report + +`; +} + +function generateHeader(result: AnalysisResult): string { + const parts: string[] = ['

OCPP DebugKit — Trace Analysis Report

']; + + // Metadata + if (result.metadata) { + const m = result.metadata; + const metaRows: string[] = []; + if (m.stationId) { + metaRows.push(`
Station
${escapeHtml(m.stationId)}
`); + } + if (m.ocppVersion) { + metaRows.push(`
OCPP Version
${escapeHtml(m.ocppVersion)}
`); + } + if (m.source) { + metaRows.push(`
Source
${escapeHtml(m.source)}
`); + } + if (m.description) { + metaRows.push(`
Description
${escapeHtml(m.description)}
`); + } + if (metaRows.length > 0) { + parts.push(`
${metaRows.join('')}
`); + } + } + + // Stats + const stats = [ + { label: 'Events', value: result.events.length }, + { label: 'Sessions', value: result.sessions.length }, + { label: 'Failures', value: result.failures.length }, + { label: 'Warnings', value: result.warnings.length }, + ]; + const statHtml = stats + .map( + (s) => + `
${escapeHtml(s.value)}
${escapeHtml(s.label)}
`, + ) + .join(''); + parts.push(`
${statHtml}
`); + + // Parse warnings + if (result.warnings.length > 0) { + const items = result.warnings.map((w) => `
  • ${escapeHtml(w.message)}
  • `).join(''); + parts.push(`

    Parse Warnings

      ${items}
    `); + } + + return parts.join('\n'); +} + +function generateSessionOverview(result: AnalysisResult): string { + if (result.sessions.length === 0) { + return '

    Session Overview

    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 ` + ${escapeHtml(session.sessionId)} + ${escapeHtml(session.stationId)} + ${escapeHtml(session.connectorId ?? '-')} + ${escapeHtml(session.transactionId ?? '-')} + ${escapeHtml(formatTimestamp(session.startTime))} + ${escapeHtml(formatTimestamp(session.endTime))} + ${escapeHtml(formatDuration(duration))} + ${escapeHtml(session.status)} +`; + }) + .join('\n'); + + return `

    Session Overview

    + + + +${rows} + +
    SessionStationConnectorTransactionStartEndDurationStatus
    `; +} + +function generateTimelineSummary(result: AnalysisResult): string { + if (result.summaries.length === 0) { + return '

    Timeline Summary

    No sessions to summarize.

    '; + } + + const blocks = result.summaries + .map((summary) => { + const actionSequence = escapeHtml(summary.actionSequence.join(' → ')) || 'None'; + return `

    ${escapeHtml(summary.sessionId)}

    +
      +
    • Events: ${escapeHtml(summary.eventCount)}
    • +
    • Duration: ${escapeHtml(formatDuration(summary.durationMs))}
    • +
    • Failures: ${escapeHtml(summary.failureCount)}
    • +
    • Action Sequence: ${actionSequence}
    • +
    `; + }) + .join('\n'); + + return `

    Timeline Summary

    \n${blocks}`; +} + +function generateFailures(result: AnalysisResult): string { + if (result.failures.length === 0) { + return '

    Failures

    No failures detected. ✅

    '; + } + + const blocks = result.failures + .map((failure) => { + const stepsItems = failure.suggestedSteps + .map((step) => `
  • ${escapeHtml(step)}
  • `) + .join('\n'); + const eventIds = failure.eventIds + .map((id) => `${escapeHtml(id)}`) + .join(', '); + return `
    +

    ${escapeHtml(severityEmoji(failure.severity))} ${escapeHtml(failure.code)}

    +

    ${escapeHtml(failure.severity)}

    +

    Description: ${escapeHtml(failure.description)}

    +

    Events: ${eventIds}

    +

    Suggested Steps:

    +
      +${stepsItems} +
    +
    `; + }) + .join('\n'); + + return `

    Failures

    \n${blocks}`; +} + +function generateSuggestedSteps(result: AnalysisResult): string { + if (result.failures.length === 0) { + return '

    Suggested Next Steps

    No issues detected. The trace appears to represent a normal charging session.

    '; + } + + const allSteps = new Set(); + for (const failure of result.failures) { + for (const step of failure.suggestedSteps) { + allSteps.add(step); + } + } + + const items = Array.from(allSteps) + .map((step) => `
  • ${escapeHtml(step)}
  • `) + .join('\n'); + + return `

    Suggested Next Steps

    \n
      \n${items}\n
    `; +} + +function generateEventAppendix(result: AnalysisResult): string { + if (result.events.length === 0) { + return '

    Event Appendix

    No events to display.

    '; + } + + function formatEventCompact(event: Event): string { + const time = formatTimestamp(event.timestamp); + const action = event.action ?? '-'; + return ` + ${escapeHtml(event.id)} + ${escapeHtml(time)} + ${escapeHtml(event.direction)} + ${escapeHtml(event.messageType)} + ${escapeHtml(action)} + ${escapeHtml(event.messageId)} +`; + } + + const rows = result.events.map(formatEventCompact).join('\n'); + + return `

    Event Appendix

    + + + +${rows} + +
    IDTimestampDirectionTypeActionMessageId
    `; +} + +// --------------------------------------------------------------------------- +// generateHtmlReport() +// --------------------------------------------------------------------------- + +/** + * Generate a self-contained HTML report from an analysis result. + * + * All user-supplied content is HTML-escaped to prevent injection / XSS. + * + * @param result - The analysis result to report on + * @returns A self-contained HTML string (inline CSS, no external dependencies) + */ +export function generateHtmlReport(result: AnalysisResult): string { + const body = [ + generateHeader(result), + generateSessionOverview(result), + generateTimelineSummary(result), + generateFailures(result), + generateSuggestedSteps(result), + generateEventAppendix(result), + ].join('\n'); + + return ` + +${generateHead()} + +
    +${body} + +
    + +`; +} diff --git a/packages/toolkit/src/reporter/index.ts b/packages/toolkit/src/reporter/index.ts index 8a05ea1..3ff2399 100644 --- a/packages/toolkit/src/reporter/index.ts +++ b/packages/toolkit/src/reporter/index.ts @@ -4,3 +4,4 @@ export type { AnalysisResult } from './types.js'; export { generateMarkdownReport } from './markdown.js'; +export { generateHtmlReport } from './html.js';