diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaa6033..e4dddba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/eslint.config.js b/eslint.config.js index 91ff144..1df37cb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,7 @@ export default tseslint.config( '**/.turbo/**', '**/coverage/**', '**/node_modules/**', + '**/tests/external-fixture/**', ], }, ); diff --git a/package.json b/package.json index ccd319f..44add7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-external-fixture.sh b/scripts/test-external-fixture.sh new file mode 100755 index 0000000..94c5b71 --- /dev/null +++ b/scripts/test-external-fixture.sh @@ -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 diff --git a/tests/external-fixture/package.json b/tests/external-fixture/package.json new file mode 100644 index 0000000..69f7186 --- /dev/null +++ b/tests/external-fixture/package.json @@ -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)." +} diff --git a/tests/external-fixture/test.mjs b/tests/external-fixture/test.mjs new file mode 100644 index 0000000..da60c5f --- /dev/null +++ b/tests/external-fixture/test.mjs @@ -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); +} diff --git a/vitest.config.ts b/vitest.config.ts index b15364c..94bc8d9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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'],