/** * E2E tests for desktop sidebar states — background processes, subagents, * and session dot transitions. * * The mock server returns scripted tool_calls that the agent executes for * real (trivial commands + real subagent delegations). The tests assert the * sidebar states driven by real gateway events. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ import { expect, test, type Page } from '@playwright/test' import { type MockBackendFixture, setupMockBackend, waitForAppReady, } from './fixtures' import { createBackgroundReleaseHandle, restartMockServer, SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, } from './mock-server' /** Background-running dot aria-label (from i18n en.ts). */ const BG_DOT_LABEL = 'Background task running' /** Foreground turn-running dot aria-label. */ const SESSION_RUNNING_DOT_LABEL = 'Session running' /** Finished-unread dot aria-label. */ const UNREAD_DOT_LABEL = 'Finished — unread' /** * The auto-title auxiliary call hits the SAME mock provider as the chat turn, * and its request carries the user's message — trigger keyword included. The * mock's trigger matching is text-based, so the title call consumes a script * index: the real chat turn then gets turn 2 (final answer, NO tool calls), * the background process is never spawned, and the bg dot never appears. * Whether that happens depends on which request lands first — the CI flake * these specs had. Disable auto-title so script indices line up with real * chat turns (same fix as interim-messages.spec.ts). */ const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false' /** Send a message and wait for the final response to appear. */ async function sendMessageAndWait( page: Page, trigger: string, finalText: string, timeout = 90_000, ): Promise { const composer = page.locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 10_000 }) await composer.click() await composer.type(trigger, { delay: 20 }) await page.keyboard.press('Enter') await page.waitForFunction( () => (document.body.textContent ?? '').includes('E2E_'), undefined, { timeout: 15_000 }, ) await page.waitForFunction( (text) => (document.body.textContent ?? '').includes(text), finalText, { timeout }, ) } // ──────────────────────────────────────────────────────────────────────── // Test 1: background process + subagent appear in sidebar during turn // ──────────────────────────────────────────────────────────────────────── test.describe('sidebar states — background process and subagent', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture test.beforeAll(async () => { restartMockServer() fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { await fixture?.cleanup() }) test('background process dot appears and disappears, subagent runs, final answer visible', async () => { const page = fixture.page await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText) // The background process (sleep 1) should have shown a "Background task // running" dot at some point during the turn. We try to catch it; if // the process was too fast, that's OK — the real assertion is that the // final answer appeared and the dot is gone afterward. try { await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 15_000, message: 'background dot should appear' }, ) .toBeGreaterThan(0) } catch { // sleep 1 may have finished before we polled — not a failure. } // After the turn completes and auto-dismiss fires, the background dot // should be gone. await page.waitForTimeout(8000) const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0) // Evidence: capture the final state — no background dot, final answer visible. await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' }) // The final answer text must be in the transcript. const viewportText = await page .locator('[data-slot="aui_thread-viewport"]') .textContent() expect(viewportText).toContain(SIDEBAR_TEXTS.finalText) }) }) // ──────────────────────────────────────────────────────────────────────── // Test 2: subagent running shows background dot too (longer bg process) // ──────────────────────────────────────────────────────────────────────── test.describe('sidebar states — subagent and background dot coexist', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture // Hold the background process open until the test releases it. Without the // sentinel the process is a bare `sleep 5` racing the agent turn (two model // trips + a real subagent spawn): on a loaded runner the turn outlives the // sleep, the process is reaped mid-turn, and the dot never appears at all — // the CI flake this spec had. const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE, mockServer: { backgroundReleasePath: bgRelease.path }, }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { bgRelease.release() await fixture?.cleanup() bgRelease.cleanup() }) test('background dot visible while subagent runs', async () => { const page = fixture.page // Start the turn — a held background process plus a real subagent. const composer = page.locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 10_000 }) await composer.click() await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) await page.keyboard.press('Enter') // Wait for the user's message to appear. await page.waitForFunction( () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), undefined, { timeout: 15_000 }, ) // While the turn is busy the dot-state priority paints the session as // "working" ('Session running') — that claim OUTRANKS 'background', so // polling for the bg dot mid-turn races the turn length against the poll // budget. Wait for the turn to END (final text + running dot cleared), // then assert the background dot as a stable, sentinel-held state. await page.waitForFunction( (text) => (document.body.textContent ?? '').includes(text), SIDEBAR_CROSS_TEXTS.finalText, { timeout: 90_000 }, ) await expect .poll( () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'session running dot should disappear after turn completes' }, ) .toBe(0) // The background process is held open by the sentinel, so the bg dot is // a stable state — poll only to absorb the event-driven flip landing a // tick after the running dot clears. await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'background dot should be visible after turn completes' }, ) .toBeGreaterThan(0) // Evidence: the background dot is visible while the process runs. await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' }) // Release the process; the dot should clear on the completion event — // event-driven, not a fixed sleep. bgRelease.release() await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'background dot should be gone after process exits' }, ) .toBe(0) }) }) // ──────────────────────────────────────────────────────────────────────── // Test 3: cross-session — dot updates when viewing a different session // ──────────────────────────────────────────────────────────────────────── test.describe('sidebar states — cross-session dot transition', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture // Keeps the background process alive until this test releases it, so the // "still running after the turn finished" state can't expire on its own. const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE, mockServer: { backgroundReleasePath: bgRelease.path }, }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { // Release first so the process exits even if the test failed early, // then drop the sentinel file. bgRelease.release() await fixture?.cleanup() bgRelease.cleanup() }) test('background dot transitions to finished when viewing another session', async () => { const page = fixture.page // Start a turn whose background process runs until we release it. const composer = page.locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 10_000 }) await composer.click() await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) await page.keyboard.press('Enter') // While the turn is busy the dot-state priority paints the session as // "working" ('Session running') — that claim OUTRANKS 'background', so // polling for the bg dot mid-turn races the turn length (two model trips // + a real subagent spawn) against the poll budget: the CI flake this // spec had. Wait for the turn to END first, then assert the bg dot as a // stable, sentinel-held state. // // The final answer text streams before message.complete, so text visibility // alone is not a completion barrier. Wait for the foreground-running state // to clear before asserting the background-process state. await page.waitForFunction( (text) => (document.body.textContent ?? '').includes(text), SIDEBAR_CROSS_TEXTS.finalText, { timeout: 90_000 }, ) await expect .poll( () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'session running dot should disappear after turn completes' }, ) .toBe(0) // The background dot must be visible now: the turn is done but the // process is held open by the sentinel, so this is a stable state rather // than a window we have to catch in time. Poll to absorb the event-driven // flip landing a tick after the running dot clears. await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'background dot should be visible after turn completes' }, ) .toBeGreaterThan(0) // Evidence: bg dot visible on session A while its turn is done but the // background process hasn't exited yet. await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' }) // Create a new session (click "New session" button). await page.locator('button:has-text("New session")').first().click() await page.waitForTimeout(2000) // Now let the background process finish. The session A dot should // transition away from "background running". bgRelease.release() await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'background dot should disappear after process finishes' }, ) .toBe(0) // The original session should show a "finished unread" indicator (green dot) // since its turn completed while we were in a different session. This is an // event-driven transition, so wait for it instead of sampling the DOM right // after the running dot disappears. await expect .poll( () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'original session should show finished-unread dot' }, ) .toBeGreaterThan(0) // Evidence: the green "finished unread" dot on the original session after // switching to a new session — the cross-session dot transition. await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' }) }) })