diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dcbc82b..922018f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,63 @@ jobs: test-artifacts/ retention-days: 7 + # ── Showcase report: .webm video + screenshots of every mode, all sizes ──── + # Documentation, not a gate (not in ci-success). Runs on every PR per the + # team's choice; uploads a single self-contained gallery as an artifact. + showcase-report: + name: Showcase Report (video + screenshots) + runs-on: ubuntu-latest + needs: [quality, unit-tests, build] + services: + neo4j: + image: neo4j:5.26.12 + env: + NEO4J_AUTH: neo4j/graphdone_password + NEO4J_PLUGINS: '["graph-data-science", "apoc"]' + NEO4J_dbms_security_procedures_unrestricted: "gds.*,apoc.*" + NEO4J_dbms_security_procedures_allowlist: "gds.*,apoc.*" + options: >- + --health-cmd "cypher-shell -u neo4j -p graphdone_password 'RETURN 1'" + --health-interval 10s --health-timeout 5s --health-retries 30 + ports: + - 7474:7474 + - 7687:7687 + env: + NEO4J_URI: bolt://localhost:7687 + NEO4J_USER: neo4j + NEO4J_PASSWORD: graphdone_password + JWT_SECRET: ci-showcase-jwt-secret + SESSION_SECRET: ci-showcase-session-secret + TEST_URL: http://localhost:3127 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + - name: Install dependencies + run: npm install --legacy-peer-deps + - name: Build + run: npm run build + - name: Install Playwright (chromium) + run: npx playwright install --with-deps chromium + - name: Start GraphDone (dev stack) + run: | + npm run dev > /tmp/dev.log 2>&1 & + timeout 240 bash -c 'until curl -sf http://localhost:3127 >/dev/null 2>&1 && curl -sf http://localhost:4127/health >/dev/null 2>&1; do sleep 3; done' + - name: Seed demo data + run: npm run db:seed || true + - name: Capture showcase + build gallery + run: npm run report:showcase + continue-on-error: true + - name: Upload showcase gallery + if: always() + uses: actions/upload-artifact@v4 + with: + name: showcase-report-${{ github.sha }} + path: test-artifacts/showcase/ + retention-days: 14 + # ── Full production-stack validation (nginx/TLS/Docker) ───────────────────── # Heavy (~45 min cold build), so it runs only on main pushes, the nightly # schedule, and manual dispatch — not on every PR. Same user-smoke suite, diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 47c41ede..7e73b392 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -15,6 +15,7 @@ | Types | `npm run typecheck` | All packages compile | | Lint | `npm run lint` | 0 errors (warnings allowed) | | Build | `npm run build` | Production build succeeds | +| Showcase report | `TEST_URL=http://localhost:3127 npm run report:showcase` | Records .webm video + screenshots of every mode at all 5 resolutions → `test-artifacts/showcase/index.html` (also an every-PR CI artifact). | **Why THE GATE exists:** a real incident — orphaned `Edge` records made the edges query 500 and the UI showed "Error" with zero edges, while every unit diff --git a/package.json b/package.json index dd415b9c..7d51bce6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "db:seed": "cd packages/server && npm run db:seed", "docker:dev": "docker compose -f deployment/docker-compose.dev.yml up", "docker:prod": "docker compose -f deployment/docker-compose.yml up", - "test:smoke": "playwright test tests/e2e/user-smoke.spec.ts --reporter=line" + "test:smoke": "playwright test tests/e2e/user-smoke.spec.ts --reporter=line", + "report:showcase": "playwright test --project=showcase && node tests/generate-showcase-report.mjs" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/playwright.config.ts b/playwright.config.ts index 3c3b66e7..0796dce7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -30,13 +30,34 @@ export default defineConfig({ ignoreHTTPSErrors: true, }, + /* Where Playwright writes per-test artifacts (videos, screenshots, traces). + * The showcase report generator reads from here. */ + outputDir: 'test-artifacts/playwright-output', + /* Configure projects for major browsers */ projects: [ { name: 'GraphDone-Core/dev-neo4j/chromium', + // The showcase tour runs in its own capture-heavy project below; keep it + // out of the default (fast) project so the smoke gate stays quick. + testIgnore: /showcase\.spec\.ts/, use: { ...devices['Desktop Chrome'] }, }, + /* Showcase: records web-friendly .webm video + full-page screenshots of + * every mode of operation, across the responsive viewport matrix. Run via + * `npm run report:showcase`. Heavy by design — not part of the smoke gate. */ + { + name: 'showcase', + testMatch: /showcase\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + video: 'on', + screenshot: 'on', + trace: 'retain-on-failure', + }, + }, + // Commented out until browsers installed with system dependencies // { // name: 'GraphDone-Core/dev-neo4j/firefox', diff --git a/tests/e2e/showcase.spec.ts b/tests/e2e/showcase.spec.ts new file mode 100644 index 00000000..85fca48e --- /dev/null +++ b/tests/e2e/showcase.spec.ts @@ -0,0 +1,149 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Showcase tour — documents every mode of operation as web-friendly .webm + * video (recorded automatically by the 'showcase' Playwright project) plus a + * labelled full-page screenshot per step, across the responsive viewport + * matrix. Output feeds `npm run report:showcase`, which stitches a single + * gallery: mode × resolution. + * + * This is DOCUMENTATION, not a gate — every step is best-effort so the tour + * always completes and produces artifacts even where a mode isn't available + * at a given size (e.g. touch-only interactions on a phone). + */ + +const VIEWPORTS = [ + { name: 'iphone-se', width: 375, height: 667 }, + { name: 'iphone-15', width: 393, height: 852 }, + { name: 'ipad', width: 820, height: 1180 }, + { name: 'laptop-1080p', width: 1920, height: 1080 }, + { name: 'desktop-4k', width: 3840, height: 2160 }, +] as const; + +const SHOT_ROOT = path.resolve(process.cwd(), 'test-artifacts/showcase'); + +async function selectRichGraph(page: Page) { + const sel = page.locator('[data-testid="graph-selector"]'); + if (!(await sel.isVisible().catch(() => false))) return; + await sel.click(); + await page.waitForTimeout(800); + const target = page.locator('text=Cycle 2: The Living Graph').first(); + if (await target.isVisible().catch(() => false)) { + await target.click(); + await page.waitForTimeout(6000); + } + await page.keyboard.press('Escape').catch(() => {}); +} + +async function nodeCenter(page: Page, index = 0) { + return page.evaluate((i) => { + const cards = [...document.querySelectorAll('.graph-container svg .node .node-bg')]; + const el = cards[i]; + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, index); +} + +function runTourAt(vp: (typeof VIEWPORTS)[number]) { + test.describe(`showcase @ ${vp.name} (${vp.width}x${vp.height})`, () => { + test.use({ viewport: { width: vp.width, height: vp.height } }); + + test(`tour of all modes @ ${vp.name}`, async ({ page }) => { + test.setTimeout(180_000); // the full multi-mode tour is long by design + const dir = path.join(SHOT_ROOT, vp.name); + fs.mkdirSync(dir, { recursive: true }); + const safeMove = async (x: number, y: number) => { try { await page.mouse.move(x, y); } catch { /* page may be busy */ } }; + let step = 0; + const captured: string[] = []; + const shot = async (label: string) => { + step += 1; + const file = `${String(step).padStart(2, '0')}-${label}.png`; + await page.screenshot({ path: path.join(dir, file), fullPage: false }).catch(() => {}); + captured.push(file); + }; + const tryStep = async (label: string, fn: () => Promise) => { + try { await fn(); await page.waitForTimeout(500); await shot(label); } + catch { await shot(`${label}-unavailable`); } + }; + + // 1. Login screen (before auth) + await page.goto('/'); + await page.waitForTimeout(2000); + await shot('login-screen'); + + // 2. Authenticated workspace + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(5000); + await selectRichGraph(page); + await page.waitForTimeout(2000); + await shot('graph-overview'); + + // 3. Node menu + await tryStep('node-menu', async () => { + const c = await nodeCenter(page, 0); + if (!c) throw new Error('no node'); + await page.mouse.click(c.x, c.y); + await page.waitForTimeout(800); + }); + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + + // 4. Grow mode (ghost preview from the + icon) + await tryStep('grow-mode', async () => { + const plus = await page.evaluate(() => { + const r = document.querySelector('.node-relationship-icon')?.getBoundingClientRect(); + return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null; + }); + if (!plus) throw new Error('no + icon'); + await page.mouse.click(plus.x, plus.y); + await page.waitForTimeout(400); + await page.mouse.move(vp.width / 2, vp.height * 0.7, { steps: 6 }); + await page.waitForTimeout(400); + }); + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + + // 5. Hover neighborhood illumination + await tryStep('hover-illumination', async () => { + const c = await nodeCenter(page, 1); + if (!c) throw new Error('no node'); + await page.mouse.move(c.x, c.y); + await page.waitForTimeout(700); + }); + await safeMove(5, 5); + await page.waitForTimeout(300); + + // 6. Edge editor (glows + opens beside the edge) + await tryStep('edge-editor', async () => { + const label = await page.evaluate(() => { + const r = document.querySelector('.edge-label-group')?.getBoundingClientRect(); + return r && r.width ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null; + }); + if (!label) throw new Error('no edge label'); + await page.mouse.click(label.x, label.y); + await page.waitForTimeout(900); + }); + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + + // 7. Minimap (persistent, bottom-right) — capture full view + await shot('minimap-and-graph'); + + // 8. Settings → adaptive Visual Quality + await tryStep('settings-quality', async () => { + await page.goto('/settings'); + await page.waitForTimeout(1500); + }); + + // Always leave at least the core captures + expect(captured.length, 'showcase produced screenshots').toBeGreaterThan(2); + console.log(`[showcase ${vp.name}] captured ${captured.length} steps: ${captured.join(', ')}`); + }); + }); +} + +for (const vp of VIEWPORTS) runTourAt(vp); diff --git a/tests/generate-showcase-report.mjs b/tests/generate-showcase-report.mjs new file mode 100644 index 00000000..0e1a4b9f --- /dev/null +++ b/tests/generate-showcase-report.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Stitches the showcase artifacts into one web-efficient gallery: + * test-artifacts/showcase/index.html + * + * Inputs (produced by `playwright test --project=showcase`): + * - test-artifacts/showcase//NN-step.png (step screenshots) + * - test-artifacts/playwright-output/.../video.webm (one .webm per viewport tour) + * + * Web-efficient: videos are VP8 .webm, lazy (preload="none") with a screenshot + * poster; images lazy-load. Open the single index.html to review every mode at + * every resolution. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = process.cwd(); +const SHOT_ROOT = path.join(ROOT, 'test-artifacts/showcase'); +const PW_OUT = path.join(ROOT, 'test-artifacts/playwright-output'); +const OUT = path.join(SHOT_ROOT, 'index.html'); + +const VIEWPORTS = ['iphone-se', 'iphone-15', 'ipad', 'laptop-1080p', 'desktop-4k']; + +function walk(dir) { + const out = []; + if (!fs.existsSync(dir)) return out; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...walk(p)); + else out.push(p); + } + return out; +} + +const allWebm = walk(PW_OUT).filter((f) => f.endsWith('.webm')); + +function sectionFor(vp) { + const dir = path.join(SHOT_ROOT, vp); + const shots = fs.existsSync(dir) + ? fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort() + : []; + // Pick the video whose output path mentions this viewport, and copy it into + // the viewport folder so the whole showcase/ dir is self-contained/portable. + const srcVideo = allWebm.find((f) => f.toLowerCase().includes(vp)); + let localVideo = null; + if (srcVideo && fs.existsSync(dir)) { + localVideo = `${vp}/tour.webm`; + fs.copyFileSync(srcVideo, path.join(SHOT_ROOT, localVideo)); + } + const poster = shots[1] || shots[0]; // graph-overview if present + const videoHtml = localVideo + ? `` + : `

