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
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:
parent
3b3d22d0ce
commit
4d471759a7
@ -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 1–4 (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.
|
||||
|
||||
530
neode-ui/e2e/perf/keepalive-remount-probe.spec.ts
Normal file
530
neode-ui/e2e/perf/keepalive-remount-probe.spec.ts
Normal 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)
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user