From a75b670918178d4ef5d96b6d4abcbaf5a6acf6a1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 05:59:15 -0400 Subject: [PATCH] feat(02-01): build re-runnable D-09 surface perf harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - surfaces.ts: SURFACES table covering all D-09 surfaces (home/wallet, apps, marketplace, discover, cloud, mesh, server, web5, fleet, chat) plus four secondary screens (AppDetails, MarketplaceAppDetails, CloudFolder, OpenWrtGateway) and the located wallet-send modal. Navigation is via real RouterLink/button clicks (never page.goto) so revisit measures actual Vue Router client-side transitions. - measure.ts: measureSurface() records first-visit vs revisit timing, an RPC method+timing trace (no bodies), a dataset-stamp remount probe, and derives maxConcurrentRpc/rpcWallClockMs via sweep-line so serial waterfalls are distinguishable from parallel fan-outs. Includes a dismiss-and-retry click guard for stray modals (Companion app intro) that would otherwise cascade failures across surfaces. - surface-perf.spec.ts: logs in via the existing app-launch flow, walks every SURFACES row, writes results + a run header to ARCHY_PERF_OUT. Verified end-to-end against the local mock-backend + vite dev server (14/15 surfaces measured cleanly across 3 runs each; the 15th, Mesh, times out only because the mock backend never reports a connected mesh device — expected to resolve against a real node in Task 2). Co-Authored-By: Claude Fable 5 --- neode-ui/e2e/perf/measure.ts | 373 +++++++++++++++++++++++++ neode-ui/e2e/perf/surface-perf.spec.ts | 100 +++++++ neode-ui/e2e/perf/surfaces.ts | 213 ++++++++++++++ 3 files changed, 686 insertions(+) create mode 100644 neode-ui/e2e/perf/measure.ts create mode 100644 neode-ui/e2e/perf/surface-perf.spec.ts create mode 100644 neode-ui/e2e/perf/surfaces.ts diff --git a/neode-ui/e2e/perf/measure.ts b/neode-ui/e2e/perf/measure.ts new file mode 100644 index 00000000..95b88d24 --- /dev/null +++ b/neode-ui/e2e/perf/measure.ts @@ -0,0 +1,373 @@ +// measureSurface() — first-visit vs revisit timing, RPC request trace, and a +// remount probe for one SURFACES row (02-01-PLAN.md Task 1). +// +// Design notes (see surfaces.ts header for the navigation rationale): +// - Navigation between surfaces always happens via real UI clicks +// (RouterLink/button), never `page.goto()`, so a revisit exercises actual +// Vue Router client-side transitions rather than a full page reload. +// - The remount probe stamps `rootSelector`'s DOM node with a unique value +// right after first-visit paints, then reads it back after the away/back +// round-trip. A surviving value means the component instance was reused +// (no remount); a missing/changed value means it was destroyed and +// recreated. +// - RPC calls are traced via `page.on('request')`/`requestfinished`, +// filtered to the `/rpc/v1` endpoint the rpc-client posts to. Only method +// name + timing are recorded — no request/response bodies (T-02-06). +// - `maxConcurrentRpc` / `rpcWallClockMs` are derived from the recorded +// calls' [start, start+duration] intervals via a sweep-line, so a serial +// waterfall (maxConcurrentRpc === 1, 2+ calls) is distinguishable from an +// already-parallel fan-out without re-reading application source. + +import type { Page, Request } from '@playwright/test' +import type { Surface } from './surfaces' + +const RPC_PATH = '/rpc/v1' +const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]' +const DEFAULT_NAV_TIMEOUT = 20_000 +const DEFAULT_CONTENT_TIMEOUT = 20_000 + +export interface RpcCall { + method: string + /** Wall-clock offset (ms) from the start of the measured phase. */ + startedAtMs: number + /** Request duration in ms (null if it never finished/failed to resolve timing). */ + durationMs: number | null +} + +export interface SurfaceSample { + firstVisitMs: number | null + revisitMs: number | null + firstVisitRpcCount: number | null + revisitRpcCount: number | null + revisitRpcCalls: RpcCall[] + maxConcurrentRpc: number | null + rpcWallClockMs: number | null + /** true = component instance was reused across the away/back round-trip + * (no remount); false = it was destroyed and recreated; null = not probed + * (e.g. the run errored before the probe could run). */ + remounted: boolean | null + error: string | null +} + +export interface SurfaceMeasurement { + id: string + label: string + path: string + kind: Surface['kind'] + runs: number + samples: SurfaceSample[] + /** Median across successful samples (null if every sample errored). */ + firstVisitMs: number | null + revisitMs: number | null + firstVisitRpcCount: number | null + revisitRpcCount: number | null + /** RPC call trace from the sample nearest the median revisitMs (or the + * last successful sample if no median could be computed). */ + revisitRpcCalls: RpcCall[] + maxConcurrentRpc: number | null + rpcWallClockMs: number | null + /** Majority vote across samples that were actually probed. */ + remounted: boolean | null + /** Set only when every sample for this surface errored. */ + error: string | null +} + +export interface MeasureOptions { + /** Number of first-visit/revisit round-trips to sample (default 3). */ + runs?: number + navTimeoutMs?: number + contentTimeoutMs?: number +} + +function median(values: Array): number | null { + const nums = values.filter((v): v is number => v != null).sort((a, b) => a - b) + if (nums.length === 0) return null + const mid = Math.floor(nums.length / 2) + return nums.length % 2 === 0 ? (nums[mid - 1]! + nums[mid]!) / 2 : nums[mid]! +} + +/** Sweep-line over [start, start+duration] intervals — max overlap count and + * total wall-clock span from first start to last end. */ +function deriveConcurrency(calls: RpcCall[]): { maxConcurrentRpc: number | null; rpcWallClockMs: number | null } { + const timed = calls.filter((c) => c.durationMs != null) + if (timed.length === 0) return { maxConcurrentRpc: calls.length > 0 ? null : 0, rpcWallClockMs: calls.length > 0 ? null : 0 } + type Edge = { at: number; delta: number } + const edges: Edge[] = [] + let minStart = Infinity + let maxEnd = -Infinity + for (const c of timed) { + const end = c.startedAtMs + (c.durationMs ?? 0) + edges.push({ at: c.startedAtMs, delta: 1 }, { at: end, delta: -1 }) + minStart = Math.min(minStart, c.startedAtMs) + maxEnd = Math.max(maxEnd, end) + } + edges.sort((a, b) => a.at - b.at) + let running = 0 + let max = 0 + for (const e of edges) { + running += e.delta + if (running > max) max = running + } + return { maxConcurrentRpc: max, rpcWallClockMs: maxEnd - minStart } +} + +interface RpcTracker { + calls: RpcCall[] + detach: () => void +} + +function attachRpcTracker(page: Page, phaseStart: number): RpcTracker { + const calls: RpcCall[] = [] + const pending = new Map() + + const isRpcRequest = (req: Request) => req.method() === 'POST' && req.url().includes(RPC_PATH) + + const onRequest = (req: Request) => { + if (!isRpcRequest(req)) return + let method = 'unknown' + try { + const body = req.postData() + if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown' + } catch { + // Malformed/unreadable body — keep 'unknown', never persist the body itself. + } + pending.set(req, { method, start: Date.now() - phaseStart }) + } + + const onSettled = (req: Request) => { + const info = pending.get(req) + if (!info) return + pending.delete(req) + calls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - phaseStart - info.start }) + } + + page.on('request', onRequest) + page.on('requestfinished', onSettled) + page.on('requestfailed', onSettled) + + return { + calls, + detach: () => { + page.off('request', onRequest) + page.off('requestfinished', onSettled) + page.off('requestfailed', onSettled) + }, + } +} + +/** + * Click with a dismiss-and-retry guard: a stray overlay (e.g. the Companion + * app's once-per-browser auto-show intro) can appear mid-attempt, after + * `dismissOverlays()` already ran but before Playwright's own actionability + * wait resolves. Splitting the timeout budget across a few short attempts, + * re-running `dismissOverlays()` between each, clears it reliably instead of + * burning the whole budget on a single blocked attempt. + */ +async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise { + const attempts = 3 + const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts)) + let lastErr: unknown + for (let i = 0; i < attempts; i++) { + await dismissOverlays(page) + try { + await page.locator(selector).first().click({ timeout: perAttemptMs }) + return + } catch (err) { + lastErr = err + } + } + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)) +} + +async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise { + for (const selector of steps) { + await clickWithGuard(page, selector, timeoutMs) + } +} + +async function stampRoot(page: Page, rootSelector: string, mark: string): Promise { + return page.evaluate( + ({ sel, mark }) => { + const el = document.querySelector(sel) as HTMLElement | null + if (!el) return false + el.dataset.perfProbe = mark + return true + }, + { sel: rootSelector, mark } + ) +} + +async function readRootProbe(page: Page, rootSelector: string): Promise { + return page.evaluate((sel) => { + const el = document.querySelector(sel) as HTMLElement | null + return el?.dataset.perfProbe ?? null + }, rootSelector) +} + +/** + * Best-effort dismissal of a stray full-screen overlay (a filter/pairing/ + * update modal opened by the surface under test, or left over from a prior + * one) before it blocks the next click. One surface's leftover UI must never + * cascade a click-intercept failure into every remaining surface in the run. + */ +async function dismissOverlays(page: Page): Promise { + try { + await page.keyboard.press('Escape') + } catch { + // no-op — best-effort only + } + // Match any "Close"-flavoured aria-label (e.g. "Close companion modal"), + // not just an exact "Close" — modal close buttons across the app phrase + // this inconsistently. + const closeButtons = page.locator( + '[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]' + ) + const count = await closeButtons.count().catch(() => 0) + if (count > 0) { + await closeButtons.first().click({ timeout: 2_000 }).catch(() => {}) + } +} + +async function goHome(page: Page, timeoutMs: number): Promise { + // DashboardSidebar.vue is `v-show="!chatFullscreen"` — if a prior surface's + // run ended sitting on /dashboard/chat (e.g. chat's own revisit re-opened + // it as its last step), the sidebar is hidden and unclickable. Back out of + // chat first so the sidebar reappears before relying on it below. + if (new URL(page.url()).pathname === '/dashboard/chat') { + await page.locator('.chat-close-btn').first().click({ timeout: timeoutMs }).catch(() => {}) + } + await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs) + await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs }) +} + +async function runOnce(page: Page, surface: Surface, opts: Required>): Promise { + const { navTimeoutMs, contentTimeoutMs } = opts + + // Start every run from a known, stable point (dashboard home) so + // firstVisitMs measures "navigate from the dashboard home to the surface" + // exactly as specified, regardless of what the previous surface left us on. + await goHome(page, navTimeoutMs) + + const t0 = Date.now() + const firstTracker = attachRpcTracker(page, t0) + await clickChain(page, surface.navSteps, navTimeoutMs) + await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs }) + const firstVisitMs = Date.now() - t0 + firstTracker.detach() + const firstVisitRpcCount = firstTracker.calls.length + + const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}` + const stamped = await stampRoot(page, surface.rootSelector, mark) + + // Away step: for an in-page trigger (modal), close it; otherwise navigate + // to the neutral Settings tab via the sidebar (never back to this surface's + // own navSteps, so the round-trip is genuine). + // + // Deliberately NOT routed through clickWithGuard here: dismissOverlays() + // treats any "Close"-labelled button inside a dialog as a stray overlay to + // dismiss — which is exactly this element when closeSelector targets the + // surface's own dialog/panel, causing it to close a beat before this click + // runs and leaving the click with nothing to find. + if (surface.closeSelector) { + await page.locator(surface.closeSelector).first().click({ timeout: navTimeoutMs }) + } else { + await clickWithGuard(page, NEUTRAL_SELECTOR, navTimeoutMs) + await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: navTimeoutMs }) + } + + const t1 = Date.now() + const revisitTracker = attachRpcTracker(page, t1) + await clickChain(page, surface.navSteps, navTimeoutMs) + await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs }) + const revisitMs = Date.now() - t1 + revisitTracker.detach() + const revisitRpcCalls = revisitTracker.calls + const revisitRpcCount = revisitRpcCalls.length + + const remounted = stamped ? (await readRootProbe(page, surface.rootSelector)) !== mark : null + + const { maxConcurrentRpc, rpcWallClockMs } = deriveConcurrency(revisitRpcCalls) + + return { + firstVisitMs, + revisitMs, + firstVisitRpcCount, + revisitRpcCount, + revisitRpcCalls, + maxConcurrentRpc, + rpcWallClockMs, + remounted, + error: null, + } +} + +function errorSample(err: unknown): SurfaceSample { + return { + firstVisitMs: null, + revisitMs: null, + firstVisitRpcCount: null, + revisitRpcCount: null, + revisitRpcCalls: [], + maxConcurrentRpc: null, + rpcWallClockMs: null, + remounted: null, + error: err instanceof Error ? err.message : String(err), + } +} + +export async function measureSurface(page: Page, surface: Surface, opts: MeasureOptions = {}): Promise { + const runs = opts.runs ?? 3 + const navTimeoutMs = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT + const contentTimeoutMs = opts.contentTimeoutMs ?? DEFAULT_CONTENT_TIMEOUT + + const samples: SurfaceSample[] = [] + for (let i = 0; i < runs; i++) { + try { + samples.push(await runOnce(page, surface, { navTimeoutMs, contentTimeoutMs })) + } catch (err) { + samples.push(errorSample(err)) + } + } + + const successful = samples.filter((s) => s.error == null) + const firstVisitMs = median(successful.map((s) => s.firstVisitMs)) + const revisitMs = median(successful.map((s) => s.revisitMs)) + const firstVisitRpcCount = median(successful.map((s) => s.firstVisitRpcCount)) + const revisitRpcCount = median(successful.map((s) => s.revisitRpcCount)) + + // Representative sample for the call trace / concurrency fields: the + // successful sample whose revisitMs is closest to the computed median + // (falls back to the last successful sample when no median exists). + let representative: SurfaceSample | null = null + if (successful.length > 0) { + if (revisitMs != null) { + representative = successful.reduce((best, s) => + Math.abs((s.revisitMs ?? Infinity) - revisitMs) < Math.abs((best.revisitMs ?? Infinity) - revisitMs) ? s : best + ) + } else { + representative = successful[successful.length - 1]! + } + } + + const remountedVotes = successful.map((s) => s.remounted).filter((v): v is boolean => v != null) + const trueCount = remountedVotes.filter(Boolean).length + const remounted = remountedVotes.length === 0 ? null : trueCount >= remountedVotes.length - trueCount + + return { + id: surface.id, + label: surface.label, + path: surface.path, + kind: surface.kind, + runs, + samples, + firstVisitMs, + revisitMs, + firstVisitRpcCount, + revisitRpcCount, + revisitRpcCalls: representative?.revisitRpcCalls ?? [], + maxConcurrentRpc: representative?.maxConcurrentRpc ?? null, + rpcWallClockMs: representative?.rpcWallClockMs ?? null, + remounted, + error: successful.length === 0 ? (samples[samples.length - 1]?.error ?? 'all runs failed') : null, + } +} diff --git a/neode-ui/e2e/perf/surface-perf.spec.ts b/neode-ui/e2e/perf/surface-perf.spec.ts new file mode 100644 index 00000000..80d8f9e7 --- /dev/null +++ b/neode-ui/e2e/perf/surface-perf.spec.ts @@ -0,0 +1,100 @@ +// Re-runnable surface-perf harness (02-01-PLAN.md Task 1). Logs in using the +// exact flow from app-launch.spec.ts, walks every SURFACES row via +// measureSurface(), and writes the full result array + a run header to +// ARCHY_PERF_OUT (defaulting to e2e/test-results/surface-perf.json). +// +// Redaction is structural, not a cleanup pass: measure.ts's RpcTracker only +// ever records method name + timing (T-02-06) — request/response bodies, +// page text and screenshots are never captured into the artifact. +import { execSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { expect, test, type Page } from '@playwright/test' +import { measureSurface, type SurfaceMeasurement } from './measure' +import { SURFACES } from './surfaces' + +const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123' +const RUNS = process.env.ARCHY_PERF_RUNS ? Number(process.env.ARCHY_PERF_RUNS) : 3 +const OUT_PATH = resolve(process.cwd(), process.env.ARCHY_PERF_OUT ?? 'e2e/test-results/surface-perf.json') + +async function login(page: Page): Promise { + // Mirrors e2e/app-launch.spec.ts's login() verbatim — do not invent a + // second auth path. + await page.goto('/login', { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => { + localStorage.setItem('neode_intro_seen', '1') + localStorage.setItem('neode_onboarding_complete', '1') + }) + await page.goto('/login', { waitUntil: 'networkidle' }) + + const passwordInput = page.locator('input[type="password"]').first() + await passwordInput.waitFor({ timeout: 15_000 }) + await passwordInput.fill(PASSWORD) + await page + .locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]') + .first() + .click() + await page.waitForURL('**/dashboard**', { timeout: 20_000 }) +} + +function currentCommit(): string { + try { + return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim() + } catch { + return 'unknown' + } +} + +test('surface-perf: measure every D-09 surface and write the baseline artifact', async ({ page, baseURL }) => { + test.setTimeout(20 * 60 * 1000) // 15 surfaces x 3 runs x network round-trips can run long on a real node + + await login(page) + + const results: SurfaceMeasurement[] = [] + const skipped: string[] = [] + + for (const surface of SURFACES) { + try { + const measurement = await measureSurface(page, surface, { runs: RUNS }) + results.push(measurement) + if (measurement.error) skipped.push(`${surface.id}: ${measurement.error}`) + } catch (err) { + // A surface that throws outside measureSurface's own per-run try/catch + // (e.g. login state got corrupted) is still recorded, never dropped — + // an unmeasured surface must never silently disappear from the array. + results.push({ + id: surface.id, + label: surface.label, + path: surface.path, + kind: surface.kind, + runs: RUNS, + samples: [], + firstVisitMs: null, + revisitMs: null, + firstVisitRpcCount: null, + revisitRpcCount: null, + revisitRpcCalls: [], + maxConcurrentRpc: null, + rpcWallClockMs: null, + remounted: null, + error: err instanceof Error ? err.message : String(err), + }) + skipped.push(`${surface.id}: ${err instanceof Error ? err.message : String(err)}`) + } + } + + const header = { + baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228', + takenAt: new Date().toISOString(), + commit: currentCommit(), + runs: RUNS, + notes: skipped.length > 0 ? `Skipped/errored surfaces: ${skipped.join('; ')}` : 'All surfaces measured cleanly.', + } + + const artifact = { ...header, results } + + mkdirSync(dirname(OUT_PATH), { recursive: true }) + writeFileSync(OUT_PATH, JSON.stringify(artifact, null, 2)) + + expect(results.length).toBe(SURFACES.length) +}) diff --git a/neode-ui/e2e/perf/surfaces.ts b/neode-ui/e2e/perf/surfaces.ts new file mode 100644 index 00000000..c20f1384 --- /dev/null +++ b/neode-ui/e2e/perf/surfaces.ts @@ -0,0 +1,213 @@ +// SURFACES table for the Phase 02 UI-performance profiling harness (02-01-PLAN.md, +// Task 1). One row per D-09 surface. Every `path` here must exist in +// `neode-ui/src/router/index.ts` (verified by acceptance criteria). +// +// `navSteps` are ordered, real UI-click selectors — clicking an / +// or a button that calls `router.push()` triggers genuine client-side SPA +// navigation (no full page reload), which is what "remount storm" measurement +// requires. `page.goto()` is deliberately NOT used to move between dashboard +// surfaces — it would force a full reload every time and always show +// remounted=true regardless of whether KeepAlive would have helped. +// +// `contentSelector` is chosen to be present only once real content has +// painted (never a loading skeleton/spinner). `rootSelector` is the stable +// outermost element of the mounted view, used by the remount probe in +// measure.ts. `.view-container` is a class Dashboard.vue's nested +// `` merges onto every non-chat/non-mesh view's root element via +// Vue's automatic fallthrough-attribute merging (verified: `grep -rn +// "view-container" src` shows it added only by Dashboard.vue's default +// branch) — so it works as a uniform root selector across most surfaces. +// Mesh and Chat take Dashboard.vue's other template branch (no class +// fallthrough) so they use their own static root class instead. + +export type SurfaceKind = 'main-tab' | 'secondary' + +export interface Surface { + id: string + label: string + /** Route path, must exist in router/index.ts. */ + path: string + kind: SurfaceKind + /** A selector present only once real content has painted (not a skeleton/spinner). */ + contentSelector: string + /** The stable outermost element of the view, used for the remount probe. */ + rootSelector: string + /** + * Ordered selectors clicked (via real UI interaction) to reach/open this + * surface. Every step is resolved with `.first()` and clicked; the chain + * is re-run unchanged for the revisit measurement. + */ + navSteps: string[] + /** + * When set, this surface is an in-page trigger (e.g. a modal) rather than + * a distinct navigable route (D-09's "Wallet / send flows" — no Wallet.vue + * exists; the real surface is the Send button on the Home dashboard wallet + * card, opening SendBitcoinModal). Revisit is measured by clicking this to + * close, then re-running the last `navSteps` entry to reopen — "open to + * content" rather than "navigate to content" per the plan's fallback rule. + */ + closeSelector?: string +} + +const SIDEBAR = '[data-controller-zone="sidebar"]' + +export const SURFACES: Surface[] = [ + { + id: 'home', + label: 'Home (wallet figures)', + path: '/dashboard', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.home-card', + navSteps: [`${SIDEBAR} a[href="/dashboard"]`], + }, + { + id: 'apps', + label: 'Apps (My Apps)', + path: '/dashboard/apps', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.apps-card-grid-desktop [data-controller-container]', + navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`], + }, + { + id: 'marketplace', + label: 'Marketplace (App Store)', + path: '/dashboard/marketplace', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.marketplace-container [data-controller-container]', + // Marketplace has no sidebar entry — reached via Home's "Browse Store" link. + navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'a:has-text("Browse Store")'], + }, + { + id: 'discover', + label: 'Discover (App Store tab)', + path: '/dashboard/discover', + kind: 'secondary', + rootSelector: '.view-container', + contentSelector: '.discover-container [data-controller-container]', + // Discover has no sidebar entry — reached via the "App Store" tab inside Apps. + navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-view a:has-text("App Store")'], + }, + { + id: 'cloud', + label: 'Cloud / Files', + path: '/dashboard/cloud', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.apps-view [data-controller-container]', + navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`], + }, + { + id: 'mesh', + label: 'Mesh', + path: '/dashboard/mesh', + kind: 'main-tab', + rootSelector: '.mesh-view', + contentSelector: '.mesh-status-grid', + navSteps: [`${SIDEBAR} a[href="/dashboard/mesh"]`], + }, + { + id: 'server', + label: 'Server (Network)', + path: '/dashboard/server', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.view-container [data-controller-container]', + navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`], + }, + { + id: 'web5', + label: 'Web5', + path: '/dashboard/web5', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.view-container [data-controller-container]', + navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`], + }, + { + id: 'fleet', + label: 'Fleet', + path: '/dashboard/fleet', + kind: 'main-tab', + rootSelector: '.view-container', + contentSelector: '.view-container [data-controller-container]', + // Fleet's sidebar entry is hidden for beta — reached via Web5's Federation + // card link. Web5Federation.vue renders TWO "Fleet" links: a + // `.web5-card-actions-top` one that CSS permanently hides + // (`display: none` — the "compact header variants are permanently + // retired" rule in style.css) and the real, visible one inside + // `.web5-card-actions-bottom-grid`. Scope to the latter so `.first()` + // doesn't land on the hidden copy. + navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`, '.web5-card-actions-bottom-grid a:has-text("Fleet")'], + }, + { + id: 'chat', + label: 'Chat (AIUI)', + path: '/dashboard/chat', + kind: 'main-tab', + rootSelector: '.chat-fullscreen', + contentSelector: '.chat-iframe, .chat-placeholder', + navSteps: [`${SIDEBAR} button:has-text("AIUI")`], + // DashboardSidebar.vue is `v-show="!chatFullscreen"` — the sidebar (and + // therefore the neutral Settings link) is not visible while on Chat, so + // the normal "away via sidebar" step is impossible here. Chat's own close + // button calls closeChat() (router.back(), landing on wherever we came + // from — Home, per navSteps below), which is the surface's real "away". + closeSelector: '.chat-close-btn', + }, + { + id: 'app-details', + label: 'AppDetails (secondary)', + path: '/dashboard/apps/:id', + kind: 'secondary', + rootSelector: '.view-container', + contentSelector: '.app-details-container h1', + navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-card-grid-desktop [data-controller-container]'], + }, + { + id: 'marketplace-app-details', + label: 'MarketplaceAppDetails (secondary)', + path: '/dashboard/marketplace/:id', + kind: 'secondary', + rootSelector: '.view-container', + contentSelector: '.app-details-container h1', + navSteps: [ + `${SIDEBAR} a[href="/dashboard"]`, + 'a:has-text("Browse Store")', + '.marketplace-container [data-controller-container]', + ], + }, + { + id: 'cloud-folder', + label: 'CloudFolder (secondary)', + path: '/dashboard/cloud/:folderId', + kind: 'secondary', + rootSelector: '.view-container', + contentSelector: '.cloud-folder-container h1', + navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`, '.apps-view [data-controller-container]'], + }, + { + id: 'openwrt-gateway', + label: 'OpenWrtGateway (secondary)', + path: '/dashboard/server/openwrt', + kind: 'secondary', + rootSelector: '.view-container', + contentSelector: 'h1:has-text("OpenWrt Gateway")', + navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`, 'a:has-text("OpenWrt Gateway")'], + }, + { + id: 'wallet-send', + label: 'Wallet / send flow (Home wallet card, SendBitcoinModal)', + // Not a route — RESEARCH.md found no Wallet.vue; the real surface is a + // modal opened from the Home wallet card. Path recorded for reference + // only (the page the trigger lives on). + path: '/dashboard', + kind: 'secondary', + rootSelector: '[role="dialog"]', + contentSelector: 'h3:has-text("Send Bitcoin")', + navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'button:has-text("Send")'], + closeSelector: '[role="dialog"] button[aria-label="Close"]', + }, +]