diff --git a/docs/guides/web-ui-architecture.md b/docs/guides/web-ui-architecture.md index ef275135..fb62a455 100644 --- a/docs/guides/web-ui-architecture.md +++ b/docs/guides/web-ui-architecture.md @@ -52,3 +52,11 @@ graph TD `tests/helpers/mobileAudit.ts` auditors (sideways-scroll, squeezed labels, low-contrast/invisible text, modals clipped under the nav) across **every** screen at phone width, in CI right after the smoke gate. + +**Massive local report** — `npm run report:matrix` (then `npm run report:matrix:open`) +runs `tests/e2e/feature-matrix.spec.ts`: every view (8) + page (6) + signin + key +features (inspector / expand peek / create modal) at **6 resolutions** (360→1920), +each cell screenshotted **and** layout/contrast-audited. `tests/generate-matrix-report.mjs` +stitches the screenshots into one self-contained gallery +(`test-artifacts/matrix/index.html`, feature × resolution grid). Heavy; local-only, +not in the smoke gate. diff --git a/package.json b/package.json index 08d6e606..6e4f5ad2 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "test:smoke": "playwright test tests/e2e/user-smoke.spec.ts --reporter=line", "test:mobile": "playwright test --grep \"@mobile|@audit\" --project=\"GraphDone-Core/dev-neo4j/chromium\" --reporter=line", "report:showcase": "playwright test --project=showcase && node tests/generate-showcase-report.mjs", + "report:matrix": "playwright test --project=matrix --reporter=line,html && node tests/generate-matrix-report.mjs", + "report:matrix:open": "open test-artifacts/matrix/index.html 2>/dev/null || xdg-open test-artifacts/matrix/index.html 2>/dev/null || echo 'Open test-artifacts/matrix/index.html'", "test:perf": "playwright test --project=perf --reporter=line", "test:perf:scale": "playwright test --project=perf-scale --reporter=line && node tests/generate-perf-report.mjs", "report:perf": "node tests/generate-perf-report.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index bfb7c281..236434ce 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,10 +41,26 @@ export default defineConfig({ // The showcase tour and the local-VLM visual eval run in their own // capture-heavy projects below; keep them out of the default (fast) // project so the smoke gate stays quick. - testIgnore: [/showcase\.spec\.ts/, /visual-vlm\.spec\.ts/], + testIgnore: [/showcase\.spec\.ts/, /visual-vlm\.spec\.ts/, /feature-matrix\.spec\.ts/, /matrix\.setup\.ts/], use: { ...devices['Desktop Chrome'] }, }, + /* The massive resolution × feature report: every screen + key feature, + * screenshotted + audited at every viewport, into the Playwright HTML report. + * Local + heavy; run via `npm run report:matrix`. One shared login (the + * matrix-setup dependency) feeds storageState so the ~100 cells don't re-login. */ + { + name: 'matrix-setup', + testMatch: /matrix\.setup\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'matrix', + testMatch: /feature-matrix\.spec\.ts/, + dependencies: ['matrix-setup'], + use: { ...devices['Desktop Chrome'], storageState: 'test-artifacts/matrix-auth.json', screenshot: 'on' }, + }, + /* 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. */ diff --git a/tests/e2e/feature-matrix.spec.ts b/tests/e2e/feature-matrix.spec.ts new file mode 100644 index 00000000..567ea88d --- /dev/null +++ b/tests/e2e/feature-matrix.spec.ts @@ -0,0 +1,153 @@ +import { test, expect, Page, TestInfo } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getBaseURL } from '../helpers/auth'; +import { auditLayout, auditContrast } from '../helpers/mobileAudit'; + +const SHOT_ROOT = path.resolve(process.cwd(), 'test-artifacts/matrix'); + +/** + * The massive matrix: every screen + key feature, captured and audited at every + * resolution, into one Playwright HTML report. Run locally: + * + * npm run report:matrix # then: npx playwright show-report test-artifacts/reports/playwright-report + * + * Each cell (resolution × feature) attaches a full-page screenshot AND runs the + * layout/contrast auditors, so the report is both a visual gallery and a + * per-resolution QA gate. Heavy by design; not part of the smoke gate. + */ + +const VIEWPORTS = [ + { name: 'phone-360', width: 360, height: 640 }, + { name: 'phone-390', width: 390, height: 844 }, + { name: 'tablet-768', width: 768, height: 1024 }, + { name: 'tablet-1024', width: 1024, height: 768 }, + { name: 'laptop-1440', width: 1440, height: 900 }, + { name: 'desktop-1920', width: 1920, height: 1080 }, +] as const; + +const VIEWS = ['cards', 'dashboard', 'table', 'kanban', 'gantt', 'calendar', 'activity', 'graph'] as const; +const PAGES = [ + { path: '/ontology', name: 'ontology' }, + { path: '/settings', name: 'settings' }, + { path: '/admin', name: 'admin' }, + { path: '/backend', name: 'system' }, + { path: '/agents', name: 'agents' }, + { path: '/analytics', name: 'analytics' }, +] as const; + +// Write the screenshot to test-artifacts/matrix//.png (the +// self-contained gallery generator reads these) AND attach to the Playwright report. +async function capture(page: Page, info: TestInfo, vpName: string, feature: string) { + const dir = path.join(SHOT_ROOT, vpName); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${feature}.png`); + const png = await page.screenshot({ path: file, fullPage: true }).catch(() => null); + if (png) await info.attach(`${vpName}-${feature}.png`, { body: png, contentType: 'image/png' }); +} + +// Hard invariants that are bugs at ANY resolution. Side-scroll / squeezed labels +// are attached as soft info (some views legitimately scroll a region). +async function auditAndAssert(page: Page, info: TestInfo, scope: string, label: string, errs: string[]) { + const layout = await auditLayout(page, scope); + const contrast = await auditContrast(page, scope); + await info.attach('audit.json', { body: JSON.stringify({ layout, contrast, errs }, null, 2), contentType: 'application/json' }); + expect(layout.pageOverflowPx, `${label}: page overflows sideways by ${layout.pageOverflowPx}px`).toBeLessThanOrEqual(2); + expect(contrast, `${label}: invisible / low-contrast text`).toEqual([]); + expect(errs, `${label}: uncaught JS errors`).toEqual([]); +} + +for (const vp of VIEWPORTS) { + test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => { + test.use({ viewport: { width: vp.width, height: vp.height } }); + test.describe.configure({ timeout: 90_000 }); + + for (const mode of VIEWS) { + test(`view: ${mode}`, async ({ page }, info) => { + const errs: string[] = []; + page.on('pageerror', (e) => errs.push(e.message)); + await page.addInitScript((m) => localStorage.setItem('graphdone:viewMode', m), mode); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(mode === 'graph' ? 4000 : 2500); + await capture(page, info, vp.name, `view-${mode}`); + // The graph is a canvas; only its chrome (page overflow + errors) is auditable. + if (mode === 'graph') { + const layout = await auditLayout(page, 'body'); + expect(layout.pageOverflowPx, 'graph: page overflows sideways').toBeLessThanOrEqual(2); + expect(errs, 'graph: uncaught JS errors').toEqual([]); + } else { + await auditAndAssert(page, info, '[data-testid="view-content"]', `view:${mode}`, errs); + } + }); + } + + for (const pg of PAGES) { + test(`page: ${pg.name}`, async ({ page }, info) => { + const errs: string[] = []; + page.on('pageerror', (e) => errs.push(e.message)); + await page.goto(`${getBaseURL()}${pg.path}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + await capture(page, info, vp.name, `page-${pg.name}`); + await auditAndAssert(page, info, 'main', `page:${pg.name}`, errs); + }); + } + + test('feature: node inspector (select a node)', async ({ page }, info) => { + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'graph')); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(4500); + const box = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + if (box) { await page.mouse.click(box.x, box.y); await page.waitForTimeout(1500); } + await capture(page, info, vp.name, 'feature-node-inspector'); + // Inspector is best-effort across sizes; the screenshot is the deliverable. + expect(true).toBe(true); + }); + + test('feature: expand-in-place peek (⛶)', async ({ page }, info) => { + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'graph')); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(4500); + const opened = await page.evaluate(() => { + const icon = document.querySelector('.graph-container svg .node .node-expand-icon'); + if (!icon) return false; + icon.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return true; + }); + if (opened) await page.waitForTimeout(1500); + await capture(page, info, vp.name, 'feature-expand-peek'); + expect(true).toBe(true); + }); + + test('feature: create work item modal', async ({ page }, info) => { + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'cards')); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + const fab = page.locator('[aria-label="New work item"]'); + if (await fab.isVisible().catch(() => false)) { await fab.click(); await page.waitForTimeout(1000); } + await capture(page, info, vp.name, 'feature-create-modal'); + expect(true).toBe(true); + }); + }); +} + +test.describe('signin (logged out)', () => { + for (const vp of VIEWPORTS) { + test(`${vp.name}: signin`, async ({ browser }, info) => { + // Fresh context with NO stored auth so we see the real signin screen. + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: undefined }); + const page = await ctx.newPage(); + const errs: string[] = []; + page.on('pageerror', (e) => errs.push(e.message)); + await page.goto(`${getBaseURL()}/login`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + await capture(page, info, vp.name, 'signin'); + await auditAndAssert(page, info, 'body', `signin@${vp.name}`, errs); + await ctx.close(); + }); + } +}); diff --git a/tests/e2e/matrix.setup.ts b/tests/e2e/matrix.setup.ts new file mode 100644 index 00000000..220a997b --- /dev/null +++ b/tests/e2e/matrix.setup.ts @@ -0,0 +1,12 @@ +import { test as setup } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +// One real login for the whole matrix run; every feature-matrix test reuses this +// storage state so the ~100-cell resolution×feature sweep doesn't re-login each +// time. (Wired via the `matrix` project's dependencies + storageState.) +const AUTH_FILE = 'test-artifacts/matrix-auth.json'; + +setup('authenticate for the matrix', async ({ page }) => { + await login(page, TEST_USERS.ADMIN); + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/tests/generate-matrix-report.mjs b/tests/generate-matrix-report.mjs new file mode 100644 index 00000000..34d067e3 --- /dev/null +++ b/tests/generate-matrix-report.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Stitches the feature-matrix screenshots into one self-contained gallery: + * test-artifacts/matrix/index.html + * + * Input (produced by `playwright test --project=matrix`): + * test-artifacts/matrix//.png + * + * Grid: one row per feature (view / page / dialog / signin), one column per + * resolution. Lazy images; click any cell to open it full-size. Open the single + * index.html — every screen at every resolution, side by side. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.join(process.cwd(), 'test-artifacts/matrix'); +if (!fs.existsSync(ROOT)) { console.error('No matrix screenshots at', ROOT, '— run `npm run report:matrix` first.'); process.exit(1); } + +const viewports = fs.readdirSync(ROOT, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort((a, b) => (parseInt(a.replace(/\D+/g, ''), 10) || 0) - (parseInt(b.replace(/\D+/g, ''), 10) || 0)); + +// Union of feature filenames across viewports, in a stable, readable order. +const featureSet = new Set(); +for (const vp of viewports) { + for (const f of fs.readdirSync(path.join(ROOT, vp)).filter((f) => f.endsWith('.png'))) featureSet.add(f.replace(/\.png$/, '')); +} +const order = (f) => (f.startsWith('view-') ? 0 : f.startsWith('page-') ? 1 : f.startsWith('feature-') ? 2 : 3); +const features = [...featureSet].sort((a, b) => order(a) - order(b) || a.localeCompare(b)); + +const cell = (vp, feat) => { + const rel = `${vp}/${feat}.png`; + return fs.existsSync(path.join(ROOT, rel)) + ? `${feat} @ ${vp}` + : `—`; +}; +const groupLabel = (f) => f.startsWith('view-') ? 'View · ' + f.slice(5) + : f.startsWith('page-') ? 'Page · ' + f.slice(5) + : f.startsWith('feature-') ? 'Feature · ' + f.slice(8) + : f; + +const total = viewports.reduce((n, vp) => n + fs.readdirSync(path.join(ROOT, vp)).filter((f) => f.endsWith('.png')).length, 0); +const rows = features.map((f) => + `${groupLabel(f)}${viewports.map((vp) => cell(vp, f)).join('')}` +).join('\n'); + +const html = ` + +GraphDone — Feature × Resolution Matrix + +
+

GraphDone — Feature × Resolution Matrix

+
${features.length} features × ${viewports.length} resolutions · ${total} screenshots · click any cell to open full-size
+
+
+${viewports.map((vp) => ``).join('')} +${rows} +
 ${vp}
`; + +const out = path.join(ROOT, 'index.html'); +fs.writeFileSync(out, html); +console.log(`Matrix gallery: ${out}`); +console.log(` ${features.length} features × ${viewports.length} resolutions = ${total} screenshots`);