test(e2e): cover the assistant lifecycle on a flow

The assistant panel is the first screen of a flow and had no coverage at all: the
four mutations behind it (create, call, stop, delete) could each have been unwired
without a single test noticing — the create path in fact was, and the fix that
went in ahead of this commit is what these tests now hold in place.

Each test pins the variables its mutation puts on the wire and the state the panel
lands in afterwards, driving the picker the way an operator does. The picker's
trigger also gains an accessible name; it previously announced itself as its
provider icon and index, which named neither the control nor its purpose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-26 05:35:56 +07:00
parent 83bd36aa36
commit 052d02ba16
3 changed files with 332 additions and 0 deletions
@@ -0,0 +1,72 @@
import type { ResultOf } from '@graphql-typed-document-node/core';
import type { AssistantFragmentFragment, AssistantLogFragmentFragment, AssistantsDocument } from '@/graphql/types';
import { MessageLogType, ResultFormat, StatusType } from '@/graphql/types';
import type { Cassette } from '../cassette.ts';
import { entity, mergeCassettes } from '../cassette.ts';
import { flowsCassette, PROVIDER } from './flows.ts';
const T = '2026-01-15T11:30:00Z';
export const makeAssistant = (
id: string,
title: string,
status: StatusType = StatusType.Waiting,
): AssistantFragmentFragment =>
entity('Assistant', {
createdAt: T,
flowId: '5',
id,
provider: PROVIDER,
status,
title,
updatedAt: T,
useAgents: false,
});
export const WAITING_ASSISTANT = makeAssistant('11', 'E2E Assistant Waiting');
export const RUNNING_ASSISTANT = makeAssistant('12', 'E2E Assistant Running', StatusType.Running);
export const CREATED_ASSISTANT = makeAssistant('13', 'E2E Assistant Created');
export const ASSISTANT_ANSWER = 'The scan found an open port on 8080.';
const assistantLog = (id: string, assistantId: string, message: string): AssistantLogFragmentFragment =>
entity('AssistantLog', {
appendPart: false,
assistantId,
createdAt: T,
flowId: '5',
id,
message,
result: ASSISTANT_ANSWER,
resultFormat: ResultFormat.Markdown,
thinking: null,
type: MessageLogType.Answer,
});
const seeded: ResultOf<typeof AssistantsDocument> = { assistants: [WAITING_ASSISTANT, RUNNING_ASSISTANT] };
export const assistantsCassette = (override: Cassette = {}): Cassette =>
mergeCassettes(
flowsCassette({
queries: {
assistantLogs: [
{
data: { assistantLogs: [assistantLog('41', '11', 'what did the scan find?')] },
variables: { assistantId: '11', flowId: '5' },
},
{
data: { assistantLogs: [assistantLog('42', '12', 'keep going')] },
variables: { assistantId: '12', flowId: '5' },
},
{ data: { assistantLogs: [] } },
],
assistants: [{ data: seeded, variables: { flowId: '5' } }],
providers: [{ data: { providers: [PROVIDER] } }],
},
}),
override,
);
+259
View File
@@ -0,0 +1,259 @@
import type { ResultOf } from '@graphql-typed-document-node/core';
import type { Page } from '@playwright/test';
import type { CallAssistantDocument, DeleteAssistantDocument } from '@/graphql/types';
import { ResultType, StatusType } from '@/graphql/types';
import { expect, test } from '../../fixtures/test.ts';
import { expectCleanPage } from '../../helpers/errors.ts';
import {
assistantsCassette,
CREATED_ASSISTANT,
makeAssistant,
RUNNING_ASSISTANT,
WAITING_ASSISTANT,
} from '../../mocks/cassettes/assistants.ts';
import { PROVIDER } from '../../mocks/cassettes/flows.ts';
const openAssistantTab = async (page: Page) => {
await page.goto('/flows/5');
await page.getByRole('tab', { name: 'Assistant' }).click();
await expect(page.getByPlaceholder('Search messages...')).toBeVisible();
};
const openPicker = async (page: Page) => {
await page.getByRole('button', { name: 'Select assistant' }).click();
await expect(page.getByPlaceholder('Search assistants...')).toBeVisible();
};
const operationRequest = (page: Page, operationName: string) =>
page.waitForRequest(
(request) => request.method() === 'POST' && request.postDataJSON()?.operationName === operationName,
);
test.describe('flow assistants', { tag: '@flows' }, () => {
test.describe('create', () => {
test.use({
cassette: assistantsCassette({
mutations: {
createAssistant: [
{
data: {
createAssistant: {
assistant: CREATED_ASSISTANT,
flow: { __typename: 'Flow', id: '5' },
},
} as never,
setFlag: 'assistant-created',
variables: { flowId: '5', input: 'plan the recon', modelProvider: PROVIDER.name },
},
],
},
subscriptions: {
assistantCreated: [
{
frames: [
{
payload: { data: { assistantCreated: CREATED_ASSISTANT } },
whenFlag: 'assistant-created',
},
],
},
],
},
}),
});
test('creates an assistant from the empty composer and selects it', async ({ page, pageErrorLog }) => {
await openAssistantTab(page);
await openPicker(page);
await page.getByRole('option', { name: 'Create new assistant' }).click();
const composer = page.getByPlaceholder('Type a message to create a new assistant...');
await expect(composer).toBeVisible();
await composer.fill('plan the recon');
// A brand-new assistant inherits no provider. The only one on offer is also the one the
// previously selected assistant used — the case a defaults-sync that ignores the picked
// value silently reverts.
await page.getByRole('button', { name: 'Select Provider' }).click();
await page.getByRole('menuitem', { name: PROVIDER.name }).click();
const request = operationRequest(page, 'createAssistant');
await page.getByRole('button', { name: 'Submit' }).click();
expect((await request).postDataJSON().variables).toEqual({
flowId: '5',
input: 'plan the recon',
modelProvider: PROVIDER.name,
useAgents: false,
});
await openPicker(page);
await expect(page.getByRole('option', { name: new RegExp(CREATED_ASSISTANT.title) })).toBeVisible();
expectCleanPage(pageErrorLog);
});
});
test.describe('call', () => {
const called: ResultOf<typeof CallAssistantDocument> = { callAssistant: ResultType.Success };
test.use({
cassette: assistantsCassette({
mutations: {
callAssistant: [
{
data: called,
variables: { assistantId: '11', flowId: '5', input: 'and the other ports?' },
},
],
},
}),
});
test('sends a follow-up to the assistant the picker already selected', async ({ page, pageErrorLog }) => {
await openAssistantTab(page);
const composer = page.getByPlaceholder('Continue the conversation...');
await composer.fill('and the other ports?');
const request = operationRequest(page, 'callAssistant');
await page.getByRole('button', { name: 'Submit' }).click();
expect((await request).postDataJSON().variables).toEqual({
assistantId: WAITING_ASSISTANT.id,
flowId: '5',
input: 'and the other ports?',
useAgents: false,
});
await expect(composer).toHaveValue('');
expectCleanPage(pageErrorLog);
});
});
test.describe('stop', () => {
test.use({
cassette: assistantsCassette({
mutations: {
stopAssistant: [
{
data: {
stopAssistant: makeAssistant(
RUNNING_ASSISTANT.id,
RUNNING_ASSISTANT.title,
StatusType.Waiting,
),
},
setFlag: 'assistant-stopped',
variables: { assistantId: '12', flowId: '5' },
},
],
},
subscriptions: {
assistantUpdated: [
{
frames: [
{
payload: {
data: {
assistantUpdated: makeAssistant(
RUNNING_ASSISTANT.id,
RUNNING_ASSISTANT.title,
StatusType.Waiting,
),
},
},
whenFlag: 'assistant-stopped',
},
],
},
],
},
}),
});
test('stops the running assistant and hands the composer back', async ({ page, pageErrorLog }) => {
await openAssistantTab(page);
await openPicker(page);
await page.getByRole('option', { name: new RegExp(RUNNING_ASSISTANT.title) }).click();
const stop = page.getByRole('button', { name: 'Cancel' });
await expect(stop).toBeVisible();
const request = operationRequest(page, 'stopAssistant');
await stop.click();
expect((await request).postDataJSON().variables).toEqual({
assistantId: RUNNING_ASSISTANT.id,
flowId: '5',
});
await expect(page.getByPlaceholder('Continue the conversation...')).toBeVisible();
expectCleanPage(pageErrorLog);
});
});
test.describe('delete', () => {
const deleted: ResultOf<typeof DeleteAssistantDocument> = { deleteAssistant: ResultType.Success };
test.use({
cassette: assistantsCassette({
mutations: {
deleteAssistant: [
{
data: deleted,
setFlag: 'assistant-deleted',
variables: { assistantId: '12', flowId: '5' },
},
],
},
subscriptions: {
assistantDeleted: [
{
frames: [
{
payload: { data: { assistantDeleted: RUNNING_ASSISTANT } },
whenFlag: 'assistant-deleted',
},
],
},
],
},
}),
});
test('deletes an assistant through the confirm dialog', async ({ page, pageErrorLog }) => {
await openAssistantTab(page);
await openPicker(page);
const doomed = page.getByRole('option', { name: new RegExp(RUNNING_ASSISTANT.title) });
await doomed.hover();
await doomed.getByRole('button').click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Delete Assistant')).toBeVisible();
const request = operationRequest(page, 'deleteAssistant');
await dialog.getByRole('button', { name: 'Delete' }).click();
expect((await request).postDataJSON().variables).toEqual({
assistantId: RUNNING_ASSISTANT.id,
flowId: '5',
});
await openPicker(page);
await expect(page.getByRole('option', { name: new RegExp(WAITING_ASSISTANT.title) })).toBeVisible();
await expect(page.getByRole('option', { name: new RegExp(RUNNING_ASSISTANT.title) })).toBeHidden();
expectCleanPage(pageErrorLog);
});
});
});
@@ -182,6 +182,7 @@ function AssistantsDropdown({
>
<PopoverTrigger asChild>
<Button
aria-label="Select assistant"
className="px-2"
disabled={isAssistantCreating}
variant="outline"