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
15 changes: 15 additions & 0 deletions .changeset/cli-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@ocpp-debugkit/cli': minor
---

Create CLI package with inspect, report, and scenario commands.

- `ocpp-debugkit inspect <file>` — parse + analyze + output summary
- `ocpp-debugkit report <file>` — generate Markdown report (stdout or --output)
- `ocpp-debugkit scenario list` — list all 5 built-in scenarios
- `ocpp-debugkit scenario run <name>` — run scenario through analysis engine,
compare detected vs expected failures
- Path safety: validated file paths, size limits
- Input validation: safe parsing, non-sensitive error messages
- 17 integration tests (execa-based)
- Converted JSON fixtures to TS modules (fixes Node.js ESM JSON import issue)
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ jobs:
- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test

- name: Build
run: pnpm build

- name: Test
run: pnpm test
23 changes: 19 additions & 4 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,36 @@ report exported. CLI and web inspector.
- ✅ Each scenario's `expectedFailures` aligns with v0.1 detection rules
- ✅ 21 tests (registry, engine integration, synthetic data policy)

### Reporter Package (in progress — this PR)
### Reporter Package (PR #40)

- ✅ `packages/reporter/` — new package
- ✅ `generateMarkdownReport()` — session overview, timeline summary, failures, suggested steps, event appendix
- ✅ `AnalysisResult` input type
- ✅ 11 tests (structure, failure inclusion, readability, metadata, severity)

### CLI Package (in progress — this PR)

- ✅ `packages/cli/` — new package
- ✅ `ocpp-debugkit inspect <file>` — parse + analyze + output
- ✅ `ocpp-debugkit report <file>` — generate Markdown report (stdout or file)
- ✅ `ocpp-debugkit scenario list` — list all 5 scenarios
- ✅ `ocpp-debugkit scenario run <name>` — run scenario through analysis engine, compare detected vs expected
- ✅ Path safety: validated file paths, size limits
- ✅ Input validation: safe parsing, non-sensitive errors
- ✅ 17 integration tests (execa-based)
- ✅ Converted JSON fixtures to TS modules (fixes Node.js ESM JSON import issue)

## What's Next

1. **Issue #20** → complete (PR #33): data model + parser + normalizer
2. **Issue #21** → complete (PR #34): timeline + detection + summarizer + validator
3. **Issue #22** → complete (PR #35): public API export + package config
4. **Issue #23** → complete (PR #39): scenarios package (format + 5 initial scenarios)
5. **Issue #24** (this PR) → complete: reporter package (Markdown report generator)
6. **Issue #25**: CLI package (scaffold + inspect + report + scenario commands)
5. **Issue #24** → complete (PR #40): reporter package (Markdown report generator)
6. **Issue #25** (this PR) → complete: CLI package (scaffold + inspect + report + scenario commands)
7. **Issue #26**: Next.js app scaffold (Next.js + Nextra + Tailwind + Shadcn)
8. **Issue #27**: Landing page
9. **Issue #28**: Inspector (trace input + timeline + message inspector)

## Known Blockers / Decisions Pending

Expand All @@ -126,7 +141,7 @@ report exported. CLI and web inspector.
| `@ocpp-debugkit/core` | in progress (package config finalized, ready for downstream) | 0.0.0 |
| `@ocpp-debugkit/scenarios` | in progress (5 scenarios + registry) | 0.0.0 |
| `@ocpp-debugkit/reporter` | in progress (Markdown generator) | 0.0.0 |
| `@ocpp-debugkit/cli` | not started | — |
| `@ocpp-debugkit/cli` | in progress (inspect + report + scenario) | 0.0.0 |
| `@ocpp-debugkit/replay` | not started | — |
| `@ocpp-debugkit/react` | not started | — |
| `apps/web` | not started | — |
54 changes: 54 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@ocpp-debugkit/cli",
"version": "0.0.0",
"description": "Command-line interface for OCPP DebugKit — inspect traces, generate reports, run scenarios.",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"ocpp-debugkit": "./dist/index.js"
},
"files": [
"dist",
"README.md",
"NOTICE"
],
"keywords": [
"ocpp",
"ocpp1.6",
"ev-charging",
"cli",
"debugging",
"trace-inspector"
],
"repository": {
"type": "git",
"url": "https://github.com/ocpp-debugkit/ocpp-debugkit.git",
"directory": "packages/cli"
},
"homepage": "https://github.com/ocpp-debugkit/ocpp-debugkit/tree/main/packages/cli",
"bugs": {
"url": "https://github.com/ocpp-debugkit/ocpp-debugkit/issues"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist .turbo"
},
"dependencies": {
"@ocpp-debugkit/core": "workspace:*",
"@ocpp-debugkit/scenarios": "workspace:*",
"@ocpp-debugkit/reporter": "workspace:*",
"commander": "^13.0.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.0.0",
"execa": "^9.0.0"
},
"publishConfig": {
"access": "public"
}
}
174 changes: 174 additions & 0 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import { execa } from 'execa';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fixtures } from '@ocpp-debugkit/core';

