✨ feat(frontend): Storybook + auto-generated Playwright e2e docs with screenshots (#30)
Storybook 8 + Playwright JSON reporter + auto-generated markdown docs with embedded screenshots and breadcrumbs. Frontend PRs now reviewable from Gitea web UI. ~95% Mistral autonomous via ICM workspace, trainer commit/PR (Mistral hit turn limit). Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,5 +42,6 @@ frontend/.output/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.env
|
frontend/.env
|
||||||
frontend/.cache/
|
frontend/.cache/
|
||||||
|
frontend/storybook-static/
|
||||||
frontend/test-results/
|
frontend/test-results/
|
||||||
frontend/playwright-report/
|
frontend/playwright-report/
|
||||||
|
|||||||
15
frontend/.storybook/main.ts
Normal file
15
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../components/**/*.stories.@(js|ts|mdx)'],
|
||||||
|
addons: ['@storybook/addon-essentials'],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/vue3-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
15
frontend/.storybook/preview.ts
Normal file
15
frontend/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Preview } from '@storybook/vue3'
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
16
frontend/components/HealthDashboard.stories.ts
Normal file
16
frontend/components/HealthDashboard.stories.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import HealthDashboard from './HealthDashboard.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof HealthDashboard> = {
|
||||||
|
title: 'Components/HealthDashboard',
|
||||||
|
component: HealthDashboard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {},
|
||||||
|
}
|
||||||
4
frontend/docs/README.md
Normal file
4
frontend/docs/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Frontend Docs
|
||||||
|
|
||||||
|
- [E2E Test Reports](./e2e/README.md) - auto-generated by `npm run docs:gen`
|
||||||
|
- Storybook (run locally: `npm run storybook` ; build: `npm run build-storybook` then open `storybook-static/index.html`)
|
||||||
7
frontend/docs/e2e/README.md
Normal file
7
frontend/docs/e2e/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# E2E Test Reports
|
||||||
|
|
||||||
|
[<- Up to docs](../README.md)
|
||||||
|
|
||||||
|
| Test | Status | Duration |
|
||||||
|
|------|--------|----------|
|
||||||
|
| [home page loads and shows server health info](./home-page-loads-and-shows-server-health-info.md) | PASSED | 168ms |
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# home page loads and shows server health info
|
||||||
|
|
||||||
|
[<- Back to index](./README.md) | [Top](../README.md)
|
||||||
|
|
||||||
|
**File**: `tests/e2e/health.spec.ts`
|
||||||
|
**Status**: PASSED
|
||||||
|
**Duration**: 168ms
|
||||||
|
|
||||||
|
## Screenshot
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Test Details
|
||||||
|
|
||||||
|
- Start Time: 2026-05-03T14:38:42.958Z
|
||||||
|
- Spec File: health.spec.ts
|
||||||
2348
frontend/package-lock.json
generated
2348
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,20 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build",
|
||||||
|
"docs:gen": "playwright test && node scripts/generate-test-docs.mjs",
|
||||||
|
"docs:full": "npm run build-storybook && npm run docs:gen"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@storybook/addon-essentials": "^8.0.0",
|
||||||
|
"@storybook/vue3": "^8.0.0",
|
||||||
|
"@storybook/vue3-vite": "^8.0.0",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"nuxt": "^3.13.0",
|
"nuxt": "^3.13.0",
|
||||||
|
"storybook": "^8.0.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "npm@11.5.2"
|
"packageManager": "npm@11.5.2"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { defineConfig } from '@playwright/test'
|
import { defineConfig } from '@playwright/test'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
reporter: [
|
||||||
|
['list'],
|
||||||
|
['json', { outputFile: path.join(process.cwd(), 'test-results', 'results.json') }],
|
||||||
|
],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: 'http://localhost:3000',
|
||||||
|
screenshot: 'on',
|
||||||
|
video: 'off',
|
||||||
},
|
},
|
||||||
|
outputDir: 'test-results/output',
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
|
|||||||
120
frontend/scripts/generate-test-docs.mjs
Normal file
120
frontend/scripts/generate-test-docs.mjs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const frontendDir = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const resultsPath = path.join(frontendDir, 'test-results', 'results.json')
|
||||||
|
const docsDir = path.join(frontendDir, 'docs', 'e2e')
|
||||||
|
const screenshotsDir = path.join(frontendDir, 'tests', 'e2e', 'screenshots')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Read results
|
||||||
|
const resultsText = await fs.readFile(resultsPath, 'utf8')
|
||||||
|
const results = JSON.parse(resultsText)
|
||||||
|
|
||||||
|
// Create output directories
|
||||||
|
await fs.mkdir(docsDir, { recursive: true })
|
||||||
|
|
||||||
|
// Extract tests from suites
|
||||||
|
const testDocs = []
|
||||||
|
for (const suite of results.suites || []) {
|
||||||
|
for (const spec of suite.specs || []) {
|
||||||
|
for (const test of spec.tests || []) {
|
||||||
|
for (const result of test.results || []) {
|
||||||
|
const testInfo = {
|
||||||
|
title: spec.title,
|
||||||
|
specFile: spec.file || suite.file,
|
||||||
|
status: result.status,
|
||||||
|
duration: result.duration,
|
||||||
|
startTime: result.startTime,
|
||||||
|
attachments: result.attachments || [],
|
||||||
|
}
|
||||||
|
testDocs.push(testInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate individual test markdown files
|
||||||
|
for (const test of testDocs) {
|
||||||
|
const slug = slugify(test.title)
|
||||||
|
const mdPath = path.join(docsDir, `${slug}.md`)
|
||||||
|
|
||||||
|
// Use slug-based screenshot name (matches explicit screenshot in test)
|
||||||
|
let screenshotPath = `${slug}.png`
|
||||||
|
|
||||||
|
// Also try to find screenshot attachment and use its basename
|
||||||
|
if (test.attachments && test.attachments.length > 0) {
|
||||||
|
for (const attachment of test.attachments) {
|
||||||
|
if (attachment.contentType === 'image/png') {
|
||||||
|
const basename = path.basename(attachment.path)
|
||||||
|
// Prefer explicit screenshot name if it matches our pattern
|
||||||
|
if (basename !== 'test-finished-1.png' && basename !== 'test-finished-2.png') {
|
||||||
|
screenshotPath = basename
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteScreenshotPath = path.join(screenshotsDir, screenshotPath)
|
||||||
|
const relativeScreenshotPath = path.relative(docsDir, absoluteScreenshotPath)
|
||||||
|
|
||||||
|
const mdContent = `# ${test.title}
|
||||||
|
|
||||||
|
[<- Back to index](./README.md) | [Top](../README.md)
|
||||||
|
|
||||||
|
**File**: \`tests/e2e/${test.specFile}\`
|
||||||
|
**Status**: ${test.status.toUpperCase()}
|
||||||
|
**Duration**: ${test.duration}ms
|
||||||
|
|
||||||
|
## Screenshot
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Test Details
|
||||||
|
|
||||||
|
- Start Time: ${test.startTime || 'N/A'}
|
||||||
|
- Spec File: ${test.specFile}
|
||||||
|
`
|
||||||
|
|
||||||
|
await fs.writeFile(mdPath, mdContent)
|
||||||
|
console.log(`Generated: ${path.relative(frontendDir, mdPath)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate index README
|
||||||
|
const indexContent = `# E2E Test Reports
|
||||||
|
|
||||||
|
[<- Up to docs](../README.md)
|
||||||
|
|
||||||
|
| Test | Status | Duration |
|
||||||
|
|------|--------|----------|
|
||||||
|
${testDocs.map(t => `| [${escapeMd(t.title)}](./${slugify(t.title)}.md) | ${t.status.toUpperCase()} | ${t.duration}ms |`).join('\n')}
|
||||||
|
`
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(docsDir, 'README.md'), indexContent)
|
||||||
|
console.log(`Generated: ${path.relative(frontendDir, path.join(docsDir, 'README.md'))}`)
|
||||||
|
|
||||||
|
console.log(`\nGenerated ${testDocs.length} test docs`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str) {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeMd(str) {
|
||||||
|
return str.replace(/[|\\\[\]\{\}]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -5,4 +5,5 @@ test('home page loads and shows server health info', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
||||||
const heading = page.getByRole('heading', { name: /dance-lessons-coach/i })
|
const heading = page.getByRole('heading', { name: /dance-lessons-coach/i })
|
||||||
await expect(heading).toBeVisible()
|
await expect(heading).toBeVisible()
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/home-page-loads-and-shows-server-health-info.png', fullPage: true })
|
||||||
})
|
})
|
||||||
|
|||||||
0
frontend/tests/e2e/screenshots/.gitkeep
Normal file
0
frontend/tests/e2e/screenshots/.gitkeep
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Reference in New Issue
Block a user