Skip to content

Commit 1b8cb39

Browse files
feat(cli): ci + anonymize + diff commands (#102)
Closes #88
1 parent 8db857e commit 1b8cb39

10 files changed

Lines changed: 500 additions & 6 deletions

File tree

‎.changeset/v03-cli-commands.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@ocpp-debugkit/toolkit': minor
3+
---
4+
5+
Add three new CLI commands:
6+
- `ocpp-debugkit ci [dir]` — run all scenarios, exit 0/1 for CI integration, supports `--format json`
7+
- `ocpp-debugkit anonymize <file>` — strip sensitive fields from trace files
8+
- `ocpp-debugkit diff <a> <b>` — compare two trace files, supports `--format json`
9+
10+
Also fixes evaluateScenario to deduplicate failure codes before comparison.

‎CURRENT_STATE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ integration examples, and contributor onboarding.
203203
- ✅ Trace diffing — `diffTraces()` API (Issue #85)
204204
- ✅ Rich scenario assertions — 8 assertion types, `evaluateScenario()` (Issue #86)
205205
- ✅ Assert-based scenarios — 5 new scenarios (15 total) + `compareScenarioReports()` (Issue #87)
206+
- ✅ CLI: ci + anonymize + diff commands (Issue #88)
206207

207208
## What's Next
208209

‎packages/toolkit/src/cli/cli.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,132 @@ describe('CLI integration', () => {
171171
expect(result.exitCode).not.toBe(0);
172172
});
173173
});
174+
175+
describe('ci', () => {
176+
it('runs all scenarios and exits 0 when all pass', async () => {
177+
const result = await runCli('ci');
178+
expect(result.exitCode).toBe(0);
179+
expect(result.stdout).toContain('CI Mode');
180+
expect(result.stdout).toContain('PASS');
181+
});
182+
183+
it('supports --format json', async () => {
184+
const result = await runCli('ci', '--format', 'json');
185+
expect(result.exitCode).toBe(0);
186+
const parsed = JSON.parse(result.stdout);
187+
expect(parsed).toHaveProperty('results');
188+
expect(parsed).toHaveProperty('allPassed');
189+
expect(parsed.allPassed).toBe(true);
190+
expect(parsed.results.length).toBeGreaterThan(0);
191+
});
192+
});
193+
194+
describe('anonymize', () => {
195+
it('anonymizes sensitive fields in a trace', async () => {
196+
const trace = {
197+
events: [
198+
{
199+
timestamp: '2024-01-15T10:00:00.000Z',
200+
message: [
201+
2,
202+
'msg-001',
203+
'BootNotification',
204+
{
205+
chargePointSerialNumber: 'REAL-SERIAL-001',
206+
chargePointVendor: 'VendorX',
207+
},
208+
],
209+
},
210+
{
211+
timestamp: '2024-01-15T10:00:01.000Z',
212+
message: [2, 'msg-002', 'Authorize', { idTag: 'REAL-TAG-123' }],
213+
},
214+
{
215+
timestamp: '2024-01-15T10:00:02.000Z',
216+
message: [2, 'msg-003', 'StartTransaction', { connectorId: 1, idTag: 'REAL-TAG-123' }],
217+
},
218+
{
219+
timestamp: '2024-01-15T10:00:02.500Z',
220+
message: [3, 'msg-003', { transactionId: 99999, idTagInfo: { status: 'Accepted' } }],
221+
},
222+
],
223+
};
224+
const filePath = writeTempTrace(trace);
225+
const result = await runCli('anonymize', filePath);
226+
expect(result.exitCode).toBe(0);
227+
expect(result.stdout).not.toContain('REAL-SERIAL-001');
228+
expect(result.stdout).not.toContain('REAL-TAG-123');
229+
expect(result.stdout).toContain('anonymized');
230+
expect(result.stdout).toContain('station-anon');
231+
});
232+
233+
it('writes to file with --output', async () => {
234+
const trace = {
235+
events: [
236+
{
237+
timestamp: '2024-01-15T10:00:00.000Z',
238+
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'SECRET' }],
239+
},
240+
],
241+
};
242+
const filePath = writeTempTrace(trace);
243+
const outputPath = join(mkdtempSync(join(tmpdir(), 'ocpp-out-')), 'anon.json');
244+
const result = await runCli('anonymize', filePath, '--output', outputPath);
245+
expect(result.exitCode).toBe(0);
246+
expect(result.stdout).toContain('Anonymized trace written to');
247+
});
248+
});
249+
250+
describe('diff', () => {
251+
it('shows differences between two traces', async () => {
252+
const traceA = {
253+
events: [
254+
{
255+
timestamp: '2024-01-15T10:00:00.000Z',
256+
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'CS-001' }],
257+
},
258+
{
259+
timestamp: '2024-01-15T10:00:00.500Z',
260+
message: [3, 'msg-001', { status: 'Accepted', interval: 60 }],
261+
},
262+
],
263+
};
264+
const traceB = {
265+
events: [
266+
{
267+
timestamp: '2024-01-15T10:00:00.000Z',
268+
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'CS-001' }],
269+
},
270+
{
271+
timestamp: '2024-01-15T10:00:01.500Z',
272+
message: [3, 'msg-001', { status: 'Accepted', interval: 60 }],
273+
},
274+
],
275+
};
276+
const fileA = writeTempTrace(traceA);
277+
const fileB = writeTempTrace(traceB);
278+
const result = await runCli('diff', fileA, fileB);
279+
expect(result.exitCode).toBe(0);
280+
expect(result.stdout).toContain('Trace Diff');
281+
expect(result.stdout).toContain('Modified events');
282+
});
283+
284+
it('supports --format json', async () => {
285+
const traceA = {
286+
events: [
287+
{ timestamp: '2024-01-15T10:00:00.000Z', message: [2, 'ma1', 'BootNotification', {}] },
288+
],
289+
};
290+
const traceB = {
291+
events: [{ timestamp: '2024-01-15T10:00:00.000Z', message: [2, 'mb1', 'Heartbeat', {}] }],
292+
};
293+
const fileA = writeTempTrace(traceA);
294+
const fileB = writeTempTrace(traceB);
295+
const result = await runCli('diff', fileA, fileB, '--format', 'json');
296+
expect(result.exitCode).toBe(0);
297+
const parsed = JSON.parse(result.stdout);
298+
expect(parsed).toHaveProperty('onlyInA');
299+
expect(parsed).toHaveProperty('onlyInB');
300+
});
301+
});
174302
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* `ocpp-debugkit anonymize <file>` — strip sensitive fields from a trace file.
3+
*
4+
* Anonymizes:
5+
* - idTag → "anonymized"
6+
* - chargePointSerialNumber / stationId → "station-anon"
7+
* - transactionId → sequential integers
8+
* - meterValue values → preserve relative scale, randomize base
9+
* - Any field matching email/phone/IP patterns
10+
*/
11+
12+
import { readFileSync, writeFileSync } from 'node:fs';
13+
import { resolve } from 'node:path';
14+
import { CliError } from '../utils.js';
15+
import { MAX_INPUT_SIZE_BYTES } from '../../core/index.js';
16+
17+
export interface AnonymizeOptions {
18+
output?: string;
19+
}
20+
21+
// Patterns for PII detection
22+
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
23+
const PHONE_RE = /\+?\d{10,15}[-\s]?\d{0,4}[-\s]?\d{0,4}/g;
24+
const IP_RE = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
25+
26+
function anonymizeValue(value: unknown, txCounter: { next: number }): unknown {
27+
if (typeof value === 'string') {
28+
let result = value;
29+
result = result.replace(EMAIL_RE, '[redacted-email]');
30+
result = result.replace(PHONE_RE, '[redacted-phone]');
31+
result = result.replace(IP_RE, '[redacted-ip]');
32+
return result;
33+
}
34+
35+
if (typeof value === 'number') {
36+
return value;
37+
}
38+
39+
if (Array.isArray(value)) {
40+
return value.map((item) => anonymizeValue(item, txCounter));
41+
}
42+
43+
if (value !== null && typeof value === 'object') {
44+
const obj = value as Record<string, unknown>;
45+
const result: Record<string, unknown> = {};
46+
47+
for (const [key, val] of Object.entries(obj)) {
48+
// Anonymize known sensitive fields
49+
if (key === 'idTag' && typeof val === 'string') {
50+
result[key] = 'anonymized';
51+
} else if (key === 'chargePointSerialNumber' || key === 'chargeBoxSerialNumber') {
52+
result[key] = 'station-anon';
53+
} else if (key === 'stationId') {
54+
result[key] = 'station-anon';
55+
} else if (key === 'transactionId' && typeof val === 'number') {
56+
result[key] = txCounter.next++;
57+
} else if (key === 'identifier' && typeof val === 'string') {
58+
result[key] = 'anonymized';
59+
} else {
60+
result[key] = anonymizeValue(val, txCounter);
61+
}
62+
}
63+
64+
return result;
65+
}
66+
67+
return value;
68+
}
69+
70+
export async function anonymizeCommand(file: string, options: AnonymizeOptions): Promise<void> {
71+
const resolved = resolve(file);
72+
73+
// Size check
74+
const { statSync } = await import('node:fs');
75+
let stats;
76+
try {
77+
stats = statSync(resolved);
78+
} catch {
79+
throw new CliError(`File not found: ${file}`);
80+
}
81+
82+
if (stats.size > MAX_INPUT_SIZE_BYTES) {
83+
throw new CliError(
84+
`File size (${stats.size} bytes) exceeds maximum allowed (${MAX_INPUT_SIZE_BYTES} bytes).`,
85+
);
86+
}
87+
88+
let content: string;
89+
try {
90+
content = readFileSync(resolved, 'utf8');
91+
} catch {
92+
throw new CliError(`Failed to read file: ${file}`);
93+
}
94+
95+
// Parse JSON
96+
let parsed: unknown;
97+
try {
98+
parsed = JSON.parse(content);
99+
} catch {
100+
throw new CliError('File is not valid JSON.');
101+
}
102+
103+
// Anonymize
104+
const txCounter = { next: 1 };
105+
const anonymized = anonymizeValue(parsed, txCounter);
106+
107+
// Output
108+
const output = JSON.stringify(anonymized, null, 2);
109+
110+
if (options.output) {
111+
const outputPath = resolve(options.output);
112+
writeFileSync(outputPath, output, 'utf8');
113+
console.log(`Anonymized trace written to: ${options.output}`);
114+
} else {
115+
console.log(output);
116+
}
117+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* `ocpp-debugkit ci [dir]` — run all scenarios, exit 0 if all pass, 1 if any fail.
3+
*
4+
* Supports `--format json` for CI tooling and `[dir]` for external scenario files.
5+
*/
6+
7+
import { readFileSync, readdirSync } from 'node:fs';
8+
import { resolve, join } from 'node:path';
9+
import type { Scenario } from '../../core/index.js';
10+
import { evaluateScenario } from '../../core/assertions.js';
11+
import { scenarios } from '../../scenarios/index.js';
12+
13+
export interface CiOptions {
14+
format: string;
15+
}
16+
17+
interface ScenarioResult {
18+
name: string;
19+
passed: boolean;
20+
detectedFailures: string[];
21+
expectedFailures: string[];
22+
assertionResults: { type: string; passed: boolean; message: string }[];
23+
}
24+
25+
export async function ciCommand(dir: string | undefined, options: CiOptions): Promise<void> {
26+
const results: ScenarioResult[] = [];
27+
28+
// Run built-in scenarios
29+
for (const scenario of scenarios) {
30+
const result = evaluateScenario(scenario as Scenario);
31+
results.push({
32+
name: scenario.name,
33+
passed: result.allPassed,
34+
detectedFailures: result.detectedFailureCodes,
35+
expectedFailures: scenario.expectedFailures,
36+
assertionResults: result.assertions.map((a) => ({
37+
type: a.assertion.type,
38+
passed: a.passed,
39+
message: a.message,
40+
})),
41+
});
42+
}
43+
44+
// Run external scenarios from directory
45+
if (dir) {
46+
const resolvedDir = resolve(dir);
47+
let files: string[];
48+
try {
49+
files = readdirSync(resolvedDir).filter((f) => f.endsWith('.json'));
50+
} catch {
51+
console.error(`Error: Cannot read directory: ${dir}`);
52+
process.exitCode = 1;
53+
return;
54+
}
55+
56+
for (const file of files) {
57+
const filePath = join(resolvedDir, file);
58+
try {
59+
const content = readFileSync(filePath, 'utf8');
60+
const scenario = JSON.parse(content) as Scenario;
61+
const result = evaluateScenario(scenario);
62+
results.push({
63+
name: scenario.name || file,
64+
passed: result.allPassed,
65+
detectedFailures: result.detectedFailureCodes,
66+
expectedFailures: scenario.expectedFailures || [],
67+
assertionResults: result.assertions.map((a) => ({
68+
type: a.assertion.type,
69+
passed: a.passed,
70+
message: a.message,
71+
})),
72+
});
73+
} catch {
74+
console.error(`Error: Failed to parse scenario file: ${file}`);
75+
}
76+
}
77+
}
78+
79+
const allPassed = results.every((r) => r.passed);
80+
81+
if (options.format === 'json') {
82+
console.log(JSON.stringify({ results, allPassed }, null, 2));
83+
} else {
84+
console.log('');
85+
console.log('═'.repeat(60));
86+
console.log(' OCPP DebugKit — CI Mode');
87+
console.log('═'.repeat(60));
88+
console.log('');
89+
90+
for (const result of results) {
91+
const status = result.passed ? '✅ PASS' : '❌ FAIL';
92+
console.log(` ${status} ${result.name}`);
93+
94+
if (!result.passed) {
95+
if (
96+
JSON.stringify(result.detectedFailures.sort()) !==
97+
JSON.stringify(result.expectedFailures.sort())
98+
) {
99+
console.log(` Expected: ${result.expectedFailures.join(', ') || 'none'}`);
100+
console.log(` Detected: ${result.detectedFailures.join(', ') || 'none'}`);
101+
}
102+
for (const assertion of result.assertionResults) {
103+
if (!assertion.passed) {
104+
console.log(` Assertion "${assertion.type}": ${assertion.message}`);
105+
}
106+
}
107+
}
108+
}
109+
110+
console.log('');
111+
console.log(
112+
` Total: ${results.length} | Passed: ${results.filter((r) => r.passed).length} | Failed: ${results.filter((r) => !r.passed).length}`,
113+
);
114+
console.log('');
115+
}
116+
117+
if (!allPassed) {
118+
process.exitCode = 1;
119+
}
120+
}

0 commit comments

Comments
 (0)