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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,30 @@ jobs:
- name: E2E tests
working-directory: apps/web
run: pnpm test:e2e

external-fixture:
name: External Fixture Test
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

- name: External fixture test
run: pnpm test:external-fixture
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default tseslint.config(
'**/.turbo/**',
'**/coverage/**',
'**/node_modules/**',
'**/tests/external-fixture/**',
],
},
);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"changeset": "changeset",
"clean": "turbo run clean && rm -rf node_modules"
"clean": "turbo run clean && rm -rf node_modules",
"test:external-fixture": "bash scripts/test-external-fixture.sh"
},
"devDependencies": {
"@changesets/cli": "^2.29.7",
Expand Down
50 changes: 50 additions & 0 deletions scripts/test-external-fixture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/bin/bash
# Run the external fixture test.
#
# Usage: pnpm test:external-fixture
#
# This script:
# 1. Builds the toolkit package
# 2. Packs it into a tarball
# 3. Creates a temp project that installs the tarball
# 4. Runs the test script against the installed package
#
# This catches subpath export misconfiguration that workspace
# tests miss because workspace symlinks bypass the exports map.

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPDIR="$(mktemp -d)"
trap "rm -rf $TMPDIR" EXIT

echo "=== Building toolkit ==="
cd "$REPO_ROOT"
pnpm build

echo "=== Packing toolkit ==="
TARBALL=$(cd "$REPO_ROOT/packages/toolkit" && pnpm pack --pack-destination "$TMPDIR" 2>&1 | grep -o '/[^ ]*\.tgz')
echo "Tarball: $TARBALL"

if [ -z "$TARBALL" ]; then
echo "ERROR: Failed to find tarball path"
exit 1
fi

echo "=== Setting up external fixture project ==="
mkdir -p "$TMPDIR/project"
cat > "$TMPDIR/project/package.json" << EOF
{
"name": "toolkit-external-fixture",
"version": "0.0.0",
"private": true,
"type": "module"
}
EOF

cd "$TMPDIR/project"
npm install "$TARBALL" 2>&1 | tail -5

echo "=== Running external fixture test ==="
cp "$REPO_ROOT/tests/external-fixture/test.mjs" .
node test.mjs
7 changes: 7 additions & 0 deletions tests/external-fixture/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "toolkit-external-fixture",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "External fixture project to verify @ocpp-debugkit/toolkit subpath exports work for real consumers (not workspace symlinks)."
}
250 changes: 250 additions & 0 deletions tests/external-fixture/test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* External fixture test — verifies @ocpp-debugkit/toolkit subpath exports
* work for real consumers (installed from tarball, not workspace symlinks).
*
* This script is run by CI after building + packing the toolkit package.
* It imports each subpath export and exercises core functionality.
*
* Run: node test.mjs
* Exit code 0 = pass, non-zero = fail.
*/

import { createRequire } from 'node:module';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execSync } from 'node:child_process';

const require = createRequire(import.meta.url);
let passed = 0;
let failed = 0;

function assert(condition, message) {
if (condition) {
passed++;
} else {
failed++;
console.error(`FAIL: ${message}`);
}
}

function assertThrows(fn, message) {
try {
fn();
failed++;
console.error(`FAIL: ${message} — expected throw`);
} catch {
passed++;
}
}

console.log('=== @ocpp-debugkit/toolkit external fixture test ===\n');

// --- /core ---
console.log('Testing /core...');
const core = await import('@ocpp-debugkit/toolkit/core');

assert(typeof core.parseTrace === 'function', 'parseTrace is a function');
assert(typeof core.buildSessionTimeline === 'function', 'buildSessionTimeline is a function');
assert(typeof core.detectFailures === 'function', 'detectFailures is a function');
assert(typeof core.summarizeSessions === 'function', 'summarizeSessions is a function');
assert(typeof core.validateMessage === 'function', 'validateMessage is a function');
assert(typeof core.normalizeEvents === 'function', 'normalizeEvents is a function');
assert(core.MAX_INPUT_SIZE_BYTES > 0, 'MAX_INPUT_SIZE_BYTES > 0');
assert(core.MAX_EVENT_COUNT > 0, 'MAX_EVENT_COUNT > 0');
assert(typeof core.ParseError === 'function', 'ParseError is a constructor');

