Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/v03-cli-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@ocpp-debugkit/toolkit': minor
---

Add three new CLI commands:
- `ocpp-debugkit ci [dir]` — run all scenarios, exit 0/1 for CI integration, supports `--format json`
- `ocpp-debugkit anonymize <file>` — strip sensitive fields from trace files
- `ocpp-debugkit diff <a> <b>` — compare two trace files, supports `--format json`

Also fixes evaluateScenario to deduplicate failure codes before comparison.
1 change: 1 addition & 0 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ integration examples, and contributor onboarding.
- ✅ Trace diffing — `diffTraces()` API (Issue #85)
- ✅ Rich scenario assertions — 8 assertion types, `evaluateScenario()` (Issue #86)
- ✅ Assert-based scenarios — 5 new scenarios (15 total) + `compareScenarioReports()` (Issue #87)
- ✅ CLI: ci + anonymize + diff commands (Issue #88)

## What's Next

Expand Down
128 changes: 128 additions & 0 deletions packages/toolkit/src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,132 @@ describe('CLI integration', () => {
expect(result.exitCode).not.toBe(0);
});
});

describe('ci', () => {
it('runs all scenarios and exits 0 when all pass', async () => {
const result = await runCli('ci');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('CI Mode');
expect(result.stdout).toContain('PASS');
});

it('supports --format json', async () => {
const result = await runCli('ci', '--format', 'json');
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed).toHaveProperty('results');
expect(parsed).toHaveProperty('allPassed');
expect(parsed.allPassed).toBe(true);
expect(parsed.results.length).toBeGreaterThan(0);
});
});

describe('anonymize', () => {
it('anonymizes sensitive fields in a trace', async () => {
const trace = {
events: [
{
timestamp: '2024-01-15T10:00:00.000Z',
message: [
2,
'msg-001',
'BootNotification',
{
chargePointSerialNumber: 'REAL-SERIAL-001',
chargePointVendor: 'VendorX',
},
],
},
{
timestamp: '2024-01-15T10:00:01.000Z',
message: [2, 'msg-002', 'Authorize', { idTag: 'REAL-TAG-123' }],
},
{
timestamp: '2024-01-15T10:00:02.000Z',
message: [2, 'msg-003', 'StartTransaction', { connectorId: 1, idTag: 'REAL-TAG-123' }],
},
{
timestamp: '2024-01-15T10:00:02.500Z',
message: [3, 'msg-003', { transactionId: 99999, idTagInfo: { status: 'Accepted' } }],
},
],
};
const filePath = writeTempTrace(trace);
const result = await runCli('anonymize', filePath);
expect(result.exitCode).toBe(0);
expect(result.stdout).not.toContain('REAL-SERIAL-001');
expect(result.stdout).not.toContain('REAL-TAG-123');
expect(result.stdout).toContain('anonymized');
expect(result.stdout).toContain('station-anon');
});

it('writes to file with --output', async () => {
const trace = {
events: [
{
timestamp: '2024-01-15T10:00:00.000Z',
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'SECRET' }],
},
],
};
const filePath = writeTempTrace(trace);
const outputPath = join(mkdtempSync(join(tmpdir(), 'ocpp-out-')), 'anon.json');
const result = await runCli('anonymize', filePath, '--output', outputPath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Anonymized trace written to');
});
});

describe('diff', () => {
it('shows differences between two traces', async () => {
const traceA = {
events: [
{
timestamp: '2024-01-15T10:00:00.000Z',
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'CS-001' }],
},
{
timestamp: '2024-01-15T10:00:00.500Z',
message: [3, 'msg-001', { status: 'Accepted', interval: 60 }],
},
],
};
const traceB = {
events: [
{
timestamp: '2024-01-15T10:00:00.000Z',
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'CS-001' }],
},
{
timestamp: '2024-01-15T10:00:01.500Z',
message: [3, 'msg-001', { status: 'Accepted', interval: 60 }],
},
],
};
const fileA = writeTempTrace(traceA);
const fileB = writeTempTrace(traceB);
const result = await runCli('diff', fileA, fileB);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Trace Diff');
expect(result.stdout).toContain('Modified events');
});

it('supports --format json', async () => {
const traceA = {
events: [
{ timestamp: '2024-01-15T10:00:00.000Z', message: [2, 'ma1', 'BootNotification', {}] },
],
};
const traceB = {
events: [{ timestamp: '2024-01-15T10:00:00.000Z', message: [2, 'mb1', 'Heartbeat', {}] }],
};
const fileA = writeTempTrace(traceA);
const fileB = writeTempTrace(traceB);
const result = await runCli('diff', fileA, fileB, '--format', 'json');
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed).toHaveProperty('onlyInA');
expect(parsed).toHaveProperty('onlyInB');
});
});
});
117 changes: 117 additions & 0 deletions packages/toolkit/src/cli/commands/anonymize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* `ocpp-debugkit anonymize <file>` — strip sensitive fields from a trace file.
*
* Anonymizes:
* - idTag → "anonymized"
* - chargePointSerialNumber / stationId → "station-anon"
* - transactionId → sequential integers
* - meterValue values → preserve relative scale, randomize base
* - Any field matching email/phone/IP patterns
*/

import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CliError } from '../utils.js';
import { MAX_INPUT_SIZE_BYTES } from '../../core/index.js';

export interface AnonymizeOptions {
output?: string;
}

// Patterns for PII detection
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
const PHONE_RE = /\+?\d{10,15}[-\s]?\d{0,4}[-\s]?\d{0,4}/g;
const IP_RE = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;

