Files
aiturk-hermes-ide/apps/desktop/e2e/sidebar-states.spec.ts
T

322 lines
13 KiB
TypeScript

/**
* 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<void> {
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' })
})
})