Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit e16b389f04
2066 changed files with 472068 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Page } from '@playwright/test'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const APP_ID = process.env.ARCHY_APP_ID ?? 'lnd'
const APP_TITLE = process.env.ARCHY_APP_TITLE ?? APP_ID
const APP_CARD_TITLE = process.env.ARCHY_APP_CARD_TITLE ?? APP_TITLE
const EXPECTED_URL = process.env.ARCHY_EXPECTED_LAUNCH_URL
const EXPECTED_URL_PATTERN = process.env.ARCHY_EXPECTED_LAUNCH_URL_PATTERN
const EXPECTED_BODY_PATTERN = process.env.ARCHY_EXPECTED_BODY_PATTERN ?? 'Connect Your Wallet|lndconnect|REST|gRPC'
const EXPECTED_MODE = process.env.ARCHY_EXPECTED_LAUNCH_MODE ?? 'popup'
async function login(page: Page) {
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 })
}
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
await login(page)
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
const appCard = page.locator('[data-controller-container]', {
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
}).first()
await appCard.waitFor({ timeout: 30_000 })
const launchButton = appCard.locator('[data-controller-launch-btn], button:has-text("Launch")').first()
await launchButton.waitFor({ timeout: 20_000 })
if (EXPECTED_MODE === 'panel') {
await launchButton.click()
const expected = new URL(EXPECTED_URL!, baseURL)
const frameSelector = `iframe[src^="${expected.toString().replace(/\/$/, '')}"]`
await expect(page.locator(frameSelector).first()).toBeVisible({ timeout: 20_000 })
const frame = page.frameLocator(frameSelector).first()
await expect(frame.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 30_000 })
return
}
const popupPromise = context.waitForEvent('page', { timeout: 15_000 })
await launchButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded', { timeout: 20_000 })
assertLaunchUrl(popup.url(), baseURL)
await expect(popup.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 20_000 })
})
function assertLaunchUrl(actual: string, baseURL: string | undefined) {
if (EXPECTED_URL_PATTERN) {
expect(actual).toMatch(new RegExp(EXPECTED_URL_PATTERN))
} else {
const expected = new URL(EXPECTED_URL!, baseURL)
expect(actual).toBe(expected.toString())
}
}
+261
View File
@@ -0,0 +1,261 @@
/**
* End-to-end verification of the FIRST-VISIT cinematic:
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
* → Archipelago logo) → onboarding intro → login (video background, finale
* armed) → dashboard full reveal (background zoom + interface assembly +
* oomph) → welcome typing — and that a SECOND login stays deliberately
* low-key.
*
* Run against the local demo stack:
* DEMO=1 node mock-backend.js (port 5959)
* VITE_DEMO=1 npx vite --port 8100
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
*
* Audio can't be heard headless, so every HTMLMediaElement.play() and
* WebAudio oscillator start is recorded into window.__audioLog via an init
* script — the assertions check the right cues fired at the right phases.
*/
import { test, expect, type Page } from '@playwright/test'
// Real Chrome (new headless): the bundled headless-shell has no working video
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
// meaningless there. Requires Google Chrome installed.
test.use({ channel: 'chrome' })
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
declare global {
interface Window {
__audioLog: string[]
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
__revealSeen: { zoom?: boolean; glass?: boolean }
}
}
async function instrumentAudioAndVideo(page: Page) {
await page.addInitScript(() => {
window.__audioLog = []
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
// The dashboard reveal classes live only ~8s; a slow run can burn that
// window between two sequential expect() polls. Record their appearance
// the moment it happens instead, and let the test assert on the record.
window.__revealSeen = {}
new MutationObserver(() => {
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
const origPlay = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function (...args) {
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
if (this.tagName === 'VIDEO') {
const v = this as HTMLVideoElement
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
}
return origPlay.apply(this, args)
}
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
const OrigOsc = OscillatorNode.prototype.start
OscillatorNode.prototype.start = function (...args) {
window.__audioLog.push('osc-start')
return OrigOsc.apply(this, args)
}
})
}
async function freshVisit(page: Page) {
await page.goto(BASE + '/')
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
await page.goto(BASE + '/')
}
/** Walk the full splash from tap-to-start through completion (real time). */
async function runSplash(page: Page, { skip }: { skip: boolean }) {
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
// on click, so don't wait for post-click actionability)
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
// Phase 2: alien typing begins (first line types out)
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
.toBeVisible({ timeout: 10_000 })
if (skip) {
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
} else {
// Let all four lines type out for real (~20s)
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
}
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
// text is only on screen ~6s, so verify the phases via durable signals:
// the video's live health here, and the audio log (speech/song) after.
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
// Wait for actual smooth playback (cold-cache buffering right after mount
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
await page.waitForFunction(() => {
const v = document.querySelector('video')
return !!v && v.readyState >= 3 && v.currentTime > 0.3
}, { timeout: 20_000 })
const s1 = await page.evaluate(() => {
const v = document.querySelector('video')!
const q = v.getVideoPlaybackQuality?.()
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
})
await page.waitForTimeout(2_000)
const s2 = await page.evaluate(() => {
const v = document.querySelector('video')
if (!v) return null
const q = v.getVideoPlaybackQuality?.()
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
})
expect(s2).not.toBeNull()
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
// Steady-state frame drops over the sample window (startup catch-up excluded).
const dTotal = s2!.total - s1.total
const dDropped = s2!.dropped - s1.dropped
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
// Splash completes → demo routes to the onboarding intro
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
}
async function enterDemoAndLogin(page: Page) {
// The CTA unmounts mid-click when the router transitions away — dispatch
// once, swallow the detach retry, and trust the URL change instead.
const cta = page.getByRole('button', { name: /Enter the demo/ })
await cta.waitFor({ timeout: 15_000 })
await Promise.all([
page.waitForURL('**/login', { timeout: 15_000 }),
cta.click({ noWaitAfter: true }).catch(() => {}),
])
// Demo prefills the password; the finale flag must be armed at this point.
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
const loginBtn = page.getByRole('button', { name: /log ?in/i })
await loginBtn.waitFor({ timeout: 10_000 })
await Promise.all([
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
])
}
test.describe('first-visit cinematic', () => {
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
test.setTimeout(240_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: false })
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
const log = await page.evaluate(() => window.__audioLog.join('|'))
expect(log).toContain('welcome-noderunner.mp3')
expect(log).toContain('cosmic-updrift.mp3')
await enterDemoAndLogin(page)
// FULL first-entry reveal: big background zoom + glass assembly classes
// (recorded by the init-script observer the instant they appear — the
// classes only live ~8s and sequential polling can miss the window).
await expect
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
.toMatchObject({ zoom: true, glass: true })
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
// The reveal runs 8s, then the zoom layer class clears.
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
// The dashboard oomph is WebAudio oscillators — at least the login pop +
// oomph layers must have started after the splash's own sounds.
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
expect(oscCount).toBeGreaterThan(0)
})
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
test.setTimeout(180_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: true })
const log = await page.evaluate(() => window.__audioLog.join('|'))
expect(log).toContain('welcome-noderunner.mp3')
expect(log).toContain('cosmic-updrift.mp3')
await enterDemoAndLogin(page)
await expect
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
.toBe(true)
})
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
test.setTimeout(180_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: true })
await enterDemoAndLogin(page)
await expect
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
.toBe(true)
// End the session the way logout does (auth token only — the intro/login
// flags survive) and revisit login directly. An authenticated /login visit
// would just bounce back to the dashboard via the router guard.
await page.evaluate(() => localStorage.removeItem('neode-auth'))
// Direct /login navigation (no splash — not a root boot) and re-login.
await page.goto(BASE + '/login')
await page.locator('#login-password').waitFor({ timeout: 15_000 })
// First login happened → static rotated background, not the video.
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
await Promise.all([
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
])
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
// reset with the /login reload, so it would have caught even a transient
// reveal since then.
await page.waitForTimeout(1500)
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
})
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
test.setTimeout(120_000)
await freshVisit(page)
await runSplash(page, { skip: true })
await enterDemoAndLogin(page)
// Reload at root = a fresh boot → the splash must return even though
// neode_intro_seen is now set.
await page.goto(BASE + '/')
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
})
test('video is warmed before the splash needs it', async ({ page }) => {
await freshVisit(page)
// The warm-up is a detached <video preload=auto> kept on window (Chromium
// has no <link rel=preload as=video>). Give it a moment to buffer.
await expect
.poll(async () => page.evaluate(() => {
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
return w ? { src: w.src, readyState: w.readyState } : null
}), { timeout: 15_000 })
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
const ready = await page.evaluate(() =>
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
})
})
@@ -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
// a test node 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). a test node 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)
})
+387
View File
@@ -0,0 +1,387 @@
// measureSurface() — first-visit vs revisit timing, RPC request trace, and a
// remount probe for one SURFACES row (02-01-PLAN.md Task 1).
//
// Design notes (see surfaces.ts header for the navigation rationale):
// - Navigation between surfaces always happens via real UI clicks
// (RouterLink/button), never `page.goto()`, so a revisit exercises actual
// Vue Router client-side transitions rather than a full page reload.
// - The remount probe stamps `rootSelector`'s DOM node with a unique value
// right after first-visit paints, then reads it back after the away/back
// round-trip. A surviving value means the component instance was reused
// (no remount); a missing/changed value means it was destroyed and
// recreated.
// - RPC calls are traced via `page.on('request')`/`requestfinished`,
// filtered to the `/rpc/v1` endpoint the rpc-client posts to. Only method
// name + timing are recorded — no request/response bodies (T-02-06).
// - `maxConcurrentRpc` / `rpcWallClockMs` are derived from the recorded
// calls' [start, start+duration] intervals via a sweep-line, so a serial
// waterfall (maxConcurrentRpc === 1, 2+ calls) is distinguishable from an
// already-parallel fan-out without re-reading application source.
import type { Page, Request } from '@playwright/test'
import type { Surface } from './surfaces'
const RPC_PATH = '/rpc/v1'
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
const DEFAULT_NAV_TIMEOUT = 20_000
const DEFAULT_CONTENT_TIMEOUT = 20_000
export interface RpcCall {
method: string
/** Wall-clock offset (ms) from the start of the measured phase. */
startedAtMs: number
/** Request duration in ms (null if it never finished/failed to resolve timing). */
durationMs: number | null
}
export interface SurfaceSample {
firstVisitMs: number | null
revisitMs: number | null
firstVisitRpcCount: number | null
revisitRpcCount: number | null
revisitRpcCalls: RpcCall[]
maxConcurrentRpc: number | null
rpcWallClockMs: number | null
/** true = component instance was reused across the away/back round-trip
* (no remount); false = it was destroyed and recreated; null = not probed
* (e.g. the run errored before the probe could run). */
remounted: boolean | null
error: string | null
}
export interface SurfaceMeasurement {
id: string
label: string
path: string
kind: Surface['kind']
runs: number
samples: SurfaceSample[]
/** Median across successful samples (null if every sample errored). */
firstVisitMs: number | null
revisitMs: number | null
firstVisitRpcCount: number | null
revisitRpcCount: number | null
/** RPC call trace from the sample nearest the median revisitMs (or the
* last successful sample if no median could be computed). */
revisitRpcCalls: RpcCall[]
maxConcurrentRpc: number | null
rpcWallClockMs: number | null
/** Majority vote across samples that were actually probed. */
remounted: boolean | null
/** Set only when every sample for this surface errored. */
error: string | null
}
export interface MeasureOptions {
/** Number of first-visit/revisit round-trips to sample (default 3). */
runs?: number
navTimeoutMs?: number
contentTimeoutMs?: number
}
function median(values: Array<number | null>): number | null {
const nums = values.filter((v): v is number => v != null).sort((a, b) => a - b)
if (nums.length === 0) return null
const mid = Math.floor(nums.length / 2)
return nums.length % 2 === 0 ? (nums[mid - 1]! + nums[mid]!) / 2 : nums[mid]!
}
/** Sweep-line over [start, start+duration] intervals — max overlap count and
* total wall-clock span from first start to last end. */
function deriveConcurrency(calls: RpcCall[]): { maxConcurrentRpc: number | null; rpcWallClockMs: number | null } {
const timed = calls.filter((c) => c.durationMs != null)
if (timed.length === 0) return { maxConcurrentRpc: calls.length > 0 ? null : 0, rpcWallClockMs: calls.length > 0 ? null : 0 }
type Edge = { at: number; delta: number }
const edges: Edge[] = []
let minStart = Infinity
let maxEnd = -Infinity
for (const c of timed) {
const end = c.startedAtMs + (c.durationMs ?? 0)
edges.push({ at: c.startedAtMs, delta: 1 }, { at: end, delta: -1 })
minStart = Math.min(minStart, c.startedAtMs)
maxEnd = Math.max(maxEnd, end)
}
edges.sort((a, b) => a.at - b.at)
let running = 0
let max = 0
for (const e of edges) {
running += e.delta
if (running > max) max = running
}
return { maxConcurrentRpc: max, rpcWallClockMs: maxEnd - minStart }
}
interface RpcTracker {
calls: RpcCall[]
detach: () => void
}
function attachRpcTracker(page: Page, phaseStart: number): RpcTracker {
const calls: RpcCall[] = []
const pending = new Map<Request, { method: string; start: number }>()
const isRpcRequest = (req: Request) => req.method() === 'POST' && req.url().includes(RPC_PATH)
const onRequest = (req: Request) => {
if (!isRpcRequest(req)) return
let method = 'unknown'
try {
const body = req.postData()
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
} catch {
// Malformed/unreadable body — keep 'unknown', never persist the body itself.
}
pending.set(req, { method, start: Date.now() - phaseStart })
}
const onSettled = (req: Request) => {
const info = pending.get(req)
if (!info) return
pending.delete(req)
calls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - phaseStart - info.start })
}
page.on('request', onRequest)
page.on('requestfinished', onSettled)
page.on('requestfailed', onSettled)
return {
calls,
detach: () => {
page.off('request', onRequest)
page.off('requestfinished', onSettled)
page.off('requestfailed', onSettled)
},
}
}
/**
* Click with a dismiss-and-retry guard: a stray overlay (e.g. the Companion
* app's once-per-browser auto-show intro) can appear mid-attempt, after
* `dismissOverlays()` already ran but before Playwright's own actionability
* wait resolves. Splitting the timeout budget across a few short attempts,
* re-running `dismissOverlays()` between each, clears it reliably instead of
* burning the whole budget on a single blocked attempt.
*/
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
const attempts = 3
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
await dismissOverlays(page)
try {
await page.locator(selector).first().click({ timeout: perAttemptMs })
return
} catch (err) {
lastErr = err
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
for (const selector of steps) {
await clickWithGuard(page, selector, timeoutMs)
}
}
async function stampRoot(page: Page, rootSelector: string, mark: string): Promise<boolean> {
return page.evaluate(
({ sel, mark }) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) return false
el.dataset.perfProbe = mark
return true
},
{ sel: rootSelector, mark }
)
}
async function readRootProbe(page: Page, rootSelector: string): Promise<string | null> {
return page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
return el?.dataset.perfProbe ?? null
}, rootSelector)
}
/**
* Best-effort dismissal of a stray full-screen overlay (a filter/pairing/
* update modal opened by the surface under test, or left over from a prior
* one) before it blocks the next click. One surface's leftover UI must never
* cascade a click-intercept failure into every remaining surface in the run.
*/
async function dismissOverlays(page: Page): Promise<void> {
try {
await page.keyboard.press('Escape')
} catch {
// no-op — best-effort only
}
// Match any "Close"-flavoured aria-label (e.g. "Close companion modal"),
// not just an exact "Close" — modal close buttons across the app phrase
// this inconsistently.
const closeButtons = page.locator(
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
)
const count = await closeButtons.count().catch(() => 0)
if (count > 0) {
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
}
}
async function goHome(page: Page, timeoutMs: number): Promise<void> {
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — if a prior surface's
// run ended sitting on /dashboard/chat (e.g. chat's own revisit re-opened
// it as its last step, or a prior run errored out mid-chat), the sidebar is
// hidden and unclickable. Back out of chat first so the sidebar reappears
// before relying on it below.
if (new URL(page.url()).pathname === '/dashboard/chat') {
const closed = await page
.locator('.chat-close-btn')
.first()
.click({ timeout: 5_000 })
.then(() => true)
.catch(() => false)
// On real AIUI hardware the close pill can sit behind AIUI's own
// "connecting" overlay for longer than any reasonable click budget — if
// the close button itself is unreachable, this is a recovery path, not a
// measurement, so a hard reload to break out is preferable to leaving
// every remaining surface stuck behind a permanently hidden sidebar.
if (!closed) {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
}
}
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
}
async function runOnce(page: Page, surface: Surface, opts: Required<Pick<MeasureOptions, 'navTimeoutMs' | 'contentTimeoutMs'>>): Promise<SurfaceSample> {
const { navTimeoutMs, contentTimeoutMs } = opts
// Start every run from a known, stable point (dashboard home) so
// firstVisitMs measures "navigate from the dashboard home to the surface"
// exactly as specified, regardless of what the previous surface left us on.
await goHome(page, navTimeoutMs)
const t0 = Date.now()
const firstTracker = attachRpcTracker(page, t0)
await clickChain(page, surface.navSteps, navTimeoutMs)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
const firstVisitMs = Date.now() - t0
firstTracker.detach()
const firstVisitRpcCount = firstTracker.calls.length
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
const stamped = await stampRoot(page, surface.rootSelector, mark)
// Away step: for an in-page trigger (modal), close it; otherwise navigate
// to the neutral Settings tab via the sidebar (never back to this surface's
// own navSteps, so the round-trip is genuine).
//
// Deliberately NOT routed through clickWithGuard here: dismissOverlays()
// treats any "Close"-labelled button inside a dialog as a stray overlay to
// dismiss — which is exactly this element when closeSelector targets the
// surface's own dialog/panel, causing it to close a beat before this click
// runs and leaving the click with nothing to find.
if (surface.closeSelector) {
await page.locator(surface.closeSelector).first().click({ timeout: navTimeoutMs })
} else {
await clickWithGuard(page, NEUTRAL_SELECTOR, navTimeoutMs)
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: navTimeoutMs })
}
const t1 = Date.now()
const revisitTracker = attachRpcTracker(page, t1)
await clickChain(page, surface.navSteps, navTimeoutMs)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
const revisitMs = Date.now() - t1
revisitTracker.detach()
const revisitRpcCalls = revisitTracker.calls
const revisitRpcCount = revisitRpcCalls.length
const remounted = stamped ? (await readRootProbe(page, surface.rootSelector)) !== mark : null
const { maxConcurrentRpc, rpcWallClockMs } = deriveConcurrency(revisitRpcCalls)
return {
firstVisitMs,
revisitMs,
firstVisitRpcCount,
revisitRpcCount,
revisitRpcCalls,
maxConcurrentRpc,
rpcWallClockMs,
remounted,
error: null,
}
}
function errorSample(err: unknown): SurfaceSample {
return {
firstVisitMs: null,
revisitMs: null,
firstVisitRpcCount: null,
revisitRpcCount: null,
revisitRpcCalls: [],
maxConcurrentRpc: null,
rpcWallClockMs: null,
remounted: null,
error: err instanceof Error ? err.message : String(err),
}
}
export async function measureSurface(page: Page, surface: Surface, opts: MeasureOptions = {}): Promise<SurfaceMeasurement> {
const runs = opts.runs ?? 3
const navTimeoutMs = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT
const contentTimeoutMs = opts.contentTimeoutMs ?? DEFAULT_CONTENT_TIMEOUT
const samples: SurfaceSample[] = []
for (let i = 0; i < runs; i++) {
try {
samples.push(await runOnce(page, surface, { navTimeoutMs, contentTimeoutMs }))
} catch (err) {
samples.push(errorSample(err))
}
}
const successful = samples.filter((s) => s.error == null)
const firstVisitMs = median(successful.map((s) => s.firstVisitMs))
const revisitMs = median(successful.map((s) => s.revisitMs))
const firstVisitRpcCount = median(successful.map((s) => s.firstVisitRpcCount))
const revisitRpcCount = median(successful.map((s) => s.revisitRpcCount))
// Representative sample for the call trace / concurrency fields: the
// successful sample whose revisitMs is closest to the computed median
// (falls back to the last successful sample when no median exists).
let representative: SurfaceSample | null = null
if (successful.length > 0) {
if (revisitMs != null) {
representative = successful.reduce((best, s) =>
Math.abs((s.revisitMs ?? Infinity) - revisitMs) < Math.abs((best.revisitMs ?? Infinity) - revisitMs) ? s : best
)
} else {
representative = successful[successful.length - 1]!
}
}
const remountedVotes = successful.map((s) => s.remounted).filter((v): v is boolean => v != null)
const trueCount = remountedVotes.filter(Boolean).length
const remounted = remountedVotes.length === 0 ? null : trueCount >= remountedVotes.length - trueCount
return {
id: surface.id,
label: surface.label,
path: surface.path,
kind: surface.kind,
runs,
samples,
firstVisitMs,
revisitMs,
firstVisitRpcCount,
revisitRpcCount,
revisitRpcCalls: representative?.revisitRpcCalls ?? [],
maxConcurrentRpc: representative?.maxConcurrentRpc ?? null,
rpcWallClockMs: representative?.rpcWallClockMs ?? null,
remounted,
error: successful.length === 0 ? (samples[samples.length - 1]?.error ?? 'all runs failed') : null,
}
}
+420
View File
@@ -0,0 +1,420 @@
// profile-revisit.spec.ts — 02-11 gap-closure diagnostic (D-10: measure before
// fix). Standalone, additive script — does NOT edit surfaces.ts / measure.ts
// / surface-perf.spec.ts (the frozen 02-01 harness stays frozen).
//
// 02-VERIFICATION.md's gap 2 confirmed six surfaces regressed on revisit with
// flat-or-improved RPC counts — i.e. the cost is client-side render/reactivity,
// not network. This script captures a REAL CPU profile (CDP Profiler domain —
// the same sampling data Chrome DevTools' Performance panel visualizes as a
// flame chart) during exactly the harness's own measured window (click chain
// -> contentSelector visible) for each named surface's revisit, then
// aggregates self-time by function so the dominant cost can be named with
// profiling evidence instead of guessed from source reading alone.
//
// Usage:
// ARCHY_BASE_URL=http://a test node ARCHY_PASSWORD=*** \
// npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line
import { expect, test, type Page } from '@playwright/test'
import { SURFACES, type Surface } from './surfaces'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
const NAV_TIMEOUT = 20_000
const CONTENT_TIMEOUT = 20_000
// Every surface 02-VERIFICATION.md named as regressed, plus Fleet (the
// out-of-scope bonus 02-10 flagged as the most severe magnitude of the same
// mechanism) — all six of this gap plan's must-haves.
const TARGET_IDS = ['web5', 'server', 'discover', 'app-details', 'openwrt-gateway', 'fleet']
async function login(page: Page): Promise<void> {
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
.first()
.click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
async function dismissOverlays(page: Page): Promise<void> {
try {
await page.keyboard.press('Escape')
} catch {
/* best-effort */
}
const closeButtons = page.locator(
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
)
const count = await closeButtons.count().catch(() => 0)
if (count > 0) await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
// HealthNotifications.vue's dismiss button has no aria-label at all (a bare
// SVG X icon) — the aria-label selector above never matches it, and per
// 02-FINDINGS.md's Outstanding section its `.fixed.right-4.z-[200]` wrapper
// has no `pointer-events: none`, so a still-open toast can intercept a
// click meant for page content underneath it (e.g. a disk-usage warning
// sitting over the "OpenWrt Gateway" link). Dismiss any visible one.
const healthToastClose = page.locator('.fixed.right-4.z-\\[200\\] button')
const healthCount = await healthToastClose.count().catch(() => 0)
for (let i = 0; i < healthCount; i++) {
await healthToastClose.first().click({ timeout: 1_000 }).catch(() => {})
}
}
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
const attempts = 4
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
await dismissOverlays(page)
try {
await page.locator(selector).first().click({ timeout: perAttemptMs, force: i === attempts - 1 })
return
} catch (err) {
lastErr = err
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
for (const selector of steps) await clickWithGuard(page, selector, timeoutMs)
}
async function goHome(page: Page, timeoutMs: number): Promise<void> {
if (new URL(page.url()).pathname === '/dashboard/chat') {
const closed = await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).then(() => true).catch(() => false)
if (!closed) await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
}
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
}
interface CpuProfileNode {
id: number
callFrame: { functionName: string; url: string; lineNumber: number; columnNumber: number; scriptId: string }
hitCount?: number
children?: number[]
}
interface CpuProfile {
nodes: CpuProfileNode[]
startTime: number
endTime: number
samples?: number[]
timeDeltas?: number[]
}
// Production build has no sourcemaps deployed (confirmed: assets/*.js.map ->
// 404 on a test node), so minified function names inside vendor-*.js/
// index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME
// instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled
// together; index = app entry/shared code; per-route chunk name = that
// view's own lazy-loaded code) rather than by node_modules path, which only
// exists pre-build.
function categorize(url: string): string {
if (!url) return '(native/gc/idle — no JS frame)'
const file = url.split('/').pop() ?? url
if (/^vendor-/.test(file)) return 'assets/vendor-*.js (Vue/Pinia/vue-router runtime bundle)'
if (/^index-/.test(file)) return 'assets/index-*.js (app entry/shared chunk)'
if (/^Dashboard-/.test(file)) return 'assets/Dashboard-*.js (Dashboard/DashboardRouterView chunk)'
if (/^Fleet-/.test(file)) return 'assets/Fleet-*.js (Fleet.vue chunk)'
if (/^Web5-/i.test(file)) return 'assets/Web5-*.js chunk'
if (/^Server-/.test(file)) return 'assets/Server-*.js chunk'
if (/^Discover-/.test(file)) return 'assets/Discover-*.js chunk'
if (/^AppDetails-/.test(file)) return 'assets/AppDetails-*.js chunk'
if (/^OpenWrtGateway-/.test(file)) return 'assets/OpenWrtGateway-*.js chunk'
if (url.startsWith('assets/')) return `assets/${file} (other chunk)`
if (url.includes('node_modules')) return 'other node_modules (dev-only build)'
return '(native/gc/idle — no JS frame)'
}
function analyzeProfile(profile: CpuProfile, label: string): void {
const byId = new Map<number, CpuProfileNode>()
for (const n of profile.nodes) byId.set(n.id, n)
const selfTimeById = new Map<number, number>()
const samples = profile.samples ?? []
const timeDeltas = profile.timeDeltas ?? []
let total = 0
for (let i = 0; i < samples.length; i++) {
const dt = timeDeltas[i] ?? 0
total += dt
const id = samples[i]!
selfTimeById.set(id, (selfTimeById.get(id) ?? 0) + dt)
}
const totalMs = total / 1000
const windowMs = (profile.endTime - profile.startTime) / 1000
// Aggregate by function identity (name@file:line) and by coarse category.
const byFunction = new Map<string, number>()
const byCategory = new Map<string, number>()
for (const [id, us] of selfTimeById) {
const node = byId.get(id)
if (!node) continue
const fnName = node.callFrame.functionName || '(anonymous)'
const file = node.callFrame.url ? node.callFrame.url.split('/').slice(-2).join('/') : '(native)'
const key = `${fnName} @ ${file}:${node.callFrame.lineNumber}`
byFunction.set(key, (byFunction.get(key) ?? 0) + us)
const cat = categorize(node.callFrame.url)
byCategory.set(cat, (byCategory.get(cat) ?? 0) + us)
}
console.log(`\n===== CPU PROFILE: ${label} =====`)
console.log(`Profiled window (Profiler start->stop): ${windowMs.toFixed(1)}ms`)
console.log(`Total sampled self-time: ${totalMs.toFixed(1)}ms (${samples.length} samples)`)
console.log(`--- By category (self time) ---`)
const catSorted = Array.from(byCategory.entries()).sort((a, b) => b[1] - a[1])
for (const [cat, us] of catSorted) {
const ms = us / 1000
console.log(` ${ms.toFixed(1)}ms (${((us / total) * 100).toFixed(1)}%) ${cat}`)
}
console.log(`--- Top 20 functions (self time) ---`)
const fnSorted = Array.from(byFunction.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
for (const [key, us] of fnSorted) {
const ms = us / 1000
console.log(` ${ms.toFixed(2)}ms (${((us / total) * 100).toFixed(1)}%) ${key}`)
}
}
test.describe.configure({ mode: 'serial' })
test('profile revisit CPU cost for named regressed surfaces', async ({ page }) => {
test.setTimeout(15 * 60 * 1000)
await login(page)
for (const id of TARGET_IDS) {
const surface = SURFACES.find((s) => s.id === id) as Surface
expect(surface, `surface ${id} must exist in SURFACES`).toBeTruthy()
await goHome(page, NAV_TIMEOUT)
// First visit — warm the cache (matches the harness's own first-visit/
// revisit structure) — not profiled.
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
// Away — to the neutral Settings tab (or close, for an in-page trigger).
if (surface.closeSelector) {
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
} else {
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
}
// Let the away-transition settle before starting the profiler so it
// captures only the revisit window, not Settings' own leave-transition.
await page.waitForTimeout(600)
// Instrument window.setTimeout/requestAnimationFrame for the profiled
// window only, so a large "idle" share in the CPU profile can be
// attributed to a concrete scheduled delay (app timer) vs. a chain of
// animation frames (CSS-transition-bound) vs. neither (network wait).
await page.evaluate(() => {
const w = window as unknown as {
__timerLog: Array<{ type: string; delay?: number; at: number }>
__origSetTimeout: typeof setTimeout
__origRaf: typeof requestAnimationFrame
}
w.__timerLog = []
w.__origSetTimeout = window.setTimeout.bind(window)
w.__origRaf = window.requestAnimationFrame.bind(window)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).setTimeout = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
w.__timerLog.push({ type: 'setTimeout', delay, at: performance.now() })
return w.__origSetTimeout(fn as any, delay, ...args) // eslint-disable-line @typescript-eslint/no-explicit-any
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
w.__timerLog.push({ type: 'raf', at: performance.now() })
return w.__origRaf(cb)
}
})
// Wall-clock RPC start/finish (matches measure.ts's own attachRpcTracker
// convention: Date.now() offsets, method name only, no bodies — T-02-06).
const rpcCalls: Array<{ method: string; startedAtMs: number; durationMs: number | null }> = []
const pendingRpc = new Map<import('@playwright/test').Request, { method: string; start: number }>()
let tRpc0 = 0
const isRpc = (req: import('@playwright/test').Request) => req.method() === 'POST' && req.url().includes('/rpc/v1')
const onReq = (req: import('@playwright/test').Request) => {
if (!isRpc(req)) return
let method = 'unknown'
try {
const body = req.postData()
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
} catch {
/* keep 'unknown' */
}
pendingRpc.set(req, { method, start: Date.now() - tRpc0 })
}
const onSettled = (req: import('@playwright/test').Request) => {
const info = pendingRpc.get(req)
if (!info) return
pendingRpc.delete(req)
rpcCalls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - tRpc0 - info.start })
}
page.on('request', onReq)
page.on('requestfinished', onSettled)
page.on('requestfailed', onSettled)
// Capture every CSS transition/animation start+end on the document during
// the window — decisive evidence for/against "a long CSS transition is
// what the human waits through" (Playwright's own 'visible' check does
// NOT wait for transitions/opacity, only a non-empty bounding box + not
// visibility:hidden, so a slow transition would NOT show up as CPU cost
// or as a blocked contentSelector — it would show up here, and would
// explain a "feels slow" gap between first-paint and contentSelector).
await page.evaluate(() => {
const w = window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }
w.__animLog = []
const describe = (el: EventTarget | null): string => {
const e = el as HTMLElement | null
if (!e || !e.tagName) return '(unknown)'
const cls = (e.className && typeof e.className === 'string') ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
return `${e.tagName.toLowerCase()}${cls}`
}
const log = (type: string) => (ev: Event) => {
const te = ev as TransitionEvent | AnimationEvent
w.__animLog.push({
type,
name: (te as TransitionEvent).propertyName ?? (te as AnimationEvent).animationName ?? '',
target: describe(ev.target),
elapsedMs: Math.round((te.elapsedTime ?? 0) * 1000),
at: performance.now(),
})
}
document.addEventListener('transitionrun', log('transitionrun'), true)
document.addEventListener('transitionend', log('transitionend'), true)
document.addEventListener('transitioncancel', log('transitioncancel'), true)
document.addEventListener('animationstart', log('animationstart'), true)
document.addEventListener('animationend', log('animationend'), true)
})
const client = await page.context().newCDPSession(page)
await client.send('Profiler.enable')
await client.send('Profiler.setSamplingInterval', { interval: 100 })
await client.send('Profiler.start')
// Raw Chrome trace events (the SAME data DevTools' Performance panel
// renders as a flame chart / summary tab) — categorizes rendering work
// (Layout, RecalculateStyles, Paint, CompositeLayers, RunTask, TimerFire,
// FireAnimationFrame, ...) that a bare JS CPU profile only sees as
// "(program)"/"(idle)" because layout/paint/compositing run on the same
// renderer main thread but outside any JS call frame.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const traceEvents: any[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client.on('Tracing.dataCollected', (data: any) => { traceEvents.push(...(data.value ?? [])) })
const tracingComplete = new Promise<void>((resolve) => client.once('Tracing.tracingComplete', () => resolve()))
await client.send('Tracing.start', {
categories: 'disabled-by-default-devtools.timeline,devtools.timeline,toplevel,v8,blink.user_timing',
transferMode: 'ReportEvents',
})
// First-paint probe, independent of Playwright's own contentSelector
// wait: records performance.now() the moment contentSelector FIRST gets
// a non-empty bounding box, polled every animation frame (not gated by
// Playwright's stricter "visible" actionability rules) — so first-paint
// and content-visible can be reported as two distinct numbers per the
// gap plan's explicit ask.
const pageT0 = await page.evaluate((sel) => {
const w = window as unknown as { __firstPaintAt: number | null }
w.__firstPaintAt = null
function poll() {
if (w.__firstPaintAt == null) {
const el = document.querySelector(sel)
if (el) {
const r = el.getBoundingClientRect()
if (r.width > 0 && r.height > 0) {
w.__firstPaintAt = performance.now()
return
}
}
requestAnimationFrame(poll)
}
}
requestAnimationFrame(poll)
return performance.now()
}, surface.contentSelector)
const t0 = Date.now()
tRpc0 = t0
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
const wallMs = Date.now() - t0
const firstPaintAt = await page.evaluate(() => (window as unknown as { __firstPaintAt: number | null }).__firstPaintAt)
const firstPaintMs = firstPaintAt != null ? Math.round(firstPaintAt - pageT0) : null
const { profile } = await client.send('Profiler.stop')
await client.send('Profiler.disable')
await client.send('Tracing.end')
await tracingComplete
await client.detach().catch(() => {})
page.off('request', onReq)
page.off('requestfinished', onSettled)
page.off('requestfailed', onSettled)
// Find the renderer main-thread tid (thread_name metadata event) so the
// breakdown below isn't polluted by compositor/IO/GPU-process threads.
const mainThreadMeta = traceEvents.find(
(e) => e.ph === 'M' && e.name === 'thread_name' && (e.args?.name === 'CrRendererMain')
)
const mainTid = mainThreadMeta?.tid
const durByName = new Map<string, number>()
for (const e of traceEvents) {
if (mainTid != null && e.tid !== mainTid) continue
if (e.ph !== 'X') continue // complete events only (have a real duration)
if (typeof e.dur !== 'number') continue
durByName.set(e.name, (durByName.get(e.name) ?? 0) + e.dur)
}
console.log(`--- Chrome trace event self/total time by name (main thread, category=devtools.timeline; NOTE: 'X' events can nest, so this is TOTAL not self time — use for relative magnitude, not a sum-to-100% budget) ---`)
const traceSorted = Array.from(durByName.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
for (const [name, us] of traceSorted) {
console.log(` ${(us / 1000).toFixed(1)}ms ${name}`)
}
const timerLog = await page.evaluate(() => {
const w = window as unknown as {
__timerLog: Array<{ type: string; delay?: number; at: number }>
__origSetTimeout: typeof setTimeout
__origRaf: typeof requestAnimationFrame
}
const log = w.__timerLog ?? []
window.setTimeout = w.__origSetTimeout
window.requestAnimationFrame = w.__origRaf
return log
})
const animLog = await page.evaluate(() => (window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }).__animLog ?? [])
console.log(`\n>>> ${surface.label} (${surface.id}) revisit wall-clock: ${wallMs}ms | first-paint: ${firstPaintMs}ms (contentSelector's first non-empty bounding box, unrelated to Playwright's own stricter 'visible' check)`)
analyzeProfile(profile as CpuProfile, `${surface.label} (${surface.id})`)
const setTimeoutEntries = timerLog.filter((e) => e.type === 'setTimeout')
const rafCount = timerLog.filter((e) => e.type === 'raf').length
console.log(`--- Timers scheduled during the revisit window (app-code setTimeout/rAF calls) ---`)
console.log(` setTimeout calls: ${setTimeoutEntries.length}${setTimeoutEntries.length ? ' — delays(ms): ' + setTimeoutEntries.map((e) => e.delay ?? 0).sort((a, b) => a - b).join(',') : ''}`)
console.log(` requestAnimationFrame calls: ${rafCount}`)
console.log(`--- CSS transition/animation events during the window (relative to window start, pageT0) ---`)
if (animLog.length === 0) {
console.log(' (none observed)')
} else {
for (const e of animLog) {
console.log(` +${Math.round(e.at - pageT0)}ms ${e.type} name="${e.name}" target=${e.target} elapsed=${e.elapsedMs}ms`)
}
}
console.log(`--- RPC calls during the revisit window (wall-clock ms from window start) ---`)
for (const c of rpcCalls) {
console.log(` ${c.method}: start=+${c.startedAtMs}ms duration=${c.durationMs}ms`)
}
console.log(` total RPC calls: ${rpcCalls.length}, wall-clock window: ${wallMs}ms`)
}
})
+105
View File
@@ -0,0 +1,105 @@
// Re-runnable surface-perf harness (02-01-PLAN.md Task 1). Logs in using the
// exact flow from app-launch.spec.ts, walks every SURFACES row via
// measureSurface(), and writes the full result array + a run header to
// ARCHY_PERF_OUT (defaulting to e2e/test-results/surface-perf.json).
//
// Redaction is structural, not a cleanup pass: measure.ts's RpcTracker only
// ever records method name + timing (T-02-06) — request/response bodies,
// page text and screenshots are never captured into the artifact.
import { execSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { expect, test, type Page } from '@playwright/test'
import { measureSurface, type SurfaceMeasurement } from './measure'
import { SURFACES } from './surfaces'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const RUNS = process.env.ARCHY_PERF_RUNS ? Number(process.env.ARCHY_PERF_RUNS) : 3
const OUT_PATH = resolve(process.cwd(), process.env.ARCHY_PERF_OUT ?? 'e2e/test-results/surface-perf.json')
async function login(page: Page): Promise<void> {
// Mirrors e2e/app-launch.spec.ts's login() verbatim — do not invent a
// second auth path.
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
.first()
.click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
function currentCommit(): string {
try {
// `__dirname` is unavailable under this package's `"type": "module"` ESM
// runtime (Playwright's own transform swallows the ReferenceError into
// the catch below, silently yielding 'unknown') — use `process.cwd()`
// instead, which Playwright always sets to the project root it was
// invoked from.
return execSync('git rev-parse --short HEAD', { cwd: process.cwd() }).toString().trim()
} catch {
return 'unknown'
}
}
test('surface-perf: measure every D-09 surface and write the baseline artifact', async ({ page, baseURL }) => {
test.setTimeout(20 * 60 * 1000) // 15 surfaces x 3 runs x network round-trips can run long on a real node
await login(page)
const results: SurfaceMeasurement[] = []
const skipped: string[] = []
for (const surface of SURFACES) {
try {
const measurement = await measureSurface(page, surface, { runs: RUNS })
results.push(measurement)
if (measurement.error) skipped.push(`${surface.id}: ${measurement.error}`)
} catch (err) {
// A surface that throws outside measureSurface's own per-run try/catch
// (e.g. login state got corrupted) is still recorded, never dropped —
// an unmeasured surface must never silently disappear from the array.
results.push({
id: surface.id,
label: surface.label,
path: surface.path,
kind: surface.kind,
runs: RUNS,
samples: [],
firstVisitMs: null,
revisitMs: null,
firstVisitRpcCount: null,
revisitRpcCount: null,
revisitRpcCalls: [],
maxConcurrentRpc: null,
rpcWallClockMs: null,
remounted: null,
error: err instanceof Error ? err.message : String(err),
})
skipped.push(`${surface.id}: ${err instanceof Error ? err.message : String(err)}`)
}
}
const header = {
baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
takenAt: new Date().toISOString(),
commit: currentCommit(),
runs: RUNS,
notes: skipped.length > 0 ? `Skipped/errored surfaces: ${skipped.join('; ')}` : 'All surfaces measured cleanly.',
}
const artifact = { ...header, results }
mkdirSync(dirname(OUT_PATH), { recursive: true })
writeFileSync(OUT_PATH, JSON.stringify(artifact, null, 2))
expect(results.length).toBe(SURFACES.length)
})
+213
View File
@@ -0,0 +1,213 @@
// SURFACES table for the Phase 02 UI-performance profiling harness (02-01-PLAN.md,
// Task 1). One row per D-09 surface. Every `path` here must exist in
// `neode-ui/src/router/index.ts` (verified by acceptance criteria).
//
// `navSteps` are ordered, real UI-click selectors — clicking an <a>/<RouterLink>
// or a button that calls `router.push()` triggers genuine client-side SPA
// navigation (no full page reload), which is what "remount storm" measurement
// requires. `page.goto()` is deliberately NOT used to move between dashboard
// surfaces — it would force a full reload every time and always show
// remounted=true regardless of whether KeepAlive would have helped.
//
// `contentSelector` is chosen to be present only once real content has
// painted (never a loading skeleton/spinner). `rootSelector` is the stable
// outermost element of the mounted view, used by the remount probe in
// measure.ts. `.view-container` is a class Dashboard.vue's nested
// `<router-view>` merges onto every non-chat/non-mesh view's root element via
// Vue's automatic fallthrough-attribute merging (verified: `grep -rn
// "view-container" src` shows it added only by Dashboard.vue's default
// branch) — so it works as a uniform root selector across most surfaces.
// Mesh and Chat take Dashboard.vue's other template branch (no class
// fallthrough) so they use their own static root class instead.
export type SurfaceKind = 'main-tab' | 'secondary'
export interface Surface {
id: string
label: string
/** Route path, must exist in router/index.ts. */
path: string
kind: SurfaceKind
/** A selector present only once real content has painted (not a skeleton/spinner). */
contentSelector: string
/** The stable outermost element of the view, used for the remount probe. */
rootSelector: string
/**
* Ordered selectors clicked (via real UI interaction) to reach/open this
* surface. Every step is resolved with `.first()` and clicked; the chain
* is re-run unchanged for the revisit measurement.
*/
navSteps: string[]
/**
* When set, this surface is an in-page trigger (e.g. a modal) rather than
* a distinct navigable route (D-09's "Wallet / send flows" — no Wallet.vue
* exists; the real surface is the Send button on the Home dashboard wallet
* card, opening SendBitcoinModal). Revisit is measured by clicking this to
* close, then re-running the last `navSteps` entry to reopen — "open to
* content" rather than "navigate to content" per the plan's fallback rule.
*/
closeSelector?: string
}
const SIDEBAR = '[data-controller-zone="sidebar"]'
export const SURFACES: Surface[] = [
{
id: 'home',
label: 'Home (wallet figures)',
path: '/dashboard',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.home-card',
navSteps: [`${SIDEBAR} a[href="/dashboard"]`],
},
{
id: 'apps',
label: 'Apps (My Apps)',
path: '/dashboard/apps',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.apps-card-grid-desktop [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`],
},
{
id: 'marketplace',
label: 'Marketplace (App Store)',
path: '/dashboard/marketplace',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.marketplace-container [data-controller-container]',
// Marketplace has no sidebar entry — reached via Home's "Browse Store" link.
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'a:has-text("Browse Store")'],
},
{
id: 'discover',
label: 'Discover (App Store tab)',
path: '/dashboard/discover',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.discover-container [data-controller-container]',
// Discover has no sidebar entry — reached via the "App Store" tab inside Apps.
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-view a:has-text("App Store")'],
},
{
id: 'cloud',
label: 'Cloud / Files',
path: '/dashboard/cloud',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.apps-view [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`],
},
{
id: 'mesh',
label: 'Mesh',
path: '/dashboard/mesh',
kind: 'main-tab',
rootSelector: '.mesh-view',
contentSelector: '.mesh-status-grid',
navSteps: [`${SIDEBAR} a[href="/dashboard/mesh"]`],
},
{
id: 'server',
label: 'Server (Network)',
path: '/dashboard/server',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.view-container [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`],
},
{
id: 'web5',
label: 'Web5',
path: '/dashboard/web5',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.view-container [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`],
},
{
id: 'fleet',
label: 'Fleet',
path: '/dashboard/fleet',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.view-container [data-controller-container]',
// Fleet's sidebar entry is hidden for beta — reached via Web5's Federation
// card link. Web5Federation.vue renders TWO "Fleet" links: a
// `.web5-card-actions-top` one that CSS permanently hides
// (`display: none` — the "compact header variants are permanently
// retired" rule in style.css) and the real, visible one inside
// `.web5-card-actions-bottom-grid`. Scope to the latter so `.first()`
// doesn't land on the hidden copy.
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`, '.web5-card-actions-bottom-grid a:has-text("Fleet")'],
},
{
id: 'chat',
label: 'Chat (AIUI)',
path: '/dashboard/chat',
kind: 'main-tab',
rootSelector: '.chat-fullscreen',
contentSelector: '.chat-iframe, .chat-placeholder',
navSteps: [`${SIDEBAR} button:has-text("AIUI")`],
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — the sidebar (and
// therefore the neutral Settings link) is not visible while on Chat, so
// the normal "away via sidebar" step is impossible here. Chat's own close
// button calls closeChat() (router.back(), landing on wherever we came
// from — Home, per navSteps below), which is the surface's real "away".
closeSelector: '.chat-close-btn',
},
{
id: 'app-details',
label: 'AppDetails (secondary)',
path: '/dashboard/apps/:id',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.app-details-container h1',
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-card-grid-desktop [data-controller-container]'],
},
{
id: 'marketplace-app-details',
label: 'MarketplaceAppDetails (secondary)',
path: '/dashboard/marketplace/:id',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.app-details-container h1',
navSteps: [
`${SIDEBAR} a[href="/dashboard"]`,
'a:has-text("Browse Store")',
'.marketplace-container [data-controller-container]',
],
},
{
id: 'cloud-folder',
label: 'CloudFolder (secondary)',
path: '/dashboard/cloud/:folderId',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.cloud-folder-container h1',
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`, '.apps-view [data-controller-container]'],
},
{
id: 'openwrt-gateway',
label: 'OpenWrtGateway (secondary)',
path: '/dashboard/server/openwrt',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: 'h1:has-text("OpenWrt Gateway")',
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`, 'a:has-text("OpenWrt Gateway")'],
},
{
id: 'wallet-send',
label: 'Wallet / send flow (Home wallet card, SendBitcoinModal)',
// Not a route — RESEARCH.md found no Wallet.vue; the real surface is a
// modal opened from the Home wallet card. Path recorded for reference
// only (the page the trigger lives on).
path: '/dashboard',
kind: 'secondary',
rootSelector: '[role="dialog"]',
contentSelector: 'h3:has-text("Send Bitcoin")',
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'button:has-text("Send")'],
closeSelector: '[role="dialog"] button[aria-label="Close"]',
},
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+134
View File
@@ -0,0 +1,134 @@
import { test, type Page } from '@playwright/test'
const SCREENSHOT_DIR = './e2e/screenshots'
const PASSWORD = 'password123'
/** Set localStorage values to skip splash screen and onboarding */
async function skipSplashAndOnboarding(page: Page) {
await page.goto('/login')
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
}
async function login(page: Page) {
await skipSplashAndOnboarding(page)
await page.goto('/login')
await page.waitForLoadState('networkidle')
// Wait for the password input to appear (server health check may delay it)
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
// Click the login/submit button
const submitBtn = page
.locator(
'button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]',
)
.first()
await submitBtn.click()
// Wait for navigation to dashboard
await page.waitForURL('**/dashboard**', { timeout: 15_000 })
await page.waitForLoadState('networkidle')
// Wait for home page content to confirm dashboard is loaded
await page.locator('text=Welcome Noderunner').waitFor({ timeout: 10_000 })
await page.waitForTimeout(1500)
}
/** Navigate to a dashboard child route via sidebar link click */
async function navigateTo(page: Page, path: string, waitForText: string) {
// Use in-page navigation to avoid full SPA reload
await page.evaluate((p) => {
window.history.pushState({}, '', p)
window.dispatchEvent(new PopStateEvent('popstate'))
}, path)
// Wait for the page-specific content to appear
await page.locator(`text=${waitForText}`).first().waitFor({ timeout: 10_000 })
// Let content settle after route change
await page.waitForTimeout(800)
}
async function screenshot(page: Page, name: string) {
// Wait for any animations to settle
await page.waitForTimeout(1000)
await page.screenshot({
path: `${SCREENSHOT_DIR}/${name}.png`,
fullPage: true,
})
}
test.describe('Visual Regression — Public Pages', () => {
test('login page', async ({ page }) => {
await skipSplashAndOnboarding(page)
await page.goto('/login')
await page.waitForLoadState('networkidle')
// Wait for server health check and form to become active
await page.locator('input[type="password"]').first().waitFor({ timeout: 15_000 })
await page.waitForTimeout(1000)
await screenshot(page, '01-login')
})
})
test.describe('Visual Regression — Dashboard Pages', () => {
test.beforeEach(async ({ page }) => {
await login(page)
})
test('home / dashboard', async ({ page }) => {
// Already on home after login
await screenshot(page, '02-dashboard-home')
})
test('apps list', async ({ page }) => {
await navigateTo(page, '/dashboard/apps', 'My Apps')
await screenshot(page, '03-apps-list')
})
test('marketplace', async ({ page }) => {
await navigateTo(page, '/dashboard/marketplace', 'App Store')
await screenshot(page, '04-marketplace')
})
test('cloud storage', async ({ page }) => {
await navigateTo(page, '/dashboard/cloud', 'Cloud')
await screenshot(page, '05-cloud')
})
test('server', async ({ page }) => {
await navigateTo(page, '/dashboard/server', 'Network')
await screenshot(page, '06-server')
})
test('web5', async ({ page }) => {
await navigateTo(page, '/dashboard/web5', 'Web5')
await screenshot(page, '07-web5')
})
test('settings', async ({ page }) => {
await navigateTo(page, '/dashboard/settings', 'Settings')
await screenshot(page, '08-settings')
})
test('chat', async ({ page }) => {
await navigateTo(page, '/dashboard/chat', 'AI Assistant')
await screenshot(page, '09-chat')
})
test('federation', async ({ page }) => {
await navigateTo(page, '/dashboard/server/federation', 'Federation')
await screenshot(page, '10-federation')
})
test('credentials', async ({ page }) => {
await navigateTo(page, '/dashboard/web5/credentials', 'Credentials')
await screenshot(page, '11-credentials')
})
test('system update', async ({ page }) => {
await navigateTo(page, '/dashboard/settings/update', 'System Update')
await screenshot(page, '12-system-update')
})
})