From f6c9862a8d55a58ec6aa02bef5a7c334a3afa222 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Thu, 9 Jul 2026 10:08:53 +0300 Subject: [PATCH] feat(cli): ci + anonymize + diff commands Closes #88 --- .changeset/v03-cli-commands.md | 10 ++ CURRENT_STATE.md | 1 + packages/toolkit/src/cli/cli.test.ts | 128 ++++++++++++++++++ .../toolkit/src/cli/commands/anonymize.ts | 117 ++++++++++++++++ packages/toolkit/src/cli/commands/ci.ts | 120 ++++++++++++++++ packages/toolkit/src/cli/commands/diff.ts | 88 ++++++++++++ packages/toolkit/src/cli/index.ts | 30 ++++ packages/toolkit/src/core/assertions.ts | 4 +- .../__scenarios__/slow-csms-response.ts | 2 +- .../__scenarios__/unexpected-start.ts | 6 +- 10 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 .changeset/v03-cli-commands.md create mode 100644 packages/toolkit/src/cli/commands/anonymize.ts create mode 100644 packages/toolkit/src/cli/commands/ci.ts create mode 100644 packages/toolkit/src/cli/commands/diff.ts diff --git a/.changeset/v03-cli-commands.md b/.changeset/v03-cli-commands.md new file mode 100644 index 0000000..507c8a6 --- /dev/null +++ b/.changeset/v03-cli-commands.md @@ -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 ` — strip sensitive fields from trace files +- `ocpp-debugkit diff ` — compare two trace files, supports `--format json` + +Also fixes evaluateScenario to deduplicate failure codes before comparison. diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 988bbe1..1135c94 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -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 diff --git a/packages/toolkit/src/cli/cli.test.ts b/packages/toolkit/src/cli/cli.test.ts index 083a4f2..49823c1 100644 --- a/packages/toolkit/src/cli/cli.test.ts +++ b/packages/toolkit/src/cli/cli.test.ts @@ -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'); + }); + }); }); diff --git a/packages/toolkit/src/cli/commands/anonymize.ts b/packages/toolkit/src/cli/commands/anonymize.ts new file mode 100644 index 0000000..b0947b6 --- /dev/null +++ b/packages/toolkit/src/cli/commands/anonymize.ts @@ -0,0 +1,117 @@ +/** + * `ocpp-debugkit anonymize ` — 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; + const result: Record = {}; + + 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 { + 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); + } +} diff --git a/packages/toolkit/src/cli/commands/ci.ts b/packages/toolkit/src/cli/commands/ci.ts new file mode 100644 index 0000000..a78922c --- /dev/null +++ b/packages/toolkit/src/cli/commands/ci.ts @@ -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 { + 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; + } +} diff --git a/packages/toolkit/src/cli/commands/diff.ts b/packages/toolkit/src/cli/commands/diff.ts new file mode 100644 index 0000000..7278305 --- /dev/null +++ b/packages/toolkit/src/cli/commands/diff.ts @@ -0,0 +1,88 @@ +/** + * `ocpp-debugkit diff ` — compare two trace files and output differences. + * + * Uses `diffTraces()` from the core module. + */ + +import { parseTrace, diffTraces, ParseError } from '../../core/index.js'; +import { CliError, readTraceFile } from '../utils.js'; + +export interface DiffOptions { + format: string; +} + +export async function diffCommand( + fileA: string, + fileB: string, + options: DiffOptions, +): Promise { + const contentA = readTraceFile(fileA); + const contentB = readTraceFile(fileB); + + let resultA, resultB; + try { + resultA = parseTrace(contentA); + resultB = parseTrace(contentB); + } catch (e) { + if (e instanceof ParseError) { + throw new CliError(e.message); + } + throw new CliError('Failed to parse trace files.'); + } + + const diff = diffTraces(resultA, resultB); + + if (options.format === 'json') { + console.log(JSON.stringify(diff, null, 2)); + return; + } + + // Human-readable output + console.log(''); + console.log('═'.repeat(60)); + console.log(' OCPP DebugKit — Trace Diff'); + console.log('═'.repeat(60)); + console.log(''); + + console.log(` Events only in A: ${diff.onlyInA.length}`); + for (const event of diff.onlyInA) { + console.log(` ${event.action ?? 'Unknown'} (messageId: ${event.messageId})`); + } + + console.log(''); + console.log(` Events only in B: ${diff.onlyInB.length}`); + for (const event of diff.onlyInB) { + console.log(` ${event.action ?? 'Unknown'} (messageId: ${event.messageId})`); + } + + console.log(''); + console.log(` Modified events: ${diff.modified.length}`); + for (const mod of diff.modified) { + console.log(` [${mod.field}] messageId: ${mod.messageId}`); + console.log(` A: ${JSON.stringify(mod.valueA)}`); + console.log(` B: ${JSON.stringify(mod.valueB)}`); + } + + console.log(''); + console.log(` Failures only in A: ${diff.failuresOnlyInA.length}`); + for (const f of diff.failuresOnlyInA) { + console.log(` [${f.severity}] ${f.code}: ${f.description}`); + } + + console.log(''); + console.log(` Failures only in B: ${diff.failuresOnlyInB.length}`); + for (const f of diff.failuresOnlyInB) { + console.log(` [${f.severity}] ${f.code}: ${f.description}`); + } + + console.log(''); + console.log(' Summary Differences:'); + if (diff.summaryDiff.differences.length > 0) { + for (const d of diff.summaryDiff.differences) { + console.log(` ${d}`); + } + } else { + console.log(' No summary differences'); + } + console.log(''); +} diff --git a/packages/toolkit/src/cli/index.ts b/packages/toolkit/src/cli/index.ts index 6ee3dbe..d3eb754 100644 --- a/packages/toolkit/src/cli/index.ts +++ b/packages/toolkit/src/cli/index.ts @@ -12,6 +12,9 @@ import { scenarioRunCommand, scenarioRunFileCommand, } from './commands/scenario.js'; +import { ciCommand } from './commands/ci.js'; +import { anonymizeCommand } from './commands/anonymize.js'; +import { diffCommand } from './commands/diff.js'; const program = new Command(); @@ -63,4 +66,31 @@ scenarioCmd } }); +// ci +program + .command('ci [dir]') + .description('Run all scenarios and exit 0 (all pass) or 1 (any fail)') + .option('--format ', 'Output format (text, json)', 'text') + .action(async (dir: string | undefined, options: { format: string }) => { + await ciCommand(dir, options); + }); + +// anonymize +program + .command('anonymize ') + .description('Strip sensitive fields from a trace file') + .option('-o, --output ', 'Write anonymized trace to file (default: stdout)') + .action(async (file: string, options: { output?: string }) => { + await anonymizeCommand(file, options); + }); + +// diff +program + .command('diff ') + .description('Compare two trace files and show differences') + .option('--format ', 'Output format (text, json)', 'text') + .action(async (fileA: string, fileB: string, options: { format: string }) => { + await diffCommand(fileA, fileB, options); + }); + program.parse(); diff --git a/packages/toolkit/src/core/assertions.ts b/packages/toolkit/src/core/assertions.ts index c16c245..e2888de 100644 --- a/packages/toolkit/src/core/assertions.ts +++ b/packages/toolkit/src/core/assertions.ts @@ -411,9 +411,9 @@ export function evaluateScenario(scenario: Scenario): ScenarioEvalResult { const { events } = parseTrace(JSON.stringify(scenario.trace)); const sessions = buildSessionTimeline(events); const failures = detectFailures(events, sessions); - const detectedFailureCodes = failures.map((f) => f.code); + const detectedFailureCodes = [...new Set(failures.map((f) => f.code))]; - // Compare detected vs expected failures + // Compare detected vs expected failures (deduplicated) const sortedDetected = [...detectedFailureCodes].sort(); const sortedExpected = [...scenario.expectedFailures].sort(); const expectedFailuresPassed = JSON.stringify(sortedDetected) === JSON.stringify(sortedExpected); diff --git a/packages/toolkit/src/scenarios/__scenarios__/slow-csms-response.ts b/packages/toolkit/src/scenarios/__scenarios__/slow-csms-response.ts index 0e07c2a..bdaa623 100644 --- a/packages/toolkit/src/scenarios/__scenarios__/slow-csms-response.ts +++ b/packages/toolkit/src/scenarios/__scenarios__/slow-csms-response.ts @@ -55,7 +55,7 @@ export default { assertions: [ { type: 'timing', - params: { actionA: 'BootNotification', actionB: 'Heartbeat', maxGapMs: 5000 }, + params: { actionA: 'BootNotification', actionB: 'Heartbeat', minGapMs: 5000 }, }, ], }; diff --git a/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts b/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts index a5ff6ef..e383928 100644 --- a/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts +++ b/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts @@ -91,7 +91,7 @@ export default { message: [3, 'msg-003', {}], }, { - timestamp: '2024-01-15T22:00:30.000Z', + timestamp: '2024-01-15T22:01:30.000Z', direction: 'CS_TO_CSMS', message: [ 2, @@ -101,13 +101,13 @@ export default { transactionId: 100008, idTag: 'SYNTHETIC-TAG-008', meterStop: 100, - timestamp: '2024-01-15T22:00:30.000Z', + timestamp: '2024-01-15T22:01:30.000Z', reason: 'EVDisconnected', }, ], }, { - timestamp: '2024-01-15T22:00:30.500Z', + timestamp: '2024-01-15T22:01:30.500Z', direction: 'CSMS_TO_CS', message: [ 3,