diff --git a/.planning/phases/02-ui-performance/02-FINDINGS.md b/.planning/phases/02-ui-performance/02-FINDINGS.md index adc32d6d..ac1fb4e9 100644 --- a/.planning/phases/02-ui-performance/02-FINDINGS.md +++ b/.planning/phases/02-ui-performance/02-FINDINGS.md @@ -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:` 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. diff --git a/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts b/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts new file mode 100644 index 00000000..1a0b3624 --- /dev/null +++ b/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts @@ -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([ + '/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 { + // 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 { + 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 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + for (const selector of steps) { + await clickWithGuard(page, selector, NAV_TIMEOUT) + } +} + +async function roundTrip(page: Page, surface: Surface): Promise { + 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() + 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) +})