Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
// keepalive-remount-probe.spec.ts — 02-09 gap-closure Task 1, Step B.
|
||||
//
|
||||
// Standalone, re-runnable Playwright spec that logs into the deployed
|
||||
// archi-dev-box build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS,
|
||||
// performs a visit -> away (to the neutral /dashboard/settings tab) ->
|
||||
// return round trip, reporting whether the component instance survived.
|
||||
//
|
||||
// Deliberately does NOT edit measure.ts / surfaces.ts / surface-perf.spec.ts
|
||||
// — the 02-01 harness stays frozen so 02-10 can re-run it unmodified. This
|
||||
// spec reuses SURFACES' navSteps/contentSelector/rootSelector (read-only
|
||||
// import) as the click recipe to reach each tab, but implements its own
|
||||
// corrected stamp/read method plus three instruments the ad-hoc 02-08 probe
|
||||
// did not have:
|
||||
//
|
||||
// 1. A monotonic per-mount signal written by the page itself, independent
|
||||
// of the DOM-element dataset stamp: Vue unconditionally attaches
|
||||
// `el.__vueParentComponent` to every mounted root element (confirmed
|
||||
// by reading node_modules/@vue/runtime-core's `mountElement`, not
|
||||
// gated behind a dev-only flag), whose `.uid` is a per-instance
|
||||
// monotonic counter. Reading this before/after the round trip gives an
|
||||
// INSTANCE-identity signal that can disagree with the dataset-mark's
|
||||
// ELEMENT-identity signal if the probe's selector picks a different
|
||||
// cached instance than the one actually under test (suspect 1).
|
||||
// 2. `page.on('pageerror')` / `page.on('console')` captured for the whole
|
||||
// round trip and printed — a Server-specific runtime error during
|
||||
// activation/deactivation (suspect 2) would surface here even though
|
||||
// it might not throw synchronously enough to fail the Playwright step
|
||||
// itself.
|
||||
// 3. After each hop: `document.querySelectorAll('.view-container').length`
|
||||
// and `location.pathname`, so cache population and the actual route
|
||||
// path are visible in the transcript (suspects 3 and 4).
|
||||
//
|
||||
// Corrected remount method (documented in 02-FINDINGS.md's ## Results
|
||||
// preamble): stamp/read the `.view-container`-class ANCESTOR (or the
|
||||
// surface's own `rootSelector`, for the two surfaces — Mesh, Chat — that
|
||||
// don't use `.view-container`) of the surface's own VISIBLE contentSelector
|
||||
// match, using `getBoundingClientRect`/`offsetParent` to exclude KeepAlive's
|
||||
// inactive cached instances — never `document.querySelector`'s first DOM
|
||||
// match, which can silently pick a different cached instance once multiple
|
||||
// `.view-container`s coexist in the document at once.
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { SURFACES, type Surface } from './surfaces'
|
||||
// Re-declared rather than imported from '@/views/dashboard/keepAliveRoutes':
|
||||
// the e2e package's tsconfig (see tsconfig.app.json's `include`) does not
|
||||
// cover `e2e/**`, so the `@/*` path alias used across neode-ui/src is not
|
||||
// guaranteed to resolve under Playwright's own TS transform. surfaces.ts and
|
||||
// measure.ts already avoid the alias for the same reason (no `@/` import in
|
||||
// either file) — this list is the exact literal from keepAliveRoutes.ts
|
||||
// (TAB_ORDER minus withheld `/dashboard/settings`, plus `/dashboard/discover`).
|
||||
const KEEP_ALIVE_PATHS = new Set<string>([
|
||||
'/dashboard',
|
||||
'/dashboard/apps',
|
||||
'/dashboard/marketplace',
|
||||
'/dashboard/cloud',
|
||||
'/dashboard/mesh',
|
||||
'/dashboard/server',
|
||||
'/dashboard/web5',
|
||||
'/dashboard/fleet',
|
||||
'/dashboard/chat',
|
||||
'/dashboard/discover',
|
||||
])
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const NAV_TIMEOUT = 20_000
|
||||
const CONTENT_TIMEOUT = 20_000
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
// Mirrors surface-perf.spec.ts's login() (itself mirroring app-launch.spec.ts)
|
||||
// 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 })
|
||||
}
|
||||
|
||||
interface HopSnapshot {
|
||||
viewContainerCount: number
|
||||
pathname: string
|
||||
}
|
||||
|
||||
async function snapshotHop(page: Page): Promise<HopSnapshot> {
|
||||
return page.evaluate(() => ({
|
||||
viewContainerCount: document.querySelectorAll('.view-container').length,
|
||||
pathname: location.pathname,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the DOM has stopped churning: the raw element count for
|
||||
* `contentSelector` (NOT filtered by visibility — some contentSelectors,
|
||||
* e.g. `.home-card`, legitimately match several sibling cards at once, so
|
||||
* "exactly 1" is the wrong invariant) reads identically across 5 consecutive
|
||||
* 100ms-spaced polls.
|
||||
*
|
||||
* Discovered mid-investigation (not hypothesized up front): `/dashboard/settings`
|
||||
* — the away tab EVERY round trip in this probe (and in measure.ts's own
|
||||
* `NEUTRAL_SELECTOR` convention) uses — renders `AccountInfoSection.vue` and
|
||||
* `KioskDisplaySection.vue` unconditionally (Settings.vue has no tabs/
|
||||
* accordion gating), and BOTH carry `data-controller-container`. Settings'
|
||||
* own root ALSO gets the `view-container` fallthrough class (every non-full-
|
||||
* bleed route does). That means Settings' OWN content matches the exact
|
||||
* generic `.view-container [data-controller-container]` selector Server,
|
||||
* Web5 and Fleet all use — and Settings' leave-transition keeps its DOM
|
||||
* genuinely present (in the document, still counted by querySelectorAll)
|
||||
* for the transition's full duration while the RETURN target's enter-
|
||||
* transition is already progressing. A stamp/read taken during that overlap
|
||||
* window can silently pick the still-present-but-leaving Settings element
|
||||
* instead of the actual target — a false "remounted" verdict that has
|
||||
* nothing to do with KeepAlive at all. Waiting for the raw count to settle
|
||||
* lets the leave-transition finish (Vue's <Transition> removes the leaving
|
||||
* element from the DOM once its leave hook completes) before this probe
|
||||
* stamps or reads anything.
|
||||
*/
|
||||
async function waitForDomSettled(page: Page, contentSelector: string, timeoutMs: number): Promise<void> {
|
||||
await page.evaluate((sel) => {
|
||||
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
|
||||
w.__probeStableCount = 0
|
||||
w.__probeLastLen = document.querySelectorAll(sel).length
|
||||
}, contentSelector)
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
|
||||
const len = document.querySelectorAll(sel).length
|
||||
if (w.__probeLastLen === len) {
|
||||
w.__probeStableCount = (w.__probeStableCount ?? 0) + 1
|
||||
} else {
|
||||
w.__probeStableCount = 0
|
||||
}
|
||||
w.__probeLastLen = len
|
||||
return (w.__probeStableCount ?? 0) >= 5
|
||||
},
|
||||
contentSelector,
|
||||
{ timeout: timeoutMs, polling: 100 }
|
||||
)
|
||||
}
|
||||
|
||||
interface ProbeReading {
|
||||
ok: boolean
|
||||
uid: number | null
|
||||
typeName: string | null
|
||||
}
|
||||
|
||||
/** Locate the VISIBLE contentSelector match's rootSelector ancestor (or
|
||||
* self, if contentSelector === rootSelector) and stamp it with `mark`,
|
||||
* recording the Vue instance uid/type-name found on it at the same moment. */
|
||||
async function stampVisibleRoot(page: Page, contentSelector: string, rootSelector: string, mark: string): Promise<ProbeReading> {
|
||||
return page.evaluate(
|
||||
({ contentSelector, rootSelector, mark }) => {
|
||||
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
|
||||
const visible = candidates.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
|
||||
})
|
||||
if (!visible) return { ok: false, uid: null, typeName: null }
|
||||
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
|
||||
if (!root) return { ok: false, uid: null, typeName: null }
|
||||
root.dataset.perfProbeMark = mark
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = (root as any).__vueParentComponent ?? null
|
||||
const uid: number | null = comp ? (comp.uid ?? null) : null
|
||||
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
|
||||
return { ok: true, uid, typeName }
|
||||
},
|
||||
{ contentSelector, rootSelector, mark }
|
||||
)
|
||||
}
|
||||
|
||||
interface RootMarkDebug {
|
||||
mark: string | null
|
||||
visible: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface ReadResult {
|
||||
ok: boolean
|
||||
markMatches: boolean
|
||||
uid: number | null
|
||||
typeName: string | null
|
||||
allRootMarks: RootMarkDebug[]
|
||||
/** The authoritative "what does the user actually see" signal:
|
||||
* `document.elementFromPoint()` at the viewport center performs real hit
|
||||
* testing (respects stacking/z-index/opacity), unlike the
|
||||
* `offsetParent`/`getBoundingClientRect` heuristic used above, which
|
||||
* cannot distinguish the true foreground root from another root that
|
||||
* merely has non-zero layout dimensions while stacked behind it. */
|
||||
elementFromPointMark: string | null
|
||||
}
|
||||
|
||||
async function readVisibleRoot(
|
||||
page: Page,
|
||||
contentSelector: string,
|
||||
rootSelector: string,
|
||||
expectedMark: string
|
||||
): Promise<ReadResult> {
|
||||
return page.evaluate(
|
||||
({ contentSelector, rootSelector, expectedMark }) => {
|
||||
// Diagnostic: every element matching rootSelector ANYWHERE in the
|
||||
// document (not just the one reachable from the visible content
|
||||
// match), reporting its own mark + visibility. If the ORIGINAL
|
||||
// stamped root still carries its mark but is not the one this read
|
||||
// considers "visible", that is a completely different finding
|
||||
// (a still-alive-but-orphaned cached instance) than the mark being
|
||||
// gone from every root entirely (a genuine destroy+recreate).
|
||||
const allRoots = Array.from(document.querySelectorAll(rootSelector)) as HTMLElement[]
|
||||
const allRootMarks = allRoots.map((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
mark: el.dataset.perfProbeMark ?? null,
|
||||
visible: el.offsetParent !== null && (rect.width > 0 || rect.height > 0),
|
||||
connected: el.isConnected,
|
||||
}
|
||||
})
|
||||
|
||||
// Authoritative real-hit-test signal, independent of the
|
||||
// offsetParent/rect heuristic above.
|
||||
const cx = Math.floor(window.innerWidth / 2)
|
||||
const cy = Math.floor(window.innerHeight / 2)
|
||||
const topEl = document.elementFromPoint(cx, cy) as HTMLElement | null
|
||||
const topRoot = (topEl?.closest(rootSelector) as HTMLElement | null) ?? null
|
||||
const elementFromPointMark = topRoot?.dataset.perfProbeMark ?? null
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
|
||||
const visible = candidates.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
|
||||
})
|
||||
if (!visible) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
|
||||
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
|
||||
if (!root) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = (root as any).__vueParentComponent ?? null
|
||||
const uid: number | null = comp ? (comp.uid ?? null) : null
|
||||
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
|
||||
return { ok: true, markMatches: root.dataset.perfProbeMark === expectedMark, uid, typeName, allRootMarks, elementFromPointMark }
|
||||
},
|
||||
{ contentSelector, rootSelector, expectedMark }
|
||||
)
|
||||
}
|
||||
|
||||
interface RoundTripResult {
|
||||
path: string
|
||||
label: string
|
||||
/** Primary verdict: did the SAME element survive the round trip (the
|
||||
* corrected 02-08 method's own signal)? null = could not be probed. */
|
||||
elementSurvived: boolean | null
|
||||
/** Independent instance-identity signal (instrument 1): did the SAME Vue
|
||||
* component instance (by internal uid) survive? null = could not be read
|
||||
* (e.g. __vueParentComponent absent, or the view was never found). */
|
||||
instanceSurvived: boolean | null
|
||||
instanceTypeNameBeforeAway: string | null
|
||||
instanceTypeNameAfterReturn: string | null
|
||||
/** Diagnostic: every rootSelector-matching element in the document at
|
||||
* read-back time, with its own mark + visibility/connected state — shows
|
||||
* whether an unmatched original root is genuinely gone vs still present
|
||||
* (just not the one the visibility filter picked). */
|
||||
allRootMarksAtRead: RootMarkDebug[]
|
||||
/** Authoritative real-hit-test signal at read-back time (see ReadResult's
|
||||
* own doc comment) — null means either the read failed or elementFromPoint
|
||||
* found no rootSelector ancestor at the viewport center. */
|
||||
elementFromPointMarkAtRead: string | null
|
||||
elementFromPointSurvived: boolean | null
|
||||
afterVisit: HopSnapshot | null
|
||||
afterAway: HopSnapshot | null
|
||||
afterReturn: HopSnapshot | null
|
||||
consoleMessages: string[]
|
||||
pageErrors: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort dismissal of a stray full-screen overlay before it blocks the
|
||||
* next click — mirrors measure.ts's own `dismissOverlays()` (this spec
|
||||
* deliberately does not import from measure.ts, so the logic is duplicated
|
||||
* here rather than shared, per the "don't edit the frozen 02-01 harness"
|
||||
* constraint). archi-dev-box currently runs at 85% disk (02-FINDINGS.md
|
||||
* Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast
|
||||
* live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper
|
||||
* has no `pointer-events: none`, so it silently intercepts clicks on
|
||||
* whatever sits behind it — an environmental condition unrelated to the
|
||||
* KeepAlive remount question this probe exists to answer, and one this
|
||||
* probe must route around rather than be blocked by.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
// no-op — best-effort only
|
||||
}
|
||||
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 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 goHome(page: Page): Promise<void> {
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).catch(() => {})
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[]): Promise<void> {
|
||||
for (const selector of steps) {
|
||||
await clickWithGuard(page, selector, NAV_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
async function roundTrip(page: Page, surface: Surface): Promise<RoundTripResult> {
|
||||
const consoleMessages: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
const onConsole = (msg: { type: () => string; text: () => string }) => {
|
||||
consoleMessages.push(`[${msg.type()}] ${msg.text()}`)
|
||||
}
|
||||
const onPageError = (err: Error) => {
|
||||
pageErrors.push(err.message)
|
||||
}
|
||||
page.on('console', onConsole)
|
||||
page.on('pageerror', onPageError)
|
||||
|
||||
let afterVisit: HopSnapshot | null = null
|
||||
let afterAway: HopSnapshot | null = null
|
||||
let afterReturn: HopSnapshot | null = null
|
||||
let stampReading: ProbeReading = { ok: false, uid: null, typeName: null }
|
||||
let readReading: ReadResult = { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks: [], elementFromPointMark: null }
|
||||
|
||||
try {
|
||||
await goHome(page)
|
||||
|
||||
await clickChain(page, surface.navSteps)
|
||||
// Wait for the URL first, THEN the content selector: three of these ten
|
||||
// surfaces (Server, Web5, Fleet) share the generic
|
||||
// `.view-container [data-controller-container]` contentSelector
|
||||
// (02-FINDINGS.md's own documented ambiguity), so waiting on the
|
||||
// selector alone can resolve instantly against the PREVIOUS tab's still-
|
||||
// visible content before the navigation actually lands — a probe
|
||||
// artifact this instrumentation (pathname logging, instrument 3) caught
|
||||
// directly rather than one I could have caught by inspection alone.
|
||||
if (!surface.path.includes(':')) {
|
||||
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
|
||||
afterVisit = await snapshotHop(page)
|
||||
|
||||
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
stampReading = await stampVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
|
||||
|
||||
if (surface.closeSelector) {
|
||||
// Deliberately NOT routed through clickWithGuard: dismissOverlays()
|
||||
// treats any "Close"-labelled dialog button as a stray overlay to
|
||||
// dismiss, which is exactly this element for a modal-trigger surface
|
||||
// — same reasoning as measure.ts's own runOnce().
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
afterAway = await snapshotHop(page)
|
||||
|
||||
await clickChain(page, surface.navSteps)
|
||||
if (!surface.path.includes(':')) {
|
||||
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
|
||||
afterReturn = await snapshotHop(page)
|
||||
|
||||
readReading = await readVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
|
||||
|
||||
const elementSurvived = stampReading.ok && readReading.ok ? readReading.markMatches : null
|
||||
const instanceSurvived =
|
||||
stampReading.ok && readReading.ok && stampReading.uid != null && readReading.uid != null
|
||||
? stampReading.uid === readReading.uid
|
||||
: null
|
||||
const elementFromPointSurvived = readReading.elementFromPointMark != null ? readReading.elementFromPointMark === mark : null
|
||||
|
||||
return {
|
||||
path: surface.path,
|
||||
label: surface.label,
|
||||
elementSurvived,
|
||||
instanceSurvived,
|
||||
instanceTypeNameBeforeAway: stampReading.typeName,
|
||||
instanceTypeNameAfterReturn: readReading.typeName,
|
||||
allRootMarksAtRead: readReading.allRootMarks,
|
||||
elementFromPointMarkAtRead: readReading.elementFromPointMark,
|
||||
elementFromPointSurvived,
|
||||
afterVisit,
|
||||
afterAway,
|
||||
afterReturn,
|
||||
consoleMessages,
|
||||
pageErrors,
|
||||
error: null,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
path: surface.path,
|
||||
label: surface.label,
|
||||
elementSurvived: null,
|
||||
instanceSurvived: null,
|
||||
instanceTypeNameBeforeAway: stampReading.typeName,
|
||||
instanceTypeNameAfterReturn: readReading.typeName,
|
||||
allRootMarksAtRead: readReading.allRootMarks,
|
||||
elementFromPointMarkAtRead: readReading.elementFromPointMark,
|
||||
elementFromPointSurvived: null,
|
||||
afterVisit,
|
||||
afterAway,
|
||||
afterReturn,
|
||||
consoleMessages,
|
||||
pageErrors,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
} finally {
|
||||
page.off('console', onConsole)
|
||||
page.off('pageerror', onPageError)
|
||||
}
|
||||
}
|
||||
|
||||
test('keepalive-remount-probe: every KEEP_ALIVE_PATHS surface survives a tab round-trip (or reports why it could not be probed)', async ({ page }) => {
|
||||
test.setTimeout(10 * 60 * 1000)
|
||||
|
||||
await login(page)
|
||||
|
||||
// Session-wide capture, in addition to roundTrip()'s own per-surface
|
||||
// listeners: a delayed/async error (e.g. a promise that settles after a
|
||||
// round trip's own listeners are already detached, mid-flight when we've
|
||||
// moved on to the next surface) would otherwise be silently missed and
|
||||
// wrongly attributed to "no error" for the surface actually responsible.
|
||||
const sessionLog: string[] = []
|
||||
const sessionStart = Date.now()
|
||||
const onSessionConsole = (msg: { type: () => string; text: () => string }) => {
|
||||
sessionLog.push(`[+${Date.now() - sessionStart}ms] [console:${msg.type()}] ${msg.text()}`)
|
||||
}
|
||||
const onSessionPageError = (err: Error) => {
|
||||
sessionLog.push(`[+${Date.now() - sessionStart}ms] [pageerror] ${err.message}`)
|
||||
}
|
||||
page.on('console', onSessionConsole)
|
||||
page.on('pageerror', onSessionPageError)
|
||||
|
||||
// De-duplicate by path, keeping the FIRST matching SURFACES row: two rows
|
||||
// share `path: '/dashboard'` (the `home` main-tab row and the `wallet-send`
|
||||
// modal-trigger row, which records Home's own path only for reference,
|
||||
// per surfaces.ts's own doc comment on `closeSelector`) — the modal is not
|
||||
// itself a KEEP_ALIVE_PATHS-registered route, so only `home` should count.
|
||||
const seenPaths = new Set<string>()
|
||||
const probeSurfaces = SURFACES.filter((s) => {
|
||||
if (!KEEP_ALIVE_PATHS.has(s.path) || seenPaths.has(s.path)) return false
|
||||
seenPaths.add(s.path)
|
||||
return true
|
||||
})
|
||||
expect(probeSurfaces.length).toBe(KEEP_ALIVE_PATHS.size)
|
||||
|
||||
const results: RoundTripResult[] = []
|
||||
for (const surface of probeSurfaces) {
|
||||
const result = await roundTrip(page, surface)
|
||||
results.push(result)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[keepalive-remount-probe] ${result.path} (${result.label}): ` +
|
||||
`elementSurvived=${result.elementSurvived} instanceSurvived=${result.instanceSurvived} ` +
|
||||
`typeName(before/after)=${result.instanceTypeNameBeforeAway}/${result.instanceTypeNameAfterReturn} ` +
|
||||
`viewContainerCount(visit/away/return)=${result.afterVisit?.viewContainerCount ?? 'n/a'}/${result.afterAway?.viewContainerCount ?? 'n/a'}/${result.afterReturn?.viewContainerCount ?? 'n/a'} ` +
|
||||
`pathname(visit/return)=${result.afterVisit?.pathname ?? 'n/a'}/${result.afterReturn?.pathname ?? 'n/a'} ` +
|
||||
`elementFromPointSurvived=${result.elementFromPointSurvived} ` +
|
||||
`pageErrors=${result.pageErrors.length} error=${result.error ?? 'none'}`
|
||||
)
|
||||
if (result.pageErrors.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] pageErrors: ${JSON.stringify(result.pageErrors)}`)
|
||||
}
|
||||
if (result.elementSurvived === false) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] allRootMarksAtRead (${result.path}): ${JSON.stringify(result.allRootMarksAtRead)}`)
|
||||
}
|
||||
}
|
||||
|
||||
page.off('console', onSessionConsole)
|
||||
page.off('pageerror', onSessionPageError)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] full session log (${sessionLog.length} entries):\n${sessionLog.join('\n')}`)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] full results JSON:\n${JSON.stringify(results, null, 2)}`)
|
||||
|
||||
// Structural assertion only — every registered path must have been
|
||||
// attempted and produce a result row, mirroring surface-perf.spec.ts's own
|
||||
// sole assertion (`expect(results.length).toBe(SURFACES.length)`). Whether
|
||||
// each one SURVIVED, or could even be probed at all, is the finding this
|
||||
// probe exists to produce, not a pass/fail gate on the spec itself: Mesh's
|
||||
// device-not-reporting-connected condition and Chat's AIUI-connection
|
||||
// timing are both pre-existing, environment-dependent blockers
|
||||
// 02-FINDINGS.md's own `## Results` section already documents as
|
||||
// "unmeasured" rather than "failed" — an errored sample is recorded, never
|
||||
// discarded and never used to fail the harness itself, exactly like
|
||||
// `measure.ts`'s `measureSurface()` treats its own per-run errors.
|
||||
expect(results.length).toBe(probeSurfaces.length)
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
// 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, or a prior run errored out mid-chat), 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') {
|
||||
const closed = await page
|
||||
.locator('.chat-close-btn')
|
||||
.first()
|
||||
.click({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
// On real AIUI hardware the close pill can sit behind AIUI's own
|
||||
// "connecting" overlay for longer than any reasonable click budget — if
|
||||
// the close button itself is unreachable, this is a recovery path, not a
|
||||
// measurement, so a hard reload to break out is preferable to leaving
|
||||
// every remaining surface stuck behind a permanently hidden sidebar.
|
||||
if (!closed) {
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// profile-revisit.spec.ts — 02-11 gap-closure diagnostic (D-10: measure before
|
||||
// fix). Standalone, additive script — does NOT edit surfaces.ts / measure.ts
|
||||
// / surface-perf.spec.ts (the frozen 02-01 harness stays frozen).
|
||||
//
|
||||
// 02-VERIFICATION.md's gap 2 confirmed six surfaces regressed on revisit with
|
||||
// flat-or-improved RPC counts — i.e. the cost is client-side render/reactivity,
|
||||
// not network. This script captures a REAL CPU profile (CDP Profiler domain —
|
||||
// the same sampling data Chrome DevTools' Performance panel visualizes as a
|
||||
// flame chart) during exactly the harness's own measured window (click chain
|
||||
// -> contentSelector visible) for each named surface's revisit, then
|
||||
// aggregates self-time by function so the dominant cost can be named with
|
||||
// profiling evidence instead of guessed from source reading alone.
|
||||
//
|
||||
// Usage:
|
||||
// ARCHY_BASE_URL=http://archi-dev-box ARCHY_PASSWORD=*** \
|
||||
// npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { SURFACES, type Surface } from './surfaces'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const NAV_TIMEOUT = 20_000
|
||||
const CONTENT_TIMEOUT = 20_000
|
||||
|
||||
// Every surface 02-VERIFICATION.md named as regressed, plus Fleet (the
|
||||
// out-of-scope bonus 02-10 flagged as the most severe magnitude of the same
|
||||
// mechanism) — all six of this gap plan's must-haves.
|
||||
const TARGET_IDS = ['web5', 'server', 'discover', 'app-details', 'openwrt-gateway', 'fleet']
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
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(() => {})
|
||||
// HealthNotifications.vue's dismiss button has no aria-label at all (a bare
|
||||
// SVG X icon) — the aria-label selector above never matches it, and per
|
||||
// 02-FINDINGS.md's Outstanding section its `.fixed.right-4.z-[200]` wrapper
|
||||
// has no `pointer-events: none`, so a still-open toast can intercept a
|
||||
// click meant for page content underneath it (e.g. a disk-usage warning
|
||||
// sitting over the "OpenWrt Gateway" link). Dismiss any visible one.
|
||||
const healthToastClose = page.locator('.fixed.right-4.z-\\[200\\] button')
|
||||
const healthCount = await healthToastClose.count().catch(() => 0)
|
||||
for (let i = 0; i < healthCount; i++) {
|
||||
await healthToastClose.first().click({ timeout: 1_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 4
|
||||
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, force: i === attempts - 1 })
|
||||
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 goHome(page: Page, timeoutMs: number): Promise<void> {
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
const closed = await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!closed) await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
|
||||
}
|
||||
|
||||
interface CpuProfileNode {
|
||||
id: number
|
||||
callFrame: { functionName: string; url: string; lineNumber: number; columnNumber: number; scriptId: string }
|
||||
hitCount?: number
|
||||
children?: number[]
|
||||
}
|
||||
interface CpuProfile {
|
||||
nodes: CpuProfileNode[]
|
||||
startTime: number
|
||||
endTime: number
|
||||
samples?: number[]
|
||||
timeDeltas?: number[]
|
||||
}
|
||||
|
||||
// Production build has no sourcemaps deployed (confirmed: assets/*.js.map ->
|
||||
// 404 on archi-dev-box), so minified function names inside vendor-*.js/
|
||||
// index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME
|
||||
// instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled
|
||||
// together; index = app entry/shared code; per-route chunk name = that
|
||||
// view's own lazy-loaded code) rather than by node_modules path, which only
|
||||
// exists pre-build.
|
||||
function categorize(url: string): string {
|
||||
if (!url) return '(native/gc/idle — no JS frame)'
|
||||
const file = url.split('/').pop() ?? url
|
||||
if (/^vendor-/.test(file)) return 'assets/vendor-*.js (Vue/Pinia/vue-router runtime bundle)'
|
||||
if (/^index-/.test(file)) return 'assets/index-*.js (app entry/shared chunk)'
|
||||
if (/^Dashboard-/.test(file)) return 'assets/Dashboard-*.js (Dashboard/DashboardRouterView chunk)'
|
||||
if (/^Fleet-/.test(file)) return 'assets/Fleet-*.js (Fleet.vue chunk)'
|
||||
if (/^Web5-/i.test(file)) return 'assets/Web5-*.js chunk'
|
||||
if (/^Server-/.test(file)) return 'assets/Server-*.js chunk'
|
||||
if (/^Discover-/.test(file)) return 'assets/Discover-*.js chunk'
|
||||
if (/^AppDetails-/.test(file)) return 'assets/AppDetails-*.js chunk'
|
||||
if (/^OpenWrtGateway-/.test(file)) return 'assets/OpenWrtGateway-*.js chunk'
|
||||
if (url.startsWith('assets/')) return `assets/${file} (other chunk)`
|
||||
if (url.includes('node_modules')) return 'other node_modules (dev-only build)'
|
||||
return '(native/gc/idle — no JS frame)'
|
||||
}
|
||||
|
||||
function analyzeProfile(profile: CpuProfile, label: string): void {
|
||||
const byId = new Map<number, CpuProfileNode>()
|
||||
for (const n of profile.nodes) byId.set(n.id, n)
|
||||
|
||||
const selfTimeById = new Map<number, number>()
|
||||
const samples = profile.samples ?? []
|
||||
const timeDeltas = profile.timeDeltas ?? []
|
||||
let total = 0
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const dt = timeDeltas[i] ?? 0
|
||||
total += dt
|
||||
const id = samples[i]!
|
||||
selfTimeById.set(id, (selfTimeById.get(id) ?? 0) + dt)
|
||||
}
|
||||
const totalMs = total / 1000
|
||||
const windowMs = (profile.endTime - profile.startTime) / 1000
|
||||
|
||||
// Aggregate by function identity (name@file:line) and by coarse category.
|
||||
const byFunction = new Map<string, number>()
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const [id, us] of selfTimeById) {
|
||||
const node = byId.get(id)
|
||||
if (!node) continue
|
||||
const fnName = node.callFrame.functionName || '(anonymous)'
|
||||
const file = node.callFrame.url ? node.callFrame.url.split('/').slice(-2).join('/') : '(native)'
|
||||
const key = `${fnName} @ ${file}:${node.callFrame.lineNumber}`
|
||||
byFunction.set(key, (byFunction.get(key) ?? 0) + us)
|
||||
const cat = categorize(node.callFrame.url)
|
||||
byCategory.set(cat, (byCategory.get(cat) ?? 0) + us)
|
||||
}
|
||||
|
||||
console.log(`\n===== CPU PROFILE: ${label} =====`)
|
||||
console.log(`Profiled window (Profiler start->stop): ${windowMs.toFixed(1)}ms`)
|
||||
console.log(`Total sampled self-time: ${totalMs.toFixed(1)}ms (${samples.length} samples)`)
|
||||
console.log(`--- By category (self time) ---`)
|
||||
const catSorted = Array.from(byCategory.entries()).sort((a, b) => b[1] - a[1])
|
||||
for (const [cat, us] of catSorted) {
|
||||
const ms = us / 1000
|
||||
console.log(` ${ms.toFixed(1)}ms (${((us / total) * 100).toFixed(1)}%) ${cat}`)
|
||||
}
|
||||
console.log(`--- Top 20 functions (self time) ---`)
|
||||
const fnSorted = Array.from(byFunction.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
for (const [key, us] of fnSorted) {
|
||||
const ms = us / 1000
|
||||
console.log(` ${ms.toFixed(2)}ms (${((us / total) * 100).toFixed(1)}%) ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('profile revisit CPU cost for named regressed surfaces', async ({ page }) => {
|
||||
test.setTimeout(15 * 60 * 1000)
|
||||
await login(page)
|
||||
|
||||
for (const id of TARGET_IDS) {
|
||||
const surface = SURFACES.find((s) => s.id === id) as Surface
|
||||
expect(surface, `surface ${id} must exist in SURFACES`).toBeTruthy()
|
||||
|
||||
await goHome(page, NAV_TIMEOUT)
|
||||
|
||||
// First visit — warm the cache (matches the harness's own first-visit/
|
||||
// revisit structure) — not profiled.
|
||||
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
|
||||
// Away — to the neutral Settings tab (or close, for an in-page trigger).
|
||||
if (surface.closeSelector) {
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
// Let the away-transition settle before starting the profiler so it
|
||||
// captures only the revisit window, not Settings' own leave-transition.
|
||||
await page.waitForTimeout(600)
|
||||
|
||||
// Instrument window.setTimeout/requestAnimationFrame for the profiled
|
||||
// window only, so a large "idle" share in the CPU profile can be
|
||||
// attributed to a concrete scheduled delay (app timer) vs. a chain of
|
||||
// animation frames (CSS-transition-bound) vs. neither (network wait).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__timerLog: Array<{ type: string; delay?: number; at: number }>
|
||||
__origSetTimeout: typeof setTimeout
|
||||
__origRaf: typeof requestAnimationFrame
|
||||
}
|
||||
w.__timerLog = []
|
||||
w.__origSetTimeout = window.setTimeout.bind(window)
|
||||
w.__origRaf = window.requestAnimationFrame.bind(window)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setTimeout = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
|
||||
w.__timerLog.push({ type: 'setTimeout', delay, at: performance.now() })
|
||||
return w.__origSetTimeout(fn as any, delay, ...args) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
w.__timerLog.push({ type: 'raf', at: performance.now() })
|
||||
return w.__origRaf(cb)
|
||||
}
|
||||
})
|
||||
|
||||
// Wall-clock RPC start/finish (matches measure.ts's own attachRpcTracker
|
||||
// convention: Date.now() offsets, method name only, no bodies — T-02-06).
|
||||
const rpcCalls: Array<{ method: string; startedAtMs: number; durationMs: number | null }> = []
|
||||
const pendingRpc = new Map<import('@playwright/test').Request, { method: string; start: number }>()
|
||||
let tRpc0 = 0
|
||||
const isRpc = (req: import('@playwright/test').Request) => req.method() === 'POST' && req.url().includes('/rpc/v1')
|
||||
const onReq = (req: import('@playwright/test').Request) => {
|
||||
if (!isRpc(req)) return
|
||||
let method = 'unknown'
|
||||
try {
|
||||
const body = req.postData()
|
||||
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
|
||||
} catch {
|
||||
/* keep 'unknown' */
|
||||
}
|
||||
pendingRpc.set(req, { method, start: Date.now() - tRpc0 })
|
||||
}
|
||||
const onSettled = (req: import('@playwright/test').Request) => {
|
||||
const info = pendingRpc.get(req)
|
||||
if (!info) return
|
||||
pendingRpc.delete(req)
|
||||
rpcCalls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - tRpc0 - info.start })
|
||||
}
|
||||
page.on('request', onReq)
|
||||
page.on('requestfinished', onSettled)
|
||||
page.on('requestfailed', onSettled)
|
||||
|
||||
// Capture every CSS transition/animation start+end on the document during
|
||||
// the window — decisive evidence for/against "a long CSS transition is
|
||||
// what the human waits through" (Playwright's own 'visible' check does
|
||||
// NOT wait for transitions/opacity, only a non-empty bounding box + not
|
||||
// visibility:hidden, so a slow transition would NOT show up as CPU cost
|
||||
// or as a blocked contentSelector — it would show up here, and would
|
||||
// explain a "feels slow" gap between first-paint and contentSelector).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }
|
||||
w.__animLog = []
|
||||
const describe = (el: EventTarget | null): string => {
|
||||
const e = el as HTMLElement | null
|
||||
if (!e || !e.tagName) return '(unknown)'
|
||||
const cls = (e.className && typeof e.className === 'string') ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
|
||||
return `${e.tagName.toLowerCase()}${cls}`
|
||||
}
|
||||
const log = (type: string) => (ev: Event) => {
|
||||
const te = ev as TransitionEvent | AnimationEvent
|
||||
w.__animLog.push({
|
||||
type,
|
||||
name: (te as TransitionEvent).propertyName ?? (te as AnimationEvent).animationName ?? '',
|
||||
target: describe(ev.target),
|
||||
elapsedMs: Math.round((te.elapsedTime ?? 0) * 1000),
|
||||
at: performance.now(),
|
||||
})
|
||||
}
|
||||
document.addEventListener('transitionrun', log('transitionrun'), true)
|
||||
document.addEventListener('transitionend', log('transitionend'), true)
|
||||
document.addEventListener('transitioncancel', log('transitioncancel'), true)
|
||||
document.addEventListener('animationstart', log('animationstart'), true)
|
||||
document.addEventListener('animationend', log('animationend'), true)
|
||||
})
|
||||
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send('Profiler.enable')
|
||||
await client.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
await client.send('Profiler.start')
|
||||
|
||||
// Raw Chrome trace events (the SAME data DevTools' Performance panel
|
||||
// renders as a flame chart / summary tab) — categorizes rendering work
|
||||
// (Layout, RecalculateStyles, Paint, CompositeLayers, RunTask, TimerFire,
|
||||
// FireAnimationFrame, ...) that a bare JS CPU profile only sees as
|
||||
// "(program)"/"(idle)" because layout/paint/compositing run on the same
|
||||
// renderer main thread but outside any JS call frame.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const traceEvents: any[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client.on('Tracing.dataCollected', (data: any) => { traceEvents.push(...(data.value ?? [])) })
|
||||
const tracingComplete = new Promise<void>((resolve) => client.once('Tracing.tracingComplete', () => resolve()))
|
||||
await client.send('Tracing.start', {
|
||||
categories: 'disabled-by-default-devtools.timeline,devtools.timeline,toplevel,v8,blink.user_timing',
|
||||
transferMode: 'ReportEvents',
|
||||
})
|
||||
|
||||
// First-paint probe, independent of Playwright's own contentSelector
|
||||
// wait: records performance.now() the moment contentSelector FIRST gets
|
||||
// a non-empty bounding box, polled every animation frame (not gated by
|
||||
// Playwright's stricter "visible" actionability rules) — so first-paint
|
||||
// and content-visible can be reported as two distinct numbers per the
|
||||
// gap plan's explicit ask.
|
||||
const pageT0 = await page.evaluate((sel) => {
|
||||
const w = window as unknown as { __firstPaintAt: number | null }
|
||||
w.__firstPaintAt = null
|
||||
function poll() {
|
||||
if (w.__firstPaintAt == null) {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
w.__firstPaintAt = performance.now()
|
||||
return
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(poll)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(poll)
|
||||
return performance.now()
|
||||
}, surface.contentSelector)
|
||||
|
||||
const t0 = Date.now()
|
||||
tRpc0 = t0
|
||||
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
const wallMs = Date.now() - t0
|
||||
const firstPaintAt = await page.evaluate(() => (window as unknown as { __firstPaintAt: number | null }).__firstPaintAt)
|
||||
const firstPaintMs = firstPaintAt != null ? Math.round(firstPaintAt - pageT0) : null
|
||||
|
||||
const { profile } = await client.send('Profiler.stop')
|
||||
await client.send('Profiler.disable')
|
||||
await client.send('Tracing.end')
|
||||
await tracingComplete
|
||||
await client.detach().catch(() => {})
|
||||
page.off('request', onReq)
|
||||
page.off('requestfinished', onSettled)
|
||||
page.off('requestfailed', onSettled)
|
||||
|
||||
// Find the renderer main-thread tid (thread_name metadata event) so the
|
||||
// breakdown below isn't polluted by compositor/IO/GPU-process threads.
|
||||
const mainThreadMeta = traceEvents.find(
|
||||
(e) => e.ph === 'M' && e.name === 'thread_name' && (e.args?.name === 'CrRendererMain')
|
||||
)
|
||||
const mainTid = mainThreadMeta?.tid
|
||||
const durByName = new Map<string, number>()
|
||||
for (const e of traceEvents) {
|
||||
if (mainTid != null && e.tid !== mainTid) continue
|
||||
if (e.ph !== 'X') continue // complete events only (have a real duration)
|
||||
if (typeof e.dur !== 'number') continue
|
||||
durByName.set(e.name, (durByName.get(e.name) ?? 0) + e.dur)
|
||||
}
|
||||
console.log(`--- Chrome trace event self/total time by name (main thread, category=devtools.timeline; NOTE: 'X' events can nest, so this is TOTAL not self time — use for relative magnitude, not a sum-to-100% budget) ---`)
|
||||
const traceSorted = Array.from(durByName.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
for (const [name, us] of traceSorted) {
|
||||
console.log(` ${(us / 1000).toFixed(1)}ms ${name}`)
|
||||
}
|
||||
|
||||
const timerLog = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__timerLog: Array<{ type: string; delay?: number; at: number }>
|
||||
__origSetTimeout: typeof setTimeout
|
||||
__origRaf: typeof requestAnimationFrame
|
||||
}
|
||||
const log = w.__timerLog ?? []
|
||||
window.setTimeout = w.__origSetTimeout
|
||||
window.requestAnimationFrame = w.__origRaf
|
||||
return log
|
||||
})
|
||||
|
||||
const animLog = await page.evaluate(() => (window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }).__animLog ?? [])
|
||||
|
||||
console.log(`\n>>> ${surface.label} (${surface.id}) revisit wall-clock: ${wallMs}ms | first-paint: ${firstPaintMs}ms (contentSelector's first non-empty bounding box, unrelated to Playwright's own stricter 'visible' check)`)
|
||||
analyzeProfile(profile as CpuProfile, `${surface.label} (${surface.id})`)
|
||||
|
||||
const setTimeoutEntries = timerLog.filter((e) => e.type === 'setTimeout')
|
||||
const rafCount = timerLog.filter((e) => e.type === 'raf').length
|
||||
console.log(`--- Timers scheduled during the revisit window (app-code setTimeout/rAF calls) ---`)
|
||||
console.log(` setTimeout calls: ${setTimeoutEntries.length}${setTimeoutEntries.length ? ' — delays(ms): ' + setTimeoutEntries.map((e) => e.delay ?? 0).sort((a, b) => a - b).join(',') : ''}`)
|
||||
console.log(` requestAnimationFrame calls: ${rafCount}`)
|
||||
|
||||
console.log(`--- CSS transition/animation events during the window (relative to window start, pageT0) ---`)
|
||||
if (animLog.length === 0) {
|
||||
console.log(' (none observed)')
|
||||
} else {
|
||||
for (const e of animLog) {
|
||||
console.log(` +${Math.round(e.at - pageT0)}ms ${e.type} name="${e.name}" target=${e.target} elapsed=${e.elapsedMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`--- RPC calls during the revisit window (wall-clock ms from window start) ---`)
|
||||
for (const c of rpcCalls) {
|
||||
console.log(` ${c.method}: start=+${c.startedAtMs}ms duration=${c.durationMs}ms`)
|
||||
}
|
||||
console.log(` total RPC calls: ${rpcCalls.length}, wall-clock window: ${wallMs}ms`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 {
|
||||
// `__dirname` is unavailable under this package's `"type": "module"` ESM
|
||||
// runtime (Playwright's own transform swallows the ReferenceError into
|
||||
// the catch below, silently yielding 'unknown') — use `process.cwd()`
|
||||
// instead, which Playwright always sets to the project root it was
|
||||
// invoked from.
|
||||
return execSync('git rev-parse --short HEAD', { cwd: process.cwd() }).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)
|
||||
})
|
||||
@@ -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"]',
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user