feat(02-01): build re-runnable D-09 surface perf harness
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
5b69664092
commit
a75b670918
373
neode-ui/e2e/perf/measure.ts
Normal file
373
neode-ui/e2e/perf/measure.ts
Normal file
@ -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>): 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<Request, { method: string; start: number }>()
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (const selector of steps) {
|
||||
await clickWithGuard(page, selector, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
async function stampRoot(page: Page, rootSelector: string, mark: string): Promise<boolean> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<Pick<MeasureOptions, 'navTimeoutMs' | 'contentTimeoutMs'>>): Promise<SurfaceSample> {
|
||||
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<SurfaceMeasurement> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
100
neode-ui/e2e/perf/surface-perf.spec.ts
Normal file
100
neode-ui/e2e/perf/surface-perf.spec.ts
Normal file
@ -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<void> {
|
||||
// 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)
|
||||
})
|
||||
213
neode-ui/e2e/perf/surfaces.ts
Normal file
213
neode-ui/e2e/perf/surfaces.ts
Normal file
@ -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 <a>/<RouterLink>
|
||||
// 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
|
||||
// `<router-view>` 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"]',
|
||||
},
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user