// Test parseTrace on a simple JSON Object trace
const jsonTrace = {
traceId: 'external-fixture-test',
metadata: {
stationId: 'CS-SYNTHETIC-TEST',
ocppVersion: '1.6',
source: 'external-fixture',
},
events: [
{
id: 'evt-1',
timestamp: '2026-01-01T00:00:00Z',
direction: 'CS_TO_CSMS',
message: [2, 'msg-001', 'BootNotification', { chargePointSerialNumber: 'CS-SYNTHETIC-001' }],
},
{
id: 'evt-2',
timestamp: '2026-01-01T00:00:05Z',
direction: 'CSMS_TO_CS',
message: [3, 'msg-001', { status: 'Accepted' }],
},
{
id: 'evt-3',
timestamp: '2026-01-01T00:00:10Z',
direction: 'CS_TO_CSMS',
message: [2, 'msg-002', 'Heartbeat', {}],
},
{
id: 'evt-4',
timestamp: '2026-01-01T00:00:15Z',
direction: 'CSMS_TO_CS',
message: [3, 'msg-002', { currentTime: '2026-01-01T00:00:15Z' }],
},
],
};

const parseResult = core.parseTrace(JSON.stringify(jsonTrace));
assert(
parseResult.events.length === 4,
`parseTrace produces 4 events (got ${parseResult.events.length})`,
);
assert(parseResult.warnings.length === 0, 'parseTrace has no warnings for valid input');

// Test detectFailures
const sessions = core.buildSessionTimeline(parseResult.events);
const failures = core.detectFailures(parseResult.events, sessions);
assert(Array.isArray(failures), 'detectFailures returns an array');

console.log(' /core tests passed\n');

// --- /scenarios ---
console.log('Testing /scenarios...');
const scenarios = await import('@ocpp-debugkit/toolkit/scenarios');

assert(Array.isArray(scenarios.scenarios), 'scenarios is an array');
assert(
scenarios.scenarios.length === 5,
`5 scenarios exported (got ${scenarios.scenarios.length})`,
);
assert(typeof scenarios.getScenario === 'function', 'getScenario is a function');
assert(scenarios.getScenario('normal-session') !== undefined, 'normal-session scenario exists');
assert(
scenarios.getScenario('nonexistent') === undefined,
'nonexistent scenario returns undefined',
);

console.log(' /scenarios tests passed\n');

// --- /reporter ---
console.log('Testing /reporter...');
const reporter = await import('@ocpp-debugkit/toolkit/reporter');

assert(
typeof reporter.generateMarkdownReport === 'function',
'generateMarkdownReport is a function',
);

// Generate a report
const reportInput = {
events: parseResult.events,
sessions,
failures,
summaries: core.summarizeSessions(sessions, failures),
warnings: parseResult.warnings,
};
const markdown = reporter.generateMarkdownReport(reportInput);
assert(typeof markdown === 'string', 'generateMarkdownReport returns a string');
assert(markdown.includes('# OCPP DebugKit'), 'report contains title');
assert(markdown.includes('## Session Overview'), 'report contains session overview');

console.log(' /reporter tests passed\n');

// --- /replay ---
console.log('Testing /replay...');
const replay = await import('@ocpp-debugkit/toolkit/replay');

assert(typeof replay.ReplayEngine === 'function', 'ReplayEngine is a constructor');

const engine = new replay.ReplayEngine(parseResult.events, failures);
assert(engine.totalEvents === 4, `ReplayEngine has 4 events (got ${engine.totalEvents})`);
assert(engine.current === 0, 'ReplayEngine starts at index 0');

const step1 = engine.step();
assert(step1 !== null, 'step() returns first event');
assert(step1.index === 0, 'first step index is 0');

