322 lines
13 KiB
TypeScript
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' })
|
|
})
|
|
})
|