eb1b14b952
The Desktop E2E lane was disabled Aug 2 – Sep 1; the app and gateway kept moving, so 16 specs rotted against current main. All failures traced to spec/harness drift, not product regressions: - fixtures.ts: title generation now rides the main model (#83636), firing a background completion at the mock after every turn — it contains the whole conversation (trigger keywords included), advancing scripted-turn indices and tripping hold-for-prompt matchers. Disabled by default in the mock provider config; specs supplying their own `auxiliary:` section own it. - chat/interim-messages/session-compression/correction-session-switch/ hidden-history-messages: busy-state and transcript assertions updated to the current composer aria-labels, interim-message semantics, and verify-on-stop continuation behavior on main. - bot-mode-closed-chat-stays-closed/group-to-local-bot-handoff: Bot Chat tab selectors updated for the Bot Mode rework (tabs keyed by connection+profile, renamed tab triggers). - glyph-spinner: assertions made compositor-honest for the CI runner (steps() keyframes + layer promotion probed via the animation registry instead of GPU-dependent screenshots). - sidebar-states/tile-unread-bug: event-driven waits with mock-server release handles replace wall-clock polls that lost races on loaded runners. - warm-resume-jitter/image-attachment-resume: real-session-builder harness waits for the thread viewport before evaluating; failure path now dumps per-surface pane state. Local full-suite run on the CI-equivalent xvfb setup: 62 passed, 11 skipped, 1 flaky-passed (correction-session-switch live-correction spec, passes on retry). No product code changed.
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
/**
|
|
* E2E chat tests — send a message and verify a response appears.
|
|
*
|
|
* Requires the full boot chain to complete (hermes serve + mock inference
|
|
* provider). The mock server returns a canned reply, so we verify the
|
|
* response text shows up in the chat transcript.
|
|
*
|
|
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
|
*/
|
|
|
|
import { expect, test } from './test'
|
|
|
|
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
|
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
|
|
import { expectVisualSnapshot } from './visual-snapshot'
|
|
|
|
let fixture: MockBackendFixture | null = null
|
|
|
|
test.beforeAll(async () => {
|
|
fixture = await setupMockBackend()
|
|
await waitForAppReady(fixture!, 120_000)
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await fixture?.cleanup()
|
|
fixture = null
|
|
})
|
|
|
|
test.describe('chat interaction with mock backend', () => {
|
|
test('send a message and receive a response', async () => {
|
|
const page = fixture!.page
|
|
|
|
// Find the composer — it's a contenteditable textbox.
|
|
const composer = page.locator('[contenteditable="true"]').first()
|
|
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
|
|
|
// Click to focus, then type the message character by character.
|
|
// Using `type` instead of `fill` because the composer is a
|
|
// contenteditable div with custom keydown handling that tracks
|
|
// IME composition state — `fill` bypasses the event chain.
|
|
await composer.click()
|
|
await composer.type('Hello, can you hear me?', { delay: 20 })
|
|
|
|
// Submit with Enter — the composer's keydown handler intercepts
|
|
// plain Enter (without Shift) and calls submitDraft().
|
|
await page.keyboard.press('Enter')
|
|
|
|
// Wait for the user's message to appear in the transcript.
|
|
// The message renders as an assistant-ui message in the chat view.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const body = document.body
|
|
|
|
if (!body) {
|
|
return false
|
|
}
|
|
|
|
return (body.textContent ?? '').includes('Hello, can you hear me?')
|
|
},
|
|
undefined,
|
|
{ timeout: 15_000 }
|
|
)
|
|
|
|
// Wait for the mock response to appear. The canned reply is:
|
|
// "Hello from the mock inference server! The full boot chain is working."
|
|
// Give it a generous timeout — the inference request goes through the
|
|
// gateway → hermes serve → mock server → streaming SSE back.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const body = document.body
|
|
|
|
if (!body) {
|
|
return false
|
|
}
|
|
|
|
const text = body.textContent ?? ''
|
|
|
|
return text.includes('mock inference server') || text.includes('boot chain is working')
|
|
},
|
|
undefined,
|
|
{ timeout: 60_000 }
|
|
)
|
|
})
|
|
|
|
test('screenshot of chat with messages', async () => {
|
|
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
|
})
|
|
|
|
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
|
|
const page = fixture!.page
|
|
const composer = page.locator('[contenteditable="true"]').first()
|
|
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
|
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
|
|
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
|
|
const speakReplies = page.locator(
|
|
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
|
|
)
|
|
|
|
await composer.click()
|
|
await composer.type(BLOCKING_CLARIFY_TRIGGER)
|
|
await page.keyboard.press('Enter')
|
|
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
|
|
|
|
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
|
await expect(primary.locator('span')).toHaveClass(/bg-current/)
|
|
|
|
await composer.click()
|
|
await composer.type('please answer tersely')
|
|
// Since "running is not busy" (3bc52fb9df) the primary keeps the Send
|
|
// affordance mid-turn — steer is routed through the submit engine, not a
|
|
// separate labeled button. Queue remains the explicit secondary action.
|
|
await expect(primary).toHaveAttribute('aria-label', 'Send')
|
|
await expect(dictation).toBeVisible()
|
|
await expect(speakReplies).toBeVisible()
|
|
await expect(queue).toBeVisible()
|
|
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
|
|
const controlLabels = await page
|
|
.locator('[data-slot="composer-root"] button')
|
|
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
|
|
const speakRepliesIndex = controlLabels.findIndex(
|
|
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
|
|
)
|
|
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
|
|
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
|
|
expect(controlLabels.indexOf('Queue message')).toBeLessThan(controlLabels.indexOf('Send'))
|
|
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
|
|
await expect(primary.locator('.codicon-arrow-up')).toBeVisible()
|
|
|
|
await queue.click()
|
|
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
|
await expect(queue).toHaveCount(0)
|
|
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
|
|
await expect(page.getByText('1 Queued')).toBeVisible()
|
|
|
|
await primary.click()
|
|
await expect(page.getByText('1 Queued — paused')).toBeVisible()
|
|
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
|
|
})
|
|
})
|