engine.reset();
assert(engine.current === 0, 'reset returns to index 0');

console.log(' /replay tests passed\n');

// --- /react ---
console.log('Testing /react...');
const react = await import('@ocpp-debugkit/toolkit/react');

assert(typeof react.SessionTimeline === 'function', 'SessionTimeline is exported');
assert(typeof react.MessageInspector === 'function', 'MessageInspector is exported');
assert(typeof react.FailureSummary === 'function', 'FailureSummary is exported');
assert(typeof react.ReportViewer === 'function', 'ReportViewer is exported');
assert(typeof react.ReplayControls === 'function', 'ReplayControls is exported');

console.log(' /react tests passed\n');

// --- /fixtures ---
console.log('Testing /fixtures...');
const fixturesMod = await import('@ocpp-debugkit/toolkit/fixtures');

assert(fixturesMod.fixtures !== undefined, 'fixtures object is exported');
assert(typeof fixturesMod.fixtures.normalSession === 'object', 'normalSession fixture exists');
assert(typeof fixturesMod.fixtures.failedAuth === 'object', 'failedAuth fixture exists');
assert(typeof fixturesMod.fixtures.connectorFault === 'object', 'connectorFault fixture exists');

console.log(' /fixtures tests passed\n');

// --- Root barrel ---
console.log('Testing root barrel export (.)...');
const toolkit = await import('@ocpp-debugkit/toolkit');

assert(typeof toolkit.parseTrace === 'function', 'root parseTrace is a function');
assert(typeof toolkit.detectFailures === 'function', 'root detectFailures is a function');
assert(
typeof toolkit.buildSessionTimeline === 'function',
'root buildSessionTimeline is a function',
);
assert(typeof toolkit.fixtures === 'object', 'root fixtures is an object');

console.log(' root barrel tests passed\n');

// --- CLI smoke test ---
console.log('Testing CLI...');
// The CLI is exported via subpath ./cli — resolve it from the package directory
const toolkitPkgPath = require.resolve('@ocpp-debugkit/toolkit/package.json');
const toolkitDir = toolkitPkgPath.replace(/\/package\.json$/, '');
const cliPath = join(toolkitDir, 'dist', 'cli', 'index.js');
assert(cliPath.endsWith('cli/index.js'), `CLI resolves to cli/index.js (got ${cliPath})`);

// Write a trace file and run the CLI
const dir = mkdtempSync(join(tmpdir(), 'toolkit-test-'));
const tracePath = join(dir, 'trace.json');
writeFileSync(tracePath, JSON.stringify(fixturesMod.fixtures.normalSession), 'utf8');

try {
const output = execSync(`node ${cliPath} inspect ${tracePath}`, { encoding: 'utf8' });
assert(output.includes('Trace Inspection'), 'CLI inspect outputs "Trace Inspection"');
assert(output.includes('Sessions:'), 'CLI inspect shows session count');
} catch (e) {
failed++;
console.error(`FAIL: CLI smoke test — ${e.message}`);
}

// CLI scenario list
try {
const output = execSync(`node ${cliPath} scenario list`, { encoding: 'utf8' });
assert(output.includes('normal-session'), 'CLI scenario list shows normal-session');
assert(output.includes('failed-auth'), 'CLI scenario list shows failed-auth');
} catch (e) {
failed++;
console.error(`FAIL: CLI scenario list — ${e.message}`);
}

console.log(' CLI tests passed\n');

// --- Summary ---
console.log('=== Summary ===');
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);

if (failed > 0) {
console.error('\n❌ External fixture test FAILED');
process.exit(1);
} else {
console.log('\n✅ External fixture test PASSED');
process.exit(0);
}
8 changes: 7 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx,js,jsx}'],
exclude: ['**/node_modules/**', '**/dist/**', '**/.next/**', '**/apps/web/tests/**'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/.next/**',
'**/apps/web/tests/**',
'**/tests/external-fixture/**',
],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
Expand Down
Loading