function anonymizeValue(value: unknown, txCounter: { next: number }): unknown {
if (typeof value === 'string') {
let result = value;
result = result.replace(EMAIL_RE, '[redacted-email]');
result = result.replace(PHONE_RE, '[redacted-phone]');
result = result.replace(IP_RE, '[redacted-ip]');
return result;
}

if (typeof value === 'number') {
return value;
}

if (Array.isArray(value)) {
return value.map((item) => anonymizeValue(item, txCounter));
}

if (value !== null && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const result: Record<string, unknown> = {};

for (const [key, val] of Object.entries(obj)) {
// Anonymize known sensitive fields
if (key === 'idTag' && typeof val === 'string') {
result[key] = 'anonymized';
} else if (key === 'chargePointSerialNumber' || key === 'chargeBoxSerialNumber') {
result[key] = 'station-anon';
} else if (key === 'stationId') {
result[key] = 'station-anon';
} else if (key === 'transactionId' && typeof val === 'number') {
result[key] = txCounter.next++;
} else if (key === 'identifier' && typeof val === 'string') {
result[key] = 'anonymized';
} else {
result[key] = anonymizeValue(val, txCounter);
}
}

return result;
}

return value;
}

export async function anonymizeCommand(file: string, options: AnonymizeOptions): Promise<void> {
const resolved = resolve(file);

// Size check
const { statSync } = await import('node:fs');
let stats;
try {
stats = statSync(resolved);
} catch {
throw new CliError(`File not found: ${file}`);
}

if (stats.size > MAX_INPUT_SIZE_BYTES) {
throw new CliError(
`File size (${stats.size} bytes) exceeds maximum allowed (${MAX_INPUT_SIZE_BYTES} bytes).`,
);
}

let content: string;
try {
content = readFileSync(resolved, 'utf8');
} catch {
throw new CliError(`Failed to read file: ${file}`);
}

// Parse JSON
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new CliError('File is not valid JSON.');
}

// Anonymize
const txCounter = { next: 1 };
const anonymized = anonymizeValue(parsed, txCounter);

// Output
const output = JSON.stringify(anonymized, null, 2);

if (options.output) {
const outputPath = resolve(options.output);
writeFileSync(outputPath, output, 'utf8');
console.log(`Anonymized trace written to: ${options.output}`);
} else {
console.log(output);
}
}
120 changes: 120 additions & 0 deletions packages/toolkit/src/cli/commands/ci.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* `ocpp-debugkit ci [dir]` — run all scenarios, exit 0 if all pass, 1 if any fail.
*
* Supports `--format json` for CI tooling and `[dir]` for external scenario files.
*/

import { readFileSync, readdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import type { Scenario } from '../../core/index.js';
import { evaluateScenario } from '../../core/assertions.js';
import { scenarios } from '../../scenarios/index.js';

export interface CiOptions {
format: string;
}

interface ScenarioResult {
name: string;
passed: boolean;
detectedFailures: string[];
expectedFailures: string[];
assertionResults: { type: string; passed: boolean; message: string }[];
}

export async function ciCommand(dir: string | undefined, options: CiOptions): Promise<void> {
const results: ScenarioResult[] = [];

// Run built-in scenarios
for (const scenario of scenarios) {
const result = evaluateScenario(scenario as Scenario);
results.push({
name: scenario.name,
passed: result.allPassed,
detectedFailures: result.detectedFailureCodes,
expectedFailures: scenario.expectedFailures,
assertionResults: result.assertions.map((a) => ({
type: a.assertion.type,
passed: a.passed,
message: a.message,
})),
});
}

// Run external scenarios from directory
if (dir) {
const resolvedDir = resolve(dir);
let files: string[];
try {
files = readdirSync(resolvedDir).filter((f) => f.endsWith('.json'));
} catch {
console.error(`Error: Cannot read directory: ${dir}`);
process.exitCode = 1;
return;
}

for (const file of files) {
const filePath = join(resolvedDir, file);
try {
const content = readFileSync(filePath, 'utf8');
const scenario = JSON.parse(content) as Scenario;
const result = evaluateScenario(scenario);
results.push({
name: scenario.name || file,
passed: result.allPassed,
detectedFailures: result.detectedFailureCodes,
expectedFailures: scenario.expectedFailures || [],
assertionResults: result.assertions.map((a) => ({
type: a.assertion.type,
passed: a.passed,
message: a.message,
})),
});
} catch {
console.error(`Error: Failed to parse scenario file: ${file}`);
}
}
}

const allPassed = results.every((r) => r.passed);

if (options.format === 'json') {
console.log(JSON.stringify({ results, allPassed }, null, 2));
} else {
console.log('');
console.log('═'.repeat(60));
console.log(' OCPP DebugKit — CI Mode');
console.log('═'.repeat(60));
console.log('');

for (const result of results) {
const status = result.passed ? '✅ PASS' : '❌ FAIL';
console.log(` ${status} ${result.name}`);

if (!result.passed) {
if (
JSON.stringify(result.detectedFailures.sort()) !==
JSON.stringify(result.expectedFailures.sort())
) {
console.log(` Expected: ${result.expectedFailures.join(', ') || 'none'}`);
console.log(` Detected: ${result.detectedFailures.join(', ') || 'none'}`);
}
for (const assertion of result.assertionResults) {
if (!assertion.passed) {
console.log(` Assertion "${assertion.type}": ${assertion.message}`);
}
}
}
}

console.log('');
console.log(
` Total: ${results.length} | Passed: ${results.filter((r) => r.passed).length} | Failed: ${results.filter((r) => !r.passed).length}`,
);
console.log('');
}

if (!allPassed) {
process.exitCode = 1;
}
}
Loading
Loading