From 8638141eb2c1c91fd866fbfe123a761d3a422707 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Fri, 17 Jul 2026 13:55:06 +0300 Subject: [PATCH] feat(core,cli): export traces to the Open OCPP Trace format toOpenOcppTraceRecords() and toOpenOcppTraceJsonl() emit any parsed trace as Open OCPP Trace v1.1 records: raw serialized from the stored frame, response action back-filled by messageId correlation, connectorId lifted from request payloads, and trace-level ocppVersion/chargePointId supplied via options. Events the format cannot represent (no timestamp, unknown direction) are skipped with a warning rather than invented. A new CLI command, ocpp-debugkit convert [--output], writes the JSONL with data on stdout and warnings on stderr, carrying trace-level metadata over from JSON Object inputs. Conformance: every exported record is validated against the vendored specification JSON Schema, and per-fixture round-trip tests prove that export-then-reparse reproduces the expected consumer view and the exact events. --- .changeset/open-ocpp-trace-export.md | 5 + CURRENT_STATE.md | 23 +- README.md | 5 +- docs/trace-format-spec.md | 11 +- packages/toolkit/README.md | 15 +- packages/toolkit/package.json | 6 +- packages/toolkit/src/cli/cli.test.ts | 54 ++++- packages/toolkit/src/cli/commands/convert.ts | 76 +++++++ packages/toolkit/src/cli/index.ts | 10 + .../__fixtures__/open-ocpp-trace/README.md | 3 +- .../open-ocpp-trace/trace-v1.schema.json | 46 ++++ packages/toolkit/src/core/index.ts | 5 + .../core/openOcppTrace.conformance.test.ts | 34 +++ .../src/core/openOcppTraceExport.test.ts | 200 ++++++++++++++++++ .../toolkit/src/core/openOcppTraceExport.ts | 191 +++++++++++++++++ packages/toolkit/src/index.ts | 4 + pnpm-lock.yaml | 44 ++++ tests/external-fixture/test.mjs | 21 ++ 18 files changed, 736 insertions(+), 17 deletions(-) create mode 100644 .changeset/open-ocpp-trace-export.md create mode 100644 packages/toolkit/src/cli/commands/convert.ts create mode 100644 packages/toolkit/src/core/__fixtures__/open-ocpp-trace/trace-v1.schema.json create mode 100644 packages/toolkit/src/core/openOcppTraceExport.test.ts create mode 100644 packages/toolkit/src/core/openOcppTraceExport.ts diff --git a/.changeset/open-ocpp-trace-export.md b/.changeset/open-ocpp-trace-export.md new file mode 100644 index 0000000..a70ebc1 --- /dev/null +++ b/.changeset/open-ocpp-trace-export.md @@ -0,0 +1,5 @@ +--- +'@ocpp-debugkit/toolkit': minor +--- + +Write the Open OCPP Trace interchange format. `toOpenOcppTraceRecords()` / `toOpenOcppTraceJsonl()` export any parsed trace as v1.1 records, with `raw` serialized from the stored frame, response `action` back-filled by messageId correlation, and skip-and-flag warnings for events the format cannot represent. A new `ocpp-debugkit convert ` CLI command emits the JSONL, carrying trace-level metadata over from JSON Object inputs. Every exported record is validated against the specification's published JSON Schema in CI, and round-trip tests prove export-then-reparse preserves the consumer view and the events. diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index e76a871..d9f2662 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,13 +8,14 @@ ## Active Milestone -**v0.4.0 - Open OCPP Trace Interop (in progress)** +**v0.4.0 - Open OCPP Trace Interop (feature complete)** v0.3.1 is published to npm (16 detection rules, 15 scenarios, trace diffing, rich scenario assertions, and the ci/anonymize/diff CLI commands). The interop milestone reads and writes the shared Open OCPP Trace v1.1 interchange format, -checked against the specification's conformance fixtures. The input half -(#121) has landed; the exporter and `convert` CLI command (#122) remain. +checked against the specification's conformance fixtures: the input adapter +(#121) and the exporter with the `convert` CLI command (#122) have both +landed. Remaining: cut the `v0.4.0` release. ## What's Done @@ -236,10 +237,22 @@ checked against the specification's conformance fixtures. The input half - ✅ `deriveOpenOcppTraceView()` exposes the format's consumer view - ✅ 15 specification conformance fixtures vendored and asserted in CI +### Open OCPP Trace Exporter + convert CLI (Issue #122) + +- ✅ `toOpenOcppTraceRecords()` / `toOpenOcppTraceJsonl()` export any parsed + trace as v1.1 records: `raw` from the stored frame, response `action` + back-filled by correlation, skip-and-flag for events the format cannot + represent +- ✅ `ocpp-debugkit convert [--output]` emits the JSONL, carrying + trace-level metadata over from JSON Object inputs +- ✅ Every exported record validates against the specification's JSON Schema + (vendored) in CI; round-trip tests prove export-then-reparse preserves the + consumer view and the events + ## What's Next -1. **v0.4.0 - Open OCPP Trace Interop** - reading (#121) is done; remaining: - the exporter and `convert` CLI command (#122), then the `v0.4.0` release +1. **v0.4.0 release** - both interop halves (#121, #122) are in; cut and + publish `v0.4.0` 2. **v0.5.0 - OCPP 2.0.1 Support** - extend the engine beyond 1.6J: message set, device model, scenarios, and detection 3. **v1.0.0 - Stable FOSS Ecosystem** - API stabilization, 20+ scenarios, docs diff --git a/README.md b/README.md index 119432f..170cce1 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ validate behavior against known scenarios. - **Replay Engine** — Deterministic, pure replay engine with step forward/back, jump-to-event, and configurable playback speed. - **Report Generation** — Export session analysis as Markdown or HTML reports. -- **Open OCPP Trace Interop** - Read the vendor-neutral +- **Open OCPP Trace Interop** - Read and write the vendor-neutral [Open OCPP Trace](https://github.com/open-ocpp-trace/specification) interchange - format, so traces from other OCPP tools can be inspected and analyzed here. + format, so traces move between DebugKit and other OCPP tools in both + directions. - **React Components** — Reusable, SSR-safe components for building custom inspector UIs (SessionTimeline, MessageInspector, FailureSummary, ReportViewer, ReplayControls). diff --git a/docs/trace-format-spec.md b/docs/trace-format-spec.md index 2bfc86f..7ea01c7 100644 --- a/docs/trace-format-spec.md +++ b/docs/trace-format-spec.md @@ -139,9 +139,18 @@ it directly. How records are consumed: - Unknown fields are ignored, so a trace from a later minor version of the format still parses. The same size and event-count [limits](#limits) apply. +DebugKit is also a producer of the format: `toOpenOcppTraceRecords()` / +`toOpenOcppTraceJsonl()` export any parsed trace as v1.1 records (`raw` +serialized from the stored frame, response `action` back-filled by +correlation), and `ocpp-debugkit convert ` does the same from the +command line. Events the format cannot represent (no timestamp, or an unknown +direction, as with the bare array input) are skipped with a warning rather +than invented. + The format is governed independently at [open-ocpp-trace/specification](https://github.com/open-ocpp-trace/specification), -which ships a conformance suite that DebugKit's parser is checked against. +which ships a conformance suite that DebugKit's parser and exporter are +checked against. --- diff --git a/packages/toolkit/README.md b/packages/toolkit/README.md index 30c8f7e..c68dcf8 100644 --- a/packages/toolkit/README.md +++ b/packages/toolkit/README.md @@ -8,10 +8,11 @@ report generation, React components, and CLI. - **Trace Parser** — Parse OCPP 1.6 JSON traces in JSON Object, JSONL, or bare array format. Safe parsing with size and event-count limits. -- **Open OCPP Trace Interop** - Read the vendor-neutral - [Open OCPP Trace](https://github.com/open-ocpp-trace/specification) format via - `parseOpenOcppTrace()` (and `parseTrace()` auto-detection), checked against the - specification's conformance fixtures. +- **Open OCPP Trace Interop** - Read and write the vendor-neutral + [Open OCPP Trace](https://github.com/open-ocpp-trace/specification) format: + `parseOpenOcppTrace()` (with `parseTrace()` auto-detection) reads it, + `toOpenOcppTraceJsonl()` and the `convert` CLI command emit it, both checked + against the specification's conformance fixtures. - **Failure Detection** — 16 detection rules (4 critical, 10 warning, 2 info) covering common failure patterns: failed authorization, connector faults, station offline, heartbeat timeout, meter value gaps, invalid stop reasons, @@ -234,6 +235,9 @@ ocpp-debugkit scenario run failed-auth # Run an external scenario file ocpp-debugkit scenario run --file ./my-scenario.json + +# Convert any readable trace to Open OCPP Trace v1.1 JSONL +ocpp-debugkit convert trace.json --output trace.openocpp.jsonl ``` ## Trace Formats @@ -252,7 +256,8 @@ Each event has a `message` field containing a raw OCPP 1.6 JSON array: `parseTrace()` additionally auto-detects the vendor-neutral [Open OCPP Trace](https://github.com/open-ocpp-trace/specification) interchange format (records carrying `messageType` and `direction`); parse it directly with -`parseOpenOcppTrace()`. +`parseOpenOcppTrace()`, and emit it with `toOpenOcppTraceJsonl()` or the +`convert` CLI command. See the [trace format specification](https://github.com/ocpp-debugkit/toolkit/blob/main/docs/trace-format-spec.md) for full details. diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 427d63a..758cf55 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -81,8 +81,8 @@ "clean": "rm -rf dist .turbo" }, "dependencies": { - "zod": "^4.4.3", - "commander": "^13.0.0" + "commander": "^13.0.0", + "zod": "^4.4.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -100,6 +100,8 @@ "@types/node": "^22.0.0", "@types/react": "^19", "@types/react-dom": "^19", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "execa": "^9.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/toolkit/src/cli/cli.test.ts b/packages/toolkit/src/cli/cli.test.ts index 49823c1..c71a0df 100644 --- a/packages/toolkit/src/cli/cli.test.ts +++ b/packages/toolkit/src/cli/cli.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { execa } from 'execa'; -import { writeFileSync, mkdtempSync } from 'node:fs'; +import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fixtures } from '../core/index.js'; @@ -299,4 +299,56 @@ describe('CLI integration', () => { expect(parsed).toHaveProperty('onlyInB'); }); }); + + describe('convert', () => { + it('converts a JSON Object trace to Open OCPP Trace JSONL with metadata carried over', async () => { + const filePath = writeTempTrace(fixtures.normalSession); + const result = await runCli('convert', filePath); + expect(result.exitCode).toBe(0); + + const lines = result.stdout.trim().split('\n'); + expect(lines).toHaveLength(fixtures.normalSession.events.length); + + const first = JSON.parse(lines[0] ?? '') as Record; + expect(first.schemaVersion).toBe('1.1'); + expect(first.messageType).toBe('CALL'); + expect(first.action).toBe('BootNotification'); + expect(first.direction).toBe('cp-to-csms'); + expect(first.ocppVersion).toBe('1.6'); + expect(first.chargePointId).toBe('CS-SYNTHETIC-001'); + expect(typeof first.raw).toBe('string'); + + // Every line is a valid record shape. + for (const line of lines) { + const record = JSON.parse(line) as Record; + expect(record.schemaVersion).toBe('1.1'); + expect(['CALL', 'CALLRESULT', 'CALLERROR']).toContain(record.messageType); + } + }); + + it('writes to file with --output', async () => { + const filePath = writeTempTrace(fixtures.failedAuth); + const outPath = filePath.replace('trace.json', 'out.jsonl'); + const result = await runCli('convert', filePath, '--output', outPath); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Open OCPP Trace written to'); + const written = readFileSync(outPath, 'utf8'); + expect(written.trim().split('\n')).toHaveLength(fixtures.failedAuth.events.length); + }); + + it('fails cleanly when no event can be represented (bare array has no timestamps)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ocpp-test-')); + const filePath = join(dir, 'bare.json'); + writeFileSync(filePath, JSON.stringify([[2, 'msg-001', 'Heartbeat', {}]]), 'utf8'); + const result = await runCli('convert', filePath); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('No events could be converted'); + }); + + it('handles missing file gracefully', async () => { + const result = await runCli('convert', '/nonexistent/trace.json'); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('File not found'); + }); + }); }); diff --git a/packages/toolkit/src/cli/commands/convert.ts b/packages/toolkit/src/cli/commands/convert.ts new file mode 100644 index 0000000..643bf4b --- /dev/null +++ b/packages/toolkit/src/cli/commands/convert.ts @@ -0,0 +1,76 @@ +/** + * `ocpp-debugkit convert ` - convert a trace file to the Open OCPP + * Trace v1.1 interchange format (JSONL). + * + * Accepts any input format `parseTrace()` understands, including the Open + * OCPP Trace format itself. When the input is a JSON Object trace, its + * `metadata.ocppVersion` and `metadata.stationId` are carried onto every + * exported record; other input formats have no trace-level metadata to carry. + * + * Converted JSONL goes to stdout (or `--output`); warnings for events the + * format cannot represent go to stderr, so piped output stays clean. + */ + +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + parseTrace, + toOpenOcppTraceJsonl, + ParseError, + type OpenOcppTraceExportOptions, +} from '../../core/index.js'; +import { CliError, readTraceFile } from '../utils.js'; + +export interface ConvertOptions { + output?: string; +} + +/** Best-effort trace-level metadata from a JSON Object input. */ +function extractExportOptions(content: string): OpenOcppTraceExportOptions { + try { + const parsed: unknown = JSON.parse(content); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const metadata = (parsed as Record).metadata; + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) return {}; + const { ocppVersion, stationId } = metadata as Record; + return { + ...(typeof ocppVersion === 'string' && { ocppVersion }), + ...(typeof stationId === 'string' && { chargePointId: stationId }), + }; + } catch { + return {}; // JSONL input; no trace-level metadata wrapper. + } +} + +export async function convertCommand(file: string, options: ConvertOptions): Promise { + const content = readTraceFile(file); + + let result; + try { + result = parseTrace(content); + } catch (e) { + if (e instanceof ParseError || e instanceof Error) { + throw new CliError(e.message); + } + throw new CliError('Unknown error during parsing'); + } + + const { jsonl, warnings } = toOpenOcppTraceJsonl(result.events, extractExportOptions(content)); + + for (const warning of [...result.warnings, ...warnings]) { + console.error(`warning: ${warning.message}`); + } + + if (jsonl === '') { + throw new CliError( + 'No events could be converted: the Open OCPP Trace format requires a timestamp and a known direction on every record.', + ); + } + + if (options.output) { + writeFileSync(resolve(options.output), jsonl, 'utf8'); + console.log(`Open OCPP Trace written to: ${options.output}`); + } else { + process.stdout.write(jsonl); + } +} diff --git a/packages/toolkit/src/cli/index.ts b/packages/toolkit/src/cli/index.ts index d3eb754..78ce609 100644 --- a/packages/toolkit/src/cli/index.ts +++ b/packages/toolkit/src/cli/index.ts @@ -14,6 +14,7 @@ import { } from './commands/scenario.js'; import { ciCommand } from './commands/ci.js'; import { anonymizeCommand } from './commands/anonymize.js'; +import { convertCommand } from './commands/convert.js'; import { diffCommand } from './commands/diff.js'; const program = new Command(); @@ -84,6 +85,15 @@ program await anonymizeCommand(file, options); }); +// convert +program + .command('convert ') + .description('Convert a trace file to Open OCPP Trace v1.1 JSONL') + .option('-o, --output ', 'Write converted trace to file (default: stdout)') + .action(async (file: string, options: { output?: string }) => { + await convertCommand(file, options); + }); + // diff program .command('diff ') diff --git a/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/README.md b/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/README.md index 5a27475..b08e336 100644 --- a/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/README.md +++ b/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/README.md @@ -4,7 +4,8 @@ Conformance fixtures from the Open OCPP Trace specification, vendored here so DebugKit's parser is checked against them in CI. - **Source:** [open-ocpp-trace/specification](https://github.com/open-ocpp-trace/specification) - (`fixtures/`). All data is synthetic. + (`fixtures/`, and `schema/trace-v1.schema.json` vendored alongside for + validating exporter output). All data is synthetic. - Each `/trace.jsonl` is a trace in the shared format. Each `/expected.json` is the consumer view a conformant implementation derives from it: correlation pairs, effective actions, unanswered calls, and diff --git a/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/trace-v1.schema.json b/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/trace-v1.schema.json new file mode 100644 index 0000000..7cda1e4 --- /dev/null +++ b/packages/toolkit/src/core/__fixtures__/open-ocpp-trace/trace-v1.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://open-ocpp-trace.github.io/schema/trace-v1.schema.json", + "title": "OcppTraceRecord", + "type": "object", + "required": ["schemaVersion", "timestamp", "transport", "direction", "messageType"], + "additionalProperties": true, + "properties": { + "schemaVersion": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" }, + "ocppVersion": { "type": "string" }, + "transport": { "type": "string", "enum": ["json", "soap"] }, + "chargePointId": { "type": "string" }, + "connectorId": { "type": "integer", "minimum": 0 }, + "direction": { "type": "string", "enum": ["cp-to-csms", "csms-to-cp"] }, + "messageType": { + "type": "string", + "enum": ["CALL", "CALLRESULT", "CALLERROR"] + }, + "messageId": { "type": "string" }, + "action": { "type": "string" }, + "payload": {}, + "raw": { "type": "string" }, + "error": { + "type": "object", + "additionalProperties": true, + "properties": { + "code": { "type": "string" }, + "description": { "type": "string" }, + "details": {} + } + }, + "meta": { "type": "object" } + }, + "allOf": [ + { + "if": { "properties": { "messageType": { "const": "CALL" } } }, + "then": { "required": ["action"] } + }, + { + "if": { "properties": { "messageType": { "const": "CALLERROR" } } }, + "then": { "required": ["error"] }, + "else": { "properties": { "error": false } } + } + ] +} diff --git a/packages/toolkit/src/core/index.ts b/packages/toolkit/src/core/index.ts index 4b3bf95..69b5f7e 100644 --- a/packages/toolkit/src/core/index.ts +++ b/packages/toolkit/src/core/index.ts @@ -26,6 +26,11 @@ export type { OpenOcppTraceDirection, OpenOcppTraceMessageType, } from './openOcppTrace.js'; +export { toOpenOcppTraceRecords, toOpenOcppTraceJsonl } from './openOcppTraceExport.js'; +export type { + OpenOcppTraceExportOptions, + OpenOcppTraceExportResult, +} from './openOcppTraceExport.js'; // Normalizer export { diff --git a/packages/toolkit/src/core/openOcppTrace.conformance.test.ts b/packages/toolkit/src/core/openOcppTrace.conformance.test.ts index 17953ef..dc6e8ca 100644 --- a/packages/toolkit/src/core/openOcppTrace.conformance.test.ts +++ b/packages/toolkit/src/core/openOcppTrace.conformance.test.ts @@ -12,11 +12,14 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; import { parseOpenOcppTrace, deriveOpenOcppTraceView, type OpenOcppTraceView, } from './openOcppTrace.js'; +import { toOpenOcppTraceJsonl, toOpenOcppTraceRecords } from './openOcppTraceExport.js'; import { buildSessionTimeline } from './timeline.js'; import { detectFailures } from './detection.js'; @@ -30,6 +33,12 @@ const fixtureNames = readdirSync(fixturesDir) .filter((name) => statSync(join(fixturesDir, name)).isDirectory()) .sort(); +const ajv = new Ajv2020({ allErrors: true }); +addFormats(ajv); +const validateRecord = ajv.compile( + JSON.parse(readFileSync(join(fixturesDir, 'trace-v1.schema.json'), 'utf8')), +); + describe('Open OCPP Trace conformance fixtures', () => { it('vendors the full 15-scenario corpus', () => { expect(fixtureNames).toHaveLength(15); @@ -65,5 +74,30 @@ describe('Open OCPP Trace conformance fixtures', () => { const sessions = buildSessionTimeline(result.events); expect(() => detectFailures(result.events, sessions)).not.toThrow(); }); + + it('round-trips: exporting the parsed events reproduces the expected consumer view', () => { + const parsed = parseOpenOcppTrace(trace); + const { jsonl, warnings } = toOpenOcppTraceJsonl(parsed.events); + expect(warnings).toEqual([]); + expect(deriveOpenOcppTraceView(jsonl)).toEqual(expected); + }); + + it('round-trips: every exported record validates against the published schema', () => { + const parsed = parseOpenOcppTrace(trace); + const { records } = toOpenOcppTraceRecords(parsed.events); + expect(records).toHaveLength(expected.counts.records); + for (const record of records) { + const valid = validateRecord(record); + expect(valid, ajv.errorsText(validateRecord.errors)).toBe(true); + } + }); + + it('round-trips: re-parsing the export yields the same events', () => { + const parsed = parseOpenOcppTrace(trace); + const { jsonl } = toOpenOcppTraceJsonl(parsed.events); + const reparsed = parseOpenOcppTrace(jsonl); + expect(reparsed.warnings).toEqual([]); + expect(reparsed.events).toEqual(parsed.events); + }); }); }); diff --git a/packages/toolkit/src/core/openOcppTraceExport.test.ts b/packages/toolkit/src/core/openOcppTraceExport.test.ts new file mode 100644 index 0000000..5cf20da --- /dev/null +++ b/packages/toolkit/src/core/openOcppTraceExport.test.ts @@ -0,0 +1,200 @@ +/** + * Unit tests for the Open OCPP Trace exporter: field mapping per message + * type, timestamp and direction requirements (skip-and-flag), response + * action back-fill, connectorId lifting, trace-level options, raw fidelity, + * and schema validity of everything emitted. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { toOpenOcppTraceRecords, toOpenOcppTraceJsonl } from './openOcppTraceExport.js'; +import { parseTrace } from './parser.js'; +import { normalSession } from './fixtures/index.js'; +import type { Event } from './types.js'; + +const schemaPath = join( + dirname(fileURLToPath(import.meta.url)), + '__fixtures__', + 'open-ocpp-trace', + 'trace-v1.schema.json', +); +const ajv = new Ajv2020({ allErrors: true }); +addFormats(ajv); +const validateRecord = ajv.compile(JSON.parse(readFileSync(schemaPath, 'utf8'))); + +function event(overrides: Partial): Event { + return { + id: 'evt-0001', + messageId: 'm1', + timestamp: Date.parse('2024-01-15T10:00:00.000Z'), + direction: 'CS_TO_CSMS', + messageType: 'Call', + action: 'Heartbeat', + payload: {}, + errorCode: null, + errorDescription: null, + rawMessage: [2, 'm1', 'Heartbeat', {}], + ...overrides, + }; +} + +describe('toOpenOcppTraceRecords - field mapping', () => { + it('maps a CALL with direction, ISO timestamp, action, payload, and raw', () => { + const { records, warnings } = toOpenOcppTraceRecords([event({})]); + expect(warnings).toEqual([]); + expect(records).toEqual([ + { + schemaVersion: '1.1', + timestamp: '2024-01-15T10:00:00.000Z', + transport: 'json', + direction: 'cp-to-csms', + messageType: 'CALL', + messageId: 'm1', + action: 'Heartbeat', + payload: {}, + raw: '[2,"m1","Heartbeat",{}]', + }, + ]); + }); + + it('maps a CALLERROR into the error object with no payload', () => { + const err = event({ + direction: 'CSMS_TO_CS', + messageType: 'CallError', + action: null, + payload: { reason: 'x' }, + errorCode: 'InternalError', + errorDescription: 'boom', + rawMessage: [4, 'm1', 'InternalError', 'boom', { reason: 'x' }], + }); + const { records } = toOpenOcppTraceRecords([err]); + const record = records[0]; + expect(record?.messageType).toBe('CALLERROR'); + expect(record?.error).toEqual({ + code: 'InternalError', + description: 'boom', + details: { reason: 'x' }, + }); + expect(record?.payload).toBeUndefined(); + expect(record?.direction).toBe('csms-to-cp'); + }); + + it('back-fills a response action from its correlated CALL', () => { + const call = event({}); + const result = event({ + id: 'evt-0002', + direction: 'CSMS_TO_CS', + messageType: 'CallResult', + action: null, + payload: { ok: true }, + rawMessage: [3, 'm1', { ok: true }], + }); + const { records } = toOpenOcppTraceRecords([call, result]); + expect(records[1]?.action).toBe('Heartbeat'); + expect(records[1]?.payload).toEqual({ ok: true }); + }); + + it('lifts a top-level integer connectorId from a CALL payload', () => { + const status = event({ + action: 'StatusNotification', + payload: { connectorId: 1, status: 'Available', errorCode: 'NoError' }, + rawMessage: [ + 2, + 'm1', + 'StatusNotification', + { connectorId: 1, status: 'Available', errorCode: 'NoError' }, + ], + }); + const { records } = toOpenOcppTraceRecords([status]); + expect(records[0]?.connectorId).toBe(1); + }); + + it('stamps trace-level options on every record', () => { + const { records } = toOpenOcppTraceRecords([event({})], { + ocppVersion: '1.6', + chargePointId: 'CS-SYNTHETIC-001', + }); + expect(records[0]?.ocppVersion).toBe('1.6'); + expect(records[0]?.chargePointId).toBe('CS-SYNTHETIC-001'); + }); +}); + +describe('toOpenOcppTraceRecords - skip-and-flag', () => { + it('skips an event with UNKNOWN direction and warns', () => { + const { records, warnings } = toOpenOcppTraceRecords([event({ direction: 'UNKNOWN' })]); + expect(records).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.message).toContain('direction is unknown'); + }); + + it('skips an event with no timestamp and warns', () => { + const { records, warnings } = toOpenOcppTraceRecords([event({ timestamp: null })]); + expect(records).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.message).toContain('no timestamp'); + }); + + it('keeps exportable events when others are skipped', () => { + const { records, warnings } = toOpenOcppTraceRecords([ + event({ direction: 'UNKNOWN' }), + event({ id: 'evt-0002', messageId: 'm2', rawMessage: [2, 'm2', 'Heartbeat', {}] }), + ]); + expect(records).toHaveLength(1); + expect(records[0]?.messageId).toBe('m2'); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.index).toBe(0); + }); +}); + +describe('toOpenOcppTraceJsonl', () => { + it('emits one record per line with a trailing newline', () => { + const { jsonl } = toOpenOcppTraceJsonl([ + event({}), + event({ id: 'evt-0002', messageId: 'm2', rawMessage: [2, 'm2', 'Heartbeat', {}] }), + ]); + expect(jsonl.endsWith('\n')).toBe(true); + expect(jsonl.trimEnd().split('\n')).toHaveLength(2); + }); + + it('emits an empty string for no exportable events', () => { + const { jsonl } = toOpenOcppTraceJsonl([event({ timestamp: null })]); + expect(jsonl).toBe(''); + }); +}); + +describe('exporter - schema validity and raw fidelity', () => { + it('every record exported from an internal-format trace validates against the published schema', () => { + const parsed = parseTrace(JSON.stringify(normalSession)); + const { records, warnings } = toOpenOcppTraceRecords(parsed.events, { + ocppVersion: '1.6', + chargePointId: 'CS-SYNTHETIC-001', + }); + expect(warnings).toEqual([]); + expect(records).toHaveLength(parsed.events.length); + for (const record of records) { + const valid = validateRecord(record); + expect(valid, ajv.errorsText(validateRecord.errors)).toBe(true); + } + }); + + it('raw decodes to exactly the fields each record decomposes', () => { + const parsed = parseTrace(JSON.stringify(normalSession)); + const { records } = toOpenOcppTraceRecords(parsed.events); + for (const record of records) { + const frame = JSON.parse(record.raw as string) as unknown[]; + const expectedType = { 2: 'CALL', 3: 'CALLRESULT', 4: 'CALLERROR' }[frame[0] as number]; + expect(record.messageType).toBe(expectedType); + expect(record.messageId).toBe(frame[1]); + if (record.messageType === 'CALL') { + expect(record.action).toBe(frame[2]); + expect(record.payload).toEqual(frame[3]); + } else if (record.messageType === 'CALLRESULT') { + expect(record.payload).toEqual(frame[2]); + } + } + }); +}); diff --git a/packages/toolkit/src/core/openOcppTraceExport.ts b/packages/toolkit/src/core/openOcppTraceExport.ts new file mode 100644 index 0000000..b125c36 --- /dev/null +++ b/packages/toolkit/src/core/openOcppTraceExport.ts @@ -0,0 +1,191 @@ +/** + * Open OCPP Trace format exporter. + * + * Emits DebugKit's internal events as the vendor-neutral Open OCPP Trace + * interchange format (https://github.com/open-ocpp-trace/specification), one + * record per event, so any trace the toolkit can parse becomes a file other + * OCPP tools can consume. The producer counterpart of `parseOpenOcppTrace`. + * + * Producer rules implemented here: + * - `raw` is emitted for every record, serialized from the stored frame, and + * decodes to exactly the fields the record decomposes. + * - `action` on a CALLRESULT/CALLERROR is back-filled by messageId + * correlation and therefore always equals the correlated CALL's action. + * - The format requires `timestamp` and a concrete `direction`; events + * missing either (for example from the bare-array input format, which has + * no timestamps) are skipped with a warning rather than inventing data. + * - Nothing is emitted outside the declared fields; producer extensions + * would belong under `meta`, and this exporter emits none. + */ + +import type { Direction, Event, MessageType, ParseWarning } from './types.js'; +import { OPEN_OCPP_TRACE_SCHEMA_VERSION } from './openOcppTrace.js'; +import type { + OpenOcppTraceDirection, + OpenOcppTraceMessageType, + OpenOcppTraceRecord, +} from './openOcppTrace.js'; + +/** Trace-level fields the internal event model does not carry. */ +export interface OpenOcppTraceExportOptions { + /** OCPP protocol version to stamp on every record (e.g. "1.6"). */ + ocppVersion?: string; + /** Charge-point identity to stamp on every record. */ + chargePointId?: string; +} + +/** Result of an export: the records plus warnings for skipped events. */ +export interface OpenOcppTraceExportResult { + records: OpenOcppTraceRecord[]; + warnings: ParseWarning[]; +} + +const DIRECTION_TO_WIRE: Partial> = { + CS_TO_CSMS: 'cp-to-csms', + CSMS_TO_CS: 'csms-to-cp', +}; + +const MESSAGE_TYPE_TO_WIRE: Record = { + Call: 'CALL', + CallResult: 'CALLRESULT', + CallError: 'CALLERROR', +}; + +/** + * Back-fill response actions by correlation: a response takes the action of + * the most recent preceding CALL with the same messageId, the opposite + * direction, that is not already answered. Derived from the full internal + * event list, so a response can carry its action even when its CALL is + * skipped from the export. + */ +function deriveResponseActions(events: Event[]): Map { + const derived = new Map(); + const answered = new Set(); + + for (let i = 0; i < events.length; i++) { + const response = events[i]; + if (response === undefined || response.messageType === 'Call') continue; + for (let j = i - 1; j >= 0; j--) { + const call = events[j]; + if ( + call !== undefined && + call.messageType === 'Call' && + call.messageId === response.messageId && + call.direction !== 'UNKNOWN' && + response.direction !== 'UNKNOWN' && + call.direction !== response.direction && + !answered.has(j) + ) { + answered.add(j); + if (call.action !== null) derived.set(i, call.action); + break; + } + } + } + + return derived; +} + +/** Lift a top-level integer connectorId from a CALL payload, when present. */ +function liftConnectorId(event: Event): number | undefined { + if (event.messageType !== 'Call') return undefined; + const payload = event.payload; + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return undefined; + const connectorId = (payload as Record).connectorId; + return typeof connectorId === 'number' && Number.isInteger(connectorId) && connectorId >= 0 + ? connectorId + : undefined; +} + +/** + * Export internal events as Open OCPP Trace records (schema version 1.1). + * + * Events the format cannot represent (no timestamp, or direction `UNKNOWN`) + * are skipped with a warning; the warning `index` is the event's position in + * the input array. + */ +export function toOpenOcppTraceRecords( + events: Event[], + options: OpenOcppTraceExportOptions = {}, +): OpenOcppTraceExportResult { + const records: OpenOcppTraceRecord[] = []; + const warnings: ParseWarning[] = []; + const responseActions = deriveResponseActions(events); + + events.forEach((event, index) => { + const direction = DIRECTION_TO_WIRE[event.direction]; + if (direction === undefined) { + warnings.push({ + index, + message: `Event ${index + 1} (${event.id}): direction is unknown; the format requires one. Skipped.`, + }); + return; + } + if (event.timestamp === null) { + warnings.push({ + index, + message: `Event ${index + 1} (${event.id}): has no timestamp; the format requires one. Skipped.`, + }); + return; + } + if (event.messageType === 'Call' && event.action === null) { + warnings.push({ + index, + message: `Event ${index + 1} (${event.id}): CALL without an action cannot be exported. Skipped.`, + }); + return; + } + + const frame = event.rawMessage; + const action = + event.messageType === 'Call' ? (event.action ?? undefined) : responseActions.get(index); + const payload = + event.messageType === 'Call' + ? frame[3] + : event.messageType === 'CallResult' + ? frame[2] + : undefined; + const connectorId = liftConnectorId(event); + + // Assembled in the format's documented field order for stable output. + const record: OpenOcppTraceRecord = { + schemaVersion: OPEN_OCPP_TRACE_SCHEMA_VERSION, + timestamp: new Date(event.timestamp).toISOString(), + ...(options.ocppVersion !== undefined && { ocppVersion: options.ocppVersion }), + transport: 'json', + ...(options.chargePointId !== undefined && { chargePointId: options.chargePointId }), + ...(connectorId !== undefined && { connectorId }), + direction, + messageType: MESSAGE_TYPE_TO_WIRE[event.messageType], + messageId: event.messageId, + ...(action !== undefined && { action }), + ...(payload !== undefined && { payload }), + raw: JSON.stringify(frame), + ...(event.messageType === 'CallError' && { + error: { + ...(typeof frame[2] === 'string' && { code: frame[2] }), + ...(typeof frame[3] === 'string' && { description: frame[3] }), + ...(frame.length >= 5 && { details: frame[4] }), + }, + }), + }; + + records.push(record); + }); + + return { records, warnings }; +} + +/** + * Export internal events as an Open OCPP Trace JSONL document (one record per + * line, trailing newline when non-empty). + */ +export function toOpenOcppTraceJsonl( + events: Event[], + options: OpenOcppTraceExportOptions = {}, +): { jsonl: string; warnings: ParseWarning[] } { + const { records, warnings } = toOpenOcppTraceRecords(events, options); + const jsonl = + records.length === 0 ? '' : records.map((record) => JSON.stringify(record)).join('\n') + '\n'; + return { jsonl, warnings }; +} diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 1ec2be6..7d31b1a 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -26,9 +26,13 @@ export { export { parseOpenOcppTrace, deriveOpenOcppTraceView, + toOpenOcppTraceRecords, + toOpenOcppTraceJsonl, OPEN_OCPP_TRACE_SCHEMA_VERSION, type OpenOcppTraceRecord, type OpenOcppTraceView, + type OpenOcppTraceExportOptions, + type OpenOcppTraceExportResult, } from './core/index.js'; export { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8cd710..fe9f915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,12 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.17) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) execa: specifier: ^9.0.0 version: 9.6.1 @@ -661,9 +667,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -878,6 +895,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1041,6 +1061,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1307,6 +1330,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2149,6 +2176,10 @@ snapshots: acorn@8.17.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2156,6 +2187,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -2398,6 +2436,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.3: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -2537,6 +2577,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonfile@4.0.0: @@ -2749,6 +2791,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} diff --git a/tests/external-fixture/test.mjs b/tests/external-fixture/test.mjs index fb9ea8a..337b6e9 100644 --- a/tests/external-fixture/test.mjs +++ b/tests/external-fixture/test.mjs @@ -139,6 +139,16 @@ assert( openView.records[1].action === 'BootNotification', 'response action derived by messageId correlation', ); +assert(typeof core.toOpenOcppTraceJsonl === 'function', 'toOpenOcppTraceJsonl is a function'); +const exported = core.toOpenOcppTraceJsonl(openResult.events); +assert( + exported.jsonl.trim().split('\n').length === 2, + 'toOpenOcppTraceJsonl emits one record per event', +); +assert( + core.parseOpenOcppTrace(exported.jsonl).events.length === 2, + 'exported JSONL re-parses (round trip)', +); console.log(' /core tests passed\n'); @@ -290,6 +300,17 @@ try { console.error(`FAIL: CLI scenario list — ${e.message}`); } +// CLI convert +try { + const output = execSync(`node ${cliPath} convert ${tracePath}`, { encoding: 'utf8' }); + const firstLine = JSON.parse(output.trim().split('\n')[0]); + assert(firstLine.schemaVersion === '1.1', 'CLI convert emits schemaVersion 1.1'); + assert(firstLine.messageType === 'CALL', 'CLI convert first record is a CALL'); +} catch (e) { + failed++; + console.error(`FAIL: CLI convert - ${e.message}`); +} + console.log(' CLI tests passed\n'); // --- Summary ---