mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
- Load overview first and fetch secondary sections when expanded - Add a covering index and select conversation IDs before loading page payloads - Preserve full-detail API compatibility and add pagination and lazy-loading tests
669 lines
20 KiB
TypeScript
669 lines
20 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
installLangBotApiMocks,
|
|
buildExecutionList,
|
|
} from './fixtures/langbot-api';
|
|
|
|
const monitoring = {
|
|
messages: [
|
|
{
|
|
id: 'input-one',
|
|
role: 'user',
|
|
message_content: 'Open this execution',
|
|
timestamp: '2026-07-02T10:00:00Z',
|
|
bot_id: 'bot',
|
|
pipeline_id: 'pipe',
|
|
pipeline_name: 'Processor A',
|
|
},
|
|
],
|
|
};
|
|
const row = buildExecutionList(monitoring).items[0];
|
|
const pageOf = (
|
|
items: unknown[],
|
|
has_more = false,
|
|
next_offset = items.length,
|
|
) => ({ items, has_more, next_offset });
|
|
|
|
test('loads collapsed history on demand, retries and pages without reloading it on reopen', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, {
|
|
authenticated: true,
|
|
monitoringData: monitoring,
|
|
});
|
|
const requests: string[] = [];
|
|
let failHistory = true;
|
|
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const section = url.searchParams.get('section');
|
|
requests.push(url.search);
|
|
if (section === 'conversation' && failHistory) {
|
|
failHistory = false;
|
|
await route.fulfill({
|
|
status: 500,
|
|
json: { code: 500, msg: 'Temporary failure' },
|
|
});
|
|
return;
|
|
}
|
|
const offset = Number(url.searchParams.get('offset') || 0);
|
|
const pages =
|
|
section === 'overview'
|
|
? {
|
|
inputs: pageOf([{ id: 'input', content: 'Selected message' }]),
|
|
outputs: pageOf([]),
|
|
deliveries: pageOf([]),
|
|
errors: pageOf([]),
|
|
}
|
|
: section === 'conversation'
|
|
? {
|
|
conversation: pageOf(
|
|
[
|
|
{
|
|
id: `history-${offset}`,
|
|
content: offset ? 'Older history' : 'Recent history',
|
|
},
|
|
],
|
|
offset === 0,
|
|
offset + 1,
|
|
),
|
|
}
|
|
: { related: pageOf([]) };
|
|
await route.fulfill({
|
|
json: { code: 0, data: { source: 'pipeline', row, pages } },
|
|
});
|
|
});
|
|
await page.goto('/home/monitoring');
|
|
await page
|
|
.getByRole('button')
|
|
.filter({ hasText: 'Open this execution' })
|
|
.click();
|
|
const sheet = page.getByRole('dialog');
|
|
await expect(
|
|
sheet.getByText('Selected message').filter({ visible: true }),
|
|
).toBeVisible();
|
|
expect(requests).toEqual(['?section=overview']);
|
|
const history = sheet.locator('details').filter({
|
|
has: page.locator('summary', { hasText: /^Conversation context/ }),
|
|
});
|
|
await history.locator(':scope > summary').click();
|
|
await expect(history.getByRole('alert')).toBeVisible();
|
|
await history.getByRole('button', { name: 'Retry', exact: true }).click();
|
|
await expect(
|
|
history.getByText('Recent history').filter({ visible: true }),
|
|
).toBeVisible();
|
|
await history.getByRole('button', { name: 'Load more', exact: true }).click();
|
|
await expect(
|
|
history.getByText('Older history').filter({ visible: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
history.getByText('Recent history').filter({ visible: true }),
|
|
).toBeVisible();
|
|
const count = requests.length;
|
|
await history.locator(':scope > summary').click();
|
|
await history.locator(':scope > summary').click();
|
|
await expect(
|
|
history.getByText('Older history').filter({ visible: true }),
|
|
).toBeVisible();
|
|
expect(requests).toHaveLength(count);
|
|
expect(requests.every((query) => !query.includes('section=related'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('ignores a late history response after selecting a different execution', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, {
|
|
authenticated: true,
|
|
monitoringData: monitoring,
|
|
});
|
|
let releaseHistory: (() => void) | undefined;
|
|
const requested: string[] = [];
|
|
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const section = url.searchParams.get('section');
|
|
const sibling = url.pathname.endsWith('/sibling');
|
|
requested.push(`${sibling ? 'sibling' : 'original'}:${section}`);
|
|
if (section === 'conversation')
|
|
await new Promise<void>((resolve) => {
|
|
releaseHistory = resolve;
|
|
});
|
|
const pages =
|
|
section === 'overview'
|
|
? {
|
|
inputs: pageOf([
|
|
{
|
|
id: 'input',
|
|
content: sibling ? 'Sibling selection' : 'Original selection',
|
|
},
|
|
]),
|
|
outputs: pageOf([]),
|
|
deliveries: pageOf([]),
|
|
errors: pageOf([]),
|
|
}
|
|
: section === 'related'
|
|
? {
|
|
related: pageOf([
|
|
{ ...row, id: 'sibling', target_name: 'Processor B' },
|
|
]),
|
|
}
|
|
: {
|
|
conversation: pageOf([
|
|
{ id: 'late', content: 'Late old history' },
|
|
]),
|
|
};
|
|
await route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: {
|
|
source: 'pipeline',
|
|
row: sibling ? { ...row, id: 'sibling' } : row,
|
|
pages,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
await page.goto('/home/monitoring');
|
|
await page
|
|
.getByRole('button')
|
|
.filter({ hasText: 'Open this execution' })
|
|
.click();
|
|
const sheet = page.getByRole('dialog');
|
|
await expect(
|
|
sheet.getByText('Original selection').filter({ visible: true }),
|
|
).toBeVisible();
|
|
await sheet
|
|
.locator('summary')
|
|
.filter({ hasText: /^Conversation context/ })
|
|
.click();
|
|
await expect.poll(() => !!releaseHistory).toBe(true);
|
|
await sheet
|
|
.locator('summary')
|
|
.filter({ hasText: /^Related executions/ })
|
|
.click();
|
|
await sheet.getByRole('button', { name: /Processor B/ }).click();
|
|
await expect(
|
|
sheet.getByText('Sibling selection').filter({ visible: true }),
|
|
).toBeVisible();
|
|
const response = page.waitForResponse(
|
|
(res) => new URL(res.url()).searchParams.get('section') === 'conversation',
|
|
);
|
|
releaseHistory!();
|
|
await response;
|
|
await expect(sheet.getByText('Late old history')).toHaveCount(0);
|
|
expect(requested.filter((value) => value.startsWith('sibling:'))).toEqual([
|
|
'sibling:overview',
|
|
]);
|
|
});
|
|
|
|
test('renders typed and custom events without duplicate text or empty attachment messages', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, {
|
|
authenticated: true,
|
|
monitoringData: monitoring,
|
|
});
|
|
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
|
|
await route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: {
|
|
source: 'pipeline',
|
|
row,
|
|
pages: {
|
|
inputs: pageOf([
|
|
{
|
|
id: 'message',
|
|
content: {
|
|
text: '你好~',
|
|
contents: [{ type: 'text', text: '你好~', image_url: null }],
|
|
attachments: [],
|
|
},
|
|
},
|
|
{
|
|
id: 'member',
|
|
event_type: 'group.member_banned',
|
|
content: {
|
|
member: { id: 'member-42', nickname: 'Alice' },
|
|
group: { id: 'group-9' },
|
|
duration: 0,
|
|
permanent: false,
|
|
custom: { reason: 'Moderation reason' },
|
|
},
|
|
},
|
|
{
|
|
id: 'edited',
|
|
content: {
|
|
message_chain: [{ type: 'Plain', text: 'Typed chain text' }],
|
|
editor: { id: 'editor-7' },
|
|
new_content: [{ type: 'Plain', text: 'Updated text' }],
|
|
},
|
|
},
|
|
{
|
|
id: 'custom',
|
|
content: {
|
|
type: 'CustomEvent',
|
|
text: 'Distinct text',
|
|
content: 'Different content',
|
|
properties: { nested: ['custom-value'] },
|
|
},
|
|
},
|
|
{
|
|
id: 'same-again',
|
|
content: {
|
|
text: '你好~',
|
|
contents: [{ type: 'text', text: '你好~' }],
|
|
},
|
|
},
|
|
{
|
|
id: 'image',
|
|
content: {
|
|
contents: [
|
|
{
|
|
type: 'image_url',
|
|
image_url: {
|
|
url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]),
|
|
outputs: pageOf([
|
|
{
|
|
id: 'assistant',
|
|
content: {
|
|
content: 'Agent answer',
|
|
tool_calls: [
|
|
{
|
|
id: 'tool-1',
|
|
function: {
|
|
name: 'event_reply',
|
|
arguments: '{"text":"sent"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
attachments: [],
|
|
},
|
|
]),
|
|
deliveries: pageOf([]),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
await page.goto('/home/monitoring');
|
|
await page
|
|
.getByRole('button')
|
|
.filter({ hasText: 'Open this execution' })
|
|
.click();
|
|
const sheet = page.getByRole('dialog');
|
|
await expect(
|
|
sheet.getByText('你好~', { exact: true }).filter({ visible: true }),
|
|
).toHaveCount(2);
|
|
await expect(sheet.getByText('[Empty message]', { exact: true })).toHaveCount(
|
|
0,
|
|
);
|
|
for (const details of await sheet
|
|
.locator('summary')
|
|
.filter({ hasText: /^Details$/ })
|
|
.all())
|
|
await details.click();
|
|
for (const text of [
|
|
'Agent answer',
|
|
'member-42',
|
|
'Alice',
|
|
'group-9',
|
|
'Moderation reason',
|
|
'0',
|
|
'false',
|
|
'Typed chain text',
|
|
'editor-7',
|
|
'Updated text',
|
|
'Distinct text',
|
|
'Different content',
|
|
'custom-value',
|
|
'event_reply',
|
|
]) {
|
|
await expect(
|
|
sheet.getByText(text, { exact: true }).filter({ visible: true }).first(),
|
|
).toBeVisible();
|
|
}
|
|
await expect(
|
|
sheet.getByRole('img', { name: 'Message attachment' }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('pages a long trace without dropping inputs and opens related executions', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, {
|
|
authenticated: true,
|
|
monitoringData: monitoring,
|
|
});
|
|
const requests: string[] = [];
|
|
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
requests.push(url.search);
|
|
const sibling = url.pathname.endsWith('/sibling');
|
|
const more = url.searchParams.get('section') === 'events';
|
|
await route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: {
|
|
source: 'pipeline',
|
|
row: sibling
|
|
? { ...row, id: 'sibling', target_name: 'Processor B' }
|
|
: row,
|
|
pages: more
|
|
? {
|
|
events: pageOf(
|
|
[
|
|
{
|
|
id: 101,
|
|
sequence: 101,
|
|
type: 'run.completed',
|
|
data: { final: 'Last trace event' },
|
|
},
|
|
],
|
|
false,
|
|
101,
|
|
),
|
|
}
|
|
: {
|
|
inputs: pageOf([
|
|
{
|
|
id: 'in',
|
|
content: sibling ? 'Sibling input' : 'Original input',
|
|
},
|
|
]),
|
|
outputs: pageOf([
|
|
{ id: 'out', content: 'Generated but not sent' },
|
|
]),
|
|
deliveries: pageOf([]),
|
|
events: pageOf(
|
|
[{ id: 1, sequence: 1, type: 'run.started', data: {} }],
|
|
true,
|
|
100,
|
|
),
|
|
related: pageOf(
|
|
sibling
|
|
? []
|
|
: [{ ...row, id: 'sibling', target_name: 'Processor B' }],
|
|
),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
await page.goto('/home/monitoring');
|
|
await page
|
|
.getByRole('button')
|
|
.filter({ hasText: 'Open this execution' })
|
|
.click();
|
|
const sheet = page.getByRole('dialog');
|
|
await expect(
|
|
sheet.getByText('Generated but not sent').filter({ visible: true }),
|
|
).toBeVisible();
|
|
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
|
|
await sheet.getByText('Execution events (1+)', { exact: true }).click();
|
|
await sheet.getByRole('button', { name: 'Load more' }).click();
|
|
await expect(sheet.getByText('run.completed', { exact: true })).toBeVisible();
|
|
await expect(
|
|
sheet.getByText('Original input').filter({ visible: true }),
|
|
).toBeVisible();
|
|
expect(
|
|
requests.some(
|
|
(query) =>
|
|
query.includes('section=events') && query.includes('offset=100'),
|
|
),
|
|
).toBe(true);
|
|
await sheet.getByText('Related executions (1)', { exact: true }).click();
|
|
await sheet.getByRole('button', { name: /Processor B/ }).click();
|
|
await expect(
|
|
sheet.getByText('Sibling input').filter({ visible: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
sheet.getByText('Original input').filter({ visible: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test('shows non-message events without fabricating a reply or a processor', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, { authenticated: true });
|
|
const event = {
|
|
...row,
|
|
source: 'event',
|
|
id: 'event-only',
|
|
event_id: 'event-only',
|
|
target_id: null,
|
|
target_kind: 'event',
|
|
target_name: null,
|
|
title: 'group.member.joined',
|
|
input_preview: 'Member joined',
|
|
status_group: 'ignored',
|
|
status: 'ignored',
|
|
};
|
|
await page.route('**/api/v1/monitoring/executions?*', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: { ...buildExecutionList(monitoring), items: [event] },
|
|
},
|
|
}),
|
|
);
|
|
await page.route('**/api/v1/monitoring/executions/event/*?*', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: {
|
|
source: 'event',
|
|
row: event,
|
|
pages: {
|
|
inputs: pageOf([
|
|
{
|
|
id: 'event-only',
|
|
event_type: 'group.member.joined',
|
|
content: { member_id: 'member-42' },
|
|
metadata: {
|
|
routes: [
|
|
{ status: 'not_matched', reason: 'No matching route' },
|
|
],
|
|
},
|
|
},
|
|
]),
|
|
outputs: pageOf([]),
|
|
deliveries: pageOf([]),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await page.goto('/home/monitoring');
|
|
await page.getByRole('button').filter({ hasText: 'Member joined' }).click();
|
|
const sheet = page.getByRole('dialog');
|
|
await expect(
|
|
sheet.getByRole('heading', { name: 'No processor run' }),
|
|
).toBeVisible();
|
|
await sheet
|
|
.locator('summary')
|
|
.filter({ hasText: /^Details$/ })
|
|
.first()
|
|
.click();
|
|
await expect(sheet.getByText(/member-42/)).toBeVisible();
|
|
await expect(sheet.getByText(/No matching route/)).toBeVisible();
|
|
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
|
|
});
|
|
|
|
test('filters all three processor kinds with the sidebar icons', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, { authenticated: true });
|
|
const processors = [
|
|
{ uuid: 'agent-one', name: 'Agent One', kind: 'agent', emoji: '🤖' },
|
|
{
|
|
uuid: 'pipeline-one',
|
|
name: 'Pipeline One',
|
|
kind: 'pipeline',
|
|
emoji: '⚙️',
|
|
},
|
|
{
|
|
uuid: 'event-one',
|
|
name: 'Event One',
|
|
kind: 'event_processor',
|
|
emoji: '🧩',
|
|
},
|
|
];
|
|
await page.route('**/api/v1/agents', (route) =>
|
|
route.fulfill({ json: { code: 0, data: { agents: processors } } }),
|
|
);
|
|
await page.goto('/home/monitoring');
|
|
const filter = page.locator('#monitoring-filter-processor');
|
|
await filter.click();
|
|
const popup = page.getByRole('dialog');
|
|
for (const processor of processors) {
|
|
const option = popup.getByRole('checkbox', {
|
|
name: processor.name,
|
|
exact: true,
|
|
});
|
|
await expect(
|
|
option.locator('..').getByText(processor.emoji, { exact: true }),
|
|
).toBeVisible();
|
|
const request = page.waitForRequest((req) => {
|
|
const url = new URL(req.url());
|
|
return (
|
|
url.pathname.endsWith('/monitoring/executions') &&
|
|
url.searchParams.getAll('pipelineId').includes(processor.uuid)
|
|
);
|
|
});
|
|
await option.check();
|
|
await request;
|
|
await expect(option).toBeChecked();
|
|
}
|
|
await expect(filter).toContainText('3');
|
|
await popup
|
|
.getByRole('button', { name: 'All Processors', exact: true })
|
|
.click();
|
|
await expect(popup.getByRole('checkbox', { checked: true })).toHaveCount(0);
|
|
await expect(filter).toContainText('All Processors');
|
|
});
|
|
|
|
test('shares mode and status filters with dashboard charts and records', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, { authenticated: true });
|
|
await page.goto('/home/monitoring');
|
|
for (const [id, option, parameter, value] of [
|
|
['monitoring-filter-mode', 'Debug only', 'mode', 'debug'],
|
|
['monitoring-filter-status', 'Failed', 'status', 'failed'],
|
|
]) {
|
|
await page.locator(`#${id}`).click();
|
|
const requests = ['/monitoring/data', '/monitoring/executions'].map(
|
|
(path) =>
|
|
page.waitForRequest((req) => {
|
|
const url = new URL(req.url());
|
|
return (
|
|
url.pathname.endsWith(path) &&
|
|
url.searchParams.get(parameter) === value
|
|
);
|
|
}),
|
|
);
|
|
await page.getByRole('option', { name: option, exact: true }).click();
|
|
await Promise.all(requests);
|
|
}
|
|
});
|
|
|
|
test('remembers all filters on reload and persists resetting them', async ({
|
|
page,
|
|
}) => {
|
|
await installLangBotApiMocks(page, { authenticated: true });
|
|
await page.route('**/api/v1/platform/bots', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: {
|
|
bots: [{ uuid: 'bot-one', name: 'Saved Bot', adapter: 'aiocqhttp' }],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await page.route('**/api/v1/agents', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
code: 0,
|
|
data: {
|
|
agents: [
|
|
{
|
|
uuid: 'processor-one',
|
|
name: 'Saved Processor',
|
|
kind: 'agent',
|
|
emoji: '🤖',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await page.goto('/home/monitoring');
|
|
const selections = [
|
|
['bot', 'Saved Bot'],
|
|
['processor', 'Saved Processor'],
|
|
['mode', 'Debug only'],
|
|
['status', 'Failed'],
|
|
['time', 'Last 7 days'],
|
|
];
|
|
for (const [id, label] of selections) {
|
|
await page.locator(`#monitoring-filter-${id}`).click();
|
|
if (id === 'processor') {
|
|
await page.getByRole('checkbox', { name: label, exact: true }).check();
|
|
await page.keyboard.press('Escape');
|
|
} else await page.getByRole('option', { name: label, exact: true }).click();
|
|
}
|
|
await page.reload();
|
|
for (const [id, label] of selections) {
|
|
if (id === 'processor') {
|
|
await page.locator('#monitoring-filter-processor').click();
|
|
await expect(
|
|
page.getByRole('checkbox', { name: label, exact: true }),
|
|
).toBeChecked();
|
|
await page.keyboard.press('Escape');
|
|
} else
|
|
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(
|
|
label,
|
|
);
|
|
}
|
|
await page
|
|
.getByRole('button', { name: 'Reset Filters', exact: true })
|
|
.click();
|
|
await page.reload();
|
|
for (const [id, label] of [
|
|
['bot', 'All Bots'],
|
|
['processor', 'All Processors'],
|
|
['mode', 'Live + debug'],
|
|
['status', 'All statuses'],
|
|
['time', 'Last 24 hours'],
|
|
]) {
|
|
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
|
|
}
|
|
});
|
|
|
|
test('reset removes linked resource filters from the URL', async ({ page }) => {
|
|
await installLangBotApiMocks(page, { authenticated: true });
|
|
await page.goto(
|
|
'/home/monitoring?botId=linked-bot&pipelineId=linked-processor',
|
|
);
|
|
await page
|
|
.getByRole('button', { name: 'Reset Filters', exact: true })
|
|
.click();
|
|
await expect(page).toHaveURL(/\/home\/monitoring$/);
|
|
await page.reload();
|
|
await expect(page.locator('#monitoring-filter-bot')).toContainText(
|
|
'All Bots',
|
|
);
|
|
await expect(page.locator('#monitoring-filter-processor')).toContainText(
|
|
'All Processors',
|
|
);
|
|
});
|