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
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/SYSTEMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
149 changes: 149 additions & 0 deletions tests/e2e/showcase.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>) => {
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);
111 changes: 111 additions & 0 deletions tests/generate-showcase-report.mjs
Original file line number Diff line number Diff line change
@@ -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/<viewport>/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
? `<video controls preload="none" ${poster ? `poster="${vp}/${poster}"` : ''} width="480">
<source src="${localVideo}" type="video/webm">
</video>`
: `<p class="muted">No video captured.</p>`;
const shotsHtml = shots.length
? shots.map((s) => `
<figure>
<img loading="lazy" src="${vp}/${s}" alt="${s}">
<figcaption>${s.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' ')}</figcaption>
</figure>`).join('')
: `<p class="muted">No screenshots.</p>`;
return `
<section>
<h2>${vp} <span class="muted">(${shots.length} steps${localVideo ? ', video' : ''})</span></h2>
<div class="video">${videoHtml}</div>
<div class="grid">${shotsHtml}</div>
</section>`;
}

const generatedAt = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
const html = `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GraphDone — Showcase Report</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; background: #0b1220; color: #e5e7eb; }
header { padding: 24px 28px; background: linear-gradient(135deg,#065f46,#0b1220); }
h1 { margin: 0 0 4px; font-size: 22px; }
.muted { color: #94a3b8; font-weight: 400; }
main { padding: 12px 28px 60px; }
section { margin: 28px 0; border-top: 1px solid #1e293b; padding-top: 18px; }
h2 { font-size: 18px; text-transform: capitalize; }
.video video { border-radius: 10px; border: 1px solid #1e293b; max-width: 100%; background:#000; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 14px; }
figure { margin: 0; background: #111827; border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; }
figure img { width: 100%; display: block; background:#000; }
figcaption { padding: 8px 10px; font-size: 12px; color: #cbd5e1; text-transform: capitalize; }
nav a { color: #34d399; margin-right: 14px; text-decoration: none; }
</style></head>
<body>
<header>
<h1>🌊 GraphDone — Showcase Report</h1>
<div class="muted">Every mode of operation, captured as web-friendly .webm video and full-page screenshots across the responsive viewport matrix. Generated ${generatedAt}.</div>
<nav style="margin-top:10px">${VIEWPORTS.map((v) => `<a href="#${v}">${v}</a>`).join('')}</nav>
</header>
<main>
${VIEWPORTS.map((vp) => `<a id="${vp}"></a>${sectionFor(vp)}`).join('')}
</main>
</body></html>`;

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`);
Loading