Files
LangBot/web/tests/e2e/crud-smoke.spec.ts
T
RockChinQandmultica-agent d13ae56ea5 fix: bot routing setup and busy-session response checks (#2728)
Apply group response rules before busy-session steering. Initialize usable private-message and group-mention routes for new bots, including Wizard-created pipelines, and preserve member-configured rules across saves and retries.

Fix empty pipeline configuration, refresh the processor sidebar after automatic creation, and show creation feedback. Align routing regressions and use Node 24 for the browser CI job.

Correct the task's erroneous agent attribution while preserving every code tree and the original human contribution.

Co-authored-by: multica-agent <[email protected]>
2026-10-20 21:22:53 +08:00

1690 lines
58 KiB
TypeScript

import { expect, Page, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
pipelineMetadata,
} from './fixtures/langbot-api';
async function save(page: Page) {
const button = page.getByRole('button', { name: /^Save(?: and enable)?$/ });
await expect(button).toBeEnabled();
await button.click();
}
async function submit(page: Page) {
await page.getByRole('button', { name: /^Submit$/ }).click();
}
async function selectPlaywrightAdapter(page: Page) {
await page
.getByTestId('adapter-gallery')
.getByRole('button', { name: /Playwright Adapter/ })
.click();
}
async function createPlaywrightBot(page: Page, name: string, description = '') {
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill(name);
if (description) {
await page.locator('input[name="description"]').fill(description);
}
await submit(page);
}
async function confirmDelete(page: Page, apiPath: string) {
const responses: boolean[] = [];
page.on('response', (response) => {
if (
response.request().method() === 'DELETE' &&
new URL(response.url()).pathname === apiPath
)
responses.push(response.ok());
});
await page
.getByRole('dialog')
.getByRole('button', { name: /^Confirm Delete$/ })
.click();
await expect.poll(() => responses).toEqual([true]);
}
async function installDelayedFirstSave(page: Page, apiPath: string) {
const payloads: Record<string, unknown>[] = [];
let releaseFirstSave = () => {};
const firstSaveGate = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
await page.route(`**${apiPath}`, async (route) => {
if (route.request().method() !== 'PUT') {
await route.fallback();
return;
}
payloads.push(
JSON.parse(route.request().postData() || '{}') as Record<string, unknown>,
);
if (payloads.length === 1) {
await firstSaveGate;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'ok',
data: {},
timestamp: Date.now(),
}),
});
});
return { payloads, releaseFirstSave };
}
async function forceFormSubmit(page: Page, formSelector: string) {
await page.locator(formSelector).evaluate((form) => {
(form as HTMLFormElement).requestSubmit();
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
test.describe('frontend CRUD smoke flows', () => {
test('localizes the pipeline processor type in Simplified Chinese', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
language: 'zh-Hans',
});
await page.goto('/home/agents?id=new');
await expect(
page.locator('[data-processor-kind="pipeline"]'),
).toContainText('流水线');
await expect(
page.locator('[data-processor-kind="pipeline"]'),
).toContainText(
'流水线只处理消息事件,由 AI 直接生成回复,并提供知识库、插件等实用功能。',
);
});
test('viewer keeps ordinary bot and pipeline monitoring access', async ({
page,
}) => {
const workspace = makeWorkspaceEntry(
'workspace-viewer',
'Viewer Workspace',
'local',
);
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.goto('/home/bots?id=new');
await createPlaywrightBot(
page,
'Viewer Test Bot',
'Proves monitoring is ordinary resource visibility.',
);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.goto('/home/agents?id=new');
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
await page.locator('input[name="name"]').fill('Viewer Pipeline');
await submit(page);
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
workspace.membership.role = 'viewer';
workspace.permissions = ['member.view', 'resource.view', 'workspace.view'];
await page.goto('/home/bots?id=bot-1');
await expect(page.getByRole('tab', { name: 'Logs' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Sessions' })).toBeVisible();
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
await page.getByRole('tab', { name: 'Logs' }).click();
await expect(page.getByText('No logs yet')).toBeVisible();
await page.goto('/home/agents?id=pipeline-1');
await expect(page.getByRole('tab', { name: 'Run logs' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
await page.goto('/home/monitoring');
await expect(
page.getByRole('button', { name: 'Refresh Data' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Export Data' })).toHaveCount(
0,
);
});
test('creates, edits, and deletes a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
const createRequest = page.waitForRequest(
(request) =>
request.method() === 'POST' &&
request.url().endsWith('/api/v1/platform/bots'),
);
await createPlaywrightBot(
page,
'Support Bot',
'Answers customer support questions.',
);
expect((await createRequest).postDataJSON()).toMatchObject({
name: 'Support Bot',
description: 'Answers customer support questions.',
adapter: 'playwright-adapter',
enable: false,
});
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.reload();
await expect(
page.getByRole('heading', { name: 'Support Bot' }),
).toBeVisible();
await expect(page.locator('input[name="name"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const botInfoDialog = page.getByRole('dialog');
await expect(botInfoDialog.getByLabel('Icon')).toHaveCount(0);
await botInfoDialog.getByLabel('Name').fill('Support Bot Updated');
await botInfoDialog
.getByLabel('Description')
.fill('Answers customer support questions with context.');
await botInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: 'Support Bot Updated' }),
).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page, '/api/v1/platform/bots/bot-1');
await expect(page).toHaveURL(/\/home\/bots$/);
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
await page.reload();
await expect(
page.getByText('Support Bot Updated', { exact: true }),
).toHaveCount(0);
});
test('creates, edits, and deletes a pipeline', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=new');
const agentTypeCard = page.locator('[data-processor-kind="agent"]');
const pipelineTypeCard = page.locator('[data-processor-kind="pipeline"]');
await expect(agentTypeCard).toHaveAttribute(
'data-slot',
'toggle-group-item',
);
await expect(pipelineTypeCard).toHaveAttribute(
'data-slot',
'toggle-group-item',
);
await agentTypeCard.click();
await expect(agentTypeCard).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId('agent-diagram')).toBeVisible();
await expect(
page.getByTestId('agent-diagram').locator('[data-motion="flow"]'),
).toHaveCount(3);
await expect(
page.getByTestId('agent-diagram').locator('[data-motion="relation"]'),
).toHaveCount(3);
await expect(page.getByTestId('pipeline-diagram')).toHaveCount(0);
await page.evaluate(() => document.documentElement.classList.add('dark'));
await expect(page.getByTestId('agent-diagram')).toHaveCSS(
'--processor-flow-opacity',
'0.72',
);
await expect(page.getByTestId('agent-diagram')).toHaveCSS(
'--processor-node-stroke-opacity',
'0.5',
);
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
await expect(pipelineTypeCard).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId('pipeline-diagram')).toBeVisible();
await expect(
page.getByTestId('pipeline-diagram').locator('[data-motion="flow"]'),
).toHaveAttribute('data-dash-cycle', '15');
await expect(page.getByTestId('agent-diagram')).toHaveCount(0);
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Escalation Pipeline');
await submit(page);
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
await page.reload();
await expect(
page.getByRole('heading', { name: /Escalation Pipeline/ }),
).toBeVisible();
await expect(page.locator('input[name="basic.name"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const pipelineInfoDialog = page.getByRole('dialog');
await pipelineInfoDialog
.getByLabel('Description')
.fill('Routes urgent customer issues to operators.');
await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(pipelineInfoDialog).toHaveCount(0);
await page.reload();
await page.getByRole('button', { name: 'Edit basic information' }).click();
await expect(
page.getByRole('dialog').getByLabel('Description'),
).toHaveValue('Routes urgent customer issues to operators.');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Management' }).click();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page, '/api/v1/pipelines/pipeline-1');
await expect(page).toHaveURL(/\/home\/agents$/);
await expect(
page.getByText('Select a processor from the sidebar'),
).toBeVisible();
await page.reload();
await expect(
page.getByText('Escalation Pipeline', { exact: true }),
).toHaveCount(0);
});
test('opens pipeline AI capabilities with malformed model options', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=pipeline-ai');
await expect(
page.getByRole('heading', { name: /pipeline-ai/ }),
).toBeVisible();
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(page.getByText('Runtime', { exact: true })).toBeVisible();
await expect(
page.locator('[data-slot="card-title"]').filter({
hasText: 'Local Agent',
}),
).toBeVisible();
await expect(
page.locator('label').filter({
hasText: 'Model',
}),
).toBeVisible();
await expect(page.getByText('A <Select.Item')).toHaveCount(0);
await expect(page.getByText('500')).toHaveCount(0);
});
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/knowledge?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Support Knowledge');
await page
.locator('input[name="description"]')
.fill('Source material for support answers.');
await save(page);
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
await page.reload();
await expect(
page.getByRole('heading', { name: /Support Knowledge/ }),
).toBeVisible();
await expect(page.locator('input[name="name"]')).toHaveCount(0);
const engineSettings = page.locator('[data-slot="card"]').filter({
has: page.getByText('Engine Settings', { exact: true }),
});
await expect(engineSettings.getByRole('combobox')).toBeVisible();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const kbInfoDialog = page.getByRole('dialog');
await kbInfoDialog.getByLabel('Name').fill('Support Knowledge Updated');
await kbInfoDialog
.getByLabel('Description')
.fill('Updated source material for support answers.');
await kbInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Support Knowledge Updated/ }),
).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page, '/api/v1/knowledge/bases/knowledge-1');
await expect(page).toHaveURL(/\/home\/knowledge$/);
await expect(
page.getByText('Select a knowledge base from the sidebar'),
).toBeVisible();
await page.reload();
await expect(
page.getByText('Support Knowledge Updated', { exact: true }),
).toHaveCount(0);
});
test('creates, edits, and deletes an MCP server', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/mcp?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('playwright-mcp');
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/sse');
await submit(page);
await expect(page).toHaveURL(/\/home\/mcp\?id=playwright-mcp$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'playwright-mcp',
);
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/updated-sse');
const update = page.waitForResponse(
(response) =>
response.request().method() === 'PUT' &&
response.url().endsWith('/api/v1/mcp/servers/playwright-mcp'),
);
await save(page);
expect((await update).request().postDataJSON()).toMatchObject({
name: 'playwright-mcp',
mode: 'remote',
extra_args: { url: 'https://mcp.example.test/updated-sse' },
});
await page.reload();
await expect(page.locator('input[name="url"]')).toHaveValue(
'https://mcp.example.test/updated-sse',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page, '/api/v1/mcp/servers/playwright-mcp');
await expect(page).toHaveURL(/\/home\/mcp$/);
await expect(
page.getByText('Select an MCP server from the sidebar'),
).toBeVisible();
await page.reload();
await expect(page.getByText('playwright-mcp', { exact: true })).toHaveCount(
0,
);
});
test('updates and deletes a manually-created skill', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/skills?action=create');
await page.locator('#display_name').fill('Release Notes');
await page.locator('#name').fill('release_notes');
await page.locator('#description').fill('Drafts release notes.');
await page
.locator('#instructions')
.fill('Summarize merged changes for the next release.');
await save(page);
await expect(page).toHaveURL(/\/home\/skills\?id=release_notes$/);
await page.reload();
await expect(page.locator('#description')).toHaveValue(
'Drafts release notes.',
);
await page
.locator('#description')
.fill('Drafts concise release notes for maintainers.');
await expect(page.locator('#description')).toHaveValue(
'Drafts concise release notes for maintainers.',
);
await save(page);
await page.reload();
await expect(page.locator('#description')).toHaveValue(
'Drafts concise release notes for maintainers.',
);
await expect(page.locator('#instructions')).toHaveValue(
'Summarize merged changes for the next release.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page, '/api/v1/skills/release_notes');
await expect(page).toHaveURL(/\/home\/add-extension$/);
await page.goto('/home/skills');
await expect(page.getByText('Release Notes', { exact: true })).toHaveCount(
0,
);
});
});
test.describe('bot advanced flows', () => {
test('keeps event routing compact and hides raw status errors', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ code: -1, msg: 'Internal server error' }),
}),
);
await page.route(
'**/api/v1/platform/bots/*/event-routes/dry-run',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: {
matched: true,
event_type: 'message.received',
matched_binding_id: 'binding-1',
matched_binding_index: 0,
target: {
target_type: 'agent',
target_uuid: 'agent-1',
target_name: 'NewAgent',
},
diagnostic_steps: ['Matched route 1'],
diagnostic_details: [],
},
}),
}),
);
let botLogPollCount = 0;
await page.route('**/api/v1/platform/bots/*/logs', (route) => {
botLogPollCount += 1;
const logs =
botLogPollCount === 1
? []
: [
{
seq_id: 7,
timestamp: Math.floor(Date.now() / 1000),
level: 'info',
text: 'Platform adapter received message.received',
images: [],
message_session_id: '',
metadata: {
kind: 'adapter_event_received',
event_type: 'message.received',
adapter: 'playwright-adapter',
bot_uuid: 'bot-1',
event_data: {
type: 'message.received',
chat_type: 'private',
chat_id: 'test-user',
message_chain: [{ type: 'Plain', text: 'adapter hello' }],
},
},
},
];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: { logs, total_count: logs.length },
}),
});
});
await page.goto('/home/bots?id=new');
await createPlaywrightBot(page, 'Route Status Bot');
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await expect(page.getByText('Supported events')).toBeVisible();
await expect(page.getByText('5 event types')).toBeVisible();
await expect(page.getByText('Messages · 2')).toBeVisible();
await expect(page.getByText('Groups · 2')).toBeVisible();
await expect(page.getByText('Internal server error')).toHaveCount(0);
await expect(
page.getByText('Events that match no route are ignored.'),
).toHaveCount(0);
const routingCard = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('Event Routing', { exact: true }) });
const adapterCard = page.locator('[data-slot="card"]').filter({
has: page.getByText('Adapter Configuration', { exact: true }),
});
const routingBox = await routingCard.boundingBox();
const adapterBox = await adapterCard.boundingBox();
expect(routingBox).not.toBeNull();
expect(adapterBox).not.toBeNull();
expect(routingBox!.x).toBeGreaterThan(adapterBox!.x + adapterBox!.width);
expect(Math.abs(routingBox!.y - adapterBox!.y)).toBeLessThan(2);
await expect(
page.getByRole('button', { name: /^Delete$/ }),
).toBeInViewport();
await routingCard.getByRole('button', { name: 'View all' }).click();
await expect(
routingCard.getByText('Messages', { exact: true }),
).toBeVisible();
await expect(
routingCard.getByText('Groups', { exact: true }),
).toBeVisible();
await routingCard.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /Reply to messages/ }).click();
const routeEventSelect = routingCard.getByRole('combobox').first();
await expect(routeEventSelect).toContainText('Message received');
await expect(routeEventSelect).toContainText('message.received');
await routeEventSelect.click();
const routeEventOption = page
.getByRole('option')
.filter({ hasText: 'Message received' });
await expect(routeEventOption).toContainText('message.received');
await expect(routeEventOption).toContainText(
'A user or group sends a new message to the bot.',
);
await page.keyboard.press('Escape');
await page
.getByRole('button', { name: 'Match records', exact: true })
.click();
await expect(
page.getByText('Failed to refresh route status.'),
).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Check route' }).click();
const routeDialog = page.getByRole('dialog');
await expect(
routeDialog.getByText('Check event route', { exact: true }),
).toBeVisible();
await expect(
routeDialog.getByText(
'Choose an event to see which route and processor it matches.',
),
).toBeVisible();
await expect(routeDialog.getByText('Sample event is ready')).toHaveCount(0);
await expect(
routeDialog.getByRole('button', { name: 'Test data' }),
).toBeVisible();
await expect(
routeDialog.getByRole('button', { name: 'View match' }),
).toBeVisible();
await expect(
routeDialog.getByRole('button', { name: 'Run full test' }),
).toHaveCount(0);
const routeEventPicker = routeDialog.getByRole('combobox', {
name: 'Event type',
});
await expect(routeEventPicker).toContainText('message.received');
await routeEventPicker.click();
await expect(
page.getByText('Messages', { exact: true }).last(),
).toBeVisible();
await expect(
page.getByText('Groups', { exact: true }).last(),
).toBeVisible();
const receivedMessageOption = page
.getByRole('option')
.filter({ hasText: 'Message received' });
await expect(receivedMessageOption).toContainText('message.received');
await expect(receivedMessageOption).toContainText(
'A user or group sends a new message to the bot.',
);
await page.keyboard.press('Escape');
await routeDialog.getByRole('button', { name: 'View match' }).click();
await expect(routeDialog.getByText('Matched route')).toBeVisible();
await expect(routeDialog.getByText('Internal server error')).toHaveCount(0);
const dialogBox = await routeDialog.boundingBox();
expect(dialogBox).not.toBeNull();
expect(dialogBox!.height).toBeLessThan(500);
await routeDialog.getByRole('button', { name: 'Close' }).first().click();
await expect(
adapterCard.getByText('Test adapter configuration'),
).toHaveCount(0);
const listenButton = page.getByRole('button', {
name: 'Test listener',
exact: true,
});
const listenBox = await listenButton.boundingBox();
const saveBox = await page
.getByRole('button', { name: /^Save(?: and enable)?$/ })
.boundingBox();
expect(listenBox).not.toBeNull();
expect(saveBox).not.toBeNull();
expect(listenBox!.x + listenBox!.width).toBeLessThan(saveBox!.x);
expect(Math.abs(listenBox!.y - saveBox!.y)).toBeLessThan(2);
await listenButton.click();
const adapterDialog = page.getByRole('dialog');
await expect(
adapterDialog.getByText('Platform event debugging', { exact: true }),
).toBeVisible();
await expect(adapterDialog).toContainText('Playwright Adapter');
await expect(adapterDialog).toContainText(
'This window only observes events. Incoming events still follow the current routes.',
);
await expect(
adapterDialog.getByText('Message received', { exact: true }),
).toBeVisible({ timeout: 5000 });
await expect(adapterDialog.getByText('message.received')).toBeVisible();
await expect(adapterDialog.getByText('adapter hello')).toBeVisible();
await adapterDialog
.getByRole('button', { name: 'View event data' })
.click();
await expect(
adapterDialog.getByText(/"chat_id": "test-user"/),
).toBeVisible();
await adapterDialog.getByRole('button', { name: 'Clear' }).click();
await expect(adapterDialog.getByText('0 events received')).toBeVisible();
await expect(
adapterDialog.getByText('Waiting for a platform event'),
).toBeVisible();
});
test('creates a disabled bot and enables it from the detail page', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot first
await page.goto('/home/bots?id=new');
await createPlaywrightBot(page, 'Toggle Test Bot');
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for the enable switch to load (it's fetched via getBot)
await expect(page.locator('#bot-enable-switch')).toBeVisible({
timeout: 5000,
});
// Draft bots remain disabled until their adapter configuration is ready.
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
// Enable after reaching the detail page.
await page.locator('#bot-enable-switch').click();
await expect(page.locator('#bot-enable-switch')).toBeChecked();
// Reload and verify state persisted
await page.reload();
await expect(page.locator('#bot-enable-switch')).toBeChecked();
});
test('switches between bot detail tabs', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/bots/bot-1/logs', (route) =>
route.fulfill({
json: {
code: 0,
data: {
logs: [
{
seq_id: 1,
level: 'info',
text: 'Bot tab log response',
images: [],
message_session_id: '',
timestamp: 1792200000,
},
],
total_count: 1,
},
},
}),
);
await page.route('**/api/v1/monitoring/sessions?**', (route) => {
expect(new URL(route.request().url()).searchParams.get('botId')).toBe(
'bot-1',
);
return route.fulfill({
json: {
code: 0,
data: {
sessions: [
{
session_id: 'person_tab-user',
bot_id: 'bot-1',
bot_name: 'Tab Test Bot',
pipeline_id: 'pipeline-tab',
pipeline_name: 'Tab pipeline',
message_count: 1,
start_time: '2026-10-17T00:00:00Z',
last_activity: '2026-10-17T00:01:00Z',
is_active: true,
user_id: 'tab-user',
user_name: 'Tab Session User',
},
],
total: 1,
},
},
});
});
// Create a bot
await page.goto('/home/bots?id=new');
await createPlaywrightBot(page, 'Tab Test Bot');
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
// Switch to Logs tab
await page.getByRole('tab', { name: /Logs/ }).click();
await expect(page.getByRole('tab', { name: /Logs/ })).toHaveAttribute(
'data-state',
'active',
);
await expect(
page.getByText('Bot tab log response', { exact: true }),
).toBeVisible();
// Switch to Sessions tab
await page.getByRole('tab', { name: /Sessions/ }).click();
await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute(
'data-state',
'active',
);
await expect(
page.getByText('Tab Session User', { exact: true }),
).toBeVisible();
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
});
test('save button is disabled when form is clean', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=new');
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
await page.locator('input[name="name"]').fill('Dirty state target');
await submit(page);
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
// Create a bot
await page.goto('/home/bots?id=new');
await createPlaywrightBot(page, 'Clean Form Bot');
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Reload the persisted record so post-create initialization has completed.
await page.reload();
await expect(
page.getByRole('heading', { name: 'Clean Form Bot' }),
).toBeVisible();
// After loading, save button should be disabled (form is clean)
const saveButton = page.getByRole('button', {
name: /^Save(?: and enable)?$/,
});
await expect(saveButton).toBeDisabled();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Description').fill('New description');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect(saveButton).toBeDisabled();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await expect(
page.getByRole('combobox').filter({ hasText: 'Dirty state target' }),
).toHaveCount(2);
await expect(saveButton).toBeEnabled();
await save(page);
await expect(saveButton).toBeDisabled();
await page.reload();
await expect(
page.getByRole('combobox').filter({ hasText: 'Dirty state target' }),
).toHaveCount(2);
});
test('shows validation error when bot name is empty', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
// Select an adapter but leave the required name empty.
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('');
await submit(page);
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/bots\?id=new$/);
});
});
test.describe('pipeline advanced flows', () => {
test('scopes runner tool catalogs to the edited pipeline', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withRunnerToolSelector: true,
});
const toolCatalogUrls: URL[] = [];
const requestedPaths: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname === '/api/v1/tools') toolCatalogUrls.push(url);
requestedPaths.push(url.pathname);
});
await page.goto('/home/agents?id=pipeline-scope');
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(
page.getByRole('button', { name: 'Edit tools' }),
).toBeVisible();
await expect
.poll(() =>
toolCatalogUrls.some(
(url) => url.searchParams.get('pipeline_uuid') === 'pipeline-scope',
),
)
.toBe(true);
await expect
.poll(() =>
requestedPaths.includes('/api/v1/pipelines/pipeline-scope/extensions'),
)
.toBe(true);
});
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/agents?id=new');
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
await page.locator('input[name="name"]').fill('Tab Test Pipeline');
await submit(page);
await expect(
page.getByRole('region', { name: 'Configuration' }),
).toBeVisible();
const viewSwitcher = page.getByRole('tablist', {
name: 'Configure & debug / Run logs',
});
const switcherPosition = await viewSwitcher.boundingBox();
await page.getByRole('tab', { name: 'Run logs' }).click();
await expect(page.getByRole('region', { name: 'Run logs' })).toBeVisible();
await expect
.poll(async () => (await viewSwitcher.boundingBox())?.x)
.toBe(switcherPosition?.x);
// Switch back to Configuration
await page.getByRole('tab', { name: 'Configure & debug' }).click();
await expect(
page.getByRole('region', { name: 'Configuration' }),
).toBeVisible();
});
test('save button reflects form dirty state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
const metadata = pipelineMetadata();
metadata.configs[0].stages[1].config.push({
name: 'instruction',
label: { en_US: 'Instruction', zh_Hans: 'Instruction' },
type: 'text',
required: true,
default: 'Original instruction',
} as any);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({ json: { code: 0, data: metadata } }),
);
// Create a pipeline
await page.goto('/home/agents?id=new');
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
await page.locator('input[name="name"]').fill('Dirty Form Pipeline');
await submit(page);
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeDisabled();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Name').fill('Dirty Form Pipeline Updated');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }),
).toBeVisible();
await expect(saveButton).toBeDisabled();
await page.getByRole('tab', { name: /^AI$/ }).click();
const instruction = page.locator('textarea[name="instruction"]');
await expect(instruction).toHaveValue('Original instruction');
await instruction.fill('Updated instruction');
await expect(saveButton).toBeEnabled();
await save(page);
await expect(saveButton).toBeDisabled();
await page.reload();
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(page.locator('textarea[name="instruction"]')).toHaveValue(
'Updated instruction',
);
});
test('shows validation error when pipeline name is empty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=new');
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
// Clear the name suggested by the selected runner.
await page.locator('input[name="name"]').fill('');
await submit(page);
// Should show validation error for name (zod validation)
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/agents\?id=new$/);
});
});
test.describe('agent runner resource selectors', () => {
for (const kind of ['agent', 'pipeline'] as const) {
test(`installs an Runner from the grouped empty selector and refreshes it in ${kind}`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const runnerId = 'plugin:qa/MarketplaceRunner/default';
let installed = false;
let finished = false;
let installRequests = 0;
let taskPolls = 0;
let marketplaceSearchBody: Record<string, unknown> | undefined;
const apiResponse = (data: unknown) =>
JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
const runnerConfig = () => ({
name: 'ai',
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
stages: [
{
name: 'runner',
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
config: [
{
name: 'id',
label: { en_US: 'Runner', zh_Hans: '运行器' },
type: 'select',
required: true,
default: '',
options: installed
? [
{
name: runnerId,
label: {
en_US: 'Marketplace Runner',
zh_Hans: '市场运行器',
},
},
]
: [],
},
],
},
...(installed
? [
{
name: runnerId,
label: {
en_US: 'Marketplace Runner',
zh_Hans: '市场运行器',
},
config: [],
},
]
: []),
],
});
await page.route('**/api/v1/agents/_/metadata', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
runner_config: runnerConfig(),
kinds: [],
}),
}),
);
await page.route('**/api/v1/agents/agent-empty', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
agent: {
uuid: 'agent-empty',
name: 'Empty Runner Agent',
description: '',
emoji: 'A',
kind: 'agent',
config: {
runner: { id: '', 'expire-time': 0 },
runner_config: {},
},
supported_event_patterns: ['*'],
},
}),
}),
);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({ configs: [runnerConfig()] }),
}),
);
await page.route('**/api/v1/plugins', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
plugins: installed
? [
{
manifest: {
manifest: {
metadata: {
author: 'qa',
name: 'MarketplaceRunner',
description: {
en_US: 'Installed plugin description',
},
},
},
},
},
]
: [],
}),
}),
);
await page.route('**/api/v1/plugins/install/marketplace', (route) => {
installRequests += 1;
installed = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({ task_id: 77 }),
});
});
await page.route('**/api/v1/system/tasks/77', (route) => {
taskPolls += 1;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
id: 77,
name: 'plugin-install-marketplace',
label: 'Marketplace Runner',
runtime: { done: finished, exception: null },
task_context: { current_action: 'complete', metadata: {} },
}),
});
});
await page.route(
'https://space.langbot.app/api/v1/marketplace/extensions/search',
async (route) => {
marketplaceSearchBody = JSON.parse(
route.request().postData() || '{}',
) as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
extensions: [
{
id: 1,
plugin_id: 'qa/MarketplaceRunner',
author: 'qa',
name: 'MarketplaceRunner',
label: {
en_US: 'Marketplace Runner',
zh_Hans: '市场运行器',
},
description: {
en_US: 'Runner used by the grouped selector test.',
zh_Hans: '用于分组选择器测试的运行器。',
},
icon: '',
repository: 'https://example.test/runner',
tags: [],
install_count: 12,
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
{
author: 'qa',
name: 'SecondRunner',
label: { en_US: 'A much longer second Runner label' },
description: { en_US: 'Second runner description' },
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
install_count: 1,
type: 'plugin',
},
].filter(
(plugin) => !installed || plugin.name !== 'MarketplaceRunner',
),
total: installed ? 1 : 2,
}),
});
},
);
await page.route('**/api/v1/pipelines/agent-empty', (route) =>
route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'agent-empty',
kind: 'pipeline',
name: 'Empty Runner Pipeline',
description: '',
emoji: 'P',
is_default: false,
config: {
ai: {
runner: { id: '', 'expire-time': 0 },
runner_config: {},
},
trigger: {},
safety: {},
output: {},
},
},
},
},
}),
);
await page.goto(
kind === 'agent'
? '/home/agents?id=agent-empty'
: '/home/pipelines?id=agent-empty',
);
await page
.getByRole('tab', { name: kind === 'agent' ? /^Runner$/ : /^AI$/ })
.click();
const runnerSelect = page.getByRole('combobox', {
name: 'Runner',
includeHidden: true,
});
const triggerBox = await runnerSelect.boundingBox();
await runnerSelect.click();
await expect(page.getByText('Installed Runners')).toBeVisible();
await expect(
page.getByText('No Runner extension is installed yet.'),
).toBeVisible();
await expect(
page.getByText('Runner plugins in Marketplace'),
).toBeVisible();
const selectorPopup = page.locator('[data-slot="select-content"]');
await expect(
selectorPopup.getByText('Runner used by the grouped selector test.', {
exact: true,
}),
).toBeVisible();
const popupBox = await selectorPopup.boundingBox();
expect(triggerBox?.width).toBeLessThanOrEqual(353);
expect(popupBox?.width ?? 0).toBeLessThanOrEqual(
(triggerBox?.width ?? 0) + 1,
);
expect(popupBox?.width ?? 0).toBeGreaterThan(
(triggerBox?.width ?? 0) - 20,
);
await expect
.poll(() => marketplaceSearchBody)
.toMatchObject({
component_filter: 'Runner',
type_filter: 'plugin',
});
const install = page.getByRole('button', {
name: 'Install Marketplace Runner',
exact: true,
});
const otherInstall = page.getByRole('button', {
name: 'Install A much longer second Runner label',
exact: true,
});
const positions = await Promise.all([
install.boundingBox(),
otherInstall.boundingBox(),
]);
expect(positions.every(Boolean)).toBe(true);
expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual(
1,
);
expect(
Math.abs(positions[0]!.width - positions[1]!.width),
).toBeLessThanOrEqual(1);
await install.click();
await expect(install).toContainText('%');
await expect(otherInstall).toBeDisabled();
await expect(runnerSelect).not.toContainText('Marketplace Runner');
finished = true;
await expect.poll(() => installRequests).toBe(1);
await expect.poll(() => taskPolls).toBeGreaterThan(0);
await expect(
page.getByRole('option', {
name: 'Marketplace Runner Installed plugin description',
exact: true,
}),
).toBeVisible();
await expect(runnerSelect).not.toContainText('Marketplace Runner');
await expect(
page.getByRole('button', {
name: 'Install Marketplace Runner',
exact: true,
}),
).toHaveCount(0);
await expect(otherInstall).toBeEnabled();
await page
.getByRole('option', {
name: 'Marketplace Runner Installed plugin description',
exact: true,
})
.click();
await expect(runnerSelect).toContainText('Marketplace Runner');
});
}
test('uses the compact Runner marketplace selector in pipeline AI settings', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const apiResponse = (data: unknown) =>
JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
await page.route(
'https://space.langbot.app/api/v1/marketplace/extensions/search',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
extensions: [
{
id: 2,
plugin_id: 'qa/PipelineMarketplaceRunner',
author: 'qa',
name: 'PipelineMarketplaceRunner',
label: {
en_US: 'Pipeline Marketplace Runner',
zh_Hans: '流水线市场运行器',
},
description: {
en_US: 'A marketplace runner shown inside pipeline settings.',
zh_Hans: '展示在流水线配置中的市场运行器。',
},
icon: '',
repository: 'https://example.test/pipeline-runner',
tags: [],
install_count: 9,
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
],
total: 1,
}),
}),
);
await page.goto('/home/agents?id=pipeline-runner-selector');
await page.getByRole('tab', { name: /^AI$/ }).click();
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
await runnerSelect.click();
await expect(page.getByText('Installed Runners')).toBeVisible();
await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible();
await expect(
page
.locator('[data-slot="select-content"]')
.getByText('A marketplace runner shown inside pipeline settings.', {
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Install Pipeline Marketplace Runner',
exact: true,
}),
).toBeVisible();
});
test('uses the global catalog and preserves temporarily unavailable tools', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const toolCatalogUrls: URL[] = [];
const requestedPaths: string[] = [];
let savedAgent: Record<string, unknown> | undefined;
page.on('request', (request) => {
const url = new URL(request.url());
requestedPaths.push(url.pathname);
if (url.pathname === '/api/v1/tools') toolCatalogUrls.push(url);
if (
url.pathname === '/api/v1/agents/agent-scope' &&
request.method() === 'PUT'
) {
savedAgent = JSON.parse(request.postData() || '{}') as Record<
string,
unknown
>;
}
});
await page.goto('/home/agents?id=agent-scope');
await page.getByRole('tab', { name: /^Runner$/ }).click();
await page.getByRole('button', { name: 'Edit tools' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('available_plugin_tool')).toBeVisible();
await dialog
.getByRole('checkbox', { name: 'Select available_plugin_tool' })
.click();
await dialog.getByRole('button', { name: /^Confirm$/ }).click();
await save(page);
await expect.poll(() => savedAgent).toBeTruthy();
expect(savedAgent).toMatchObject({
config: {
runner_config: {
'plugin:langbot-team/LocalAgent/default': {
tools: ['unavailable_plugin_tool', 'available_plugin_tool'],
},
},
},
});
expect(
toolCatalogUrls.some((url) => url.searchParams.has('pipeline_uuid')),
).toBe(false);
expect(requestedPaths).not.toContain(
'/api/v1/pipelines/agent-scope/extensions',
);
});
});
test.describe('agent and pipeline save concurrency', () => {
test('agent save freezes its payload and keeps later edits dirty', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const delayedSave = await installDelayedFirstSave(
page,
'/api/v1/agents/agent-save-race',
);
await page.goto('/home/agents?id=agent-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
await page.getByRole('tab', { name: 'Events & tools' }).click();
const eventPatterns = page.getByRole('button', {
name: 'Add event',
exact: true,
});
await expect(eventPatterns).toBeVisible();
await eventPatterns.click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await eventPatterns.click();
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
await page
.getByRole('button', { name: 'Remove event', exact: true })
.first()
.click();
await page.keyboard.press('Escape');
await forceFormSubmit(page, '#agent-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
expect(delayedSave.payloads[0]).toMatchObject({
supported_event_patterns: ['message.received'],
});
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
supported_event_patterns: ['group.*'],
});
await expect(saveButton).toBeDisabled();
});
test('pipeline save freezes its payload and keeps later edits dirty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const delayedSave = await installDelayedFirstSave(
page,
'/api/v1/pipelines/pipeline-save-race',
);
await page.goto('/home/agents?id=pipeline-save-race');
await page.getByRole('button', { name: 'Edit basic information' }).click();
let infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Name').fill('Submitted Pipeline');
const dialogSaveButton = infoDialog.getByRole('button', { name: 'Save' });
await dialogSaveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(
infoDialog.getByRole('button', { name: 'Saving...' }),
).toBeDisabled();
delayedSave.releaseFirstSave();
await expect(infoDialog).toHaveCount(0);
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Pipeline',
description: '',
});
await page.getByRole('button', { name: 'Edit basic information' }).click();
infoDialog = page.getByRole('dialog');
await infoDialog
.getByLabel('Description')
.fill('Edited in the next basic information save');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Pipeline',
description: 'Edited in the next basic information save',
});
await expect(infoDialog).toHaveCount(0);
});
});
test.describe('cross-resource flows', () => {
test('adds custom bot events and reorders routes with a drag preview', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.goto('/home/bots?id=new');
await createPlaywrightBot(page, 'Routing Bot');
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.getByRole('button', { name: 'Add behavior' }).click();
await expect(
page.getByText('Common scenarios', { exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Welcome new members/ }).click();
const routeCards = page.locator('[data-testid^="event-route-"]');
await expect(routeCards).toHaveCount(3);
await expect(routeCards.nth(0)).toContainText('Message received');
await expect(routeCards.nth(1)).toContainText('Message received');
await expect(routeCards.nth(2)).toContainText('Member joined group');
await page.getByRole('button', { name: 'Add behavior' }).click();
await page
.getByRole('menuitem', { name: /^Configure another event/ })
.hover();
const eventSubmenu = page.locator(
'[data-slot="dropdown-menu-sub-content"]',
);
await expect(
eventSubmenu.getByText('Messages', { exact: true }),
).toBeVisible();
await expect(
eventSubmenu.getByRole('menuitem', { name: /^Message edited/ }),
).toBeVisible();
await eventSubmenu
.getByRole('menuitem', { name: /^Message edited/ })
.click();
await expect(routeCards).toHaveCount(4);
await expect(routeCards.nth(3)).toContainText('Message edited');
const memberHandle = page.getByRole('button', { name: 'Drag route 3' });
const firstCard = routeCards.nth(0);
await memberHandle.scrollIntoViewIfNeeded();
const handleBox = await memberHandle.boundingBox();
const targetBox = await firstCard.boundingBox();
expect(handleBox).not.toBeNull();
expect(targetBox).not.toBeNull();
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2 + 10,
{ steps: 4 },
);
await expect(page.locator('[data-drag-overlay="true"]')).toBeVisible();
await page.mouse.move(
targetBox!.x + targetBox!.width / 2,
targetBox!.y + targetBox!.height - 4,
{ steps: 12 },
);
await page.mouse.up();
await expect(page.locator('[data-drag-overlay="true"]')).toHaveCount(0);
await expect(routeCards.nth(0)).toContainText('Member joined group');
await expect(routeCards.nth(1)).toContainText('Message received');
await save(page);
await page.reload();
const savedRouteCards = page.locator('[data-testid^="event-route-"]');
await expect(savedRouteCards.nth(0)).toContainText('Member joined group');
await expect(savedRouteCards.nth(1)).toContainText('Message received');
await expect(savedRouteCards.nth(2)).toContainText('Message received');
await expect(savedRouteCards.nth(3)).toContainText('Message edited');
});
test('creates a pipeline then binds it to a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline first
await page.goto('/home/agents?id=new');
await page.getByRole('radio', { name: /^Pipeline/ }).click();
await page.locator('#agent-create-form').getByRole('combobox').click();
await page.getByRole('option', { name: /Local Agent/ }).click();
await page.locator('input[name="name"]').fill('Production Pipeline');
await submit(page);
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
// Create a bot
await page.goto('/home/bots?id=new');
await createPlaywrightBot(page, 'Bound Bot');
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for form to fully load
await expect(
page.getByRole('heading', { name: 'Bound Bot' }),
).toBeVisible();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await expect(
page.getByRole('combobox').filter({ hasText: 'Production Pipeline' }),
).toHaveCount(2);
// Save the bot
await save(page);
// Reload and verify binding persisted
await page.reload();
// The pipeline name should appear in the select trigger (not in sidebar or options)
await expect(
page.getByRole('combobox').filter({ hasText: 'Production Pipeline' }),
).toHaveCount(2);
});
});