Archipelago — open-source initial import
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// archi-dev-box build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS,
|
||||
// performs a visit -> away (to the neutral /dashboard/settings tab) ->
|
||||
// return round trip, reporting whether the component instance survived.
|
||||
//
|
||||
// Deliberately does NOT edit measure.ts / surfaces.ts / surface-perf.spec.ts
|
||||
// — the 02-01 harness stays frozen so 02-10 can re-run it unmodified. This
|
||||
// spec reuses SURFACES' navSteps/contentSelector/rootSelector (read-only
|
||||
// import) as the click recipe to reach each tab, but implements its own
|
||||
// corrected stamp/read method plus three instruments the ad-hoc 02-08 probe
|
||||
// did not have:
|
||||
//
|
||||
// 1. A monotonic per-mount signal written by the page itself, independent
|
||||
// of the DOM-element dataset stamp: Vue unconditionally attaches
|
||||
// `el.__vueParentComponent` to every mounted root element (confirmed
|
||||
// by reading node_modules/@vue/runtime-core's `mountElement`, not
|
||||
// gated behind a dev-only flag), whose `.uid` is a per-instance
|
||||
// monotonic counter. Reading this before/after the round trip gives an
|
||||
// INSTANCE-identity signal that can disagree with the dataset-mark's
|
||||
// ELEMENT-identity signal if the probe's selector picks a different
|
||||
// cached instance than the one actually under test (suspect 1).
|
||||
// 2. `page.on('pageerror')` / `page.on('console')` captured for the whole
|
||||
// round trip and printed — a Server-specific runtime error during
|
||||
// activation/deactivation (suspect 2) would surface here even though
|
||||
// it might not throw synchronously enough to fail the Playwright step
|
||||
// itself.
|
||||
// 3. After each hop: `document.querySelectorAll('.view-container').length`
|
||||
// and `location.pathname`, so cache population and the actual route
|
||||
// path are visible in the transcript (suspects 3 and 4).
|
||||
//
|
||||
// Corrected remount method (documented in 02-FINDINGS.md's ## Results
|
||||
// preamble): stamp/read the `.view-container`-class ANCESTOR (or the
|
||||
// surface's own `rootSelector`, for the two surfaces — Mesh, Chat — that
|
||||
// don't use `.view-container`) of the surface's own VISIBLE contentSelector
|
||||
// match, using `getBoundingClientRect`/`offsetParent` to exclude KeepAlive's
|
||||
// inactive cached instances — never `document.querySelector`'s first DOM
|
||||
// match, which can silently pick a different cached instance once multiple
|
||||
// `.view-container`s coexist in the document at once.
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { SURFACES, type Surface } from './surfaces'
|
||||
// Re-declared rather than imported from '@/views/dashboard/keepAliveRoutes':
|
||||
// the e2e package's tsconfig (see tsconfig.app.json's `include`) does not
|
||||
// cover `e2e/**`, so the `@/*` path alias used across neode-ui/src is not
|
||||
// guaranteed to resolve under Playwright's own TS transform. surfaces.ts and
|
||||
// measure.ts already avoid the alias for the same reason (no `@/` import in
|
||||
// either file) — this list is the exact literal from keepAliveRoutes.ts
|
||||
// (TAB_ORDER minus withheld `/dashboard/settings`, plus `/dashboard/discover`).
|
||||
const KEEP_ALIVE_PATHS = new Set<string>([
|
||||
'/dashboard',
|
||||
'/dashboard/apps',
|
||||
'/dashboard/marketplace',
|
||||
'/dashboard/cloud',
|
||||
'/dashboard/mesh',
|
||||
'/dashboard/server',
|
||||
'/dashboard/web5',
|
||||
'/dashboard/fleet',
|
||||
'/dashboard/chat',
|
||||
'/dashboard/discover',
|
||||
])
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const NAV_TIMEOUT = 20_000
|
||||
const CONTENT_TIMEOUT = 20_000
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
// Mirrors surface-perf.spec.ts's login() (itself mirroring app-launch.spec.ts)
|
||||
// verbatim — do not invent a second auth path.
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page
|
||||
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
interface HopSnapshot {
|
||||
viewContainerCount: number
|
||||
pathname: string
|
||||
}
|
||||
|
||||
async function snapshotHop(page: Page): Promise<HopSnapshot> {
|
||||
return page.evaluate(() => ({
|
||||
viewContainerCount: document.querySelectorAll('.view-container').length,
|
||||
pathname: location.pathname,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the DOM has stopped churning: the raw element count for
|
||||
* `contentSelector` (NOT filtered by visibility — some contentSelectors,
|
||||
* e.g. `.home-card`, legitimately match several sibling cards at once, so
|
||||
* "exactly 1" is the wrong invariant) reads identically across 5 consecutive
|
||||
* 100ms-spaced polls.
|
||||
*
|
||||
* Discovered mid-investigation (not hypothesized up front): `/dashboard/settings`
|
||||
* — the away tab EVERY round trip in this probe (and in measure.ts's own
|
||||
* `NEUTRAL_SELECTOR` convention) uses — renders `AccountInfoSection.vue` and
|
||||
* `KioskDisplaySection.vue` unconditionally (Settings.vue has no tabs/
|
||||
* accordion gating), and BOTH carry `data-controller-container`. Settings'
|
||||
* own root ALSO gets the `view-container` fallthrough class (every non-full-
|
||||
* bleed route does). That means Settings' OWN content matches the exact
|
||||
* generic `.view-container [data-controller-container]` selector Server,
|
||||
* Web5 and Fleet all use — and Settings' leave-transition keeps its DOM
|
||||
* genuinely present (in the document, still counted by querySelectorAll)
|
||||
* for the transition's full duration while the RETURN target's enter-
|
||||
* transition is already progressing. A stamp/read taken during that overlap
|
||||
* window can silently pick the still-present-but-leaving Settings element
|
||||
* instead of the actual target — a false "remounted" verdict that has
|
||||
* nothing to do with KeepAlive at all. Waiting for the raw count to settle
|
||||
* lets the leave-transition finish (Vue's <Transition> removes the leaving
|
||||
* element from the DOM once its leave hook completes) before this probe
|
||||
* stamps or reads anything.
|
||||
*/
|
||||
async function waitForDomSettled(page: Page, contentSelector: string, timeoutMs: number): Promise<void> {
|
||||
await page.evaluate((sel) => {
|
||||
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
|
||||
w.__probeStableCount = 0
|
||||
w.__probeLastLen = document.querySelectorAll(sel).length
|
||||
}, contentSelector)
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
|
||||
const len = document.querySelectorAll(sel).length
|
||||
if (w.__probeLastLen === len) {
|
||||
w.__probeStableCount = (w.__probeStableCount ?? 0) + 1
|
||||
} else {
|
||||
w.__probeStableCount = 0
|
||||
}
|
||||
w.__probeLastLen = len
|
||||
return (w.__probeStableCount ?? 0) >= 5
|
||||
},
|
||||
contentSelector,
|
||||
{ timeout: timeoutMs, polling: 100 }
|
||||
)
|
||||
}
|
||||
|
||||
interface ProbeReading {
|
||||
ok: boolean
|
||||
uid: number | null
|
||||
typeName: string | null
|
||||
}
|
||||
|
||||
/** Locate the VISIBLE contentSelector match's rootSelector ancestor (or
|
||||
* self, if contentSelector === rootSelector) and stamp it with `mark`,
|
||||
* recording the Vue instance uid/type-name found on it at the same moment. */
|
||||
async function stampVisibleRoot(page: Page, contentSelector: string, rootSelector: string, mark: string): Promise<ProbeReading> {
|
||||
return page.evaluate(
|
||||
({ contentSelector, rootSelector, mark }) => {
|
||||
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
|
||||
const visible = candidates.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
|
||||
})
|
||||
if (!visible) return { ok: false, uid: null, typeName: null }
|
||||
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
|
||||
if (!root) return { ok: false, uid: null, typeName: null }
|
||||
root.dataset.perfProbeMark = mark
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = (root as any).__vueParentComponent ?? null
|
||||
const uid: number | null = comp ? (comp.uid ?? null) : null
|
||||
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
|
||||
return { ok: true, uid, typeName }
|
||||
},
|
||||
{ contentSelector, rootSelector, mark }
|
||||
)
|
||||
}
|
||||
|
||||
interface RootMarkDebug {
|
||||
mark: string | null
|
||||
visible: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface ReadResult {
|
||||
ok: boolean
|
||||
markMatches: boolean
|
||||
uid: number | null
|
||||
typeName: string | null
|
||||
allRootMarks: RootMarkDebug[]
|
||||
/** The authoritative "what does the user actually see" signal:
|
||||
* `document.elementFromPoint()` at the viewport center performs real hit
|
||||
* testing (respects stacking/z-index/opacity), unlike the
|
||||
* `offsetParent`/`getBoundingClientRect` heuristic used above, which
|
||||
* cannot distinguish the true foreground root from another root that
|
||||
* merely has non-zero layout dimensions while stacked behind it. */
|
||||
elementFromPointMark: string | null
|
||||
}
|
||||
|
||||
async function readVisibleRoot(
|
||||
page: Page,
|
||||
contentSelector: string,
|
||||
rootSelector: string,
|
||||
expectedMark: string
|
||||
): Promise<ReadResult> {
|
||||
return page.evaluate(
|
||||
({ contentSelector, rootSelector, expectedMark }) => {
|
||||
// Diagnostic: every element matching rootSelector ANYWHERE in the
|
||||
// document (not just the one reachable from the visible content
|
||||
// match), reporting its own mark + visibility. If the ORIGINAL
|
||||
// stamped root still carries its mark but is not the one this read
|
||||
// considers "visible", that is a completely different finding
|
||||
// (a still-alive-but-orphaned cached instance) than the mark being
|
||||
// gone from every root entirely (a genuine destroy+recreate).
|
||||
const allRoots = Array.from(document.querySelectorAll(rootSelector)) as HTMLElement[]
|
||||
const allRootMarks = allRoots.map((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
mark: el.dataset.perfProbeMark ?? null,
|
||||
visible: el.offsetParent !== null && (rect.width > 0 || rect.height > 0),
|
||||
connected: el.isConnected,
|
||||
}
|
||||
})
|
||||
|
||||
// Authoritative real-hit-test signal, independent of the
|
||||
// offsetParent/rect heuristic above.
|
||||
const cx = Math.floor(window.innerWidth / 2)
|
||||
const cy = Math.floor(window.innerHeight / 2)
|
||||
const topEl = document.elementFromPoint(cx, cy) as HTMLElement | null
|
||||
const topRoot = (topEl?.closest(rootSelector) as HTMLElement | null) ?? null
|
||||
const elementFromPointMark = topRoot?.dataset.perfProbeMark ?? null
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
|
||||
const visible = candidates.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
|
||||
})
|
||||
if (!visible) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
|
||||
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
|
||||
if (!root) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = (root as any).__vueParentComponent ?? null
|
||||
const uid: number | null = comp ? (comp.uid ?? null) : null
|
||||
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
|
||||
return { ok: true, markMatches: root.dataset.perfProbeMark === expectedMark, uid, typeName, allRootMarks, elementFromPointMark }
|
||||
},
|
||||
{ contentSelector, rootSelector, expectedMark }
|
||||
)
|
||||
}
|
||||
|
||||
interface RoundTripResult {
|
||||
path: string
|
||||
label: string
|
||||
/** Primary verdict: did the SAME element survive the round trip (the
|
||||
* corrected 02-08 method's own signal)? null = could not be probed. */
|
||||
elementSurvived: boolean | null
|
||||
/** Independent instance-identity signal (instrument 1): did the SAME Vue
|
||||
* component instance (by internal uid) survive? null = could not be read
|
||||
* (e.g. __vueParentComponent absent, or the view was never found). */
|
||||
instanceSurvived: boolean | null
|
||||
instanceTypeNameBeforeAway: string | null
|
||||
instanceTypeNameAfterReturn: string | null
|
||||
/** Diagnostic: every rootSelector-matching element in the document at
|
||||
* read-back time, with its own mark + visibility/connected state — shows
|
||||
* whether an unmatched original root is genuinely gone vs still present
|
||||
* (just not the one the visibility filter picked). */
|
||||
allRootMarksAtRead: RootMarkDebug[]
|
||||
/** Authoritative real-hit-test signal at read-back time (see ReadResult's
|
||||
* own doc comment) — null means either the read failed or elementFromPoint
|
||||
* found no rootSelector ancestor at the viewport center. */
|
||||
elementFromPointMarkAtRead: string | null
|
||||
elementFromPointSurvived: boolean | null
|
||||
afterVisit: HopSnapshot | null
|
||||
afterAway: HopSnapshot | null
|
||||
afterReturn: HopSnapshot | null
|
||||
consoleMessages: string[]
|
||||
pageErrors: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort dismissal of a stray full-screen overlay before it blocks the
|
||||
* next click — mirrors measure.ts's own `dismissOverlays()` (this spec
|
||||
* deliberately does not import from measure.ts, so the logic is duplicated
|
||||
* here rather than shared, per the "don't edit the frozen 02-01 harness"
|
||||
* constraint). archi-dev-box currently runs at 85% disk (02-FINDINGS.md
|
||||
* Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast
|
||||
* live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper
|
||||
* has no `pointer-events: none`, so it silently intercepts clicks on
|
||||
* whatever sits behind it — an environmental condition unrelated to the
|
||||
* KeepAlive remount question this probe exists to answer, and one this
|
||||
* probe must route around rather than be blocked by.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
// no-op — best-effort only
|
||||
}
|
||||
const closeButtons = page.locator(
|
||||
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
|
||||
)
|
||||
const count = await closeButtons.count().catch(() => 0)
|
||||
if (count > 0) {
|
||||
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 3
|
||||
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await dismissOverlays(page)
|
||||
try {
|
||||
await page.locator(selector).first().click({ timeout: perAttemptMs })
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
|
||||
async function goHome(page: Page): Promise<void> {
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).catch(() => {})
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[]): Promise<void> {
|
||||
for (const selector of steps) {
|
||||
await clickWithGuard(page, selector, NAV_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
async function roundTrip(page: Page, surface: Surface): Promise<RoundTripResult> {
|
||||
const consoleMessages: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
const onConsole = (msg: { type: () => string; text: () => string }) => {
|
||||
consoleMessages.push(`[${msg.type()}] ${msg.text()}`)
|
||||
}
|
||||
const onPageError = (err: Error) => {
|
||||
pageErrors.push(err.message)
|
||||
}
|
||||
page.on('console', onConsole)
|
||||
page.on('pageerror', onPageError)
|
||||
|
||||
let afterVisit: HopSnapshot | null = null
|
||||
let afterAway: HopSnapshot | null = null
|
||||
let afterReturn: HopSnapshot | null = null
|
||||
let stampReading: ProbeReading = { ok: false, uid: null, typeName: null }
|
||||
let readReading: ReadResult = { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks: [], elementFromPointMark: null }
|
||||
|
||||
try {
|
||||
await goHome(page)
|
||||
|
||||
await clickChain(page, surface.navSteps)
|
||||
// Wait for the URL first, THEN the content selector: three of these ten
|
||||
// surfaces (Server, Web5, Fleet) share the generic
|
||||
// `.view-container [data-controller-container]` contentSelector
|
||||
// (02-FINDINGS.md's own documented ambiguity), so waiting on the
|
||||
// selector alone can resolve instantly against the PREVIOUS tab's still-
|
||||
// visible content before the navigation actually lands — a probe
|
||||
// artifact this instrumentation (pathname logging, instrument 3) caught
|
||||
// directly rather than one I could have caught by inspection alone.
|
||||
if (!surface.path.includes(':')) {
|
||||
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
|
||||
afterVisit = await snapshotHop(page)
|
||||
|
||||
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
stampReading = await stampVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
|
||||
|
||||
if (surface.closeSelector) {
|
||||
// Deliberately NOT routed through clickWithGuard: dismissOverlays()
|
||||
// treats any "Close"-labelled dialog button as a stray overlay to
|
||||
// dismiss, which is exactly this element for a modal-trigger surface
|
||||
// — same reasoning as measure.ts's own runOnce().
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
afterAway = await snapshotHop(page)
|
||||
|
||||
await clickChain(page, surface.navSteps)
|
||||
if (!surface.path.includes(':')) {
|
||||
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
|
||||
afterReturn = await snapshotHop(page)
|
||||
|
||||
readReading = await readVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
|
||||
|
||||
const elementSurvived = stampReading.ok && readReading.ok ? readReading.markMatches : null
|
||||
const instanceSurvived =
|
||||
stampReading.ok && readReading.ok && stampReading.uid != null && readReading.uid != null
|
||||
? stampReading.uid === readReading.uid
|
||||
: null
|
||||
const elementFromPointSurvived = readReading.elementFromPointMark != null ? readReading.elementFromPointMark === mark : null
|
||||
|
||||
return {
|
||||
path: surface.path,
|
||||
label: surface.label,
|
||||
elementSurvived,
|
||||
instanceSurvived,
|
||||
instanceTypeNameBeforeAway: stampReading.typeName,
|
||||
instanceTypeNameAfterReturn: readReading.typeName,
|
||||
allRootMarksAtRead: readReading.allRootMarks,
|
||||
elementFromPointMarkAtRead: readReading.elementFromPointMark,
|
||||
elementFromPointSurvived,
|
||||
afterVisit,
|
||||
afterAway,
|
||||
afterReturn,
|
||||
consoleMessages,
|
||||
pageErrors,
|
||||
error: null,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
path: surface.path,
|
||||
label: surface.label,
|
||||
elementSurvived: null,
|
||||
instanceSurvived: null,
|
||||
instanceTypeNameBeforeAway: stampReading.typeName,
|
||||
instanceTypeNameAfterReturn: readReading.typeName,
|
||||
allRootMarksAtRead: readReading.allRootMarks,
|
||||
elementFromPointMarkAtRead: readReading.elementFromPointMark,
|
||||
elementFromPointSurvived: null,
|
||||
afterVisit,
|
||||
afterAway,
|
||||
afterReturn,
|
||||
consoleMessages,
|
||||
pageErrors,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
} finally {
|
||||
page.off('console', onConsole)
|
||||
page.off('pageerror', onPageError)
|
||||
}
|
||||
}
|
||||
|
||||
test('keepalive-remount-probe: every KEEP_ALIVE_PATHS surface survives a tab round-trip (or reports why it could not be probed)', async ({ page }) => {
|
||||
test.setTimeout(10 * 60 * 1000)
|
||||
|
||||
await login(page)
|
||||
|
||||
// Session-wide capture, in addition to roundTrip()'s own per-surface
|
||||
// listeners: a delayed/async error (e.g. a promise that settles after a
|
||||
// round trip's own listeners are already detached, mid-flight when we've
|
||||
// moved on to the next surface) would otherwise be silently missed and
|
||||
// wrongly attributed to "no error" for the surface actually responsible.
|
||||
const sessionLog: string[] = []
|
||||
const sessionStart = Date.now()
|
||||
const onSessionConsole = (msg: { type: () => string; text: () => string }) => {
|
||||
sessionLog.push(`[+${Date.now() - sessionStart}ms] [console:${msg.type()}] ${msg.text()}`)
|
||||
}
|
||||
const onSessionPageError = (err: Error) => {
|
||||
sessionLog.push(`[+${Date.now() - sessionStart}ms] [pageerror] ${err.message}`)
|
||||
}
|
||||
page.on('console', onSessionConsole)
|
||||
page.on('pageerror', onSessionPageError)
|
||||
|
||||
// De-duplicate by path, keeping the FIRST matching SURFACES row: two rows
|
||||
// share `path: '/dashboard'` (the `home` main-tab row and the `wallet-send`
|
||||
// modal-trigger row, which records Home's own path only for reference,
|
||||
// per surfaces.ts's own doc comment on `closeSelector`) — the modal is not
|
||||
// itself a KEEP_ALIVE_PATHS-registered route, so only `home` should count.
|
||||
const seenPaths = new Set<string>()
|
||||
const probeSurfaces = SURFACES.filter((s) => {
|
||||
if (!KEEP_ALIVE_PATHS.has(s.path) || seenPaths.has(s.path)) return false
|
||||
seenPaths.add(s.path)
|
||||
return true
|
||||
})
|
||||
expect(probeSurfaces.length).toBe(KEEP_ALIVE_PATHS.size)
|
||||
|
||||
const results: RoundTripResult[] = []
|
||||
for (const surface of probeSurfaces) {
|
||||
const result = await roundTrip(page, surface)
|
||||
results.push(result)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[keepalive-remount-probe] ${result.path} (${result.label}): ` +
|
||||
`elementSurvived=${result.elementSurvived} instanceSurvived=${result.instanceSurvived} ` +
|
||||
`typeName(before/after)=${result.instanceTypeNameBeforeAway}/${result.instanceTypeNameAfterReturn} ` +
|
||||
`viewContainerCount(visit/away/return)=${result.afterVisit?.viewContainerCount ?? 'n/a'}/${result.afterAway?.viewContainerCount ?? 'n/a'}/${result.afterReturn?.viewContainerCount ?? 'n/a'} ` +
|
||||
`pathname(visit/return)=${result.afterVisit?.pathname ?? 'n/a'}/${result.afterReturn?.pathname ?? 'n/a'} ` +
|
||||
`elementFromPointSurvived=${result.elementFromPointSurvived} ` +
|
||||
`pageErrors=${result.pageErrors.length} error=${result.error ?? 'none'}`
|
||||
)
|
||||
if (result.pageErrors.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] pageErrors: ${JSON.stringify(result.pageErrors)}`)
|
||||
}
|
||||
if (result.elementSurvived === false) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] allRootMarksAtRead (${result.path}): ${JSON.stringify(result.allRootMarksAtRead)}`)
|
||||
}
|
||||
}
|
||||
|
||||
page.off('console', onSessionConsole)
|
||||
page.off('pageerror', onSessionPageError)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] full session log (${sessionLog.length} entries):\n${sessionLog.join('\n')}`)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] full results JSON:\n${JSON.stringify(results, null, 2)}`)
|
||||
|
||||
// Structural assertion only — every registered path must have been
|
||||
// attempted and produce a result row, mirroring surface-perf.spec.ts's own
|
||||
// sole assertion (`expect(results.length).toBe(SURFACES.length)`). Whether
|
||||
// each one SURVIVED, or could even be probed at all, is the finding this
|
||||
// probe exists to produce, not a pass/fail gate on the spec itself: Mesh's
|
||||
// device-not-reporting-connected condition and Chat's AIUI-connection
|
||||
// timing are both pre-existing, environment-dependent blockers
|
||||
// 02-FINDINGS.md's own `## Results` section already documents as
|
||||
// "unmeasured" rather than "failed" — an errored sample is recorded, never
|
||||
// discarded and never used to fail the harness itself, exactly like
|
||||
// `measure.ts`'s `measureSurface()` treats its own per-run errors.
|
||||
expect(results.length).toBe(probeSurfaces.length)
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
// measureSurface() — first-visit vs revisit timing, RPC request trace, and a
|
||||
// remount probe for one SURFACES row (02-01-PLAN.md Task 1).
|
||||
//
|
||||
// Design notes (see surfaces.ts header for the navigation rationale):
|
||||
// - Navigation between surfaces always happens via real UI clicks
|
||||
// (RouterLink/button), never `page.goto()`, so a revisit exercises actual
|
||||
// Vue Router client-side transitions rather than a full page reload.
|
||||
// - The remount probe stamps `rootSelector`'s DOM node with a unique value
|
||||
// right after first-visit paints, then reads it back after the away/back
|
||||
// round-trip. A surviving value means the component instance was reused
|
||||
// (no remount); a missing/changed value means it was destroyed and
|
||||
// recreated.
|
||||
// - RPC calls are traced via `page.on('request')`/`requestfinished`,
|
||||
// filtered to the `/rpc/v1` endpoint the rpc-client posts to. Only method
|
||||
// name + timing are recorded — no request/response bodies (T-02-06).
|
||||
// - `maxConcurrentRpc` / `rpcWallClockMs` are derived from the recorded
|
||||
// calls' [start, start+duration] intervals via a sweep-line, so a serial
|
||||
// waterfall (maxConcurrentRpc === 1, 2+ calls) is distinguishable from an
|
||||
// already-parallel fan-out without re-reading application source.
|
||||
|
||||
import type { Page, Request } from '@playwright/test'
|
||||
import type { Surface } from './surfaces'
|
||||
|
||||
const RPC_PATH = '/rpc/v1'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const DEFAULT_NAV_TIMEOUT = 20_000
|
||||
const DEFAULT_CONTENT_TIMEOUT = 20_000
|
||||
|
||||
export interface RpcCall {
|
||||
method: string
|
||||
/** Wall-clock offset (ms) from the start of the measured phase. */
|
||||
startedAtMs: number
|
||||
/** Request duration in ms (null if it never finished/failed to resolve timing). */
|
||||
durationMs: number | null
|
||||
}
|
||||
|
||||
export interface SurfaceSample {
|
||||
firstVisitMs: number | null
|
||||
revisitMs: number | null
|
||||
firstVisitRpcCount: number | null
|
||||
revisitRpcCount: number | null
|
||||
revisitRpcCalls: RpcCall[]
|
||||
maxConcurrentRpc: number | null
|
||||
rpcWallClockMs: number | null
|
||||
/** true = component instance was reused across the away/back round-trip
|
||||
* (no remount); false = it was destroyed and recreated; null = not probed
|
||||
* (e.g. the run errored before the probe could run). */
|
||||
remounted: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SurfaceMeasurement {
|
||||
id: string
|
||||
label: string
|
||||
path: string
|
||||
kind: Surface['kind']
|
||||
runs: number
|
||||
samples: SurfaceSample[]
|
||||
/** Median across successful samples (null if every sample errored). */
|
||||
firstVisitMs: number | null
|
||||
revisitMs: number | null
|
||||
firstVisitRpcCount: number | null
|
||||
revisitRpcCount: number | null
|
||||
/** RPC call trace from the sample nearest the median revisitMs (or the
|
||||
* last successful sample if no median could be computed). */
|
||||
revisitRpcCalls: RpcCall[]
|
||||
maxConcurrentRpc: number | null
|
||||
rpcWallClockMs: number | null
|
||||
/** Majority vote across samples that were actually probed. */
|
||||
remounted: boolean | null
|
||||
/** Set only when every sample for this surface errored. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface MeasureOptions {
|
||||
/** Number of first-visit/revisit round-trips to sample (default 3). */
|
||||
runs?: number
|
||||
navTimeoutMs?: number
|
||||
contentTimeoutMs?: number
|
||||
}
|
||||
|
||||
function median(values: Array<number | null>): number | null {
|
||||
const nums = values.filter((v): v is number => v != null).sort((a, b) => a - b)
|
||||
if (nums.length === 0) return null
|
||||
const mid = Math.floor(nums.length / 2)
|
||||
return nums.length % 2 === 0 ? (nums[mid - 1]! + nums[mid]!) / 2 : nums[mid]!
|
||||
}
|
||||
|
||||
/** Sweep-line over [start, start+duration] intervals — max overlap count and
|
||||
* total wall-clock span from first start to last end. */
|
||||
function deriveConcurrency(calls: RpcCall[]): { maxConcurrentRpc: number | null; rpcWallClockMs: number | null } {
|
||||
const timed = calls.filter((c) => c.durationMs != null)
|
||||
if (timed.length === 0) return { maxConcurrentRpc: calls.length > 0 ? null : 0, rpcWallClockMs: calls.length > 0 ? null : 0 }
|
||||
type Edge = { at: number; delta: number }
|
||||
const edges: Edge[] = []
|
||||
let minStart = Infinity
|
||||
let maxEnd = -Infinity
|
||||
for (const c of timed) {
|
||||
const end = c.startedAtMs + (c.durationMs ?? 0)
|
||||
edges.push({ at: c.startedAtMs, delta: 1 }, { at: end, delta: -1 })
|
||||
minStart = Math.min(minStart, c.startedAtMs)
|
||||
maxEnd = Math.max(maxEnd, end)
|
||||
}
|
||||
edges.sort((a, b) => a.at - b.at)
|
||||
let running = 0
|
||||
let max = 0
|
||||
for (const e of edges) {
|
||||
running += e.delta
|
||||
if (running > max) max = running
|
||||
}
|
||||
return { maxConcurrentRpc: max, rpcWallClockMs: maxEnd - minStart }
|
||||
}
|
||||
|
||||
interface RpcTracker {
|
||||
calls: RpcCall[]
|
||||
detach: () => void
|
||||
}
|
||||
|
||||
function attachRpcTracker(page: Page, phaseStart: number): RpcTracker {
|
||||
const calls: RpcCall[] = []
|
||||
const pending = new Map<Request, { method: string; start: number }>()
|
||||
|
||||
const isRpcRequest = (req: Request) => req.method() === 'POST' && req.url().includes(RPC_PATH)
|
||||
|
||||
const onRequest = (req: Request) => {
|
||||
if (!isRpcRequest(req)) return
|
||||
let method = 'unknown'
|
||||
try {
|
||||
const body = req.postData()
|
||||
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
|
||||
} catch {
|
||||
// Malformed/unreadable body — keep 'unknown', never persist the body itself.
|
||||
}
|
||||
pending.set(req, { method, start: Date.now() - phaseStart })
|
||||
}
|
||||
|
||||
const onSettled = (req: Request) => {
|
||||
const info = pending.get(req)
|
||||
if (!info) return
|
||||
pending.delete(req)
|
||||
calls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - phaseStart - info.start })
|
||||
}
|
||||
|
||||
page.on('request', onRequest)
|
||||
page.on('requestfinished', onSettled)
|
||||
page.on('requestfailed', onSettled)
|
||||
|
||||
return {
|
||||
calls,
|
||||
detach: () => {
|
||||
page.off('request', onRequest)
|
||||
page.off('requestfinished', onSettled)
|
||||
page.off('requestfailed', onSettled)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click with a dismiss-and-retry guard: a stray overlay (e.g. the Companion
|
||||
* app's once-per-browser auto-show intro) can appear mid-attempt, after
|
||||
* `dismissOverlays()` already ran but before Playwright's own actionability
|
||||
* wait resolves. Splitting the timeout budget across a few short attempts,
|
||||
* re-running `dismissOverlays()` between each, clears it reliably instead of
|
||||
* burning the whole budget on a single blocked attempt.
|
||||
*/
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 3
|
||||
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await dismissOverlays(page)
|
||||
try {
|
||||
await page.locator(selector).first().click({ timeout: perAttemptMs })
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
|
||||
for (const selector of steps) {
|
||||
await clickWithGuard(page, selector, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
async function stampRoot(page: Page, rootSelector: string, mark: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
({ sel, mark }) => {
|
||||
const el = document.querySelector(sel) as HTMLElement | null
|
||||
if (!el) return false
|
||||
el.dataset.perfProbe = mark
|
||||
return true
|
||||
},
|
||||
{ sel: rootSelector, mark }
|
||||
)
|
||||
}
|
||||
|
||||
async function readRootProbe(page: Page, rootSelector: string): Promise<string | null> {
|
||||
return page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel) as HTMLElement | null
|
||||
return el?.dataset.perfProbe ?? null
|
||||
}, rootSelector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort dismissal of a stray full-screen overlay (a filter/pairing/
|
||||
* update modal opened by the surface under test, or left over from a prior
|
||||
* one) before it blocks the next click. One surface's leftover UI must never
|
||||
* cascade a click-intercept failure into every remaining surface in the run.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
// no-op — best-effort only
|
||||
}
|
||||
// Match any "Close"-flavoured aria-label (e.g. "Close companion modal"),
|
||||
// not just an exact "Close" — modal close buttons across the app phrase
|
||||
// this inconsistently.
|
||||
const closeButtons = page.locator(
|
||||
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
|
||||
)
|
||||
const count = await closeButtons.count().catch(() => 0)
|
||||
if (count > 0) {
|
||||
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function goHome(page: Page, timeoutMs: number): Promise<void> {
|
||||
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — if a prior surface's
|
||||
// run ended sitting on /dashboard/chat (e.g. chat's own revisit re-opened
|
||||
// it as its last step, or a prior run errored out mid-chat), the sidebar is
|
||||
// hidden and unclickable. Back out of chat first so the sidebar reappears
|
||||
// before relying on it below.
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
const closed = await page
|
||||
.locator('.chat-close-btn')
|
||||
.first()
|
||||
.click({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
// On real AIUI hardware the close pill can sit behind AIUI's own
|
||||
// "connecting" overlay for longer than any reasonable click budget — if
|
||||
// the close button itself is unreachable, this is a recovery path, not a
|
||||
// measurement, so a hard reload to break out is preferable to leaving
|
||||
// every remaining surface stuck behind a permanently hidden sidebar.
|
||||
if (!closed) {
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
}
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
|
||||
}
|
||||
|
||||
async function runOnce(page: Page, surface: Surface, opts: Required<Pick<MeasureOptions, 'navTimeoutMs' | 'contentTimeoutMs'>>): Promise<SurfaceSample> {
|
||||
const { navTimeoutMs, contentTimeoutMs } = opts
|
||||
|
||||
// Start every run from a known, stable point (dashboard home) so
|
||||
// firstVisitMs measures "navigate from the dashboard home to the surface"
|
||||
// exactly as specified, regardless of what the previous surface left us on.
|
||||
await goHome(page, navTimeoutMs)
|
||||
|
||||
const t0 = Date.now()
|
||||
const firstTracker = attachRpcTracker(page, t0)
|
||||
await clickChain(page, surface.navSteps, navTimeoutMs)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
|
||||
const firstVisitMs = Date.now() - t0
|
||||
firstTracker.detach()
|
||||
const firstVisitRpcCount = firstTracker.calls.length
|
||||
|
||||
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const stamped = await stampRoot(page, surface.rootSelector, mark)
|
||||
|
||||
// Away step: for an in-page trigger (modal), close it; otherwise navigate
|
||||
// to the neutral Settings tab via the sidebar (never back to this surface's
|
||||
// own navSteps, so the round-trip is genuine).
|
||||
//
|
||||
// Deliberately NOT routed through clickWithGuard here: dismissOverlays()
|
||||
// treats any "Close"-labelled button inside a dialog as a stray overlay to
|
||||
// dismiss — which is exactly this element when closeSelector targets the
|
||||
// surface's own dialog/panel, causing it to close a beat before this click
|
||||
// runs and leaving the click with nothing to find.
|
||||
if (surface.closeSelector) {
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: navTimeoutMs })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, navTimeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: navTimeoutMs })
|
||||
}
|
||||
|
||||
const t1 = Date.now()
|
||||
const revisitTracker = attachRpcTracker(page, t1)
|
||||
await clickChain(page, surface.navSteps, navTimeoutMs)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
|
||||
const revisitMs = Date.now() - t1
|
||||
revisitTracker.detach()
|
||||
const revisitRpcCalls = revisitTracker.calls
|
||||
const revisitRpcCount = revisitRpcCalls.length
|
||||
|
||||
const remounted = stamped ? (await readRootProbe(page, surface.rootSelector)) !== mark : null
|
||||
|
||||
const { maxConcurrentRpc, rpcWallClockMs } = deriveConcurrency(revisitRpcCalls)
|
||||
|
||||
return {
|
||||
firstVisitMs,
|
||||
revisitMs,
|
||||
firstVisitRpcCount,
|
||||
revisitRpcCount,
|
||||
revisitRpcCalls,
|
||||
maxConcurrentRpc,
|
||||
rpcWallClockMs,
|
||||
remounted,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function errorSample(err: unknown): SurfaceSample {
|
||||
return {
|
||||
firstVisitMs: null,
|
||||
revisitMs: null,
|
||||
firstVisitRpcCount: null,
|
||||
revisitRpcCount: null,
|
||||
revisitRpcCalls: [],
|
||||
maxConcurrentRpc: null,
|
||||
rpcWallClockMs: null,
|
||||
remounted: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
|
||||
export async function measureSurface(page: Page, surface: Surface, opts: MeasureOptions = {}): Promise<SurfaceMeasurement> {
|
||||
const runs = opts.runs ?? 3
|
||||
const navTimeoutMs = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT
|
||||
const contentTimeoutMs = opts.contentTimeoutMs ?? DEFAULT_CONTENT_TIMEOUT
|
||||
|
||||
const samples: SurfaceSample[] = []
|
||||
for (let i = 0; i < runs; i++) {
|
||||
try {
|
||||
samples.push(await runOnce(page, surface, { navTimeoutMs, contentTimeoutMs }))
|
||||
} catch (err) {
|
||||
samples.push(errorSample(err))
|
||||
}
|
||||
}
|
||||
|
||||
const successful = samples.filter((s) => s.error == null)
|
||||
const firstVisitMs = median(successful.map((s) => s.firstVisitMs))
|
||||
const revisitMs = median(successful.map((s) => s.revisitMs))
|
||||
const firstVisitRpcCount = median(successful.map((s) => s.firstVisitRpcCount))
|
||||
const revisitRpcCount = median(successful.map((s) => s.revisitRpcCount))
|
||||
|
||||
// Representative sample for the call trace / concurrency fields: the
|
||||
// successful sample whose revisitMs is closest to the computed median
|
||||
// (falls back to the last successful sample when no median exists).
|
||||
let representative: SurfaceSample | null = null
|
||||
if (successful.length > 0) {
|
||||
if (revisitMs != null) {
|
||||
representative = successful.reduce((best, s) =>
|
||||
Math.abs((s.revisitMs ?? Infinity) - revisitMs) < Math.abs((best.revisitMs ?? Infinity) - revisitMs) ? s : best
|
||||
)
|
||||
} else {
|
||||
representative = successful[successful.length - 1]!
|
||||
}
|
||||
}
|
||||
|
||||
const remountedVotes = successful.map((s) => s.remounted).filter((v): v is boolean => v != null)
|
||||
const trueCount = remountedVotes.filter(Boolean).length
|
||||
const remounted = remountedVotes.length === 0 ? null : trueCount >= remountedVotes.length - trueCount
|
||||
|
||||
return {
|
||||
id: surface.id,
|
||||
label: surface.label,
|
||||
path: surface.path,
|
||||
kind: surface.kind,
|
||||
runs,
|
||||
samples,
|
||||
firstVisitMs,
|
||||
revisitMs,
|
||||
firstVisitRpcCount,
|
||||
revisitRpcCount,
|
||||
revisitRpcCalls: representative?.revisitRpcCalls ?? [],
|
||||
maxConcurrentRpc: representative?.maxConcurrentRpc ?? null,
|
||||
rpcWallClockMs: representative?.rpcWallClockMs ?? null,
|
||||
remounted,
|
||||
error: successful.length === 0 ? (samples[samples.length - 1]?.error ?? 'all runs failed') : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// profile-revisit.spec.ts — 02-11 gap-closure diagnostic (D-10: measure before
|
||||
// fix). Standalone, additive script — does NOT edit surfaces.ts / measure.ts
|
||||
// / surface-perf.spec.ts (the frozen 02-01 harness stays frozen).
|
||||
//
|
||||
// 02-VERIFICATION.md's gap 2 confirmed six surfaces regressed on revisit with
|
||||
// flat-or-improved RPC counts — i.e. the cost is client-side render/reactivity,
|
||||
// not network. This script captures a REAL CPU profile (CDP Profiler domain —
|
||||
// the same sampling data Chrome DevTools' Performance panel visualizes as a
|
||||
// flame chart) during exactly the harness's own measured window (click chain
|
||||
// -> contentSelector visible) for each named surface's revisit, then
|
||||
// aggregates self-time by function so the dominant cost can be named with
|
||||
// profiling evidence instead of guessed from source reading alone.
|
||||
//
|
||||
// Usage:
|
||||
// ARCHY_BASE_URL=http://archi-dev-box ARCHY_PASSWORD=*** \
|
||||
// npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { SURFACES, type Surface } from './surfaces'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const NAV_TIMEOUT = 20_000
|
||||
const CONTENT_TIMEOUT = 20_000
|
||||
|
||||
// Every surface 02-VERIFICATION.md named as regressed, plus Fleet (the
|
||||
// out-of-scope bonus 02-10 flagged as the most severe magnitude of the same
|
||||
// mechanism) — all six of this gap plan's must-haves.
|
||||
const TARGET_IDS = ['web5', 'server', 'discover', 'app-details', 'openwrt-gateway', 'fleet']
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page
|
||||
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
const closeButtons = page.locator(
|
||||
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
|
||||
)
|
||||
const count = await closeButtons.count().catch(() => 0)
|
||||
if (count > 0) await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
|
||||
// HealthNotifications.vue's dismiss button has no aria-label at all (a bare
|
||||
// SVG X icon) — the aria-label selector above never matches it, and per
|
||||
// 02-FINDINGS.md's Outstanding section its `.fixed.right-4.z-[200]` wrapper
|
||||
// has no `pointer-events: none`, so a still-open toast can intercept a
|
||||
// click meant for page content underneath it (e.g. a disk-usage warning
|
||||
// sitting over the "OpenWrt Gateway" link). Dismiss any visible one.
|
||||
const healthToastClose = page.locator('.fixed.right-4.z-\\[200\\] button')
|
||||
const healthCount = await healthToastClose.count().catch(() => 0)
|
||||
for (let i = 0; i < healthCount; i++) {
|
||||
await healthToastClose.first().click({ timeout: 1_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 4
|
||||
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await dismissOverlays(page)
|
||||
try {
|
||||
await page.locator(selector).first().click({ timeout: perAttemptMs, force: i === attempts - 1 })
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
|
||||
for (const selector of steps) await clickWithGuard(page, selector, timeoutMs)
|
||||
}
|
||||
|
||||
async function goHome(page: Page, timeoutMs: number): Promise<void> {
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
const closed = await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!closed) await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
|
||||
}
|
||||
|
||||
interface CpuProfileNode {
|
||||
id: number
|
||||
callFrame: { functionName: string; url: string; lineNumber: number; columnNumber: number; scriptId: string }
|
||||
hitCount?: number
|
||||
children?: number[]
|
||||
}
|
||||
interface CpuProfile {
|
||||
nodes: CpuProfileNode[]
|
||||
startTime: number
|
||||
endTime: number
|
||||
samples?: number[]
|
||||
timeDeltas?: number[]
|
||||
}
|
||||
|
||||
// Production build has no sourcemaps deployed (confirmed: assets/*.js.map ->
|
||||
// 404 on archi-dev-box), so minified function names inside vendor-*.js/
|
||||
// index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME
|
||||
// instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled
|
||||
// together; index = app entry/shared code; per-route chunk name = that
|
||||
// view's own lazy-loaded code) rather than by node_modules path, which only
|
||||
// exists pre-build.
|
||||
function categorize(url: string): string {
|
||||
if (!url) return '(native/gc/idle — no JS frame)'
|
||||
const file = url.split('/').pop() ?? url
|
||||
if (/^vendor-/.test(file)) return 'assets/vendor-*.js (Vue/Pinia/vue-router runtime bundle)'
|
||||
if (/^index-/.test(file)) return 'assets/index-*.js (app entry/shared chunk)'
|
||||
if (/^Dashboard-/.test(file)) return 'assets/Dashboard-*.js (Dashboard/DashboardRouterView chunk)'
|
||||
if (/^Fleet-/.test(file)) return 'assets/Fleet-*.js (Fleet.vue chunk)'
|
||||
if (/^Web5-/i.test(file)) return 'assets/Web5-*.js chunk'
|
||||
if (/^Server-/.test(file)) return 'assets/Server-*.js chunk'
|
||||
if (/^Discover-/.test(file)) return 'assets/Discover-*.js chunk'
|
||||
if (/^AppDetails-/.test(file)) return 'assets/AppDetails-*.js chunk'
|
||||
if (/^OpenWrtGateway-/.test(file)) return 'assets/OpenWrtGateway-*.js chunk'
|
||||
if (url.startsWith('assets/')) return `assets/${file} (other chunk)`
|
||||
if (url.includes('node_modules')) return 'other node_modules (dev-only build)'
|
||||
return '(native/gc/idle — no JS frame)'
|
||||
}
|
||||
|
||||
function analyzeProfile(profile: CpuProfile, label: string): void {
|
||||
const byId = new Map<number, CpuProfileNode>()
|
||||
for (const n of profile.nodes) byId.set(n.id, n)
|
||||
|
||||
const selfTimeById = new Map<number, number>()
|
||||
const samples = profile.samples ?? []
|
||||
const timeDeltas = profile.timeDeltas ?? []
|
||||
let total = 0
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const dt = timeDeltas[i] ?? 0
|
||||
total += dt
|
||||
const id = samples[i]!
|
||||
selfTimeById.set(id, (selfTimeById.get(id) ?? 0) + dt)
|
||||
}
|
||||
const totalMs = total / 1000
|
||||
const windowMs = (profile.endTime - profile.startTime) / 1000
|
||||
|
||||
// Aggregate by function identity (name@file:line) and by coarse category.
|
||||
const byFunction = new Map<string, number>()
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const [id, us] of selfTimeById) {
|
||||
const node = byId.get(id)
|
||||
if (!node) continue
|
||||
const fnName = node.callFrame.functionName || '(anonymous)'
|
||||
const file = node.callFrame.url ? node.callFrame.url.split('/').slice(-2).join('/') : '(native)'
|
||||
const key = `${fnName} @ ${file}:${node.callFrame.lineNumber}`
|
||||
byFunction.set(key, (byFunction.get(key) ?? 0) + us)
|
||||
const cat = categorize(node.callFrame.url)
|
||||
byCategory.set(cat, (byCategory.get(cat) ?? 0) + us)
|
||||
}
|
||||
|
||||
console.log(`\n===== CPU PROFILE: ${label} =====`)
|
||||
console.log(`Profiled window (Profiler start->stop): ${windowMs.toFixed(1)}ms`)
|
||||
console.log(`Total sampled self-time: ${totalMs.toFixed(1)}ms (${samples.length} samples)`)
|
||||
console.log(`--- By category (self time) ---`)
|
||||
const catSorted = Array.from(byCategory.entries()).sort((a, b) => b[1] - a[1])
|
||||
for (const [cat, us] of catSorted) {
|
||||
const ms = us / 1000
|
||||
console.log(` ${ms.toFixed(1)}ms (${((us / total) * 100).toFixed(1)}%) ${cat}`)
|
||||
}
|
||||
console.log(`--- Top 20 functions (self time) ---`)
|
||||
const fnSorted = Array.from(byFunction.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
for (const [key, us] of fnSorted) {
|
||||
const ms = us / 1000
|
||||
console.log(` ${ms.toFixed(2)}ms (${((us / total) * 100).toFixed(1)}%) ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('profile revisit CPU cost for named regressed surfaces', async ({ page }) => {
|
||||
test.setTimeout(15 * 60 * 1000)
|
||||
await login(page)
|
||||
|
||||
for (const id of TARGET_IDS) {
|
||||
const surface = SURFACES.find((s) => s.id === id) as Surface
|
||||
expect(surface, `surface ${id} must exist in SURFACES`).toBeTruthy()
|
||||
|
||||
await goHome(page, NAV_TIMEOUT)
|
||||
|
||||
// First visit — warm the cache (matches the harness's own first-visit/
|
||||
// revisit structure) — not profiled.
|
||||
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
|
||||
// Away — to the neutral Settings tab (or close, for an in-page trigger).
|
||||
if (surface.closeSelector) {
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
// Let the away-transition settle before starting the profiler so it
|
||||
// captures only the revisit window, not Settings' own leave-transition.
|
||||
await page.waitForTimeout(600)
|
||||
|
||||
// Instrument window.setTimeout/requestAnimationFrame for the profiled
|
||||
// window only, so a large "idle" share in the CPU profile can be
|
||||
// attributed to a concrete scheduled delay (app timer) vs. a chain of
|
||||
// animation frames (CSS-transition-bound) vs. neither (network wait).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__timerLog: Array<{ type: string; delay?: number; at: number }>
|
||||
__origSetTimeout: typeof setTimeout
|
||||
__origRaf: typeof requestAnimationFrame
|
||||
}
|
||||
w.__timerLog = []
|
||||
w.__origSetTimeout = window.setTimeout.bind(window)
|
||||
w.__origRaf = window.requestAnimationFrame.bind(window)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setTimeout = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
|
||||
w.__timerLog.push({ type: 'setTimeout', delay, at: performance.now() })
|
||||
return w.__origSetTimeout(fn as any, delay, ...args) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
w.__timerLog.push({ type: 'raf', at: performance.now() })
|
||||
return w.__origRaf(cb)
|
||||
}
|
||||
})
|
||||
|
||||
// Wall-clock RPC start/finish (matches measure.ts's own attachRpcTracker
|
||||
// convention: Date.now() offsets, method name only, no bodies — T-02-06).
|
||||
const rpcCalls: Array<{ method: string; startedAtMs: number; durationMs: number | null }> = []
|
||||
const pendingRpc = new Map<import('@playwright/test').Request, { method: string; start: number }>()
|
||||
let tRpc0 = 0
|
||||
const isRpc = (req: import('@playwright/test').Request) => req.method() === 'POST' && req.url().includes('/rpc/v1')
|
||||
const onReq = (req: import('@playwright/test').Request) => {
|
||||
if (!isRpc(req)) return
|
||||
let method = 'unknown'
|
||||
try {
|
||||
const body = req.postData()
|
||||
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
|
||||
} catch {
|
||||
/* keep 'unknown' */
|
||||
}
|
||||
pendingRpc.set(req, { method, start: Date.now() - tRpc0 })
|
||||
}
|
||||
const onSettled = (req: import('@playwright/test').Request) => {
|
||||
const info = pendingRpc.get(req)
|
||||
if (!info) return
|
||||
pendingRpc.delete(req)
|
||||
rpcCalls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - tRpc0 - info.start })
|
||||
}
|
||||
page.on('request', onReq)
|
||||
page.on('requestfinished', onSettled)
|
||||
page.on('requestfailed', onSettled)
|
||||
|
||||
// Capture every CSS transition/animation start+end on the document during
|
||||
// the window — decisive evidence for/against "a long CSS transition is
|
||||
// what the human waits through" (Playwright's own 'visible' check does
|
||||
// NOT wait for transitions/opacity, only a non-empty bounding box + not
|
||||
// visibility:hidden, so a slow transition would NOT show up as CPU cost
|
||||
// or as a blocked contentSelector — it would show up here, and would
|
||||
// explain a "feels slow" gap between first-paint and contentSelector).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }
|
||||
w.__animLog = []
|
||||
const describe = (el: EventTarget | null): string => {
|
||||
const e = el as HTMLElement | null
|
||||
if (!e || !e.tagName) return '(unknown)'
|
||||
const cls = (e.className && typeof e.className === 'string') ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
|
||||
return `${e.tagName.toLowerCase()}${cls}`
|
||||
}
|
||||
const log = (type: string) => (ev: Event) => {
|
||||
const te = ev as TransitionEvent | AnimationEvent
|
||||
w.__animLog.push({
|
||||
type,
|
||||
name: (te as TransitionEvent).propertyName ?? (te as AnimationEvent).animationName ?? '',
|
||||
target: describe(ev.target),
|
||||
elapsedMs: Math.round((te.elapsedTime ?? 0) * 1000),
|
||||
at: performance.now(),
|
||||
})
|
||||
}
|
||||
document.addEventListener('transitionrun', log('transitionrun'), true)
|
||||
document.addEventListener('transitionend', log('transitionend'), true)
|
||||
document.addEventListener('transitioncancel', log('transitioncancel'), true)
|
||||
document.addEventListener('animationstart', log('animationstart'), true)
|
||||
document.addEventListener('animationend', log('animationend'), true)
|
||||
})
|
||||
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send('Profiler.enable')
|
||||
await client.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
await client.send('Profiler.start')
|
||||
|
||||
// Raw Chrome trace events (the SAME data DevTools' Performance panel
|
||||
// renders as a flame chart / summary tab) — categorizes rendering work
|
||||
// (Layout, RecalculateStyles, Paint, CompositeLayers, RunTask, TimerFire,
|
||||
// FireAnimationFrame, ...) that a bare JS CPU profile only sees as
|
||||
// "(program)"/"(idle)" because layout/paint/compositing run on the same
|
||||
// renderer main thread but outside any JS call frame.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const traceEvents: any[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client.on('Tracing.dataCollected', (data: any) => { traceEvents.push(...(data.value ?? [])) })
|
||||
const tracingComplete = new Promise<void>((resolve) => client.once('Tracing.tracingComplete', () => resolve()))
|
||||
await client.send('Tracing.start', {
|
||||
categories: 'disabled-by-default-devtools.timeline,devtools.timeline,toplevel,v8,blink.user_timing',
|
||||
transferMode: 'ReportEvents',
|
||||
})
|
||||
|
||||
// First-paint probe, independent of Playwright's own contentSelector
|
||||
// wait: records performance.now() the moment contentSelector FIRST gets
|
||||
// a non-empty bounding box, polled every animation frame (not gated by
|
||||
// Playwright's stricter "visible" actionability rules) — so first-paint
|
||||
// and content-visible can be reported as two distinct numbers per the
|
||||
// gap plan's explicit ask.
|
||||
const pageT0 = await page.evaluate((sel) => {
|
||||
const w = window as unknown as { __firstPaintAt: number | null }
|
||||
w.__firstPaintAt = null
|
||||
function poll() {
|
||||
if (w.__firstPaintAt == null) {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
w.__firstPaintAt = performance.now()
|
||||
return
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(poll)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(poll)
|
||||
return performance.now()
|
||||
}, surface.contentSelector)
|
||||
|
||||
const t0 = Date.now()
|
||||
tRpc0 = t0
|
||||
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
const wallMs = Date.now() - t0
|
||||
const firstPaintAt = await page.evaluate(() => (window as unknown as { __firstPaintAt: number | null }).__firstPaintAt)
|
||||
const firstPaintMs = firstPaintAt != null ? Math.round(firstPaintAt - pageT0) : null
|
||||
|
||||
const { profile } = await client.send('Profiler.stop')
|
||||
await client.send('Profiler.disable')
|
||||
await client.send('Tracing.end')
|
||||
await tracingComplete
|
||||
await client.detach().catch(() => {})
|
||||
page.off('request', onReq)
|
||||
page.off('requestfinished', onSettled)
|
||||
page.off('requestfailed', onSettled)
|
||||
|
||||
// Find the renderer main-thread tid (thread_name metadata event) so the
|
||||
// breakdown below isn't polluted by compositor/IO/GPU-process threads.
|
||||
const mainThreadMeta = traceEvents.find(
|
||||
(e) => e.ph === 'M' && e.name === 'thread_name' && (e.args?.name === 'CrRendererMain')
|
||||
)
|
||||
const mainTid = mainThreadMeta?.tid
|
||||
const durByName = new Map<string, number>()
|
||||
for (const e of traceEvents) {
|
||||
if (mainTid != null && e.tid !== mainTid) continue
|
||||
if (e.ph !== 'X') continue // complete events only (have a real duration)
|
||||
if (typeof e.dur !== 'number') continue
|
||||
durByName.set(e.name, (durByName.get(e.name) ?? 0) + e.dur)
|
||||
}
|
||||
console.log(`--- Chrome trace event self/total time by name (main thread, category=devtools.timeline; NOTE: 'X' events can nest, so this is TOTAL not self time — use for relative magnitude, not a sum-to-100% budget) ---`)
|
||||
const traceSorted = Array.from(durByName.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
for (const [name, us] of traceSorted) {
|
||||
console.log(` ${(us / 1000).toFixed(1)}ms ${name}`)
|
||||
}
|
||||
|
||||
const timerLog = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__timerLog: Array<{ type: string; delay?: number; at: number }>
|
||||
__origSetTimeout: typeof setTimeout
|
||||
__origRaf: typeof requestAnimationFrame
|
||||
}
|
||||
const log = w.__timerLog ?? []
|
||||
window.setTimeout = w.__origSetTimeout
|
||||
window.requestAnimationFrame = w.__origRaf
|
||||
return log
|
||||
})
|
||||
|
||||
const animLog = await page.evaluate(() => (window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }).__animLog ?? [])
|
||||
|
||||
console.log(`\n>>> ${surface.label} (${surface.id}) revisit wall-clock: ${wallMs}ms | first-paint: ${firstPaintMs}ms (contentSelector's first non-empty bounding box, unrelated to Playwright's own stricter 'visible' check)`)
|
||||
analyzeProfile(profile as CpuProfile, `${surface.label} (${surface.id})`)
|
||||
|
||||
const setTimeoutEntries = timerLog.filter((e) => e.type === 'setTimeout')
|
||||
const rafCount = timerLog.filter((e) => e.type === 'raf').length
|
||||
console.log(`--- Timers scheduled during the revisit window (app-code setTimeout/rAF calls) ---`)
|
||||
console.log(` setTimeout calls: ${setTimeoutEntries.length}${setTimeoutEntries.length ? ' — delays(ms): ' + setTimeoutEntries.map((e) => e.delay ?? 0).sort((a, b) => a - b).join(',') : ''}`)
|
||||
console.log(` requestAnimationFrame calls: ${rafCount}`)
|
||||
|
||||
console.log(`--- CSS transition/animation events during the window (relative to window start, pageT0) ---`)
|
||||
if (animLog.length === 0) {
|
||||
console.log(' (none observed)')
|
||||
} else {
|
||||
for (const e of animLog) {
|
||||
console.log(` +${Math.round(e.at - pageT0)}ms ${e.type} name="${e.name}" target=${e.target} elapsed=${e.elapsedMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`--- RPC calls during the revisit window (wall-clock ms from window start) ---`)
|
||||
for (const c of rpcCalls) {
|
||||
console.log(` ${c.method}: start=+${c.startedAtMs}ms duration=${c.durationMs}ms`)
|
||||
}
|
||||
console.log(` total RPC calls: ${rpcCalls.length}, wall-clock window: ${wallMs}ms`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// Re-runnable surface-perf harness (02-01-PLAN.md Task 1). Logs in using the
|
||||
// exact flow from app-launch.spec.ts, walks every SURFACES row via
|
||||
// measureSurface(), and writes the full result array + a run header to
|
||||
// ARCHY_PERF_OUT (defaulting to e2e/test-results/surface-perf.json).
|
||||
//
|
||||
// Redaction is structural, not a cleanup pass: measure.ts's RpcTracker only
|
||||
// ever records method name + timing (T-02-06) — request/response bodies,
|
||||
// page text and screenshots are never captured into the artifact.
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { measureSurface, type SurfaceMeasurement } from './measure'
|
||||
import { SURFACES } from './surfaces'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const RUNS = process.env.ARCHY_PERF_RUNS ? Number(process.env.ARCHY_PERF_RUNS) : 3
|
||||
const OUT_PATH = resolve(process.cwd(), process.env.ARCHY_PERF_OUT ?? 'e2e/test-results/surface-perf.json')
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
// Mirrors e2e/app-launch.spec.ts's login() verbatim — do not invent a
|
||||
// second auth path.
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page
|
||||
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
function currentCommit(): string {
|
||||
try {
|
||||
// `__dirname` is unavailable under this package's `"type": "module"` ESM
|
||||
// runtime (Playwright's own transform swallows the ReferenceError into
|
||||
// the catch below, silently yielding 'unknown') — use `process.cwd()`
|
||||
// instead, which Playwright always sets to the project root it was
|
||||
// invoked from.
|
||||
return execSync('git rev-parse --short HEAD', { cwd: process.cwd() }).toString().trim()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
test('surface-perf: measure every D-09 surface and write the baseline artifact', async ({ page, baseURL }) => {
|
||||
test.setTimeout(20 * 60 * 1000) // 15 surfaces x 3 runs x network round-trips can run long on a real node
|
||||
|
||||
await login(page)
|
||||
|
||||
const results: SurfaceMeasurement[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const surface of SURFACES) {
|
||||
try {
|
||||
const measurement = await measureSurface(page, surface, { runs: RUNS })
|
||||
results.push(measurement)
|
||||
if (measurement.error) skipped.push(`${surface.id}: ${measurement.error}`)
|
||||
} catch (err) {
|
||||
// A surface that throws outside measureSurface's own per-run try/catch
|
||||
// (e.g. login state got corrupted) is still recorded, never dropped —
|
||||
// an unmeasured surface must never silently disappear from the array.
|
||||
results.push({
|
||||
id: surface.id,
|
||||
label: surface.label,
|
||||
path: surface.path,
|
||||
kind: surface.kind,
|
||||
runs: RUNS,
|
||||
samples: [],
|
||||
firstVisitMs: null,
|
||||
revisitMs: null,
|
||||
firstVisitRpcCount: null,
|
||||
revisitRpcCount: null,
|
||||
revisitRpcCalls: [],
|
||||
maxConcurrentRpc: null,
|
||||
rpcWallClockMs: null,
|
||||
remounted: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
skipped.push(`${surface.id}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const header = {
|
||||
baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228',
|
||||
takenAt: new Date().toISOString(),
|
||||
commit: currentCommit(),
|
||||
runs: RUNS,
|
||||
notes: skipped.length > 0 ? `Skipped/errored surfaces: ${skipped.join('; ')}` : 'All surfaces measured cleanly.',
|
||||
}
|
||||
|
||||
const artifact = { ...header, results }
|
||||
|
||||
mkdirSync(dirname(OUT_PATH), { recursive: true })
|
||||
writeFileSync(OUT_PATH, JSON.stringify(artifact, null, 2))
|
||||
|
||||
expect(results.length).toBe(SURFACES.length)
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
// SURFACES table for the Phase 02 UI-performance profiling harness (02-01-PLAN.md,
|
||||
// Task 1). One row per D-09 surface. Every `path` here must exist in
|
||||
// `neode-ui/src/router/index.ts` (verified by acceptance criteria).
|
||||
//
|
||||
// `navSteps` are ordered, real UI-click selectors — clicking an <a>/<RouterLink>
|
||||
// or a button that calls `router.push()` triggers genuine client-side SPA
|
||||
// navigation (no full page reload), which is what "remount storm" measurement
|
||||
// requires. `page.goto()` is deliberately NOT used to move between dashboard
|
||||
// surfaces — it would force a full reload every time and always show
|
||||
// remounted=true regardless of whether KeepAlive would have helped.
|
||||
//
|
||||
// `contentSelector` is chosen to be present only once real content has
|
||||
// painted (never a loading skeleton/spinner). `rootSelector` is the stable
|
||||
// outermost element of the mounted view, used by the remount probe in
|
||||
// measure.ts. `.view-container` is a class Dashboard.vue's nested
|
||||
// `<router-view>` merges onto every non-chat/non-mesh view's root element via
|
||||
// Vue's automatic fallthrough-attribute merging (verified: `grep -rn
|
||||
// "view-container" src` shows it added only by Dashboard.vue's default
|
||||
// branch) — so it works as a uniform root selector across most surfaces.
|
||||
// Mesh and Chat take Dashboard.vue's other template branch (no class
|
||||
// fallthrough) so they use their own static root class instead.
|
||||
|
||||
export type SurfaceKind = 'main-tab' | 'secondary'
|
||||
|
||||
export interface Surface {
|
||||
id: string
|
||||
label: string
|
||||
/** Route path, must exist in router/index.ts. */
|
||||
path: string
|
||||
kind: SurfaceKind
|
||||
/** A selector present only once real content has painted (not a skeleton/spinner). */
|
||||
contentSelector: string
|
||||
/** The stable outermost element of the view, used for the remount probe. */
|
||||
rootSelector: string
|
||||
/**
|
||||
* Ordered selectors clicked (via real UI interaction) to reach/open this
|
||||
* surface. Every step is resolved with `.first()` and clicked; the chain
|
||||
* is re-run unchanged for the revisit measurement.
|
||||
*/
|
||||
navSteps: string[]
|
||||
/**
|
||||
* When set, this surface is an in-page trigger (e.g. a modal) rather than
|
||||
* a distinct navigable route (D-09's "Wallet / send flows" — no Wallet.vue
|
||||
* exists; the real surface is the Send button on the Home dashboard wallet
|
||||
* card, opening SendBitcoinModal). Revisit is measured by clicking this to
|
||||
* close, then re-running the last `navSteps` entry to reopen — "open to
|
||||
* content" rather than "navigate to content" per the plan's fallback rule.
|
||||
*/
|
||||
closeSelector?: string
|
||||
}
|
||||
|
||||
const SIDEBAR = '[data-controller-zone="sidebar"]'
|
||||
|
||||
export const SURFACES: Surface[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home (wallet figures)',
|
||||
path: '/dashboard',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.home-card',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard"]`],
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Apps (My Apps)',
|
||||
path: '/dashboard/apps',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.apps-card-grid-desktop [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`],
|
||||
},
|
||||
{
|
||||
id: 'marketplace',
|
||||
label: 'Marketplace (App Store)',
|
||||
path: '/dashboard/marketplace',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.marketplace-container [data-controller-container]',
|
||||
// Marketplace has no sidebar entry — reached via Home's "Browse Store" link.
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'a:has-text("Browse Store")'],
|
||||
},
|
||||
{
|
||||
id: 'discover',
|
||||
label: 'Discover (App Store tab)',
|
||||
path: '/dashboard/discover',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.discover-container [data-controller-container]',
|
||||
// Discover has no sidebar entry — reached via the "App Store" tab inside Apps.
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-view a:has-text("App Store")'],
|
||||
},
|
||||
{
|
||||
id: 'cloud',
|
||||
label: 'Cloud / Files',
|
||||
path: '/dashboard/cloud',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.apps-view [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`],
|
||||
},
|
||||
{
|
||||
id: 'mesh',
|
||||
label: 'Mesh',
|
||||
path: '/dashboard/mesh',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.mesh-view',
|
||||
contentSelector: '.mesh-status-grid',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/mesh"]`],
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
label: 'Server (Network)',
|
||||
path: '/dashboard/server',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.view-container [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`],
|
||||
},
|
||||
{
|
||||
id: 'web5',
|
||||
label: 'Web5',
|
||||
path: '/dashboard/web5',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.view-container [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`],
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
label: 'Fleet',
|
||||
path: '/dashboard/fleet',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.view-container [data-controller-container]',
|
||||
// Fleet's sidebar entry is hidden for beta — reached via Web5's Federation
|
||||
// card link. Web5Federation.vue renders TWO "Fleet" links: a
|
||||
// `.web5-card-actions-top` one that CSS permanently hides
|
||||
// (`display: none` — the "compact header variants are permanently
|
||||
// retired" rule in style.css) and the real, visible one inside
|
||||
// `.web5-card-actions-bottom-grid`. Scope to the latter so `.first()`
|
||||
// doesn't land on the hidden copy.
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`, '.web5-card-actions-bottom-grid a:has-text("Fleet")'],
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat (AIUI)',
|
||||
path: '/dashboard/chat',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.chat-fullscreen',
|
||||
contentSelector: '.chat-iframe, .chat-placeholder',
|
||||
navSteps: [`${SIDEBAR} button:has-text("AIUI")`],
|
||||
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — the sidebar (and
|
||||
// therefore the neutral Settings link) is not visible while on Chat, so
|
||||
// the normal "away via sidebar" step is impossible here. Chat's own close
|
||||
// button calls closeChat() (router.back(), landing on wherever we came
|
||||
// from — Home, per navSteps below), which is the surface's real "away".
|
||||
closeSelector: '.chat-close-btn',
|
||||
},
|
||||
{
|
||||
id: 'app-details',
|
||||
label: 'AppDetails (secondary)',
|
||||
path: '/dashboard/apps/:id',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.app-details-container h1',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-card-grid-desktop [data-controller-container]'],
|
||||
},
|
||||
{
|
||||
id: 'marketplace-app-details',
|
||||
label: 'MarketplaceAppDetails (secondary)',
|
||||
path: '/dashboard/marketplace/:id',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.app-details-container h1',
|
||||
navSteps: [
|
||||
`${SIDEBAR} a[href="/dashboard"]`,
|
||||
'a:has-text("Browse Store")',
|
||||
'.marketplace-container [data-controller-container]',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cloud-folder',
|
||||
label: 'CloudFolder (secondary)',
|
||||
path: '/dashboard/cloud/:folderId',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.cloud-folder-container h1',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`, '.apps-view [data-controller-container]'],
|
||||
},
|
||||
{
|
||||
id: 'openwrt-gateway',
|
||||
label: 'OpenWrtGateway (secondary)',
|
||||
path: '/dashboard/server/openwrt',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: 'h1:has-text("OpenWrt Gateway")',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`, 'a:has-text("OpenWrt Gateway")'],
|
||||
},
|
||||
{
|
||||
id: 'wallet-send',
|
||||
label: 'Wallet / send flow (Home wallet card, SendBitcoinModal)',
|
||||
// Not a route — RESEARCH.md found no Wallet.vue; the real surface is a
|
||||
// modal opened from the Home wallet card. Path recorded for reference
|
||||
// only (the page the trigger lives on).
|
||||
path: '/dashboard',
|
||||
kind: 'secondary',
|
||||
rootSelector: '[role="dialog"]',
|
||||
contentSelector: 'h3:has-text("Send Bitcoin")',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'button:has-text("Send")'],
|
||||
closeSelector: '[role="dialog"] button[aria-label="Close"]',
|
||||
},
|
||||
]
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 785 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 610 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||