diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0166d84..aaa6033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: ci: name: Lint, Typecheck, Test, Build runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - name: Checkout @@ -49,3 +49,11 @@ jobs: - name: Test run: pnpm test + + - name: Install Playwright browsers + working-directory: apps/web + run: pnpm exec playwright install --with-deps chromium + + - name: E2E tests + working-directory: apps/web + run: pnpm test:e2e diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 5a4681d..8bb4540 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -135,7 +135,7 @@ report exported. CLI and web inspector. - ✅ Inspector: failure summary (severity, description, suggested steps) - ✅ Inspector: Markdown report export (download) -### Inspector Polish (in progress — this PR) +### Inspector Polish (PR #44) - ✅ Loading state with spinner ("Parsing trace…") - ✅ Error state improvements (non-sensitive messages, empty input guidance) @@ -144,6 +144,14 @@ report exported. CLI and web inspector. - ✅ Sticky header for better UX on long traces - ✅ Analyze button shows "Analyzing…" and disables during parsing +### Playwright Smoke Tests (in progress — this PR) + +- ✅ `playwright.config.ts` — chromium, auto-start dev server +- ✅ Landing page tests: page loads, hero, CTA links, features, footer +- ✅ Navigation tests: landing → inspector, landing → docs +- ✅ Inspector tests: empty state, sample scenario → timeline, failures, event click → message inspector, export button, invalid input error +- ✅ CI workflow updated: install browsers + run E2E after unit tests + ## What's Next 1. **Issue #20** → complete (PR #33): data model + parser + normalizer @@ -155,8 +163,10 @@ report exported. CLI and web inspector. 7. **Issue #26** → complete (PR #42): Next.js app scaffold (Next.js + Tailwind, routes for /, /inspector, /docs) 8. **Issue #27** → complete (PR #43): Landing page (hero, features, architecture, quick start, footer) 9. **Issue #28** → complete (PR #43): Inspector (trace input + timeline + message inspector + failures + report export) -10. **Issue #29** (this PR) → complete: Inspector polish (loading states, responsive, keyboard nav) -11. **Issue #30**: Playwright smoke tests +10. **Issue #29** → complete (PR #44): Inspector polish (loading states, responsive, keyboard nav) +11. **Issue #30** (this PR) → complete: Playwright smoke tests +12. **Issue #31**: Docs content (quickstart, glossary, architecture, CLI reference, API reference) +13. **Issue #32**: Release v0.1.0 ## Known Blockers / Decisions Pending diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52..3f753f2 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -39,3 +39,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/apps/web/package.json b/apps/web/package.json index 2b7f8a2..385ae27 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,17 +8,19 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "clean": "rm -rf .next .turbo" + "clean": "rm -rf .next .turbo", + "test:e2e": "playwright test" }, "dependencies": { "@ocpp-debugkit/core": "workspace:*", - "@ocpp-debugkit/scenarios": "workspace:*", "@ocpp-debugkit/reporter": "workspace:*", + "@ocpp-debugkit/scenarios": "workspace:*", "next": "16.2.10", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..e2bf9b3 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/web/tests/inspector.spec.ts b/apps/web/tests/inspector.spec.ts new file mode 100644 index 0000000..cb47bf4 --- /dev/null +++ b/apps/web/tests/inspector.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Inspector', () => { + test('page loads with empty state', async ({ page }) => { + await page.goto('/inspector'); + await expect(page.getByText('OCPP Inspector')).toBeVisible(); + await expect(page.getByText('Paste OCPP Trace')).toBeVisible(); + await expect(page.getByText('Sample Scenarios')).toBeVisible(); + // Empty state message + await expect( + page.getByText('Paste a trace, upload a file, or select a sample scenario'), + ).toBeVisible(); + }); + + test('load sample scenario and see timeline', async ({ page }) => { + await page.goto('/inspector'); + + // Click the normal-session scenario button + await page.getByRole('button', { name: 'normal-session' }).click(); + + // The textarea should now have content + const textarea = page.locator('textarea'); + await expect(textarea).not.toBeEmpty(); + + // Click Analyze + await page.getByRole('button', { name: 'Analyze' }).click(); + + // Wait for results — stat cards should appear + await expect(page.getByText('Events').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Sessions').first()).toBeVisible(); + + // Timeline should be present + await expect(page.getByText('Event Timeline')).toBeVisible(); + }); + + test('load failed-auth scenario and see failures', async ({ page }) => { + await page.goto('/inspector'); + + await page.getByRole('button', { name: 'failed-auth' }).click(); + await page.getByRole('button', { name: 'Analyze' }).click(); + + // Wait for failure to appear + await expect(page.getByText('FAILED_AUTHORIZATION')).toBeVisible({ + timeout: 10000, + }); + }); + + test('click event in timeline to inspect message', async ({ page }) => { + await page.goto('/inspector'); + + await page.getByRole('button', { name: 'normal-session' }).click(); + await page.getByRole('button', { name: 'Analyze' }).click(); + + // Wait for timeline + await expect(page.getByText('Event Timeline')).toBeVisible({ timeout: 10000 }); + + // Click first event button in the timeline + const firstEvent = page.locator('[data-event-id]').first(); + await firstEvent.click(); + + // Message inspector should show details + await expect(page.getByText('Message Inspector')).toBeVisible(); + await expect(page.getByText('Raw Message')).toBeVisible(); + await expect(page.getByText('Payload')).toBeVisible(); + }); + + test('export report button appears after analysis', async ({ page }) => { + await page.goto('/inspector'); + + await page.getByRole('button', { name: 'normal-session' }).click(); + await page.getByRole('button', { name: 'Analyze' }).click(); + + // Wait for results + await expect(page.getByText('Event Timeline')).toBeVisible({ timeout: 10000 }); + + // Export button should be visible + await expect(page.getByRole('button', { name: 'Export Report' })).toBeVisible(); + }); + + test('handles invalid input gracefully', async ({ page }) => { + await page.goto('/inspector'); + + // Type invalid JSON + await page.locator('textarea').fill('{ invalid json }'); + await page.getByRole('button', { name: 'Analyze' }).click(); + + // Should show error message (not crash) + await expect(page.getByText('Error:')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/apps/web/tests/landing.spec.ts b/apps/web/tests/landing.spec.ts new file mode 100644 index 0000000..8d84beb --- /dev/null +++ b/apps/web/tests/landing.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Landing page', () => { + test('page loads and shows hero', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle('OCPP DebugKit'); + await expect(page.locator('h1')).toHaveText('OCPP DebugKit'); + }); + + test('hero has CTA links', async ({ page }) => { + await page.goto('/'); + const inspectorLink = page.getByRole('link', { name: 'Try Inspector' }); + await expect(inspectorLink).toBeVisible(); + await expect(inspectorLink).toHaveAttribute('href', '/inspector'); + + const githubLink = page.getByRole('link', { name: 'View on GitHub' }); + await expect(githubLink).toBeVisible(); + await expect(githubLink).toHaveAttribute('href', /github\.com/); + }); + + test('features section is present', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Features' })).toBeVisible(); + await expect(page.getByText('Inspect').first()).toBeVisible(); + await expect(page.getByText('Detect').first()).toBeVisible(); + await expect(page.getByText('Test').first()).toBeVisible(); + await expect(page.getByText('Report').first()).toBeVisible(); + }); + + test("what it's not section is present", async ({ page }) => { + await page.goto('/'); + await expect(page.getByText("What it's not")).toBeVisible(); + await expect(page.getByText('Not a CSMS')).toBeVisible(); + await expect(page.getByText('Not a simulator')).toBeVisible(); + await expect(page.getByText('Not a compliance tool')).toBeVisible(); + }); + + test('footer has links', async ({ page }) => { + await page.goto('/'); + const footer = page.locator('footer'); + await expect(footer.getByRole('link', { name: 'GitHub' })).toBeVisible(); + await expect(footer.getByRole('link', { name: 'npm' })).toBeVisible(); + await expect(footer.getByRole('link', { name: 'Docs' })).toBeVisible(); + await expect(footer.getByText('Apache 2.0 License')).toBeVisible(); + }); +}); + +test.describe('Navigation', () => { + test('can navigate from landing to inspector', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Try Inspector' }).click(); + await expect(page).toHaveURL('/inspector'); + await expect(page.getByText('OCPP Inspector')).toBeVisible(); + }); + + test('can navigate to docs', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Docs' }).first().click(); + await expect(page).toHaveURL('/docs'); + await expect(page.getByRole('heading', { name: 'Documentation' })).toBeVisible(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 121300d..cdb97e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: link:../../packages/scenarios next: specifier: 16.2.10 - version: 16.2.10(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -57,6 +57,9 @@ importers: specifier: 19.2.4 version: 19.2.4(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4 version: 4.3.2 @@ -748,6 +751,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -1768,6 +1776,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2400,6 +2413,16 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3494,6 +3517,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -4337,7 +4364,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.10 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -4360,7 +4387,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -4375,14 +4402,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -4397,7 +4424,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -4637,6 +4664,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5076,7 +5106,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.10 '@swc/helpers': 0.5.15 @@ -5095,6 +5125,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.10 '@next/swc-win32-arm64-msvc': 16.2.10 '@next/swc-win32-x64-msvc': 16.2.10 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5229,6 +5260,14 @@ snapshots: pify@4.0.1: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: diff --git a/vitest.config.ts b/vitest.config.ts index bf3e3f8..b15364c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ globals: true, environment: 'node', include: ['**/*.{test,spec}.{ts,tsx,js,jsx}'], - exclude: ['**/node_modules/**', '**/dist/**', '**/.next/**'], + exclude: ['**/node_modules/**', '**/dist/**', '**/.next/**', '**/apps/web/tests/**'], coverage: { provider: 'v8', reporter: ['text', 'lcov'],