fix(02-09): name Server/Web5 KeepAlive "remount" as a proven probe artifact
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m36s

Task 1: built keepalive-remount-probe.spec.ts, a committed, re-runnable
Playwright spec covering every KEEP_ALIVE_PATHS tab with three added
instruments (instance-uid, session-wide console/pageerror capture, DOM
population/pathname logging per hop) beyond 02-08's ad-hoc probe.

Eliminated suspects 2 (runtime error), 3 (LRU eviction), 4 (route.path
mismatch) and 5 (include-name matching) by direct measurement. Confirmed
suspect 1 with positive proof: an authoritative document.elementFromPoint()
hit-test signal contradicted the naive selector-match method's "remounted"
verdict for both Server and Web5 across independent runs, and a companion
diagnostic found the original stamped root still connected+visible under
a different (unpicked) match. Root cause: Server, Web5 and Fleet share the
fully generic .view-container [data-controller-container] selector every
KeepAlive-cached main tab's root carries via fallthrough, which cannot
disambiguate "the foreground tab" from "another cached tab still connected
to the document" once more than one tab has been visited — the normal,
intended KeepAlive state. Settings (the away tab every round trip uses)
independently renders matching content too (AccountInfoSection/
KioskDisplaySection), compounding the ambiguity.

Server.vue and Web5.vue's instances survive tab round-trips exactly like
every other registered tab — no defect in DashboardRouterView.vue,
dashboardViewWrappers.ts, keepAliveRoutes.ts or Server.vue's KeepAlive
wiring. 02-FINDINGS.md records the full method, eliminated suspects and
verdict per D-10 (this commit lands before any src change, per gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-31 05:42:54 -04:00
parent 3b3d22d0ce
commit 4d471759a7
2 changed files with 675 additions and 0 deletions

View File

@ -448,3 +448,148 @@ any of these three, per standing direction (captured separately as phase-1/gener
those events. There is no app-level rebuffering/PiP-continuity logic to have regressed;
PiP-during-rebuffer behavior here is entirely native browser/HTML5-video default behavior,
untouched by any app code in any phase. **Pre-existing (by absence), not phase 2.**
## Server KeepAlive Root Cause (gap closure)
**Named cause (one sentence):** Server.vue's (and, discovered mid-investigation, Web5.vue's)
component instance genuinely survives every tab round-trip on archi-dev-box exactly like every
other registered `KEEP_ALIVE_PATHS` tab — the "remounts" verdict this plan set out to root-cause
is a confirmed **measurement artifact** of the remount probe's own selector, not a real KeepAlive
or component-lifecycle defect; the underlying architecture (`DashboardRouterView.vue` /
`dashboardViewWrappers.ts` / `keepAliveRoutes.ts`) works correctly for these tabs and needs no fix.
### Method
Per D-10's cheapest-discriminator-first order:
**Step A (jsdom reproduction, no device):** Extended `keepAliveLifecycle.test.ts`'s pattern with
a new test mounting the REAL `DashboardRouterView` + REAL `Server.vue` (both a directly-imported
and an `() => import(...)`-based async-component route, matching production's own route
definition exactly) at `/dashboard/server`, doing an away-hop to `/dashboard/settings` and back,
comparing `findComponent(Server).vm.$.uid` (Vue's internal per-instance monotonic id) before and
after. **Did not reproduce**`uid` was identical both times in every variation tried (bare
router, async-import router, full 10-surface `KEEP_ALIVE_PATHS` sequence with real LRU pressure
and real Web5.vue mounted alongside). This positively rules out a pure client-side/router bug
reproducible without a real backend or real network timing (suspect 2's own framing: "a
device-only failure would explain why Step A passes while the device does not" — confirmed
true, but not for the reason originally guessed).
**Step B (on-device instrumented probe):** Built `neode-ui/e2e/perf/keepalive-remount-probe.spec.ts`
— a standalone, committed, re-runnable Playwright spec (imports `KEEP_ALIVE_PATHS` paths as a
literal re-declared list per the e2e tsconfig's alias gap; reuses `SURFACES`' `navSteps`/
`contentSelector`/`rootSelector` read-only) that logs into archi-dev-box and, for every
registered path, does visit → stamp → away → return → read, with three added instruments:
1. **Instance-identity via `__vueParentComponent.uid`** (confirmed unconditionally set by
`node_modules/@vue/runtime-core`'s `mountElement`, not gated behind `__DEV__`) — returned
`null` for every reading against the deployed build. Confirmed this is the production bundle
stripping the devtools hook (`__VUE_PROD_DEVTOOLS__` defaults off), not a probe bug: the
property is genuinely absent from every root, survivors and non-survivors alike, on the real
deployed build, while the identical code path DOES populate it under `vitest`'s dev-mode Vue
build (used to confirm Step A). This instrument was inconclusive on-device by construction —
an honest limitation, not a finding either way.
2. **`page.on('pageerror')`/`page.on('console')`**, captured for the ENTIRE session (not just
per-surface — session-wide capture added after per-surface listeners risked missing a
delayed/async error) — across four independent full-session runs, **zero** errors or warnings
were ever attributed to Server or Web5's activation/deactivation. Suspect 2 (a runtime error
tearing down the cached subtree) has no supporting evidence.
3. **`document.querySelectorAll('.view-container').length` + `location.pathname`** after every
hop — this instrument caught a REAL bug in the probe's own first iteration (not a hypothesis):
the initial version's `waitFor({state:'visible'})` on the shared, generic
`.view-container [data-controller-container]` selector could resolve BEFORE the navigation
actually landed, because the PREVIOUS tab's own matching content was still visible — `pathname`
logging caught this directly (Server's `afterVisit.pathname` read back `/dashboard`, not
`/dashboard/server`). Fixed by gating on `page.waitForURL()` matching the exact literal path
before checking content visibility.
**Suspects eliminated by direct measurement (not by inspection alone):**
- **Suspect 3 (LRU eviction, `KEEP_ALIVE_MAX=6`):** instrument 3's viewContainerCount trace,
combined with the exact SURFACES-order navigation sequence, shows no NEW distinct
`KEEP_ALIVE_PATHS` entry is added between any surface's own away-then-return hop — the cap
cannot be the mechanism evicting Server/Web5 specifically during their own isolated round trip.
**Eliminated.**
- **Suspect 4 (`route.path` mismatch):** instrument 3's pathname logging confirms
`location.pathname` matches the surface's literal registered path exactly on every visit and
return, for all ten paths including Server and Web5, across every run. **Eliminated.**
- **Suspect 5 (`include` comma-split matching):** none of the ten `KeepWrap:<path>` names contain
a comma; Vue's own `matches()` only splits on commas, so this holds identically and vacuously
for every registered path — structurally proven, not just measured. **Eliminated.**
- **Suspect 2 (Server-specific runtime error):** zero captured errors/warnings across four
independent full-session-captured device runs, plus a real jsdom reproduction attempt covering
the exact real components in the exact real sequence found no error either. **Not supported by
any evidence gathered.**
**Suspect 1 (probe measures DOM-element identity, not component-instance identity) — CONFIRMED,
with direct proof, and refined beyond its original framing.** The generic
`.view-container [data-controller-container]` / `.view-container` pair — shared by Server, Web5
AND Fleet, and structurally shared by every OTHER main tab too, since `DashboardRouterView.vue`
applies the `view-container` class via fallthrough to every non-full-bleed routed view's own
root (`:class="isFullBleedPath(route.path) ? undefined : 'view-container flex-none'"`) — cannot
reliably distinguish "the tab actually visible on screen right now" from "a different
KeepAlive-cached tab whose root happens to still be connected to the document" once more than
one main tab has been visited (the NORMAL, intended state — that is the entire point of
KeepAlive). Every `.view-wrapper` is `position: absolute; inset: 0`, so ANY currently-mounted
alternative view's root reports a non-zero `getBoundingClientRect()` and a non-null
`offsetParent` — the exact "visible" check the corrected 02-08 method (and this plan's own
Step-B probe, initially) used — REGARDLESS of whether that view is the true foreground tab.
`Array.prototype.find()`'s first-DOM-order match has no way to tell these apart.
Two additional discoveries sharpened this beyond a hypothesis into direct, decisive proof:
- **`/dashboard/settings` (the away tab EVERY round trip in this probe, and in `measure.ts`'s own
`NEUTRAL_SELECTOR` convention, uses) itself matches the exact same generic selector.**
`Settings.vue` renders `AccountSection` (→ `AccountInfoSection.vue`, has
`data-controller-container`) and `SystemSection` (→ `KioskDisplaySection.vue`, has
`data-controller-container`) unconditionally — no tabs/accordion gating — and Settings' own
root ALSO gets the `view-container` fallthrough class like every other non-full-bleed route.
Its leave-transition keeps that DOM genuinely connected and "visible" by the offsetParent/rect
check for the transition's full duration while the target's enter-transition is already
progressing.
- **Adding an authoritative, independent signal — `document.elementFromPoint()` at the viewport
center (real hit-testing, respects actual stacking/z-index, unlike the offsetParent heuristic)
— directly contradicted the naive method's "remounted" verdict.** Across the runs where this
signal resolved to a definite answer at all (it is `null` when the exact center pixel doesn't
land inside a `.view-container`-classed element, which happens often enough to not be usable
as the SOLE signal): it read **`true` (survived) for Server** in one run where the naive
first-DOM-match method read **`false`**, and **`true` (survived) for Web5** in a separate run
where the naive method ALSO read **`false`** — direct, same-round-trip contradictions between
"what's actually rendered on screen" and "what the naive selector-match probe reports." It
never once read `false` for either Server or Web5 across all decisive runs. A companion
diagnostic — enumerating every `.view-container`-matching root in the document at read-back
time with its own stamp mark — independently confirmed the same thing directly: one read for
Web5 found the ORIGINAL stamped root still `connected: true` and `visible: true` (by the
offsetParent check), simply not the one the naive `.find()` picked first.
### Verdict
Server.vue's and Web5.vue's component instances survive a tab round-trip on archi-dev-box —
proven directly via an authoritative, hit-test-based signal that independently contradicts the
"remounted" reading the naive selector-based method (both 02-08's and this plan's own first-pass
correction) produces for these two specific tabs. This is not a guess or an absence-of-evidence
argument: it is a positive, repeated (elementFromPoint agreed with survival in every run where it
resolved a verdict at all — zero contradicting readings) observation that the actual foreground,
user-visible element at read-back time is the SAME element this probe stamped at visit time.
Per the plan's own explicitly anticipated branch: **the earlier "genuinely remounts" reading (in
02-08 and in this plan's own early iterations) was a probe artifact, not a real defect.** Server
and Web5 are — like Fleet — surfaces whose `contentSelector`/`rootSelector` happens to be the
fully generic pair shared by every KeepAlive-cached main tab, which makes THEIR OWN measurement
uniquely vulnerable to this artifact even though the underlying KeepAlive architecture treats them
identically to every reliably-measured tab (Home, Apps, Marketplace, Discover, Cloud, Chat — all
of which have their own unique CSS class and never hit this ambiguity).
**Fleet is NOT independently re-confirmed either way here** (out of this gap's named scope —
02-08 already reported it surviving with a large timing regression, unaffected by this finding);
`elementFromPointSurvived` never resolved to a definite verdict for Fleet across the runs
performed (its viewport center did not land inside a matching root at read-back time), so this
finding neither confirms nor overturns 02-08's existing Fleet verdict.
**Intended fix (Task 2):** No change to `DashboardRouterView.vue`, `dashboardViewWrappers.ts`,
`keepAliveRoutes.ts` or `Server.vue`'s KeepAlive/lifecycle wiring — the architecture already
delivers PERF-02's instance-caching promise for this tab. Task 2 lands Tests 14 (component-
instance-identity assertions via `findComponent(...).vm.$.uid`, which sidesteps the generic-
selector ambiguity entirely by using Vue's own component tree instead of a CSS selector) as a
permanent regression pin, confirms they pass immediately against the CURRENT, unmodified code
(itself the proof), and updates this section with the pre-existing-code pass observation. No
build/deploy/redeploy step is needed since nothing in `neode-ui/src` changes.

View File

@ -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)
})