/** * E2E regression: warm-route resume must not re-render the transcript more * than once. * * When a session is already in the runtime-id cache (the "warm" path in * `resumeSession()`), clicking its sidebar row should paint the transcript * exactly once. Before the fix, the warm cache painted via * `syncSessionStateToView`, then the `session.activate` RPC returned a * reconciled message list with different message object references, causing * `syncSessionStateToView` to fire a second `setMessages` — a visual * flicker as the transcript DOM was updated. * * This test pre-seeds a session into state.db, boots the app, * clicks the session (cold resume — populates the warm cache), navigates * away to a new chat, then clicks back (warm resume). Two detectors run: * * 1. A MutationObserver counts additive DOM mutation bursts (childList * additions). More than 1 burst = the transcript was repainted. * * 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes * that happen AFTER the initial paint, while messages are already on * screen. This catches the case where React reconciles by key without * adding/removing nodes (same keys → in-place prop update → no * MutationObserver burst), but `$messages` was still set twice. * * The test passes when bursts === 1 AND reconciles === 0. * The sidebar "+" keeps the session warm in another tab. Its reactivation * follows the same contract: one additive paint and zero reconciles. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ import { expect, test } from './test' import { type MockBackendFixture, waitForAppReady, createSandbox, writeMockProviderConfig, writeEnvFile, buildAppEnv, launchDesktop, } from './fixtures' import { startMockServer } from './mock-server' import { RealSessionBuilder } from './real-session-builder' const SESSION_TITLE = 'E2E Warm Resume Jitter Test' // Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the // renderer's keep-alive visibility policy instead of relying on DOM order. const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' const ALL_SURFACES = '[data-composer-target]' /** * 16 messages (8 user/assistant pairs) — enough DOM churn for detection while * still fitting a hot-hidden pane's retention budget. A kept-alive pane keeps * only its live tail (HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40 weight units in * thread/list.tsx); 16 short messages ≈ 32 units, so the whole transcript * survives hiding. Above the budget, reveal legitimately backfills trimmed * turns (additive DOM bursts) — that is paging, not the repaint bug this * suite hunts, and it would drown the detectors. */ const MESSAGE_COUNT = 16 /** Seeded PRNG so the generated content is deterministic across runs. */ const RNG_SEED = 42 /** Mulberry32 — tiny deterministic PRNG. */ function mulberry32(seed: number): () => number { let a = seed return () => { a |= 0 a = (a + 0x6d2b79f5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } /** Generate ~40 chars of gibberish from a seeded PRNG. */ function gibberish(rng: () => number): string { const len = 30 + Math.floor(rng() * 20) let s = '' for (let i = 0; i < len; i++) { s += String.fromCharCode(97 + Math.floor(rng() * 26)) } return s } /** First user message — used as a wait target in the test. */ const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED)) /** * Generate the user turns for a real session. The mock provider produces the * assistant side of each pair through the normal AIAgent persistence path. */ function generateSessionTurns(): string[] { const rng = mulberry32(RNG_SEED) const turns: string[] = [] for (let i = 0; i < MESSAGE_COUNT / 2; i++) { turns.push(gibberish(rng)) gibberish(rng) } return turns } /** * Set up a mock-backend sandbox with a real persisted session in state.db. * * Unlike the shared `setupMockBackend()`, this variant creates the session * through the real stdio gateway before launching desktop so the session is * visible in the sidebar on first load. */ async function setupSeededMockBackend(): Promise { // 1. Start mock server const mock = await startMockServer() // 2. Create sandbox + write config const sandbox = createSandbox('warm-seed') writeMockProviderConfig(sandbox.hermesHome, mock.url) writeEnvFile(sandbox.hermesHome) // 3. Produce all 16 user/assistant pairs through the real TUI gateway, // AIAgent, mock provider, and SessionDB persistence path before desktop starts. const builder = await RealSessionBuilder.start(sandbox.hermesHome) try { await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() }) } finally { await builder.close() } // 4. Build env + launch const env = buildAppEnv(sandbox) const { app, page } = await launchDesktop(env) return { app, page, mock, mockUrl: mock.url, sandbox, cleanup: async () => { await app.close().catch(() => undefined) await mock.close() sandbox.cleanup() }, } } let fixture: MockBackendFixture | null = null test.beforeAll(async () => { fixture = await setupSeededMockBackend() await waitForAppReady(fixture!, 120_000) }) test.afterAll(async () => { await fixture?.cleanup() fixture = null }) /** * Install a MutationObserver + text-content poll on the thread viewport * to detect re-renders after the initial paint. Returns nothing — call * `readRenderCount` to stop and collect results. * * - MutationObserver: counts additive childList bursts (5ms coalescing). * - Text-content poll: counts "reconciles" — first-message text changes * after the initial paint, catching key-based reconciles that don't * add/remove nodes. */ async function installRenderCounter( page: import('@playwright/test').Page, transcriptText?: string, ): Promise { await page.evaluate(([visibleSelector, allSelector, expected]: [string, string, string | undefined]) => { const surfaces = [...document.querySelectorAll(expected ? allSelector : visibleSelector)] const surface = expected ? surfaces.find(candidate => (candidate.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), ) : surfaces.at(-1) const viewport = surface?.querySelector('[data-slot="aui_thread-viewport"]') if (!viewport) { const diag = [...document.querySelectorAll(allSelector)].map(s => ({ hidden: Boolean(s.closest('[data-pane-hidden]')), target: s.getAttribute('data-composer-target'), hasViewport: Boolean(s.querySelector('[data-slot="aui_thread-viewport"]')), textLen: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').length, head: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(0, 80), tail: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(-80), includesExpected: expected ? (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) : null, })) throw new Error('Thread viewport not found before warm resume DIAG=' + JSON.stringify(diag) + ' expected=' + expected) } const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 } const debugWindow = window as unknown as { __RENDER_COUNT__: typeof state __RENDER_VIEWPORT__: Element } debugWindow.__RENDER_COUNT__ = state debugWindow.__RENDER_VIEWPORT__ = viewport let currentBatch = 0 let flushTimer: ReturnType | null = null const flush = () => { flushTimer = null if (currentBatch > 0 && !state.stopped) { state.bursts += 1 state.timeline.push(currentBatch) currentBatch = 0 } } const observer = new MutationObserver(records => { if (state.stopped) return let batchAdded = 0 for (const record of records) { state.mutations += 1 if (record.type === 'childList' && record.addedNodes.length > 0) { batchAdded += 1 } } if (batchAdded > 0) { currentBatch += batchAdded if (flushTimer) clearTimeout(flushTimer) flushTimer = setTimeout(flush, 5) } }) observer.observe(viewport, { childList: true, subtree: true, attributes: false, characterData: false, }) // Poll the first message's text content every 2ms. The MutationObserver // only catches childList additions; React may reconcile by key without // adding/removing nodes (same keys → in-place prop update → no childList // mutation). The poll catches this by detecting text content changes in // the first message after the initial paint. Metadata-only changes (model // name, busy indicator) don't affect message text, so they don't produce // false positives. const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport let lastFirstMsgText = '' let hasMessages = false const pollInterval = setInterval(() => { if (state.stopped) { clearInterval(pollInterval) return } const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]') const firstMsgText = firstMsg?.textContent ?? '' if (firstMsgText && firstMsgText !== lastFirstMsgText) { if (hasMessages) { state.reconciles = (state.reconciles ?? 0) + 1 } lastFirstMsgText = firstMsgText hasMessages = true } }, 2) }, [SURFACE, ALL_SURFACES, transcriptText] as [string, string, string | undefined]) } /** Wait until the ACTIVE chat surface's transcript contains `text`. */ async function waitForActiveTranscriptText( page: import('@playwright/test').Page, text: string, timeout = 30_000, ): Promise { await page.waitForFunction( ([expected, surfaceSelector]: [string, string]) => { const surfaces = document.querySelectorAll(surfaceSelector) const active = surfaces[surfaces.length - 1] return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) }, [text, SURFACE] as [string, string], { timeout }, ) } async function waitForActiveTranscriptWithoutText( page: import('@playwright/test').Page, text: string, ): Promise { await page.waitForFunction( ([expected, surfaceSelector]: [string, string]) => { const surfaces = document.querySelectorAll(surfaceSelector) const active = surfaces[surfaces.length - 1] return !(active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) }, [text, SURFACE] as [string, string], { timeout: 15_000 }, ) } /** Replace the primary surface with a draft while retaining its warm cache. */ async function openFreshDraft(page: import('@playwright/test').Page, priorText: string): Promise { await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N') await waitForActiveTranscriptWithoutText(page, priorText) } /** Stack an empty tab while leaving the current transcript mounted and warm. */ async function openNewSessionTab(page: import('@playwright/test').Page, priorText: string): Promise { await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() await waitForActiveTranscriptWithoutText(page, priorText) } /** Stop the render counter and return the recorded burst/reconcile counts. */ async function readRenderCount(page: import('@playwright/test').Page): Promise<{ bursts: number mutations: number timeline: number[] reconciles: number } | null> { return page.evaluate(() => { type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number } const w = window as unknown as { __RENDER_COUNT__?: RenderCount } const rc = w.__RENDER_COUNT__ if (rc) { rc.stopped = true } return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null }) } async function observedViewportIsActive(page: import('@playwright/test').Page): Promise { return page.evaluate((surfaceSelector: string) => { const surfaces = document.querySelectorAll(surfaceSelector) const activeViewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') const observedViewport = (window as unknown as { __RENDER_VIEWPORT__?: Element }).__RENDER_VIEWPORT__ return activeViewport === observedViewport }, SURFACE) } /** A kept-alive tab must become visible without rebuilding its transcript. */ function assertNoRepaint(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { expect(result, 'MutationObserver should have recorded render data').toBeTruthy() expect( result!.bursts, `Expected no additive render bursts for a kept-alive tab, but got ${result!.bursts}. ` + `Mutation timeline: ${JSON.stringify(result!.timeline)}.`, ).toBe(0) expect( result!.reconciles, `Expected no transcript reconciles for a kept-alive tab, but got ${result!.reconciles}.`, ).toBe(0) } /** Assert the render counter shows exactly one paint with no re-renders. */ function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { expect(result, 'MutationObserver should have recorded render data').toBeTruthy() expect( result!.bursts, `Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` + `Mutation timeline: ${JSON.stringify(result!.timeline)}.`, ).toBe(1) expect( result!.reconciles, `Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` + `This means the warm-route resume re-rendered the transcript after the initial paint ` + `— the "warm resume jitter" bug is present.`, ).toBe(0) } test('tab reactivation preserves the mounted transcript without repainting', async ({}, testInfo) => { const page = fixture!.page // Wait for the sidebar to populate with our seeded session. const sessionRow = page .locator('[data-slot="sidebar"] button') .filter({ hasText: SESSION_TITLE }) .first() await sessionRow.waitFor({ state: 'visible', timeout: 60_000 }) // Step 1: Cold resume — click the session row to load it. // This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId). await sessionRow.click() // Wait for the transcript to appear — the first user message text confirms // the cold-path prefetch painted. await waitForActiveTranscriptText(page, FIRST_USER_MSG) // Wait for the session to fully settle (cold-path RPC + reconciliation). await page.waitForTimeout(2_000) // Stack a new tab, then observe the seeded transcript while it is hidden. // Installing after the switch isolates reactivation from mutations caused // while the new tab was being created. await openNewSessionTab(page, FIRST_USER_MSG) await page.waitForTimeout(500) await installRenderCounter(page, FIRST_USER_MSG) // Step 3: Click back and verify the same kept-alive viewport becomes active // without rebuilding or reconciling its transcript. await sessionRow.click() await waitForActiveTranscriptText(page, FIRST_USER_MSG) await page.waitForTimeout(2_000) expect(await observedViewportIsActive(page), 'Reactivation should reveal the observed kept-alive viewport').toBe(true) const result = await readRenderCount(page) await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') }) assertNoRepaint(result) }) test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => { test.fixme( true, 'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).', ) const page = fixture!.page const { mock } = fixture! // Wait for the sidebar to populate with our seeded session. const sessionRow = page .locator('[data-slot="sidebar"] button') .filter({ hasText: SESSION_TITLE }) .first() await sessionRow.waitFor({ state: 'visible', timeout: 60_000 }) // Step 1: Cold resume — populate the warm cache. await sessionRow.click() await waitForActiveTranscriptText(page, FIRST_USER_MSG) await page.waitForTimeout(2_000) // Step 2: Send a message — triggers inference via the mock server. const PROMPT = 'E2E post-inference warm resume test prompt' const composer = page.locator('[contenteditable="true"]').first() await composer.click() await composer.type(PROMPT, { delay: 10 }) await page.keyboard.press('Enter') // Wait for the mock response to appear in the transcript, confirming // the turn completed and message.complete fired (which updates the warm // cache via updateSessionState). await waitForActiveTranscriptText(page, 'mock inference server', 60_000) // Extra settle for message.complete → updateSessionState → cache write. await page.waitForTimeout(2_000) // Verify the prompt was received by the mock server. expect(mock.receivedPrompts).toContain(PROMPT) // Step 3: Replace the primary chat; the warm cache retains the updated messages. await openFreshDraft(page, PROMPT) await page.waitForTimeout(500) // Step 4: Install render counter, click back (warm resume), wait, assert. await installRenderCounter(page) await sessionRow.click() // Wait for the transcript to reappear — the warm cache should already // have the completed turn (updated by message.complete events). await waitForActiveTranscriptText(page, FIRST_USER_MSG) // Wait for at least 1 burst, then settle. await page.waitForFunction( () => { const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } } return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0) }, undefined, { timeout: 10_000 }, ) await page.waitForTimeout(2_000) const result = await readRenderCount(page) await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') }) assertNoJitter(result) })