Skip to content

Commit c991763

Browse files
committed
feat(reporter): HTML report format + CLI --format html
Closes #62
1 parent 3f9569f commit c991763

4 files changed

Lines changed: 738 additions & 3 deletions

File tree

packages/toolkit/src/cli/commands/report.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
ParseError,
1111
type Trace,
1212
} from '../../core/index.js';
13-
import { generateMarkdownReport } from '../../reporter/index.js';
13+
import { generateMarkdownReport, generateHtmlReport } from '../../reporter/index.js';
1414
import { CliError, readTraceFile } from '../utils.js';
1515

1616
export interface ReportOptions {
@@ -58,14 +58,19 @@ export async function reportCommand(file: string, options: ReportOptions): Promi
5858
const failures = detectFailures(result.events, sessions);
5959
const summaries = summarizeSessions(sessions, failures);
6060

61-
const report = generateMarkdownReport({
61+
const analysisResult = {
6262
events: result.events,
6363
sessions,
6464
failures,
6565
summaries,
6666
warnings: result.warnings,
6767
metadata,
68-
});
68+
};
69+
70+
const report =
71+
options.format === 'html'
72+
? generateHtmlReport(analysisResult)
73+
: generateMarkdownReport(analysisResult);
6974

7075
if (options.output) {
7176
const { writeFileSync } = await import('node:fs');
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generateHtmlReport } from './html.js';
3+
import type { AnalysisResult } from './types.js';
4+
import type { Event, Failure, Session, SessionSummary, RawOcppMessage } from '../core/index.js';
5+
6+
// Helpers
7+
function makeEvent(
8+
id: string,
9+
messageId: string,
10+
messageType: 'Call' | 'CallResult' | 'CallError',
11+
action: string | null,
12+
timestamp: number | null = null,
13+
): Event {
14+
let rawMessage: RawOcppMessage;
15+
if (messageType === 'Call') {
16+
rawMessage = [2, messageId, action as string, {}];
17+
} else if (messageType === 'CallResult') {
18+
rawMessage = [3, messageId, {}];
19+
} else {
20+
rawMessage = [4, messageId, 'Error', 'desc', {}];
21+
}
22+
return {
23+
id,
24+
messageId,
25+
timestamp,
26+
direction: 'CS_TO_CSMS',
27+
messageType,
28+
action,
29+
payload: {},
30+
errorCode: messageType === 'CallError' ? 'Error' : null,
31+
errorDescription: messageType === 'CallError' ? 'desc' : null,
32+
rawMessage,
33+
};
34+
}
35+
36+
function makeSession(
37+
sessionId: string,
38+
events: Event[],
39+
transactionId: number | null = 100001,
40+
): Session {
41+
return {
42+
sessionId,
43+
stationId: 'CS-001',
44+
connectorId: 1,
45+
transactionId,
46+
startTime: events[0]?.timestamp ?? null,
47+
endTime: events[events.length - 1]?.timestamp ?? null,
48+
events,
49+
status: 'completed',
50+
};
51+
}
52+
53+
function makeFailure(): Failure {
54+
return {
55+
code: 'FAILED_AUTHORIZATION',
56+
description: 'Authorization rejected: idTag returned "Invalid" status',
57+
severity: 'warning',
58+
eventIds: ['evt-0001', 'evt-0002'],
59+
suggestedSteps: ['Verify the idTag is valid', 'Check the CSMS local authorization list'],
60+
};
61+
}
62+
63+
// ---------------------------------------------------------------------------
64+
// Tests
65+
// ---------------------------------------------------------------------------
66+
67+
describe('generateHtmlReport', () => {
68+
it('generates a non-empty HTML string', () => {
69+
const result: AnalysisResult = {
70+
events: [makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000)],
71+
sessions: [],
72+
failures: [],
73+
summaries: [],
74+
warnings: [],
75+
};
76+
77+
const report = generateHtmlReport(result);
78+
expect(typeof report).toBe('string');
79+
expect(report.length).toBeGreaterThan(0);
80+
expect(report.toLowerCase()).toContain('<html');
81+
expect(report.toLowerCase()).toContain('</html>');
82+
});
83+
84+
it('contains the main title', () => {
85+
const result: AnalysisResult = {
86+
events: [],
87+
sessions: [],
88+
failures: [],
89+
summaries: [],
90+
warnings: [],
91+
};
92+
93+
const report = generateHtmlReport(result);
94+
expect(report).toContain('OCPP DebugKit — Trace Analysis Report');
95+
});
96+
97+
it('contains section headers (Session Overview, Failures, etc.)', () => {
98+
const result: AnalysisResult = {
99+
events: [],
100+
sessions: [],
101+
failures: [],
102+
summaries: [],
103+
warnings: [],
104+
};
105+
106+
const report = generateHtmlReport(result);
107+
expect(report).toContain('Session Overview');
108+
expect(report).toContain('Timeline Summary');
109+
expect(report).toContain('Failures');
110+
expect(report).toContain('Suggested Next Steps');
111+
expect(report).toContain('Event Appendix');
112+
});
113+
114+
it('shows no-failures message when clean', () => {
115+
const result: AnalysisResult = {
116+
events: [],
117+
sessions: [],
118+
failures: [],
119+
summaries: [],
120+
warnings: [],
121+
};
122+
123+
const report = generateHtmlReport(result);
124+
expect(report).toContain('No failures detected');
125+
});
126+
127+
it('includes failure details when failures are present', () => {
128+
const failure = makeFailure();
129+
const result: AnalysisResult = {
130+
events: [],
131+
sessions: [],
132+
failures: [failure],
133+
summaries: [],
134+
warnings: [],
135+
};
136+
137+
const report = generateHtmlReport(result);
138+
expect(report).toContain('FAILED_AUTHORIZATION');
139+
expect(report).toContain('Authorization rejected');
140+
expect(report).toContain('Verify the idTag is valid');
141+
expect(report).toContain('Check the CSMS local authorization list');
142+
});
143+
144+
it('includes session overview with session details', () => {
145+
const events = [
146+
makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000),
147+
makeEvent('evt-0002', 'msg-002', 'Call', 'StartTransaction', 2000),
148+
];
149+
const session = makeSession('session-0', events);
150+
const result: AnalysisResult = {
151+
events,
152+
sessions: [session],
153+
failures: [],
154+
summaries: [
155+
{
156+
sessionId: 'session-0',
157+
stationId: 'CS-001',
158+
connectorId: 1,
159+
transactionId: 100001,
160+
status: 'completed',
161+
eventCount: 2,
162+
durationMs: 1000,
163+
failureCount: 0,
164+
actionSequence: ['BootNotification', 'StartTransaction'],
165+
},
166+
],
167+
warnings: [],
168+
};
169+
170+
const report = generateHtmlReport(result);
171+
expect(report).toContain('CS-001');
172+
expect(report).toContain('session-0');
173+
});
174+
175+
it('includes timeline summary with action sequence', () => {
176+
const summary: SessionSummary = {
177+
sessionId: 'session-0',
178+
stationId: 'CS-001',
179+
connectorId: 1,
180+
transactionId: 100001,
181+
status: 'completed',
182+
eventCount: 5,
183+
durationMs: 30000,
184+
failureCount: 0,
185+
actionSequence: ['BootNotification', 'Authorize', 'StartTransaction'],
186+
};
187+
188+
const result: AnalysisResult = {
189+
events: [],
190+
sessions: [],
191+
failures: [],
192+
summaries: [summary],
193+
warnings: [],
194+
};
195+
196+
const report = generateHtmlReport(result);
197+
expect(report).toContain('BootNotification → Authorize → StartTransaction');
198+
});
199+
200+
it('includes event appendix with event details', () => {
201+
const events = [
202+
makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000),
203+
makeEvent('evt-0002', 'msg-001', 'CallResult', null, 1500),
204+
];
205+
const result: AnalysisResult = {
206+
events,
207+
sessions: [],
208+
failures: [],
209+
summaries: [],
210+
warnings: [],
211+
};
212+
213+
const report = generateHtmlReport(result);
214+
expect(report).toContain('evt-0001');
215+
expect(report).toContain('BootNotification');
216+
});
217+
218+
it('escapes HTML entities in failure descriptions to prevent XSS', () => {
219+
const failure: Failure = {
220+
code: 'FAILED_AUTHORIZATION',
221+
description: 'Payload contained <script>alert("xss")</script>',
222+
severity: 'warning',
223+
eventIds: ['evt-0001'],
224+
suggestedSteps: ['Fix the <b>config</b>'],
225+
};
226+
const result: AnalysisResult = {
227+
events: [],
228+
sessions: [],
229+
failures: [failure],
230+
summaries: [],
231+
warnings: [],
232+
};
233+
234+
const report = generateHtmlReport(result);
235+
// The literal <script> tag must NOT appear unescaped
236+
expect(report).not.toContain('<script>alert("xss")</script>');
237+
// It must be escaped
238+
expect(report).toContain('&lt;script&gt;');
239+
expect(report).toContain('&lt;b&gt;config&lt;/b&gt;');
240+
});
241+
242+
it('escapes HTML entities in metadata', () => {
243+
const result: AnalysisResult = {
244+
events: [],
245+
sessions: [],
246+
failures: [],
247+
summaries: [],
248+
warnings: [],
249+
metadata: {
250+
stationId: 'CS-<img src=x>',
251+
description: '<script>evil()</script>',
252+
},
253+
};
254+
255+
const report = generateHtmlReport(result);
256+
expect(report).not.toContain('<script>evil()</script>');
257+
expect(report).toContain('&lt;img src=x&gt;');
258+
expect(report).toContain('&lt;script&gt;evil()&lt;/script&gt;');
259+
});
260+
261+
it('produces a self-contained document with no external src/href links', () => {
262+
const events = [makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', 1000)];
263+
const result: AnalysisResult = {
264+
events,
265+
sessions: [makeSession('session-0', events)],
266+
failures: [makeFailure()],
267+
summaries: [
268+
{
269+
sessionId: 'session-0',
270+
stationId: 'CS-001',
271+
connectorId: 1,
272+
transactionId: 100001,
273+
status: 'completed',
274+
eventCount: 1,
275+
durationMs: 1000,
276+
failureCount: 1,
277+
actionSequence: ['BootNotification'],
278+
},
279+
],
280+
warnings: [{ index: 0, message: 'Line 1: test warning' }],
281+
metadata: {
282+
stationId: 'CS-001',
283+
ocppVersion: '1.6',
284+
source: 'csms-log',
285+
description: 'Test trace',
286+
},
287+
};
288+
289+
const report = generateHtmlReport(result);
290+
// No external resource links: src="http or href="http
291+
expect(report).not.toMatch(/src\s*=\s*["']https?:\/\//i);
292+
expect(report).not.toMatch(/href\s*=\s*["']https?:\/\//i);
293+
// No <link> tags to external stylesheets
294+
expect(report).not.toMatch(/<link\s/i);
295+
// No <script src="..."> external imports
296+
expect(report).not.toMatch(/<script\s+src\s*=\s*["']/i);
297+
});
298+
});

0 commit comments

Comments
 (0)