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
8 changes: 8 additions & 0 deletions docs/guides/web-ui-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
153 changes: 153 additions & 0 deletions tests/e2e/feature-matrix.spec.ts
Original file line number Diff line number Diff line change
@@ -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/<viewport>/<feature>.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();
});
}
});
12 changes: 12 additions & 0 deletions tests/e2e/matrix.setup.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
77 changes: 77 additions & 0 deletions tests/generate-matrix-report.mjs
Original file line number Diff line number Diff line change
@@ -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/<viewport>/<feature>.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))
? `<td><a href="${rel}" target="_blank"><img loading="lazy" src="${rel}" alt="${feat} @ ${vp}"></a></td>`
: `<td class="missing">—</td>`;
};
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) =>
`<tr><th class="rowhead">${groupLabel(f)}</th>${viewports.map((vp) => cell(vp, f)).join('')}</tr>`
).join('\n');

const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GraphDone — Feature × Resolution Matrix</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; background: #0b0f1a; color: #e5e7eb; font: 14px/1.4 system-ui, sans-serif; }
header { position: sticky; top: 0; z-index: 3; background: #0b0f1aee; backdrop-filter: blur(6px); padding: 14px 18px; border-bottom: 1px solid #1f2937; }
h1 { margin: 0; font-size: 18px; } .sub { color: #9ca3af; font-size: 13px; margin-top: 2px; }
.wrap { overflow: auto; padding: 8px; }
table { border-collapse: separate; border-spacing: 8px; }
th.colhead { position: sticky; top: 64px; z-index: 2; background: #111827; color: #a5b4fc; font-weight: 600; padding: 6px 10px; border-radius: 8px; white-space: nowrap; }
th.rowhead { position: sticky; left: 0; z-index: 1; background: #111827; text-align: left; padding: 8px 12px; border-radius: 8px; white-space: nowrap; vertical-align: middle; }
td { vertical-align: top; }
td.missing { color: #4b5563; text-align: center; }
img { display: block; width: 320px; height: auto; border: 1px solid #1f2937; border-radius: 8px; background: #000; }
a { line-height: 0; }
</style></head><body>
<header>
<h1>GraphDone — Feature × Resolution Matrix</h1>
<div class="sub">${features.length} features × ${viewports.length} resolutions · ${total} screenshots · click any cell to open full-size</div>
</header>
<div class="wrap"><table>
<tr><th class="rowhead">&nbsp;</th>${viewports.map((vp) => `<th class="colhead">${vp}</th>`).join('')}</tr>
${rows}
</table></div></body></html>`;

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