diff --git a/.changeset/reporter-package.md b/.changeset/reporter-package.md new file mode 100644 index 0000000..77ecbfb --- /dev/null +++ b/.changeset/reporter-package.md @@ -0,0 +1,11 @@ +--- +'@ocpp-debugkit/reporter': minor +--- + +Create reporter package with Markdown report generator. + +- `generateMarkdownReport()` produces a structured Markdown report with: + session overview, timeline summary, failures (with severity and suggested + steps), suggested next steps, and raw event appendix +- `AnalysisResult` input type representing the analysis pipeline output +- 11 tests covering structure, failure inclusion, readability, metadata, severity diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 9f14a31..2b0d5e9 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -91,7 +91,7 @@ report exported. CLI and web inspector. - ✅ Pinned `changesets/action` to `v1.9.0` - ✅ Fixed root `changeset` script: `"changeset add"` → `"changeset"` (was causing release workflow failures) -### Scenarios Package (in progress — this PR) +### Scenarios Package (PR #39) - ✅ `packages/scenarios/` — new package - ✅ 5 scenarios: normal-session, failed-auth, connector-fault, station-offline, unexpected-stop-reason @@ -99,13 +99,20 @@ report exported. CLI and web inspector. - ✅ Each scenario's `expectedFailures` aligns with v0.1 detection rules - ✅ 21 tests (registry, engine integration, synthetic data policy) +### Reporter Package (in progress — this PR) + +- ✅ `packages/reporter/` — new package +- ✅ `generateMarkdownReport()` — session overview, timeline summary, failures, suggested steps, event appendix +- ✅ `AnalysisResult` input type +- ✅ 11 tests (structure, failure inclusion, readability, metadata, severity) + ## What's Next 1. **Issue #20** → complete (PR #33): data model + parser + normalizer 2. **Issue #21** → complete (PR #34): timeline + detection + summarizer + validator 3. **Issue #22** → complete (PR #35): public API export + package config -4. **Issue #23** (this PR) → complete: scenarios package (format + 5 initial scenarios) -5. **Issue #24**: Reporter package (Markdown report generator) +4. **Issue #23** → complete (PR #39): scenarios package (format + 5 initial scenarios) +5. **Issue #24** (this PR) → complete: reporter package (Markdown report generator) 6. **Issue #25**: CLI package (scaffold + inspect + report + scenario commands) ## Known Blockers / Decisions Pending @@ -118,7 +125,7 @@ report exported. CLI and web inspector. |---------|--------|---------| | `@ocpp-debugkit/core` | in progress (package config finalized, ready for downstream) | 0.0.0 | | `@ocpp-debugkit/scenarios` | in progress (5 scenarios + registry) | 0.0.0 | -| `@ocpp-debugkit/reporter` | not started | — | +| `@ocpp-debugkit/reporter` | in progress (Markdown generator) | 0.0.0 | | `@ocpp-debugkit/cli` | not started | — | | `@ocpp-debugkit/replay` | not started | — | | `@ocpp-debugkit/react` | not started | — | diff --git a/packages/reporter/package.json b/packages/reporter/package.json new file mode 100644 index 0000000..bd6e332 --- /dev/null +++ b/packages/reporter/package.json @@ -0,0 +1,55 @@ +{ + "name": "@ocpp-debugkit/reporter", + "version": "0.0.0", + "description": "Report generators (Markdown, HTML) for OCPP DebugKit analysis results.", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "NOTICE" + ], + "keywords": [ + "ocpp", + "ocpp1.6", + "ev-charging", + "report", + "markdown", + "debugging" + ], + "repository": { + "type": "git", + "url": "https://github.com/ocpp-debugkit/ocpp-debugkit.git", + "directory": "packages/reporter" + }, + "homepage": "https://github.com/ocpp-debugkit/ocpp-debugkit/tree/main/packages/reporter", + "bugs": { + "url": "https://github.com/ocpp-debugkit/ocpp-debugkit/issues" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@ocpp-debugkit/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/reporter/src/index.ts b/packages/reporter/src/index.ts new file mode 100644 index 0000000..8a05ea1 --- /dev/null +++ b/packages/reporter/src/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for the @ocpp-debugkit/reporter package. + */ + +export type { AnalysisResult } from './types.js'; +export { generateMarkdownReport } from './markdown.js'; diff --git a/packages/reporter/src/markdown.test.ts b/packages/reporter/src/markdown.test.ts new file mode 100644 index 0000000..ead7c26 --- /dev/null +++ b/packages/reporter/src/markdown.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from 'vitest'; +import { generateMarkdownReport } from './markdown.js'; +import type { AnalysisResult } from './types.js'; +import type { Event, Failure, Session, SessionSummary, RawOcppMessage } from '@ocpp-debugkit/core'; + +// 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('generateMarkdownReport', () => { + it('generates a non-empty Markdown string', () => { + const result: AnalysisResult = { + events: [makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000)], + sessions: [], + failures: [], + summaries: [], + warnings: [], + }; + + const report = generateMarkdownReport(result); + expect(typeof report).toBe('string'); + expect(report.length).toBeGreaterThan(0); + }); + + it('includes the main title', () => { + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [], + summaries: [], + warnings: [], + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('# OCPP DebugKit — Trace Analysis Report'); + }); + + it('includes session overview section', () => { + 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 = generateMarkdownReport(result); + expect(report).toContain('## Session Overview'); + expect(report).toContain('CS-001'); + expect(report).toContain('session-0'); + }); + + it('includes failures section with details', () => { + const failure = makeFailure(); + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [failure], + summaries: [], + warnings: [], + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('## Failures'); + expect(report).toContain('FAILED_AUTHORIZATION'); + expect(report).toContain('Authorization rejected'); + expect(report).toContain('Verify the idTag is valid'); + }); + + it('shows no failures message when clean', () => { + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [], + summaries: [], + warnings: [], + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('## Failures'); + expect(report).toContain('No failures detected'); + }); + + 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 = generateMarkdownReport(result); + expect(report).toContain('## Timeline Summary'); + expect(report).toContain('BootNotification → Authorize → StartTransaction'); + }); + + it('includes event appendix', () => { + 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 = generateMarkdownReport(result); + expect(report).toContain('## Event Appendix'); + expect(report).toContain('evt-0001'); + expect(report).toContain('BootNotification'); + }); + + it('includes parse warnings', () => { + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [], + summaries: [], + warnings: [{ index: 5, message: 'Line 6: invalid JSON' }], + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('## Parse Warnings'); + expect(report).toContain('Line 6: invalid JSON'); + }); + + it('includes metadata when provided', () => { + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [], + summaries: [], + warnings: [], + metadata: { + stationId: 'CS-SYNTHETIC-001', + ocppVersion: '1.6', + source: 'csms-log', + description: 'Test trace', + }, + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('CS-SYNTHETIC-001'); + expect(report).toContain('1.6'); + expect(report).toContain('csms-log'); + expect(report).toContain('Test trace'); + }); + + it('includes suggested next steps from failures', () => { + const failure = makeFailure(); + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [failure], + summaries: [], + warnings: [], + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('## Suggested Next Steps'); + expect(report).toContain('Verify the idTag is valid'); + expect(report).toContain('Check the CSMS local authorization list'); + }); + + it('includes severity emoji for failures', () => { + const failure: Failure = { + code: 'CONNECTOR_FAULT', + description: 'Connector fault detected', + severity: 'critical', + eventIds: ['evt-0001'], + suggestedSteps: ['Inspect the connector'], + }; + + const result: AnalysisResult = { + events: [], + sessions: [], + failures: [failure], + summaries: [], + warnings: [], + }; + + const report = generateMarkdownReport(result); + expect(report).toContain('🔴'); + expect(report).toContain('CONNECTOR_FAULT'); + }); +}); diff --git a/packages/reporter/src/markdown.ts b/packages/reporter/src/markdown.ts new file mode 100644 index 0000000..0b36085 --- /dev/null +++ b/packages/reporter/src/markdown.ts @@ -0,0 +1,237 @@ +/** + * Markdown report generator — produces a human-readable Markdown report + * from an AnalysisResult. + * + * Sections: + * 1. Session overview (station ID, connector, duration, status) + * 2. Timeline summary (event count, action sequence) + * 3. Failures (severity, description, suggested steps) + * 4. Suggested next steps + * 5. Raw event appendix (compact) + */ + +import type { AnalysisResult } from './types.js'; +import type { Event } from '@ocpp-debugkit/core'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** 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 emoji for a failure. */ +function severityEmoji(severity: string): string { + switch (severity) { + case 'critical': + return '🔴'; + case 'warning': + return '🟡'; + case 'info': + return '🔵'; + default: + return '⚪'; + } +} + +/** Compact representation of an event for the appendix. */ +function formatEventCompact(event: Event): string { + const time = formatTimestamp(event.timestamp); + const dir = event.direction; + const type = event.messageType; + const action = event.action ?? '-'; + const msgId = event.messageId; + return `| ${event.id} | ${time} | ${dir} | ${type} | ${action} | ${msgId} |`; +} + +// --------------------------------------------------------------------------- +// Section generators +// --------------------------------------------------------------------------- + +function generateHeader(result: AnalysisResult): string { + const lines: string[] = ['# OCPP DebugKit — Trace Analysis Report', '']; + + if (result.metadata?.stationId) { + lines.push(`**Station:** ${result.metadata.stationId}`); + } + if (result.metadata?.ocppVersion) { + lines.push(`**OCPP Version:** ${result.metadata.ocppVersion}`); + } + if (result.metadata?.source) { + lines.push(`**Source:** ${result.metadata.source}`); + } + if (result.metadata?.description) { + lines.push(`**Description:** ${result.metadata.description}`); + } + + lines.push( + `**Events:** ${result.events.length}`, + `**Sessions:** ${result.sessions.length}`, + `**Failures:** ${result.failures.length}`, + `**Warnings:** ${result.warnings.length}`, + ); + + if (result.warnings.length > 0) { + lines.push('', '## Parse Warnings', ''); + for (const w of result.warnings) { + lines.push(`- ${w.message}`); + } + } + + return lines.join('\n'); +} + +function generateSessionOverview(result: AnalysisResult): string { + if (result.sessions.length === 0) { + return '## Session Overview\n\nNo sessions detected.\n'; + } + + const lines: string[] = [ + '## Session Overview', + '', + '| Session | Station | Connector | Transaction | Start | End | Duration | Status |', + '|---------|---------|-----------|-------------|-------|-----|----------|--------|', + ]; + + for (const session of result.sessions) { + const summary = result.summaries.find((s) => s.sessionId === session.sessionId); + const duration = summary?.durationMs ?? null; + lines.push( + `| ${session.sessionId} | ${session.stationId} | ${session.connectorId ?? '-'} | ${session.transactionId ?? '-'} | ${formatTimestamp(session.startTime)} | ${formatTimestamp(session.endTime)} | ${formatDuration(duration)} | ${session.status} |`, + ); + } + + return lines.join('\n'); +} + +function generateTimelineSummary(result: AnalysisResult): string { + if (result.summaries.length === 0) { + return '## Timeline Summary\n\nNo sessions to summarize.\n'; + } + + const lines: string[] = ['## Timeline Summary', '']; + + for (const summary of result.summaries) { + lines.push( + `### ${summary.sessionId}`, + '', + `- **Events:** ${summary.eventCount}`, + `- **Duration:** ${formatDuration(summary.durationMs)}`, + `- **Failures:** ${summary.failureCount}`, + `- **Action Sequence:** ${summary.actionSequence.join(' → ') || 'None'}`, + '', + ); + } + + return lines.join('\n'); +} + +function generateFailures(result: AnalysisResult): string { + if (result.failures.length === 0) { + return '## Failures\n\nNo failures detected. ✅\n'; + } + + const lines: string[] = ['## Failures', '']; + + for (const failure of result.failures) { + lines.push( + `### ${severityEmoji(failure.severity)} ${failure.code}`, + '', + `**Severity:** ${failure.severity}`, + `**Description:** ${failure.description}`, + `**Events:** ${failure.eventIds.join(', ')}`, + '', + '**Suggested Steps:**', + '', + ); + for (const step of failure.suggestedSteps) { + lines.push(`1. ${step}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +function generateSuggestedSteps(result: AnalysisResult): string { + if (result.failures.length === 0) { + return '## Suggested Next Steps\n\nNo issues detected. The trace appears to represent a normal charging session.\n'; + } + + const lines: string[] = ['## Suggested Next Steps', '']; + + // Collect unique suggested steps from all failures + const allSteps = new Set(); + for (const failure of result.failures) { + for (const step of failure.suggestedSteps) { + allSteps.add(step); + } + } + + let i = 1; + for (const step of allSteps) { + lines.push(`${i}. ${step}`); + i++; + } + + return lines.join('\n'); +} + +function generateEventAppendix(result: AnalysisResult): string { + if (result.events.length === 0) { + return '## Event Appendix\n\nNo events to display.\n'; + } + + const lines: string[] = [ + '## Event Appendix', + '', + '| ID | Timestamp | Direction | Type | Action | MessageId |', + '|----|-----------|-----------|------|--------|-----------|', + ]; + + for (const event of result.events) { + lines.push(formatEventCompact(event)); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// generateMarkdownReport() +// --------------------------------------------------------------------------- + +/** + * Generate a Markdown report from an analysis result. + * + * @param result - The analysis result to report on + * @returns A Markdown string + */ +export function generateMarkdownReport(result: AnalysisResult): string { + const sections: string[] = [ + generateHeader(result), + generateSessionOverview(result), + generateTimelineSummary(result), + generateFailures(result), + generateSuggestedSteps(result), + generateEventAppendix(result), + ]; + + return sections.join('\n\n'); +} diff --git a/packages/reporter/src/types.ts b/packages/reporter/src/types.ts new file mode 100644 index 0000000..924f28c --- /dev/null +++ b/packages/reporter/src/types.ts @@ -0,0 +1,31 @@ +/** + * Input types for the reporter package. + * + * These types represent the analysis result that reporters consume. + * They are produced by the core package's parseTrace + buildSessionTimeline + + * detectFailures pipeline. + */ + +import type { Event, Failure, Session, SessionSummary } from '@ocpp-debugkit/core'; + +/** Analysis result passed to report generators. */ +export interface AnalysisResult { + /** All normalized events from the trace. */ + events: Event[]; + /** Sessions derived from the events. */ + sessions: Session[]; + /** Failures detected in the trace. */ + failures: Failure[]; + /** Summaries for each session. */ + summaries: SessionSummary[]; + /** Warnings produced during parsing. */ + warnings: { index: number; message: string; rawInput?: string }[]; + /** Trace metadata, if available. */ + metadata?: { + traceId?: string; + stationId?: string; + ocppVersion?: string; + source?: string; + description?: string; + }; +} diff --git a/packages/reporter/tsconfig.json b/packages/reporter/tsconfig.json new file mode 100644 index 0000000..dd1cdcb --- /dev/null +++ b/packages/reporter/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c0b21..1170c7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,19 @@ importers: specifier: ^3.0.0 version: 3.2.7(@types/node@22.20.0) + packages/reporter: + dependencies: + '@ocpp-debugkit/core': + specifier: workspace:* + version: link:../core + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.0) + packages/scenarios: dependencies: '@ocpp-debugkit/core':