const CLI_PATH = join(process.cwd(), 'packages/cli/dist/index.js');

// Helper: run the CLI with given args
async function runCli(
...args: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const result = await execa('node', [CLI_PATH, ...args]);
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode ?? 0 };
} catch (e) {
const error = e as { stdout?: string; stderr?: string; exitCode?: number };
return {
stdout: error.stdout ?? '',
stderr: error.stderr ?? '',
exitCode: error.exitCode ?? 1,
};
}
}

// Helper: write a trace to a temp file
function writeTempTrace(trace: unknown): string {
const dir = mkdtempSync(join(tmpdir(), 'ocpp-test-'));
const filePath = join(dir, 'trace.json');
writeFileSync(filePath, JSON.stringify(trace), 'utf8');
return filePath;
}

describe('CLI integration', () => {
describe('--version', () => {
it('outputs version', async () => {
const result = await runCli('--version');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('0.0.0');
});
});

describe('--help', () => {
it('shows help with all commands', async () => {
const result = await runCli('--help');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('inspect');
expect(result.stdout).toContain('report');
expect(result.stdout).toContain('scenario');
});
});

describe('inspect', () => {
it('inspects a normal session trace', async () => {
const filePath = writeTempTrace(fixtures.normalSession);
const result = await runCli('inspect', filePath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Trace Inspection');
expect(result.stdout).toContain('Sessions: 1');
expect(result.stdout).toContain('No failures detected');
});

it('inspects a failed-auth trace and shows failures', async () => {
const filePath = writeTempTrace(fixtures.failedAuth);
const result = await runCli('inspect', filePath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('FAILED_AUTHORIZATION');
});

it('inspects a connector-fault trace and shows failures', async () => {
const filePath = writeTempTrace(fixtures.connectorFault);
const result = await runCli('inspect', filePath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('CONNECTOR_FAULT');
});

it('handles missing file gracefully', async () => {
const result = await runCli('inspect', '/nonexistent/file.json');
expect(result.exitCode).not.toBe(0);
});

it('handles invalid JSON gracefully', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ocpp-test-'));
const filePath = join(dir, 'bad.json');
writeFileSync(filePath, '{ invalid json }', 'utf8');
const result = await runCli('inspect', filePath);
expect(result.exitCode).not.toBe(0);
});
});

describe('report', () => {
it('generates a Markdown report to stdout', async () => {
const filePath = writeTempTrace(fixtures.normalSession);
const result = await runCli('report', filePath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('OCPP DebugKit — Trace Analysis Report');
expect(result.stdout).toContain('## Session Overview');
expect(result.stdout).toContain('## Failures');
});

it('generates a report with failures', async () => {
const filePath = writeTempTrace(fixtures.failedAuth);
const result = await runCli('report', filePath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('FAILED_AUTHORIZATION');
expect(result.stdout).toContain('## Suggested Next Steps');
});

it('writes report to a file with --output', async () => {
const filePath = writeTempTrace(fixtures.normalSession);
const dir = mkdtempSync(join(tmpdir(), 'ocpp-test-'));
const outputPath = join(dir, 'report.md');
const result = await runCli('report', filePath, '--output', outputPath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Report written to');
});
});

describe('scenario list', () => {
it('lists all available scenarios', async () => {
const result = await runCli('scenario', 'list');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('normal-session');
expect(result.stdout).toContain('failed-auth');
expect(result.stdout).toContain('connector-fault');
expect(result.stdout).toContain('station-offline');
expect(result.stdout).toContain('unexpected-stop-reason');
});
});

describe('scenario run', () => {
it('runs normal-session scenario and passes', async () => {
const result = await runCli('scenario', 'run', 'normal-session');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Running Scenario: normal-session');
expect(result.stdout).toContain('No failures expected, none detected');
expect(result.stdout).toContain('PASS');
});

it('runs failed-auth scenario and detects FAILED_AUTHORIZATION', async () => {
const result = await runCli('scenario', 'run', 'failed-auth');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('FAILED_AUTHORIZATION');
expect(result.stdout).toContain('PASS');
});

it('runs connector-fault scenario and detects CONNECTOR_FAULT', async () => {
const result = await runCli('scenario', 'run', 'connector-fault');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('CONNECTOR_FAULT');
expect(result.stdout).toContain('PASS');
});

it('runs station-offline scenario and detects STATION_OFFLINE_DURING_SESSION', async () => {
const result = await runCli('scenario', 'run', 'station-offline');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('STATION_OFFLINE_DURING_SESSION');
expect(result.stdout).toContain('PASS');
});

it('runs unexpected-stop-reason scenario (no failures)', async () => {
const result = await runCli('scenario', 'run', 'unexpected-stop-reason');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('No failures expected, none detected');
expect(result.stdout).toContain('PASS');
});

it('handles unknown scenario name', async () => {
const result = await runCli('scenario', 'run', 'nonexistent');
expect(result.exitCode).not.toBe(0);
});
});
});
82 changes: 82 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* `ocpp-debugkit inspect <file>` — parse and analyze an OCPP trace file.
*/

import {
parseTrace,
buildSessionTimeline,
detectFailures,
summarizeSessions,
ParseError,
} from '@ocpp-debugkit/core';
import { CliError, readTraceFile } from '../utils.js';

export interface InspectOptions {
format: string;
}

export async function inspectCommand(file: string, _options: InspectOptions): Promise<void> {
const content = readTraceFile(file);

let result;
try {
result = parseTrace(content);
} catch (e) {
if (e instanceof ParseError) {
throw new CliError(e.message);
}
if (e instanceof Error) {
throw new CliError(e.message);
}
throw new CliError('Unknown error during parsing');
}

const sessions = buildSessionTimeline(result.events);
const failures = detectFailures(result.events, sessions);
const summaries = summarizeSessions(sessions, failures);

// Output to stdout
console.log('');
console.log('═'.repeat(60));
console.log(' OCPP DebugKit — Trace Inspection');
console.log('═'.repeat(60));
console.log('');

console.log(` Events: ${result.events.length}`);
console.log(` Sessions: ${sessions.length}`);
console.log(` Failures: ${failures.length}`);
console.log(` Warnings: ${result.warnings.length}`);
console.log('');

if (result.warnings.length > 0) {
console.log('── Parse Warnings ──────────────────────────────────────');
for (const w of result.warnings) {
console.log(` ⚠ ${w.message}`);
}
console.log('');
}

console.log('── Sessions ────────────────────────────────────────────');
for (const summary of summaries) {
const duration =
summary.durationMs !== null ? `${(summary.durationMs / 1000).toFixed(1)}s` : 'Unknown';
console.log(
` ${summary.sessionId} | station=${summary.stationId} | conn=${summary.connectorId ?? '-'} | tx=${summary.transactionId ?? '-'} | ${summary.status} | ${duration} | ${summary.eventCount} events | ${summary.failureCount} failures`,
);
}
console.log('');

if (failures.length > 0) {
console.log('── Failures ────────────────────────────────────────────');
for (const failure of failures) {
console.log(` [${failure.severity.toUpperCase()}] ${failure.code}`);
console.log(` ${failure.description}`);
console.log(` Events: ${failure.eventIds.join(', ')}`);
}
console.log('');
} else {
console.log('── Failures ────────────────────────────────────────────');
console.log(' No failures detected. ✅');
console.log('');
}
}
Loading
Loading