From f5acb1638ba0ad3080f4b244956f2ddc44e817ed Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 20 Jun 2026 10:23:09 -0700 Subject: [PATCH 1/3] fix(web): Batch A live-audit user-facing fixes (offline overlay, logout, minimap, a11y, login default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the live Cloudflare audit: - Offline/connection-error overlay no longer leaks localhost + `./start` dev hints in production; env-aware message (generic on hosted, dev hints under import.meta.env.DEV). (InteractiveGraphVisualization.tsx) - A transient `me`-query failure no longer wipes the token / logs the user out; only clear session on genuine UNAUTHENTICATED/401, else keep session + hydrate from cached user. (AuthContext.tsx) - Mini-Map seeds from the laid-out graph on load (bounded publish over the first few seconds) instead of staying "No nodes yet" until a node is clicked. (IGV) - A11y: view-switcher + login toggle get role=tab/aria-selected; mobile "More" sheet gets role=dialog + Esc-to-close + focus; main graph gets role=img + aria-label + sr-only description; login active-tab contrast bumped to AA. (ViewSwitcher.tsx, Signin.tsx, MobileBottomNav.tsx, IGV) - Login defaults to Passwordless on a passwordless product (Password tab still available for admin). (Signin.tsx) Deferred: phone bottom-row label overlap (needs visual iteration on a 390px device). Verified: web typecheck clean; web unit suite 228/228. (Smoke gate flaky only due to a concurrent unrelated Playwright suite starving this box β€” proven by stashing these changes and reproducing the same smoke failure on pristine dev.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 52 ++++++++++++++++--- .../web/src/components/MobileBottomNav.tsx | 19 ++++++- packages/web/src/components/ViewSwitcher.tsx | 7 ++- packages/web/src/contexts/AuthContext.tsx | 19 +++++-- packages/web/src/pages/Signin.tsx | 12 +++-- 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index bce2b4eb..67c035b0 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -4194,6 +4194,35 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; }, []); + // Seed the minimap from the laid-out graph on load. The tick-based publish + // (in the simulation tick) only runs while the sim is ticking; with one-shot + // physics the graph settles and stops, so without this the minimap showed + // "No nodes yet" until the user clicked a node. Publish a few times over the + // first few seconds (covers fast + slow settles), then stop β€” no idle cost. + useEffect(() => { + if (!currentGraph) return undefined; + let n = 0; + const publish = () => { + const sim = simulationRef.current; + const upd = (window as { updateMiniMapPositions?: (p: Record) => void }).updateMiniMapPositions; + if (!sim || !upd) return; + const simNodes = sim.nodes() as { id: string; x?: number; y?: number; type?: string }[]; + if (!simNodes?.length) return; + const positions: Record = {}; + const types: Record = {}; + for (const nd of simNodes) { + if (nd.x !== undefined && nd.y !== undefined) { positions[nd.id] = { x: nd.x, y: nd.y }; types[nd.id] = nd.type as string; } + } + if (Object.keys(positions).length) { + upd(positions); + (window as { updateMiniMapTypes?: (t: Record) => void }).updateMiniMapTypes?.(types); + } + }; + const id = setInterval(() => { publish(); if (++n >= 5) clearInterval(id); }, 800); + publish(); + return () => clearInterval(id); + }, [currentGraph?.id, workItemsData?.workItems?.length]); + // Center on specific node const centerOnNode = useCallback((nodeId?: string) => { const nodeToCenter = nodeId @@ -4817,9 +4846,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const message = err.message || err.toString(); - // Network/connection errors + // Network/connection errors β€” keep dev hints (localhost URL) out of the + // hosted build; production users get a generic, actionable message. if (message.includes('NetworkError') || message.includes('fetch')) { - return "Cannot connect to GraphDone server. Please check if the server is running at http://localhost:4127"; + return import.meta.env.DEV + ? `Cannot connect to GraphDone server. Please check the server is running at ${import.meta.env.VITE_API_URL || 'http://localhost:4127'}` + : "Can't reach GraphDone right now. Please check your connection and try again."; } // GraphQL/Database errors @@ -4866,7 +4898,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i {errorMessage} - {isNetworkError && ( + {isNetworkError && import.meta.env.DEV && (
πŸ’‘ Quick fixes:
β€’ Run ./start to start the server
@@ -5038,11 +5070,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return (
- - +

+ Force-directed graph of work items and their dependencies. Use the view switcher to see the same data as a list, table, or board, which are keyboard-navigable. +

+ {/* Empty State Overlay */} {showEmptyStateOverlay && ( diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx index 814a0607..7c397194 100644 --- a/packages/web/src/components/MobileBottomNav.tsx +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate, useLocation } from 'react-router-dom'; import { @@ -21,6 +21,16 @@ export function MobileBottomNav() { const navigate = useNavigate(); const location = useLocation(); const [moreOpen, setMoreOpen] = useState(false); + const sheetRef = useRef(null); + + // The "More" sheet is a modal: close on Esc and move focus into it on open. + useEffect(() => { + if (!moreOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMoreOpen(false); }; + document.addEventListener('keydown', onKey); + sheetRef.current?.focus(); + return () => document.removeEventListener('keydown', onKey); + }, [moreOpen]); const onWorkspace = location.pathname === '/' || location.pathname === '/workspace'; const goView = (m: ViewMode) => { @@ -83,8 +93,13 @@ export function MobileBottomNav() { >
e.stopPropagation()} >
diff --git a/packages/web/src/components/ViewSwitcher.tsx b/packages/web/src/components/ViewSwitcher.tsx index b29de1c7..205260ae 100644 --- a/packages/web/src/components/ViewSwitcher.tsx +++ b/packages/web/src/components/ViewSwitcher.tsx @@ -50,14 +50,17 @@ const viewOptions = [ const ViewSwitcher: React.FC = ({ currentView, onViewChange, className = '' }) => { return ( -
+
{viewOptions.map((option) => { const Icon = option.icon; const isActive = currentView === option.id; - + return (
}> } /> } /> @@ -113,6 +116,7 @@ function AuthenticatedApp() { } /> } /> + diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 67c035b0..b56dbc22 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -262,7 +262,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } } : { where: {} }, fetchPolicy: currentGraph ? 'cache-and-network' : 'cache-only', - pollInterval: currentGraph ? 2000 : 0, + // Cross-client freshness without the 2s idle hammer: poll every 15s and skip + // the tick entirely while the tab is hidden. Own edits still refetch instantly. + pollInterval: currentGraph ? 15000 : 0, + skipPollAttempt: () => typeof document !== 'undefined' && document.visibilityState !== 'visible', errorPolicy: 'all' }); @@ -277,7 +280,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } } : { where: {} }, fetchPolicy: currentGraph ? 'cache-and-network' : 'cache-only', - pollInterval: currentGraph ? 2000 : 0, + // Cross-client freshness without the 2s idle hammer: poll every 15s and skip + // the tick entirely while the tab is hidden. Own edits still refetch instantly. + pollInterval: currentGraph ? 15000 : 0, + skipPollAttempt: () => typeof document !== 'undefined' && document.visibilityState !== 'visible', errorPolicy: 'all' }); diff --git a/packages/web/src/hooks/useAdaptiveQuality.ts b/packages/web/src/hooks/useAdaptiveQuality.ts index 8fbab3c1..a215bb90 100644 --- a/packages/web/src/hooks/useAdaptiveQuality.ts +++ b/packages/web/src/hooks/useAdaptiveQuality.ts @@ -53,6 +53,8 @@ export function useAdaptiveQuality(): { }, [governor]); // FPS sampling: count frames per second, feed smoothed samples to the governor. + // Paused while the tab is hidden so we don't run a perpetual rAF loop in + // backgrounded tabs (no rendering to measure there anyway). useEffect(() => { let raf = 0; let frames = 0; @@ -67,8 +69,12 @@ export function useAdaptiveQuality(): { } raf = requestAnimationFrame(loop); }; - raf = requestAnimationFrame(loop); - return () => cancelAnimationFrame(raf); + const start = () => { if (!raf) { windowStart = performance.now(); frames = 0; raf = requestAnimationFrame(loop); } }; + const stop = () => { if (raf) { cancelAnimationFrame(raf); raf = 0; } }; + const onVisibility = () => (document.visibilityState === 'visible' ? start() : stop()); + document.addEventListener('visibilitychange', onVisibility); + if (document.visibilityState === 'visible') start(); + return () => { document.removeEventListener('visibilitychange', onVisibility); stop(); }; }, [governor]); const setOverride = useCallback( diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index a9b5862b..8dfeb140 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -69,6 +69,14 @@ export default defineConfig({ sourcemap: false, rollupOptions: { output: { + // Split heavy vendors into separate, independently-cacheable chunks so the + // entry isn't one monolithic ~1.5MB file (live-audit perf finding). + manualChunks: { + react: ['react', 'react-dom', 'react-router-dom'], + apollo: ['@apollo/client', 'graphql'], + d3: ['d3'], + icons: ['lucide-react'], + }, // Force new filenames to break cache entryFileNames: `assets/[name]-${Date.now()}.js`, chunkFileNames: `assets/[name]-${Date.now()}.js`, From e2207c2ce6e6fdb2b124a6c06b27e2d9eaa4af41 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 20 Jun 2026 19:42:10 -0700 Subject: [PATCH 3/3] test(e2e): live-site user journey report (desktop + mobile, video + narration) Drives the real production SPA (graphdone-cloud.pages.dev) as a guest on desktop (1440x900) and phone (390x844), recording .webm video + a labelled screenshot per step, then folds into the unified report + live dashboard + piper-tts narration. - tests/e2e/reports/live-cloud-journey.spec.ts: the journey. Guest entry uses the verify-live recipe (mint guest token from the live Worker, seed localStorage, reload); captures the real sign-in + guest dialog first. Each screenshot is testInfo.attach'd so it lands in the report. Hard-asserts a guest sees nodes render + no JS errors (desktop) and no horizontal overflow (mobile); every interaction is best-effort so the tour always produces media. - playwright.config.ts: new 'live-journey' project (video:'on', screenshot:'on'). - tests/sequences/unified.config.mjs: 'live-journey' sequence + 'live' profile. - scripts/narrate-report.mjs: give the live desktop/mobile clips clean spoken names + real descriptions (was reading the raw test title twice). Run: TEST_URL=https://graphdone-cloud.pages.dev node tests/run-unified.mjs \ --profile live --out ../GraphDone-Cloud/live-full-report/ Co-Authored-By: Claude Opus 4.8 (1M context) --- playwright.config.ts | 17 ++ scripts/narrate-report.mjs | 16 +- tests/e2e/reports/live-cloud-journey.spec.ts | 226 +++++++++++++++++++ tests/sequences/unified.config.mjs | 5 + 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/reports/live-cloud-journey.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 7ace5769..ae6d412f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -75,6 +75,23 @@ export default defineConfig({ }, }, + /* LIVE Cloudflare user journey: drives the real production SPA as a guest on + * desktop + phone, recording .webm video + a labelled screenshot per step. + * Run via the unified harness: + * TEST_URL=https://graphdone-cloud.pages.dev node tests/run-unified.mjs --profile live --out ... + * Heavy + network-bound; report-only, never part of the smoke gate. */ + { + name: 'live-journey', + testMatch: /live-cloud-journey\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + video: 'on', + screenshot: 'on', + trace: 'retain-on-failure', + ignoreHTTPSErrors: true, + }, + }, + /* Performance budgets (ADAPT-8). Lives in tests/perf, run via * `npm run test:perf`. Reads window.__graphPerf / API latency and asserts * budgets. Kept separate from the smoke gate so it can have its own diff --git a/scripts/narrate-report.mjs b/scripts/narrate-report.mjs index d8baee82..4d677872 100644 --- a/scripts/narrate-report.mjs +++ b/scripts/narrate-report.mjs @@ -81,12 +81,26 @@ const perf = PERF_ORDER.filter((k) => latestByMetric.has(k)).map((k) => { return { metric: k, label: PERF_LABELS[k] || k, value: m.value, unit: m.unit, better: m.better }; }); +// Give the live-site journey clips a clean spoken name + a real description +// (the raw attachment name is just the test title, which would otherwise be +// read out twice). Falls back to the generic name for any other video clip. +const liveClipMeta = (title) => { + const t = String(title || '').toLowerCase(); + if (/phone|mobile/.test(t)) return { name: 'The mobile journey', note: 'On a phone, a guest lands on the list view, switches to the live graph, opens the navigation, and reaches settings β€” with no horizontal overflow.' }; + if (/desktop/.test(t)) return { name: 'The desktop journey', note: 'On the desktop, a guest signs in, the live graph renders with real nodes and edges, and they open a node card, re-fit the view, and tour the pages.' }; + return null; +}; + const tour = []; if (mediaRun) { for (const s of mediaRun.report.sequences || []) { for (const c of s.cases || []) { for (const a of c.attachments || []) { - if (a.type === 'video') tour.push({ name: a.name || c.ref || `clip-${tour.length + 1}`, note: c.title || '', kind: 'video', runId: mediaRun.runId, href: a.href }); + if (a.type !== 'video') continue; + const meta = liveClipMeta(c.title || a.name); + tour.push(meta + ? { name: meta.name, note: meta.note, kind: 'video', runId: mediaRun.runId, href: a.href } + : { name: a.name || c.ref || `clip-${tour.length + 1}`, note: c.title || '', kind: 'video', runId: mediaRun.runId, href: a.href }); } } } diff --git a/tests/e2e/reports/live-cloud-journey.spec.ts b/tests/e2e/reports/live-cloud-journey.spec.ts new file mode 100644 index 00000000..aee741dc --- /dev/null +++ b/tests/e2e/reports/live-cloud-journey.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, Page, TestInfo } from '@playwright/test'; + +/** + * LIVE Cloudflare user journey β€” drives the real production SPA + * (https://graphdone-cloud.pages.dev) exactly as a guest user would, on both + * desktop and a phone, recording web-friendly .webm video (the 'live-journey' + * Playwright project sets video:'on') plus a labelled screenshot per step. + * + * This is DOCUMENTATION + a live health check, not the smoke gate: every UI + * interaction is best-effort (tryStep) so the tour always completes and + * produces media, but the load-bearing invariant β€” a guest sees a graph render + * with real nodes on the live site β€” is asserted hard. + * + * Guest entry uses the proven recipe from GraphDone-Cloud/scripts/verify-live.mjs: + * mint a guest token from the live Worker, seed it into localStorage, reload. + * Output feeds the unified report (npm run test:unified) and the live dashboard. + */ + +const LIVE = process.env.TEST_URL || 'https://graphdone-cloud.pages.dev'; +const API = process.env.LIVE_API_URL || 'https://graphdone-api.valpatel.workers.dev/api/graphql'; + +const NODE_SEL = '.graph-container svg .node'; +const EDGE_SEL = '.graph-container svg .edge'; + +async function mintGuest(page: Page): Promise<{ token: string; graphId: string | null }> { + let token = ''; + for (let i = 0; i < 5 && !token; i++) { + try { + const r = await page.request.post(API, { + headers: { 'content-type': 'application/json' }, + data: { query: 'mutation{guestLogin{token}}' }, + }); + token = (await r.json())?.data?.guestLogin?.token || ''; + } catch { /* retry */ } + if (!token) await page.waitForTimeout(2000); + } + let graphId: string | null = null; + if (token) { + try { + const r = await page.request.post(API, { + headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token }, + data: { query: '{graphs{id name}}' }, + }); + graphId = (await r.json())?.data?.graphs?.[0]?.id || null; + } catch { /* graph optional; app can auto-select */ } + } + return { token, graphId }; +} + +async function countNodes(page: Page): Promise { + return page.locator(NODE_SEL).count().catch(() => 0); +} + +async function waitForGraph(page: Page, secs = 30): Promise { + let n = 0; + for (let i = 0; i < secs; i++) { + n = await countNodes(page); + if (n > 0) break; + await page.waitForTimeout(1000); + } + return n; +} + +function makeShooter(page: Page, testInfo: TestInfo) { + let step = 0; + const shots: string[] = []; + const shot = async (label: string) => { + step += 1; + const name = `${String(step).padStart(2, '0')}-${label}`; + const p = testInfo.outputPath(`${name}.png`); + try { + await page.screenshot({ path: p, fullPage: false }); + await testInfo.attach(name, { path: p, contentType: 'image/png' }); + shots.push(name); + } catch { /* page busy β€” skip this frame */ } + }; + const tryStep = async (label: string, fn: () => Promise) => { + try { await fn(); await page.waitForTimeout(600); await shot(label); } + catch { await shot(`${label}-unavailable`); } + }; + return { shot, tryStep, shots: () => shots }; +} + +async function enterAsGuest(page: Page, shot: (l: string) => Promise) { + // 1. Authentic landing: the real sign-in screen a first-time visitor sees. + await page.goto(LIVE + '/', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + await shot('signin-landing'); + + // 2. Best-effort: show the real "Continue as Guest" affordance + dialog. + try { + const guestBtn = page.getByRole('button', { name: /continue as guest/i }).first(); + if (await guestBtn.isVisible({ timeout: 4000 })) { + await guestBtn.click({ timeout: 3000 }); + await page.waitForTimeout(1200); + await shot('guest-dialog'); + } + } catch { /* affordance may differ on the deployed build β€” injection below is authoritative */ } + + // 3. Authoritative entry (verify-live recipe): mint + seed + reload. + const { token, graphId } = await mintGuest(page); + expect(token, 'live Worker minted a guest token').toBeTruthy(); + await page.evaluate(({ t, gid }) => { + localStorage.setItem('authToken', t); + localStorage.setItem('currentUser', JSON.stringify({ + id: '0303535c-21ad-4917-acab-ee26db864d18', role: 'GUEST', email: 'g@guest.local', + username: 'guest', name: 'Guest', isActive: true, isEmailVerified: true, + })); + if (gid) localStorage.setItem('currentGraphId', gid); + }, { t: token, gid: graphId }); + await page.reload({ waitUntil: 'domcontentloaded' }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DESKTOP journey (1440Γ—900) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('live cloud user journey β€” desktop @live', () => { + test.use({ viewport: { width: 1440, height: 900 } }); + + test('a guest explores the live graph on desktop', async ({ page }, testInfo) => { + test.setTimeout(150_000); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + const { shot, tryStep } = makeShooter(page, testInfo); + + await enterAsGuest(page, shot); + + const nodes = await waitForGraph(page, 30); + await page.waitForTimeout(4000); // let the force layout settle for a clean frame + await shot('graph-overview'); + expect(nodes, 'live guest graph renders nodes').toBeGreaterThan(0); + + const edges = await page.locator(EDGE_SEL).count().catch(() => 0); + console.log(`[live-journey desktop] nodes=${nodes} edges=${edges} errors=${errors.length}`); + + // Open a node's expand panel (PR-3 feature) β€” the core "drill into work" gesture. + await tryStep('open-node-card', async () => { + const opened = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node'); + const icon = n?.querySelector('.node-expand-icon') as HTMLElement | null; + if (icon) { icon.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return true; } + return false; + }); + if (!opened) throw new Error('no expand icon'); + await page.waitForTimeout(1500); + }); + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(400); + + // Zoom out / re-fit to show the whole graph composition. + await tryStep('zoom-to-fit', async () => { + await page.mouse.move(720, 450); + await page.mouse.wheel(0, -300); + await page.waitForTimeout(800); + }); + + // Tour the main pages a guest can reach. + for (const [label, route] of [ + ['ontology-page', '/ontology'], + ['analytics-page', '/analytics'], + ['settings-page', '/settings'], + ] as const) { + await tryStep(label, async () => { + await page.goto(LIVE + route, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + }); + } + + // Admin is role-guarded β€” a guest should NOT get in. Capture the guard. + await tryStep('admin-guarded-for-guest', async () => { + await page.goto(LIVE + '/admin', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + }); + + expect(errors, `no uncaught JS errors (saw: ${errors.slice(0, 2).join(' | ')})`).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// MOBILE journey (390Γ—844) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('live cloud user journey β€” mobile @live', () => { + test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + + test('a guest explores the live graph on a phone', async ({ page }, testInfo) => { + test.setTimeout(150_000); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + const { shot, tryStep } = makeShooter(page, testInfo); + + await enterAsGuest(page, shot); + + // Phones default to the list/cards view, not the graph. + await page.waitForTimeout(4000); + await shot('mobile-default-view'); + + // No horizontal overflow is a hard mobile invariant. + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + console.log(`[live-journey mobile] horizontal-overflow=${overflow}px errors=${errors.length}`); + + // Drive the bottom nav: Graph, then the "More" sheet. + await tryStep('mobile-graph-view', async () => { + const nav = page.getByTestId('mobile-bottom-nav'); + const graphBtn = nav.getByText('Graph', { exact: true }); + if (await graphBtn.isVisible({ timeout: 3000 })) await graphBtn.click(); + else throw new Error('no bottom-nav Graph button'); + await page.waitForTimeout(4000); + }); + + await tryStep('mobile-more-sheet', async () => { + const nav = page.getByTestId('mobile-bottom-nav'); + const moreBtn = nav.getByText('More', { exact: true }); + if (await moreBtn.isVisible({ timeout: 3000 })) await moreBtn.click(); + else throw new Error('no bottom-nav More button'); + await page.waitForTimeout(1200); + }); + await page.keyboard.press('Escape').catch(() => {}); + + await tryStep('mobile-settings', async () => { + await page.goto(LIVE + '/settings', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + }); + + expect(overflow, 'no horizontal overflow on phone').toBeLessThanOrEqual(2); + }); +}); diff --git a/tests/sequences/unified.config.mjs b/tests/sequences/unified.config.mjs index 779d5fe4..ed782d90 100644 --- a/tests/sequences/unified.config.mjs +++ b/tests/sequences/unified.config.mjs @@ -24,6 +24,10 @@ export const SEQUENCES = { 'matrix': { adapter: 'playwright', title: 'Feature Γ— resolution matrix', args: ['--project=matrix'], blocking: false }, 'vlm': { adapter: 'playwright', title: 'Local-VLM visual eval (skips w/o endpoints)', args: ['--project=vlm'], blocking: false }, 'perf-scale': { adapter: 'playwright', title: 'Large-scale perf sweep', args: ['--project=perf-scale'], blocking: false }, + // LIVE Cloudflare site, from a guest user's point of view (desktop + phone), + // recording .webm video + a labelled screenshot per step. Drives the real + // production SPA β€” run with TEST_URL=https://graphdone-cloud.pages.dev. + 'live-journey': { adapter: 'playwright', title: 'Live site user journey (desktop + mobile)', args: ['--project=live-journey'], blocking: false }, // Cross-repo: ingest the newest GraphDone-Cloud live-audit findings.json (sibling // repo) into the unified report. Skips cleanly when absent. 'cloud-audit': { adapter: 'cloud-audit', title: 'Cloud live audit (ingested)', blocking: false }, @@ -34,4 +38,5 @@ export const PROFILES = { pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'mobile', 'perf-budgets'], full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], report: ['diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], + live: ['live-journey'], };