chore: import upstream snapshot with attribution
Test Migrations / Migrations (SQLite) (push) Has been cancelled
Build Dev Image / build-dev-image (push) Has been cancelled
Check i18n Keys / Check i18n Key Consistency (push) Has been cancelled
Lint / Ruff Lint & Format (push) Has been cancelled
Lint / Frontend Lint (push) Has been cancelled
Test Migrations / Migrations (PostgreSQL) (push) Has been cancelled
Test Migrations / Migrations (SQLite) (push) Has been cancelled
Build Dev Image / build-dev-image (push) Has been cancelled
Check i18n Keys / Check i18n Key Consistency (push) Has been cancelled
Lint / Ruff Lint & Format (push) Has been cancelled
Lint / Frontend Lint (push) Has been cancelled
Test Migrations / Migrations (PostgreSQL) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const botId = 'bot-tool-timeline';
|
||||
const sessionId = 'person-tool-timeline-user';
|
||||
const botName = 'Tool Timeline Bot';
|
||||
const pipelineId = 'pipeline-tool-timeline';
|
||||
const pipelineName = 'Tool Timeline Pipeline';
|
||||
|
||||
function at(minute: number, second = 0) {
|
||||
return `2026-07-02T10:${String(minute).padStart(2, '0')}:${String(
|
||||
second,
|
||||
).padStart(2, '0')}Z`;
|
||||
}
|
||||
|
||||
function sessionMessage(
|
||||
id: string,
|
||||
role: 'user' | 'assistant',
|
||||
minute: number,
|
||||
content: string,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_content: content,
|
||||
session_id: sessionId,
|
||||
status: 'success',
|
||||
level: 'info',
|
||||
platform: role === 'user' ? 'person' : 'bot',
|
||||
user_id: 'timeline-user',
|
||||
user_name: 'Timeline User',
|
||||
runner_name: role === 'assistant' ? 'local-agent' : null,
|
||||
variables: '{}',
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
toolName: string,
|
||||
duration: number,
|
||||
status: 'success' | 'error' = 'success',
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute, 30),
|
||||
tool_name: toolName,
|
||||
tool_source: 'native',
|
||||
duration,
|
||||
status,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
session_id: sessionId,
|
||||
message_id: 'user-message',
|
||||
arguments: JSON.stringify({ target: toolName }),
|
||||
result: status === 'success' ? JSON.stringify({ ok: true }) : null,
|
||||
error_message: status === 'error' ? 'Tool execution failed' : null,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('bot session monitor tool timeline', () => {
|
||||
test('renders tool calls as left-side agent events interleaved with messages', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringSessions: [
|
||||
{
|
||||
session_id: sessionId,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 3,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
platform: 'person',
|
||||
user_id: 'timeline-user',
|
||||
user_name: 'Timeline User',
|
||||
},
|
||||
],
|
||||
sessionMessages: {
|
||||
[sessionId]: [
|
||||
sessionMessage('user-message', 'user', 0, 'Need a timeline check'),
|
||||
sessionMessage(
|
||||
'assistant-step-1',
|
||||
'assistant',
|
||||
2,
|
||||
'Agent step 1: inspected repository files',
|
||||
),
|
||||
sessionMessage(
|
||||
'assistant-step-2',
|
||||
'assistant',
|
||||
4,
|
||||
'Agent step 2: test suite finished',
|
||||
),
|
||||
],
|
||||
},
|
||||
sessionAnalyses: {
|
||||
[sessionId]: {
|
||||
session_id: sessionId,
|
||||
found: true,
|
||||
tool_calls: [
|
||||
toolCall('tool-repo-read', 1, 'repo_file_read', 80),
|
||||
toolCall('tool-test-run', 3, 'run_test_suite', 140),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await page.getByRole('button', { name: /Timeline User/ }).click();
|
||||
|
||||
await expect(page.getByText('Need a timeline check')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('repo_file_read', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Agent step 1: inspected repository files'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('run_test_suite', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Agent step 2: test suite finished'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('{"target":"repo_file_read"}')).toHaveCount(0);
|
||||
await expect(page.getByText('{"ok":true}')).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
page.locator('div.flex.justify-start').filter({
|
||||
hasText: 'repo_file_read',
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('div.flex.justify-start').filter({
|
||||
hasText: 'run_test_suite',
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('div.flex.justify-end').filter({
|
||||
hasText: 'repo_file_read',
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('div.flex.justify-end').filter({
|
||||
hasText: 'run_test_suite',
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
|
||||
const text = await page.locator('body').innerText();
|
||||
expect(text.indexOf('Need a timeline check')).toBeLessThan(
|
||||
text.indexOf('repo_file_read'),
|
||||
);
|
||||
expect(text.indexOf('repo_file_read')).toBeLessThan(
|
||||
text.indexOf('Agent step 1: inspected repository files'),
|
||||
);
|
||||
expect(
|
||||
text.indexOf('Agent step 1: inspected repository files'),
|
||||
).toBeLessThan(text.indexOf('run_test_suite'));
|
||||
expect(text.indexOf('run_test_suite')).toBeLessThan(
|
||||
text.indexOf('Agent step 2: test suite finished'),
|
||||
);
|
||||
|
||||
await page.getByText('repo_file_read', { exact: true }).click();
|
||||
await expect(page.getByText('{"target":"repo_file_read"}')).toBeVisible();
|
||||
await expect(page.getByText('{"ok":true}').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
async function save(page: Page) {
|
||||
const button = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
}
|
||||
|
||||
async function submit(page: Page) {
|
||||
await page.getByRole('button', { name: /^Submit$/ }).click();
|
||||
}
|
||||
|
||||
async function confirmDelete(page: Page) {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^Confirm Delete$/ })
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('creates, edits, and deletes a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Support Bot');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Answers customer support questions.');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Answers customer support questions with context.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Answers customer support questions with context.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots$/);
|
||||
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a pipeline', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.locator('input[name="basic.name"]').fill('Escalation Pipeline');
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Routes urgent customer issues.');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
|
||||
'Escalation Pipeline',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Routes urgent customer issues to operators.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
|
||||
'Routes urgent customer issues to operators.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/pipelines$/);
|
||||
await expect(
|
||||
page.getByText('Select a pipeline from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens pipeline AI capabilities with malformed model options', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=pipeline-ai');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.getByRole('button', { name: /^AI$/ }).click();
|
||||
|
||||
await expect(page.getByText('Runtime')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="card-title"]').filter({
|
||||
hasText: 'Built-in 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 submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'Support Knowledge',
|
||||
);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Updated source material for support answers.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Updated source material for support answers.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge$/);
|
||||
await expect(
|
||||
page.getByText('Select a knowledge base from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
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');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="url"]')).toHaveValue(
|
||||
'https://mcp.example.test/updated-sse',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/mcp$/);
|
||||
await expect(
|
||||
page.getByText('Select an MCP server from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/add-extension$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('bot advanced flows', () => {
|
||||
test('toggles bot enable/disable state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot first
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Toggle Test Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Verify initial state is enabled
|
||||
await expect(page.locator('#bot-enable-switch')).toBeChecked();
|
||||
|
||||
// Toggle to disabled
|
||||
await page.locator('#bot-enable-switch').click();
|
||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
||||
|
||||
// Reload and verify state persisted
|
||||
await page.reload();
|
||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('switches between bot detail tabs', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Tab Test Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
|
||||
// Switch to Logs tab
|
||||
await page.getByRole('tab', { name: /Logs/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Logs/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch to Sessions tab
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button is disabled when form is clean', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Clean Form Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// After creation, save button should be disabled (form is clean)
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Edit the form
|
||||
await page.locator('input[name="description"]').fill('New description');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows validation error when bot name is empty', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
|
||||
// Select adapter but leave name empty
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
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\/bots\?id=new$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pipeline advanced flows', () => {
|
||||
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
|
||||
// Skip Debug tab as it requires WebSocket connection
|
||||
await page.getByRole('tab', { name: /Dashboard/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button reflects form dirty state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Wait for the page to fully load and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the form - use the name field which definitely triggers dirty state
|
||||
await page
|
||||
.locator('input[name="basic.name"]')
|
||||
.fill('Dirty Form Pipeline Updated');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('shows validation error when pipeline name is empty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
|
||||
// Submit without filling name
|
||||
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\/pipelines\?id=new$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('cross-resource flows', () => {
|
||||
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/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Production Pipeline');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Bound Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for form to fully load
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
|
||||
|
||||
// Find the pipeline select by its label "Bind Pipeline"
|
||||
const pipelineCard = page.getByText('Bind Pipeline').locator('..');
|
||||
await expect(pipelineCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click on the select trigger within the pipeline binding card
|
||||
// The select trigger shows "Select Pipeline" placeholder initially
|
||||
const pipelineSelectTrigger = page.getByText('Select Pipeline').first();
|
||||
await pipelineSelectTrigger.click();
|
||||
|
||||
// Select the pipeline option
|
||||
await page.getByRole('option', { name: 'Production Pipeline' }).click();
|
||||
|
||||
// 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
|
||||
.locator('[data-slot="select-trigger"]')
|
||||
.filter({ hasText: 'Production Pipeline' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('empty states', () => {
|
||||
test('shows empty state when no bots exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots');
|
||||
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no pipelines exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines');
|
||||
await expect(
|
||||
page.getByText('Select a pipeline from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no knowledge bases exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/knowledge');
|
||||
await expect(
|
||||
page.getByText('Select a knowledge base from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no MCP servers exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/mcp');
|
||||
await expect(
|
||||
page.getByText('Select an MCP server from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,999 @@
|
||||
import { Page, Route } from '@playwright/test';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface SkillMock {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
package_root: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface PipelineMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config: JsonRecord;
|
||||
emoji: string;
|
||||
is_default: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface KnowledgeBaseMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
knowledge_engine_plugin_id: string;
|
||||
creation_settings: JsonRecord;
|
||||
retrieval_settings: JsonRecord;
|
||||
knowledge_engine: {
|
||||
plugin_id: string;
|
||||
name: {
|
||||
en_US: string;
|
||||
zh_Hans: string;
|
||||
};
|
||||
capabilities: string[];
|
||||
};
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MCPServerMock {
|
||||
name: string;
|
||||
mode: 'sse' | 'stdio' | 'http';
|
||||
enable: boolean;
|
||||
extra_args: JsonRecord;
|
||||
runtime_info: {
|
||||
status: 'connected';
|
||||
tool_count: number;
|
||||
tools: unknown[];
|
||||
};
|
||||
readme: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface BotMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enable: boolean;
|
||||
adapter: string;
|
||||
adapter_config: JsonRecord;
|
||||
use_pipeline_uuid?: string;
|
||||
pipeline_routing_rules: unknown[];
|
||||
adapter_runtime_values: JsonRecord;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface LangBotApiMockState {
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
mcpServers: MCPServerMock[];
|
||||
monitoringData: unknown;
|
||||
monitoringSessions: unknown[];
|
||||
pipelines: PipelineMock[];
|
||||
sessionAnalyses: Record<string, unknown>;
|
||||
sessionMessages: Record<string, unknown[]>;
|
||||
skills: SkillMock[];
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(ok(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function routePath(route: Route) {
|
||||
return new URL(route.request().url()).pathname;
|
||||
}
|
||||
|
||||
function parseJsonBody(route: Route): JsonRecord {
|
||||
return JSON.parse(route.request().postData() || '{}') as JsonRecord;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function nextId(state: LangBotApiMockState, prefix: string) {
|
||||
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
|
||||
return `${prefix}-${state.counters[prefix]}`;
|
||||
}
|
||||
|
||||
function emptyMonitoringData() {
|
||||
return {
|
||||
overview: {
|
||||
total_messages: 0,
|
||||
llm_calls: 0,
|
||||
embedding_calls: 0,
|
||||
model_calls: 0,
|
||||
success_rate: 0,
|
||||
active_sessions: 0,
|
||||
},
|
||||
messages: [],
|
||||
llmCalls: [],
|
||||
toolCalls: [],
|
||||
embeddingCalls: [],
|
||||
sessions: [],
|
||||
errors: [],
|
||||
totalCount: {
|
||||
messages: 0,
|
||||
llmCalls: 0,
|
||||
toolCalls: 0,
|
||||
embeddingCalls: 0,
|
||||
sessions: 0,
|
||||
errors: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTokenStatistics() {
|
||||
return {
|
||||
summary: {
|
||||
total_calls: 0,
|
||||
success_calls: 0,
|
||||
error_calls: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
avg_tokens_per_call: 0,
|
||||
avg_duration_ms: 0,
|
||||
avg_tokens_per_second: 0,
|
||||
zero_token_success_calls: 0,
|
||||
},
|
||||
by_model: [],
|
||||
timeseries: [],
|
||||
bucket: 'day',
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkill(data: JsonRecord): SkillMock {
|
||||
return {
|
||||
name: String(data.name || ''),
|
||||
display_name: String(data.display_name || ''),
|
||||
description: String(data.description || ''),
|
||||
instructions: String(data.instructions || ''),
|
||||
package_root: String(data.package_root || ''),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makePipeline(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'pipeline'),
|
||||
): PipelineMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
config: (data.config as JsonRecord | undefined) || {
|
||||
ai: {},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
emoji: String(data.emoji || '⚙️'),
|
||||
is_default: false,
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineMetadata() {
|
||||
return {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: {
|
||||
en_US: 'AI Capabilities',
|
||||
zh_Hans: 'AI 能力',
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: {
|
||||
en_US: 'Runtime',
|
||||
zh_Hans: '运行方式',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'runner',
|
||||
name: 'runner',
|
||||
label: {
|
||||
en_US: 'Runner',
|
||||
zh_Hans: '运行器',
|
||||
},
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'local-agent',
|
||||
options: [
|
||||
{
|
||||
name: 'local-agent',
|
||||
label: {
|
||||
en_US: 'Built-in Agent',
|
||||
zh_Hans: '内置 Agent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'local-agent',
|
||||
label: {
|
||||
en_US: 'Built-in Agent',
|
||||
zh_Hans: '内置 Agent',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
label: {
|
||||
en_US: 'Model',
|
||||
zh_Hans: '模型',
|
||||
},
|
||||
type: 'model-fallback-selector',
|
||||
required: true,
|
||||
default: {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function providerModelList() {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
uuid: '',
|
||||
name: 'Broken Empty UUID Model',
|
||||
provider_uuid: 'provider-empty',
|
||||
provider: {
|
||||
uuid: 'provider-empty',
|
||||
name: 'Broken Provider',
|
||||
requester: 'mock-provider',
|
||||
},
|
||||
},
|
||||
{
|
||||
uuid: 'llm-valid',
|
||||
name: 'Valid Mock Model',
|
||||
provider_uuid: 'provider-valid',
|
||||
provider: {
|
||||
uuid: 'provider-valid',
|
||||
name: 'Mock Provider',
|
||||
requester: 'mock-provider',
|
||||
},
|
||||
abilities: ['func_call'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function knowledgeEngine() {
|
||||
return {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
name: {
|
||||
en_US: 'Minimal Knowledge Engine',
|
||||
zh_Hans: '最小知识库引擎',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimal mocked engine for frontend smoke tests.',
|
||||
zh_Hans: '用于前端冒烟测试的最小模拟引擎。',
|
||||
},
|
||||
capabilities: ['text_retrieval'],
|
||||
creation_schema: [],
|
||||
retrieval_schema: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeKnowledgeBase(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'knowledge'),
|
||||
): KnowledgeBaseMock {
|
||||
const engine = knowledgeEngine();
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
emoji: String(data.emoji || '📚'),
|
||||
knowledge_engine_plugin_id: String(
|
||||
data.knowledge_engine_plugin_id || engine.plugin_id,
|
||||
),
|
||||
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
|
||||
retrieval_settings:
|
||||
(data.retrieval_settings as JsonRecord | undefined) || {},
|
||||
knowledge_engine: {
|
||||
plugin_id: engine.plugin_id,
|
||||
name: engine.name,
|
||||
capabilities: engine.capabilities,
|
||||
},
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMCPServer(data: JsonRecord): MCPServerMock {
|
||||
return {
|
||||
name: String(data.name || ''),
|
||||
mode: (data.mode as MCPServerMock['mode']) || 'sse',
|
||||
enable: data.enable !== false,
|
||||
extra_args: (data.extra_args as JsonRecord | undefined) || {},
|
||||
runtime_info: {
|
||||
status: 'connected',
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
},
|
||||
readme: '',
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBot(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'bot'),
|
||||
): BotMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
enable: data.enable !== false,
|
||||
adapter: String(data.adapter || 'playwright-adapter'),
|
||||
adapter_config: (data.adapter_config as JsonRecord | undefined) || {},
|
||||
use_pipeline_uuid: data.use_pipeline_uuid
|
||||
? String(data.use_pipeline_uuid)
|
||||
: undefined,
|
||||
pipeline_routing_rules:
|
||||
(data.pipeline_routing_rules as unknown[] | undefined) || [],
|
||||
adapter_runtime_values: {
|
||||
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
|
||||
extra_webhook_full_url: '',
|
||||
},
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockAdapters() {
|
||||
return [
|
||||
{
|
||||
name: 'playwright-adapter',
|
||||
label: {
|
||||
en_US: 'Playwright Adapter',
|
||||
zh_Hans: 'Playwright 适配器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimal adapter for frontend E2E tests.',
|
||||
zh_Hans: '用于前端 E2E 测试的最小适配器。',
|
||||
},
|
||||
spec: {
|
||||
categories: ['testing'],
|
||||
config: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
const method = request.method();
|
||||
|
||||
if (path === '/api/v1/system/info') {
|
||||
return fulfillJson(route, {
|
||||
debug: false,
|
||||
version: 'frontend-smoke',
|
||||
edition: 'community',
|
||||
cloud_service_url: 'https://space.langbot.app',
|
||||
enable_marketplace: true,
|
||||
allow_modify_login_info: true,
|
||||
disable_models_service: false,
|
||||
limitation: {
|
||||
max_bots: -1,
|
||||
max_pipelines: -1,
|
||||
max_extensions: -1,
|
||||
},
|
||||
outbound_ips: [],
|
||||
wizard_status: 'completed',
|
||||
wizard_progress: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/account-info') {
|
||||
return fulfillJson(route, {
|
||||
initialized: true,
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/check-token') {
|
||||
return fulfillJson(route, { token: '' });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/auth') {
|
||||
return fulfillJson(route, { token: 'playwright-token' });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/info') {
|
||||
return fulfillJson(route, {
|
||||
user: 'admin@example.com',
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/space-credits') {
|
||||
return fulfillJson(route, { credits: null });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/adapters') {
|
||||
return fulfillJson(route, { adapters: mockAdapters() });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/bots') {
|
||||
if (method === 'POST') {
|
||||
const bot = makeBot(state, parseJsonBody(route));
|
||||
state.bots = [
|
||||
...state.bots.filter((item) => item.uuid !== bot.uuid),
|
||||
bot,
|
||||
];
|
||||
return fulfillJson(route, { uuid: bot.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { bots: state.bots });
|
||||
}
|
||||
|
||||
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
|
||||
if (botLogsMatch) {
|
||||
return fulfillJson(route, { logs: [], total: 0 });
|
||||
}
|
||||
|
||||
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
|
||||
if (botMatch) {
|
||||
const botId = decodeURIComponent(botMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const bot = makeBot(state, parseJsonBody(route), botId);
|
||||
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.bots = state.bots.filter((item) => item.uuid !== botId);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const bot = state.bots.find((item) => item.uuid === botId);
|
||||
return fulfillJson(route, {
|
||||
bot: bot || makeBot(state, { name: botId }, botId),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/provider/models/llm') {
|
||||
return fulfillJson(route, providerModelList());
|
||||
}
|
||||
|
||||
if (path === '/api/v1/provider/models/embedding') {
|
||||
return fulfillJson(route, { models: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/provider/models/rerank') {
|
||||
return fulfillJson(route, { models: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines/_/metadata') {
|
||||
return fulfillJson(route, pipelineMetadata());
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines') {
|
||||
if (method === 'POST') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route));
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, { uuid: pipeline.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { pipelines: state.pipelines });
|
||||
}
|
||||
|
||||
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
|
||||
if (pipelineMatch) {
|
||||
const pipelineId = decodeURIComponent(pipelineMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipelineId),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.pipelines = state.pipelines.filter(
|
||||
(item) => item.uuid !== pipelineId,
|
||||
);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const pipeline = state.pipelines.find((item) => item.uuid === pipelineId);
|
||||
return fulfillJson(route, {
|
||||
pipeline:
|
||||
pipeline || makePipeline(state, { name: pipelineId }, pipelineId),
|
||||
});
|
||||
}
|
||||
|
||||
const pipelineExtensionsMatch = path.match(
|
||||
/^\/api\/v1\/pipelines\/([^/]+)\/extensions$/,
|
||||
);
|
||||
if (pipelineExtensionsMatch) {
|
||||
return fulfillJson(route, {
|
||||
enable_all_plugins: true,
|
||||
enable_all_mcp_servers: true,
|
||||
enable_all_skills: true,
|
||||
bound_plugins: [],
|
||||
available_plugins: [],
|
||||
bound_mcp_servers: [],
|
||||
available_mcp_servers: state.mcpServers,
|
||||
bound_skills: [],
|
||||
available_skills: state.skills,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/bases') {
|
||||
if (method === 'POST') {
|
||||
const base = makeKnowledgeBase(state, parseJsonBody(route));
|
||||
state.knowledgeBases = [
|
||||
...state.knowledgeBases.filter((item) => item.uuid !== base.uuid),
|
||||
base,
|
||||
];
|
||||
return fulfillJson(route, { uuid: base.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { bases: state.knowledgeBases });
|
||||
}
|
||||
|
||||
const knowledgeBaseFilesMatch = path.match(
|
||||
/^\/api\/v1\/knowledge\/bases\/([^/]+)\/files$/,
|
||||
);
|
||||
if (knowledgeBaseFilesMatch) {
|
||||
return fulfillJson(route, { files: [] });
|
||||
}
|
||||
|
||||
const knowledgeBaseMatch = path.match(
|
||||
/^\/api\/v1\/knowledge\/bases\/([^/]+)$/,
|
||||
);
|
||||
if (knowledgeBaseMatch) {
|
||||
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const base = makeKnowledgeBase(state, parseJsonBody(route), baseId);
|
||||
state.knowledgeBases = [
|
||||
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
|
||||
base,
|
||||
];
|
||||
return fulfillJson(route, { uuid: base.uuid });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.knowledgeBases = state.knowledgeBases.filter(
|
||||
(item) => item.uuid !== baseId,
|
||||
);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const base = state.knowledgeBases.find((item) => item.uuid === baseId);
|
||||
return fulfillJson(route, {
|
||||
base: base || makeKnowledgeBase(state, { name: baseId }, baseId),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/engines') {
|
||||
return fulfillJson(route, { engines: [knowledgeEngine()] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/migration/status') {
|
||||
return fulfillJson(route, {
|
||||
needed: false,
|
||||
internal_kb_count: 0,
|
||||
external_kb_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/plugins') {
|
||||
return fulfillJson(route, { plugins: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/extensions') {
|
||||
return fulfillJson(route, { extensions: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/mcp/servers') {
|
||||
if (method === 'POST') {
|
||||
const server = makeMCPServer(parseJsonBody(route));
|
||||
state.mcpServers = [
|
||||
...state.mcpServers.filter((item) => item.name !== server.name),
|
||||
server,
|
||||
];
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { servers: state.mcpServers });
|
||||
}
|
||||
|
||||
const mcpTestMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)\/test$/);
|
||||
if (mcpTestMatch) {
|
||||
return fulfillJson(route, {
|
||||
runtime_info: {
|
||||
status: 'connected',
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mcpServerMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)$/);
|
||||
if (mcpServerMatch) {
|
||||
const serverName = decodeURIComponent(mcpServerMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const existing = state.mcpServers.find(
|
||||
(item) => item.name === serverName,
|
||||
);
|
||||
const server = makeMCPServer({
|
||||
...(existing || {}),
|
||||
...parseJsonBody(route),
|
||||
name: serverName,
|
||||
});
|
||||
state.mcpServers = [
|
||||
...state.mcpServers.filter((item) => item.name !== serverName),
|
||||
server,
|
||||
];
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.mcpServers = state.mcpServers.filter(
|
||||
(item) => item.name !== serverName,
|
||||
);
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
const server = state.mcpServers.find((item) => item.name === serverName);
|
||||
return fulfillJson(route, {
|
||||
server: server || makeMCPServer({ name: serverName }),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/skills') {
|
||||
if (method === 'POST') {
|
||||
const skill = makeSkill(
|
||||
JSON.parse(request.postData() || '{}') as JsonRecord,
|
||||
);
|
||||
state.skills = [
|
||||
...state.skills.filter((item) => item.name !== skill.name),
|
||||
skill,
|
||||
];
|
||||
return fulfillJson(route, { skill });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { skills: state.skills });
|
||||
}
|
||||
|
||||
const skillFileMatch = path.match(
|
||||
/^\/api\/v1\/skills\/([^/]+)\/files\/(.+)$/,
|
||||
);
|
||||
if (skillFileMatch) {
|
||||
const skillName = decodeURIComponent(skillFileMatch[1]);
|
||||
const filePath = decodeURIComponent(skillFileMatch[2]);
|
||||
const skill = state.skills.find((item) => item.name === skillName);
|
||||
return fulfillJson(route, {
|
||||
skill: { name: skillName },
|
||||
path: filePath,
|
||||
content: skill?.instructions || '',
|
||||
});
|
||||
}
|
||||
|
||||
const skillFilesMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/files$/);
|
||||
if (skillFilesMatch) {
|
||||
const skillName = decodeURIComponent(skillFilesMatch[1]);
|
||||
return fulfillJson(route, {
|
||||
skill: { name: skillName },
|
||||
base_path: '.',
|
||||
entries: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
name: 'SKILL.md',
|
||||
is_dir: false,
|
||||
size: null,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/);
|
||||
if (skillMatch) {
|
||||
const skillName = decodeURIComponent(skillMatch[1]);
|
||||
if (method === 'PUT') {
|
||||
const skill = makeSkill({
|
||||
...parseJsonBody(route),
|
||||
name: skillName,
|
||||
});
|
||||
state.skills = [
|
||||
...state.skills.filter((item) => item.name !== skillName),
|
||||
skill,
|
||||
];
|
||||
return fulfillJson(route, { skill });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.skills = state.skills.filter((item) => item.name !== skillName);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const skill = state.skills.find((item) => item.name === skillName) || {
|
||||
name: skillName,
|
||||
display_name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
package_root: '',
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
return fulfillJson(route, { skill });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/system/status/plugin-system') {
|
||||
return fulfillJson(route, {
|
||||
is_enable: true,
|
||||
is_connected: true,
|
||||
plugin_connector_error: '',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/plugins/debug-info') {
|
||||
return fulfillJson(route, {
|
||||
debug_url: 'ws://127.0.0.1:5300/plugin/debug',
|
||||
plugin_debug_key: 'test-debug-key',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/box/status') {
|
||||
return fulfillJson(route, {
|
||||
available: true,
|
||||
enabled: true,
|
||||
profile: 'playwright',
|
||||
recent_error_count: 0,
|
||||
active_sessions: 0,
|
||||
managed_processes: 0,
|
||||
session_ttl_sec: 3600,
|
||||
backend: {
|
||||
name: 'playwright',
|
||||
available: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/box/sessions') {
|
||||
return fulfillJson(route, []);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/data') {
|
||||
return fulfillJson(route, state.monitoringData);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/sessions') {
|
||||
return fulfillJson(route, {
|
||||
sessions: state.monitoringSessions,
|
||||
total: state.monitoringSessions.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/messages') {
|
||||
const sessionId = url.searchParams.get('sessionId') || '';
|
||||
const messages = state.sessionMessages[sessionId] || [];
|
||||
return fulfillJson(route, {
|
||||
messages,
|
||||
total: messages.length,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionAnalysisMatch = path.match(
|
||||
/^\/api\/v1\/monitoring\/sessions\/([^/]+)\/analysis$/,
|
||||
);
|
||||
if (sessionAnalysisMatch) {
|
||||
const sessionId = decodeURIComponent(sessionAnalysisMatch[1]);
|
||||
return fulfillJson(
|
||||
route,
|
||||
state.sessionAnalyses[sessionId] || {
|
||||
session_id: sessionId,
|
||||
found: true,
|
||||
tool_calls: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/overview') {
|
||||
const data = state.monitoringData as { overview?: unknown };
|
||||
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/token-statistics') {
|
||||
return fulfillJson(route, emptyTokenStatistics());
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/feedback/stats') {
|
||||
return fulfillJson(route, {
|
||||
total_feedback: 0,
|
||||
total_likes: 0,
|
||||
total_dislikes: 0,
|
||||
satisfaction_rate: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/feedback') {
|
||||
return fulfillJson(route, { feedback: [], total: 0 });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/survey/pending') {
|
||||
return fulfillJson(route, { survey: null });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/system/tasks') {
|
||||
return fulfillJson(route, { tasks: [] });
|
||||
}
|
||||
|
||||
if (
|
||||
path === '/api/v1/marketplace/plugins' ||
|
||||
path === '/api/v1/marketplace/plugins/search' ||
|
||||
path === '/api/v1/marketplace/extensions/search' ||
|
||||
path === '/api/v1/marketplace/mcps/search' ||
|
||||
path === '/api/v1/marketplace/skills/search'
|
||||
) {
|
||||
return fulfillJson(route, { plugins: [], total: 0 });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/marketplace/tags') {
|
||||
return fulfillJson(route, { tags: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/marketplace/recommendation-lists') {
|
||||
return fulfillJson(route, { lists: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/dist/info/releases') {
|
||||
return fulfillJson(route, []);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/dist/info/repo') {
|
||||
return fulfillJson(route, {
|
||||
repo: {
|
||||
stargazers_count: 0,
|
||||
forks_count: 0,
|
||||
open_issues_count: 0,
|
||||
},
|
||||
contributors: [],
|
||||
});
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
}
|
||||
|
||||
async function handleCloudApi(route: Route) {
|
||||
const path = routePath(route);
|
||||
|
||||
if (
|
||||
path === '/api/v1/marketplace/plugins' ||
|
||||
path === '/api/v1/marketplace/plugins/search' ||
|
||||
path === '/api/v1/marketplace/extensions/search' ||
|
||||
path === '/api/v1/marketplace/mcps/search' ||
|
||||
path === '/api/v1/marketplace/skills/search'
|
||||
) {
|
||||
return fulfillJson(route, { plugins: [], total: 0 });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/marketplace/tags') {
|
||||
return fulfillJson(route, { tags: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/marketplace/recommendation-lists') {
|
||||
return fulfillJson(route, { lists: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/dist/info/releases') {
|
||||
return fulfillJson(route, []);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/dist/info/repo') {
|
||||
return fulfillJson(route, {
|
||||
repo: {
|
||||
stargazers_count: 0,
|
||||
forks_count: 0,
|
||||
open_issues_count: 0,
|
||||
},
|
||||
contributors: [],
|
||||
});
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
}
|
||||
|
||||
export async function installLangBotApiMocks(
|
||||
page: Page,
|
||||
options: {
|
||||
authenticated?: boolean;
|
||||
monitoringData?: unknown;
|
||||
monitoringSessions?: unknown[];
|
||||
sessionAnalyses?: Record<string, unknown>;
|
||||
sessionMessages?: Record<string, unknown[]>;
|
||||
storage?: JsonRecord;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
authenticated = false,
|
||||
monitoringData,
|
||||
monitoringSessions,
|
||||
sessionAnalyses,
|
||||
sessionMessages,
|
||||
storage = {},
|
||||
} = options;
|
||||
const state: LangBotApiMockState = {
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
mcpServers: [],
|
||||
monitoringData: monitoringData || emptyMonitoringData(),
|
||||
monitoringSessions: monitoringSessions || [],
|
||||
pipelines: [],
|
||||
sessionAnalyses: sessionAnalyses || {},
|
||||
sessionMessages: sessionMessages || {},
|
||||
skills: [],
|
||||
};
|
||||
|
||||
await page.addInitScript(
|
||||
({ authenticated, storage }) => {
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
localStorage.setItem('extensions_group_by_type', 'false');
|
||||
|
||||
if (authenticated) {
|
||||
localStorage.setItem('token', 'playwright-token');
|
||||
localStorage.setItem('userEmail', 'admin@example.com');
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(storage)) {
|
||||
localStorage.setItem(key, String(value));
|
||||
}
|
||||
},
|
||||
{ authenticated, storage },
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/**', (route) => handleBackendApi(route, state));
|
||||
await page.route('https://space.langbot.app/**', handleCloudApi);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const appRoutes = [
|
||||
{
|
||||
path: '/home/bots',
|
||||
heading: 'Bots',
|
||||
bodyText: 'Select a bot from the sidebar',
|
||||
},
|
||||
{
|
||||
path: '/home/pipelines',
|
||||
heading: 'Pipelines',
|
||||
bodyText: 'Select a pipeline from the sidebar',
|
||||
},
|
||||
{
|
||||
path: '/home/extensions',
|
||||
heading: 'Extensions',
|
||||
bodyText: 'No extensions installed',
|
||||
},
|
||||
{
|
||||
path: '/home/mcp',
|
||||
heading: 'MCP',
|
||||
bodyText: 'Select an MCP server from the sidebar',
|
||||
},
|
||||
{
|
||||
path: '/home/knowledge',
|
||||
heading: 'Knowledge',
|
||||
bodyText: 'Select a knowledge base from the sidebar',
|
||||
},
|
||||
];
|
||||
|
||||
test.describe('authenticated app shell', () => {
|
||||
for (const route of appRoutes) {
|
||||
test(`${route.path} renders without a backend process`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto(route.path);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${route.path}$`));
|
||||
await expect(page.getByText('Home').first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Dashboard' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Extensions').first()).toBeVisible();
|
||||
await expect(page.getByText(route.heading).first()).toBeVisible();
|
||||
await expect(page.getByText(route.bodyText)).toBeVisible();
|
||||
await expect(page.getByText('Backend unavailable')).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('/home/monitoring loads dashboard data from mocked APIs', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/monitoring$/);
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Message Records' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Token Monitoring' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('tab', { name: 'Token Monitoring' }).click();
|
||||
await expect(
|
||||
page.getByText('No token usage in the selected time range'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/home/extensions shows plugin debug information from the backend', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/extensions');
|
||||
|
||||
await page.getByRole('button', { name: 'Debug Info' }).click();
|
||||
|
||||
await expect(page.getByText('Plugin Debug Information')).toBeVisible();
|
||||
await expect(page.getByRole('textbox').nth(0)).toHaveValue(
|
||||
'ws://127.0.0.1:5300/plugin/debug',
|
||||
);
|
||||
await expect(page.getByRole('textbox').nth(1)).toHaveValue(
|
||||
'test-debug-key',
|
||||
);
|
||||
});
|
||||
|
||||
test('/home/skills?action=create creates a manual skill', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/skills?action=create');
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/skills\?action=create$/);
|
||||
await expect(page.getByText('Create Skill').first()).toBeVisible();
|
||||
await expect(page.getByText('Import Local Skill Directory')).toBeVisible();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save' });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(page.getByText('Skill name cannot be empty')).toBeVisible();
|
||||
|
||||
await page.locator('#display_name').fill('Daily Summary');
|
||||
await page.locator('#name').fill('daily_summary');
|
||||
await page
|
||||
.locator('#description')
|
||||
.fill('Summarizes the current conversation for handoff.');
|
||||
await page
|
||||
.locator('#instructions')
|
||||
.fill('Summarize the conversation in five concise bullet points.');
|
||||
await saveButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/skills\?id=daily_summary$/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Daily Summary' }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('#name')).toHaveValue('daily_summary');
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Summarizes the current conversation for handoff.',
|
||||
);
|
||||
await expect(page.locator('#instructions')).toHaveValue(
|
||||
'Summarize the conversation in five concise bullet points.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
test('local account login reaches the authenticated home shell', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page);
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.getByText('Welcome')).toBeVisible();
|
||||
await page.getByPlaceholder('Enter email address').fill('admin@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/home$/);
|
||||
await expect(page.getByText('Home').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
|
||||
import {
|
||||
ErrorLog,
|
||||
LLMCall,
|
||||
MonitoringMessage,
|
||||
ToolCall,
|
||||
} from '../../src/app/home/monitoring/types/monitoring';
|
||||
|
||||
const bot = {
|
||||
id: 'bot-monitoring',
|
||||
name: 'Monitoring Bot',
|
||||
};
|
||||
|
||||
const pipeline = {
|
||||
id: 'pipeline-monitoring',
|
||||
name: 'Monitoring Pipeline',
|
||||
};
|
||||
|
||||
function time(minute: number) {
|
||||
return new Date(`2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`);
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: 'user' | 'assistant',
|
||||
minute: number,
|
||||
content: string,
|
||||
sessionId = 'session-agent',
|
||||
): MonitoringMessage {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
messageContent: content,
|
||||
sessionId,
|
||||
status: 'success',
|
||||
level: 'info',
|
||||
platform: role === 'user' ? 'person' : 'bot',
|
||||
userId: 'user-1',
|
||||
userName: 'Playwright User',
|
||||
runnerName: 'local-agent',
|
||||
variables: '{}',
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function llmCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
messageId: string | undefined,
|
||||
input: number,
|
||||
output: number,
|
||||
duration: number,
|
||||
sessionId = 'session-agent',
|
||||
): LLMCall {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
modelName: 'gpt-5.5',
|
||||
tokens: {
|
||||
input,
|
||||
output,
|
||||
total: input + output,
|
||||
},
|
||||
duration,
|
||||
status: 'success',
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
sessionId,
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function errorLog(id: string, minute: number, messageId: string): ErrorLog {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
errorType: 'ToolExecutionError',
|
||||
errorMessage: 'Tool retry failed',
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
sessionId: 'session-agent',
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
messageId: string | undefined,
|
||||
toolName: string,
|
||||
duration: number,
|
||||
sessionId = 'session-agent',
|
||||
status: 'success' | 'error' = 'success',
|
||||
): ToolCall {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
toolName,
|
||||
toolSource: 'native',
|
||||
duration,
|
||||
status,
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
sessionId,
|
||||
messageId,
|
||||
arguments: JSON.stringify({ query: toolName }),
|
||||
result: status === 'success' ? JSON.stringify({ ok: true }) : undefined,
|
||||
errorMessage: status === 'error' ? 'Tool failed' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rawMessage(message: MonitoringMessage) {
|
||||
return {
|
||||
id: message.id,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
bot_id: message.botId,
|
||||
bot_name: message.botName,
|
||||
pipeline_id: message.pipelineId,
|
||||
pipeline_name: message.pipelineName,
|
||||
message_content: message.messageContent,
|
||||
session_id: message.sessionId,
|
||||
status: message.status,
|
||||
level: message.level,
|
||||
platform: message.platform,
|
||||
user_id: message.userId,
|
||||
user_name: message.userName,
|
||||
runner_name: message.runnerName,
|
||||
variables: message.variables,
|
||||
role: message.role,
|
||||
};
|
||||
}
|
||||
|
||||
function rawLlmCall(call: LLMCall) {
|
||||
return {
|
||||
id: call.id,
|
||||
timestamp: call.timestamp.toISOString(),
|
||||
model_name: call.modelName,
|
||||
input_tokens: call.tokens.input,
|
||||
output_tokens: call.tokens.output,
|
||||
total_tokens: call.tokens.total,
|
||||
duration: call.duration,
|
||||
cost: call.cost,
|
||||
status: call.status,
|
||||
bot_id: call.botId,
|
||||
bot_name: call.botName,
|
||||
pipeline_id: call.pipelineId,
|
||||
pipeline_name: call.pipelineName,
|
||||
session_id: call.sessionId,
|
||||
error_message: call.errorMessage,
|
||||
message_id: call.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function rawError(error: ErrorLog) {
|
||||
return {
|
||||
id: error.id,
|
||||
timestamp: error.timestamp.toISOString(),
|
||||
error_type: error.errorType,
|
||||
error_message: error.errorMessage,
|
||||
bot_id: error.botId,
|
||||
bot_name: error.botName,
|
||||
pipeline_id: error.pipelineId,
|
||||
pipeline_name: error.pipelineName,
|
||||
session_id: error.sessionId,
|
||||
stack_trace: error.stackTrace,
|
||||
message_id: error.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function rawToolCall(call: ToolCall) {
|
||||
return {
|
||||
id: call.id,
|
||||
timestamp: call.timestamp.toISOString(),
|
||||
tool_name: call.toolName,
|
||||
tool_source: call.toolSource,
|
||||
duration: call.duration,
|
||||
status: call.status,
|
||||
bot_id: call.botId,
|
||||
bot_name: call.botName,
|
||||
pipeline_id: call.pipelineId,
|
||||
pipeline_name: call.pipelineName,
|
||||
session_id: call.sessionId,
|
||||
message_id: call.messageId,
|
||||
arguments: call.arguments,
|
||||
result: call.result,
|
||||
error_message: call.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function monitoringScenario() {
|
||||
const messages = [
|
||||
message(
|
||||
'single-user',
|
||||
'user',
|
||||
1,
|
||||
'Standalone question with no reply',
|
||||
'session-single',
|
||||
),
|
||||
message('agent-user-1', 'user', 10, 'Need deployment plan'),
|
||||
message('agent-assistant-1', 'assistant', 11, 'Agent step 1: inspect repo'),
|
||||
message('agent-assistant-2', 'assistant', 12, 'Agent step 2: run tests'),
|
||||
message(
|
||||
'agent-assistant-3',
|
||||
'assistant',
|
||||
13,
|
||||
'Final answer: deployment plan ready',
|
||||
),
|
||||
message('agent-user-2', 'user', 20, 'Continue with rollback plan'),
|
||||
message('agent-assistant-4', 'assistant', 21, 'Rollback plan ready'),
|
||||
];
|
||||
const llmCalls = [
|
||||
llmCall('agent-call-1', 10, 'agent-user-1', 100, 40, 120),
|
||||
llmCall('agent-call-2', 11, 'agent-user-1', 200, 60, 220),
|
||||
llmCall('agent-call-3', 12, 'agent-user-1', 300, 90, 260),
|
||||
llmCall('agent-call-4', 20, 'agent-user-2', 50, 25, 80),
|
||||
];
|
||||
const errors = [errorLog('agent-error-1', 12, 'agent-user-1')];
|
||||
const toolCalls = [
|
||||
toolCall('agent-tool-1', 11, 'agent-user-1', 'repo_search', 90),
|
||||
toolCall('agent-tool-2', 12, 'agent-user-1', 'run_tests', 150),
|
||||
toolCall('agent-tool-3', 20, 'agent-user-2', 'rollback_lookup', 70),
|
||||
];
|
||||
|
||||
return {
|
||||
messages,
|
||||
llmCalls,
|
||||
toolCalls,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function rawMonitoringData() {
|
||||
const scenario = monitoringScenario();
|
||||
|
||||
return {
|
||||
overview: {
|
||||
total_messages: scenario.messages.length,
|
||||
llm_calls: scenario.llmCalls.length,
|
||||
embedding_calls: 0,
|
||||
model_calls: scenario.llmCalls.length,
|
||||
success_rate: 100,
|
||||
active_sessions: 2,
|
||||
},
|
||||
messages: scenario.messages.map(rawMessage),
|
||||
llmCalls: scenario.llmCalls.map(rawLlmCall),
|
||||
toolCalls: scenario.toolCalls.map(rawToolCall),
|
||||
embeddingCalls: [],
|
||||
sessions: [],
|
||||
errors: scenario.errors.map(rawError),
|
||||
totalCount: {
|
||||
messages: scenario.messages.length,
|
||||
llmCalls: scenario.llmCalls.length,
|
||||
toolCalls: scenario.toolCalls.length,
|
||||
embeddingCalls: 0,
|
||||
sessions: 0,
|
||||
errors: scenario.errors.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('monitoring conversation turn grouping', () => {
|
||||
test('keeps a single user message as one observable turn', () => {
|
||||
const userOnly = message(
|
||||
'single-user-only',
|
||||
'user',
|
||||
1,
|
||||
'No answer yet',
|
||||
'session-user-only',
|
||||
);
|
||||
|
||||
const turns = buildConversationTurns([userOnly], [], []);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].id).toBe(userOnly.id);
|
||||
expect(turns[0].userMessage?.messageContent).toBe('No answer yet');
|
||||
expect(turns[0].assistantMessages).toHaveLength(0);
|
||||
expect(turns[0].llmCalls).toHaveLength(0);
|
||||
expect(turns[0].totalTokens).toBe(0);
|
||||
});
|
||||
|
||||
test('groups multi-step agent execution and multiple replies into one user turn', () => {
|
||||
const scenario = monitoringScenario();
|
||||
const turns = buildConversationTurns(
|
||||
scenario.messages,
|
||||
scenario.llmCalls,
|
||||
scenario.errors,
|
||||
scenario.toolCalls,
|
||||
);
|
||||
|
||||
const agentTurn = turns.find((turn) => turn.id === 'agent-user-1');
|
||||
|
||||
expect(agentTurn).toBeTruthy();
|
||||
expect(agentTurn?.userMessage?.messageContent).toBe('Need deployment plan');
|
||||
expect(
|
||||
agentTurn?.assistantMessages.map((item) => item.messageContent),
|
||||
).toEqual([
|
||||
'Agent step 1: inspect repo',
|
||||
'Agent step 2: run tests',
|
||||
'Final answer: deployment plan ready',
|
||||
]);
|
||||
expect(agentTurn?.llmCalls).toHaveLength(3);
|
||||
expect(agentTurn?.toolCalls).toHaveLength(2);
|
||||
expect(agentTurn?.errors).toHaveLength(1);
|
||||
expect(agentTurn?.totalTokens).toBe(790);
|
||||
expect(agentTurn?.totalDuration).toBe(600);
|
||||
expect(agentTurn?.totalToolDuration).toBe(240);
|
||||
});
|
||||
|
||||
test('starts a new turn for each later user message in the same session', () => {
|
||||
const firstUser = message('same-session-user-1', 'user', 1, 'First');
|
||||
const firstReply = message(
|
||||
'same-session-reply-1',
|
||||
'assistant',
|
||||
2,
|
||||
'First reply',
|
||||
);
|
||||
const secondUser = message('same-session-user-2', 'user', 3, 'Second');
|
||||
const secondReply = message(
|
||||
'same-session-reply-2',
|
||||
'assistant',
|
||||
4,
|
||||
'Second reply',
|
||||
);
|
||||
|
||||
const turns = buildConversationTurns(
|
||||
[firstUser, firstReply, secondUser, secondReply],
|
||||
[
|
||||
llmCall('same-session-call-1', 1, firstUser.id, 10, 5, 40),
|
||||
llmCall('same-session-call-2', 3, secondUser.id, 20, 10, 50),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(turns.map((turn) => turn.id)).toEqual([
|
||||
'same-session-user-2',
|
||||
'same-session-user-1',
|
||||
]);
|
||||
expect(
|
||||
turns[0].assistantMessages.map((item) => item.messageContent),
|
||||
).toEqual(['Second reply']);
|
||||
expect(
|
||||
turns[1].assistantMessages.map((item) => item.messageContent),
|
||||
).toEqual(['First reply']);
|
||||
});
|
||||
|
||||
test('attaches calls without message ids by session time', () => {
|
||||
const user = message('fallback-user', 'user', 1, 'Use session fallback');
|
||||
const assistant = message(
|
||||
'fallback-assistant',
|
||||
'assistant',
|
||||
2,
|
||||
'Fallback reply',
|
||||
);
|
||||
const call = llmCall('fallback-call', 2, undefined, 25, 5, 70);
|
||||
|
||||
const turns = buildConversationTurns([user, assistant], [call], []);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].llmCalls).toHaveLength(1);
|
||||
expect(turns[0].llmCalls[0].id).toBe(call.id);
|
||||
expect(turns[0].totalTokens).toBe(30);
|
||||
});
|
||||
|
||||
test('attaches tool calls without message ids by session time', () => {
|
||||
const user = message('tool-fallback-user', 'user', 1, 'Use tool fallback');
|
||||
const assistant = message(
|
||||
'tool-fallback-assistant',
|
||||
'assistant',
|
||||
2,
|
||||
'Tool fallback reply',
|
||||
);
|
||||
const call = toolCall(
|
||||
'tool-fallback-call',
|
||||
2,
|
||||
undefined,
|
||||
'memory_lookup',
|
||||
45,
|
||||
);
|
||||
|
||||
const turns = buildConversationTurns([user, assistant], [], [], [call]);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].toolCalls).toHaveLength(1);
|
||||
expect(turns[0].toolCalls[0].id).toBe(call.id);
|
||||
expect(turns[0].totalToolDuration).toBe(45);
|
||||
});
|
||||
|
||||
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: rawMonitoringData(),
|
||||
});
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
await expect(page.getByText('3 conversation turns')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Standalone question with no reply'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
|
||||
await expect(page.getByText('Need deployment plan')).toBeVisible();
|
||||
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
|
||||
await expect(page.getByText('Assistant +2')).toBeVisible();
|
||||
await expect(page.getByText('3 LLM')).toBeVisible();
|
||||
await expect(page.getByText('2 tools')).toBeVisible();
|
||||
await expect(page.getByText('790 tokens')).toBeVisible();
|
||||
await expect(page.getByText('1 errors')).toBeVisible();
|
||||
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
|
||||
await expect(page.getByText('Rollback plan ready')).toBeVisible();
|
||||
|
||||
const agentTurn = page
|
||||
.locator('div[role="button"]')
|
||||
.filter({ hasText: 'Need deployment plan' });
|
||||
await expect(agentTurn).toHaveCount(1);
|
||||
await agentTurn.click();
|
||||
|
||||
await expect(page.getByText('Conversation Trace')).toBeVisible();
|
||||
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Final answer: deployment plan ready'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
|
||||
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
|
||||
await expect(page.getByText('In: 300')).toBeVisible();
|
||||
await expect(page.getByText('Out: 90')).toBeVisible();
|
||||
await expect(page.getByText('Total: 390')).toBeVisible();
|
||||
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
|
||||
await expect(page.getByText('#1 repo_search')).toBeVisible();
|
||||
await expect(page.getByText('#2 run_tests')).toBeVisible();
|
||||
await expect(page.getByText('Arguments')).toHaveCount(0);
|
||||
await expect(page.getByText('Result')).toHaveCount(0);
|
||||
|
||||
await page.getByText('#1 repo_search').click();
|
||||
await expect(page.getByText('Arguments').first()).toBeVisible();
|
||||
await expect(page.getByText('Result').first()).toBeVisible();
|
||||
await expect(page.getByText('Tool retry failed')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const bot = {
|
||||
id: 'bot-pipeline-monitoring',
|
||||
name: 'Pipeline Bot',
|
||||
};
|
||||
|
||||
const pipeline = {
|
||||
id: 'pipeline-monitoring',
|
||||
name: 'Pipeline Under Test',
|
||||
};
|
||||
|
||||
function at(minute: number) {
|
||||
return `2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`;
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: 'user' | 'assistant',
|
||||
minute: number,
|
||||
content: string,
|
||||
sessionId = 'session-pipeline-agent',
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
bot_id: bot.id,
|
||||
bot_name: bot.name,
|
||||
pipeline_id: pipeline.id,
|
||||
pipeline_name: pipeline.name,
|
||||
message_content: content,
|
||||
session_id: sessionId,
|
||||
status: 'success',
|
||||
level: 'info',
|
||||
platform: role === 'user' ? 'person' : 'bot',
|
||||
user_id: 'pipeline-user',
|
||||
user_name: 'Pipeline User',
|
||||
runner_name: 'local-agent',
|
||||
variables: '{}',
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function llmCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
messageId: string,
|
||||
input: number,
|
||||
output: number,
|
||||
duration: number,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
model_name: 'gpt-5.5',
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
total_tokens: input + output,
|
||||
duration,
|
||||
cost: 0,
|
||||
status: 'success',
|
||||
bot_id: bot.id,
|
||||
bot_name: bot.name,
|
||||
pipeline_id: pipeline.id,
|
||||
pipeline_name: pipeline.name,
|
||||
session_id: 'session-pipeline-agent',
|
||||
message_id: messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, minute: number, messageId: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
tool_name: name,
|
||||
tool_source: 'native',
|
||||
duration: 120,
|
||||
status: 'success',
|
||||
bot_id: bot.id,
|
||||
bot_name: bot.name,
|
||||
pipeline_id: pipeline.id,
|
||||
pipeline_name: pipeline.name,
|
||||
session_id: 'session-pipeline-agent',
|
||||
message_id: messageId,
|
||||
arguments: JSON.stringify({ query: name }),
|
||||
result: JSON.stringify({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function monitoringData() {
|
||||
const messages = [
|
||||
message(
|
||||
'single-user',
|
||||
'user',
|
||||
1,
|
||||
'Pipeline single user message without reply',
|
||||
'session-pipeline-single',
|
||||
),
|
||||
message('agent-user', 'user', 10, 'Pipeline needs a deployment plan'),
|
||||
message(
|
||||
'agent-assistant-1',
|
||||
'assistant',
|
||||
11,
|
||||
'Pipeline agent step 1: inspect repository',
|
||||
),
|
||||
message(
|
||||
'agent-assistant-2',
|
||||
'assistant',
|
||||
12,
|
||||
'Pipeline agent step 2: run tests',
|
||||
),
|
||||
message(
|
||||
'agent-assistant-3',
|
||||
'assistant',
|
||||
13,
|
||||
'Pipeline final answer: deployment ready',
|
||||
),
|
||||
];
|
||||
const llmCalls = [
|
||||
llmCall('pipeline-call-1', 10, 'agent-user', 100, 40, 180),
|
||||
llmCall('pipeline-call-2', 11, 'agent-user', 140, 50, 220),
|
||||
];
|
||||
const toolCalls = [
|
||||
toolCall('pipeline-tool-1', 11, 'agent-user', 'repo_search'),
|
||||
toolCall('pipeline-tool-2', 12, 'agent-user', 'run_tests'),
|
||||
];
|
||||
|
||||
return {
|
||||
overview: {
|
||||
total_messages: messages.length,
|
||||
llm_calls: llmCalls.length,
|
||||
embedding_calls: 0,
|
||||
model_calls: llmCalls.length,
|
||||
success_rate: 100,
|
||||
active_sessions: 2,
|
||||
},
|
||||
messages,
|
||||
llmCalls,
|
||||
toolCalls,
|
||||
embeddingCalls: [],
|
||||
sessions: [],
|
||||
errors: [],
|
||||
totalCount: {
|
||||
messages: messages.length,
|
||||
llmCalls: llmCalls.length,
|
||||
toolCalls: toolCalls.length,
|
||||
embeddingCalls: 0,
|
||||
sessions: 0,
|
||||
errors: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('pipeline monitoring conversation turns', () => {
|
||||
test('uses conversation turns and folded tool calls in the pipeline dashboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: monitoringData(),
|
||||
});
|
||||
|
||||
await page.goto(`/home/pipelines?id=${pipeline.id}`);
|
||||
await page.getByRole('tab', { name: 'Dashboard' }).click();
|
||||
|
||||
await expect(page.getByText('2 conversation turns')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Pipeline single user message without reply'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Pipeline needs a deployment plan'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Pipeline agent step 1: inspect repository'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Assistant +2')).toBeVisible();
|
||||
await expect(page.getByText('2 tools')).toBeVisible();
|
||||
|
||||
const agentTurn = page
|
||||
.locator('div[role="button"]')
|
||||
.filter({ hasText: 'Pipeline needs a deployment plan' });
|
||||
await expect(agentTurn).toHaveCount(1);
|
||||
await agentTurn.click();
|
||||
|
||||
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
|
||||
await expect(page.getByText('#1 repo_search')).toBeVisible();
|
||||
await expect(page.getByText('#2 run_tests')).toBeVisible();
|
||||
await expect(page.getByText('Arguments')).toHaveCount(0);
|
||||
await page.getByText('#1 repo_search').click();
|
||||
await expect(page.getByText('Arguments')).toBeVisible();
|
||||
await expect(page.getByText('Result')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user