No video captured.

`; + const shotsHtml = shots.length + ? shots.map((s) => ` +
+ ${s} +
${s.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' ')}
+
`).join('') + : `

No screenshots.

`; + return ` +
+

${vp} (${shots.length} steps${localVideo ? ', video' : ''})

+
${videoHtml}
+
${shotsHtml}
+
`; +} + +const generatedAt = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; +const html = ` + + +GraphDone — Showcase Report + + +
+

🌊 GraphDone — Showcase Report

+
Every mode of operation, captured as web-friendly .webm video and full-page screenshots across the responsive viewport matrix. Generated ${generatedAt}.
+ +
+
+${VIEWPORTS.map((vp) => `${sectionFor(vp)}`).join('')} +
+`; + +fs.mkdirSync(SHOT_ROOT, { recursive: true }); +fs.writeFileSync(OUT, html); +const videoCount = VIEWPORTS.filter((v) => allWebm.some((f) => f.toLowerCase().includes(v))).length; +const shotCount = VIEWPORTS.reduce((n, v) => { + const d = path.join(SHOT_ROOT, v); + return n + (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => f.endsWith('.png')).length : 0); +}, 0); +console.log(`Showcase report: ${OUT}`); +console.log(` ${videoCount}/${VIEWPORTS.length} viewports with video, ${shotCount} screenshots total`);