mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 13:25:18 +08:00
fix: resolve master conflicts and align connection mode regression
This commit is contained in:
@@ -157,12 +157,16 @@ test.describe('bot session request recovery', () => {
|
||||
.click();
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No sessions found', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'analysis') {
|
||||
await expect(page.getByRole('alert')).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -177,10 +181,13 @@ test.describe('bot session request recovery', () => {
|
||||
).toHaveCount(0);
|
||||
failing = false;
|
||||
await page
|
||||
.getByRole('tabpanel', { name: /Sessions/ })
|
||||
.getByRole('alert')
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'initial list' || failure === 'list page') {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
@@ -313,7 +320,9 @@ test.describe('bot session request races', () => {
|
||||
await expect.poll(() => released).toBe(true);
|
||||
// Allow the released HTTP response and React's queued update to settle.
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -203,7 +203,8 @@ test('bot guide starts on the detail page after adapter-only creation', async ({
|
||||
const adapterCard = page.locator('[data-slot="card"]').filter({
|
||||
has: page.getByText('Adapter Configuration', { exact: true }),
|
||||
});
|
||||
await expect(adapterCard.getByRole('switch')).toBeChecked();
|
||||
await expect(adapterCard.getByRole('switch')).toHaveCount(0);
|
||||
await expect(page.getByRole('radio', { name: /^Webhook/ })).toBeChecked();
|
||||
await guide.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
for (const scenario of ['install', 'retry', 'reload'] as const) {
|
||||
test(`event processor marketplace ${scenario}: filters usage and selects registered component`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const ref = 'plugin:qa/events/default';
|
||||
let installed = false;
|
||||
let finish = scenario !== 'reload';
|
||||
let installs = 0;
|
||||
let registrations = 0;
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const descriptor = {
|
||||
id: ref,
|
||||
plugin_author: 'qa',
|
||||
plugin_name: 'events',
|
||||
label: { en_US: 'Event handler' },
|
||||
supported_event_patterns: ['group.member_joined'],
|
||||
config_schema: [
|
||||
{
|
||||
name: 'greeting',
|
||||
type: 'string',
|
||||
label: { en_US: 'Greeting' },
|
||||
default: 'Welcome',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
let processor = {
|
||||
uuid: 'event-market',
|
||||
kind: 'event_processor',
|
||||
name: 'Event marketplace',
|
||||
component_ref: '',
|
||||
config: {},
|
||||
supported_event_patterns: [],
|
||||
};
|
||||
await page.route('**/api/v1/agents**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
let data: unknown;
|
||||
if (path.endsWith('/_/metadata')) {
|
||||
if (installed) registrations++;
|
||||
data = {
|
||||
event_processors: installed && registrations > 1 ? [descriptor] : [],
|
||||
kinds: [],
|
||||
platform_tools: [],
|
||||
};
|
||||
} else if (path.endsWith('/runs')) data = { items: [], has_more: false };
|
||||
else if (path.endsWith('/event-market')) {
|
||||
if (route.request().method() === 'PUT')
|
||||
processor = { ...processor, ...route.request().postDataJSON() };
|
||||
data = { agent: processor };
|
||||
} else return route.fallback();
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.route('**/api/v1/marketplace/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (!path.endsWith('/search')) return route.fallback();
|
||||
requests.push(route.request().postDataJSON());
|
||||
const plugin = (
|
||||
name: string,
|
||||
usages?: string[],
|
||||
components = { Runner: 1 },
|
||||
) => ({
|
||||
author: 'qa',
|
||||
name,
|
||||
label: { en_US: name },
|
||||
description: { en_US: name + ' description' },
|
||||
latest_version: '1.0.0',
|
||||
type: 'plugin',
|
||||
components,
|
||||
runner_usages: usages,
|
||||
install_count: 0,
|
||||
});
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
total: 5,
|
||||
[path.includes('/extensions/') ? 'extensions' : 'plugins']: [
|
||||
plugin('events', ['event']),
|
||||
plugin('agent-only', ['agent']),
|
||||
plugin('mixed', ['agent', 'event']),
|
||||
plugin('unknown'),
|
||||
plugin('knowledge-only', ['event'], {
|
||||
KnowledgeEngine: 1,
|
||||
} as any),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
|
||||
installs++;
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
plugin_author: 'qa',
|
||||
plugin_name: 'events',
|
||||
plugin_version: '1.0.0',
|
||||
});
|
||||
await route.fulfill({ json: { code: 0, data: { task_id: installs } } });
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/*', async (route) => {
|
||||
const failed = scenario === 'retry' && installs === 1;
|
||||
installed = finish && !failed;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
id: installs,
|
||||
name: 'plugin-install-marketplace',
|
||||
runtime: {
|
||||
done: finish,
|
||||
exception: failed ? 'Download failed' : null,
|
||||
},
|
||||
task_context: {
|
||||
current_action: 'Downloading',
|
||||
metadata: { progress_percent: 25 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/home/agents?id=event-market');
|
||||
const select = page.getByRole('combobox', {
|
||||
name: 'Plugin processor',
|
||||
includeHidden: true,
|
||||
});
|
||||
await select.click();
|
||||
const install = page.getByRole('button', {
|
||||
name: 'Install events',
|
||||
exact: true,
|
||||
});
|
||||
await expect(install).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Install mixed', exact: true }),
|
||||
).toBeVisible();
|
||||
for (const excluded of ['agent-only', 'unknown', 'knowledge-only'])
|
||||
await expect(
|
||||
page.getByRole('button', { name: `Install ${excluded}`, exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
for (const request of requests)
|
||||
expect(request).toMatchObject({
|
||||
component_filter: 'Runner',
|
||||
runner_usage: 'event',
|
||||
type_filter: 'plugin',
|
||||
});
|
||||
await install.click();
|
||||
if (scenario === 'retry') {
|
||||
await expect(
|
||||
page
|
||||
.getByRole('alert', { includeHidden: true })
|
||||
.filter({ hasText: 'Download failed' }),
|
||||
).toBeVisible();
|
||||
await install.click();
|
||||
}
|
||||
if (scenario === 'reload') {
|
||||
await expect.poll(() => installs).toBe(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Object.keys(sessionStorage).some((key) =>
|
||||
key.includes('event-processor:event-market'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
finish = true;
|
||||
await page.reload();
|
||||
}
|
||||
await expect(select).toContainText('Event handler', { timeout: 20000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('input[name="greeting"]')).toHaveValue('Welcome');
|
||||
expect(installs).toBe(scenario === 'retry' ? 2 : 1);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => processor.component_ref).toBe(ref);
|
||||
});
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export interface WorkspaceEntryMock {
|
||||
|
||||
interface LangBotApiMockState {
|
||||
authenticated: boolean;
|
||||
withAssistant: boolean;
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
@@ -227,6 +228,128 @@ function emptyMonitoringData() {
|
||||
};
|
||||
}
|
||||
|
||||
type RawMonitoringMessage = Record<string, unknown>;
|
||||
|
||||
/** Mirror of the backend execution status groups so fixtures stay realistic. */
|
||||
function executionStatusGroup(status: string, source: 'agent' | 'pipeline') {
|
||||
if (source === 'agent') {
|
||||
if (status === 'failed' || status === 'timeout') return 'failed';
|
||||
if (status === 'created' || status === 'queued') return 'queued';
|
||||
if (status === 'claimed' || status === 'running') return 'running';
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
return status;
|
||||
}
|
||||
if (status === 'success') return 'completed';
|
||||
if (status === 'error') return 'failed';
|
||||
if (status === 'pending') return 'running';
|
||||
if (status === 'discarded' || status === 'not_matched') return 'ignored';
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Best-effort text extraction from a serialized message chain (mirrors backend). */
|
||||
function executionText(raw: unknown): string {
|
||||
const source = String(raw ?? '');
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
const walk = (node: unknown, depth = 0): void => {
|
||||
if (depth > 6) return;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item) => walk(item, depth + 1));
|
||||
} else if (node && typeof node === 'object') {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === 'text' && typeof value === 'string') parts.push(value);
|
||||
else walk(value, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(JSON.parse(source));
|
||||
return parts.join(' ').trim() || source;
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the unified execution list from a mocked monitoring payload. */
|
||||
export function buildExecutionList(monitoringData: unknown) {
|
||||
const payload = monitoringData as
|
||||
| { messages?: RawMonitoringMessage[] }
|
||||
| undefined;
|
||||
const items = (payload?.messages ?? [])
|
||||
.filter((message) => message.role !== 'assistant')
|
||||
.map((message) => {
|
||||
const status = String(message.status ?? 'success');
|
||||
return {
|
||||
source: 'pipeline' as const,
|
||||
id: String(message.id),
|
||||
event_id: null,
|
||||
status,
|
||||
status_group: executionStatusGroup(status, 'pipeline'),
|
||||
title: executionText(message.message_content),
|
||||
input_preview: executionText(message.message_content),
|
||||
target_kind: 'pipeline' as const,
|
||||
target_id: (message.pipeline_id as string) ?? null,
|
||||
target_name: (message.pipeline_name as string) ?? null,
|
||||
bot_id: (message.bot_id as string) ?? null,
|
||||
bot_name: (message.bot_name as string) ?? null,
|
||||
pipeline_id: (message.pipeline_id as string) ?? null,
|
||||
pipeline_name: (message.pipeline_name as string) ?? null,
|
||||
runner_id: (message.runner_name as string) ?? null,
|
||||
conversation_id: null,
|
||||
session_id: (message.session_id as string) ?? null,
|
||||
created_at_ms: Date.parse(String(message.timestamp ?? '')) || null,
|
||||
started_at_ms: null,
|
||||
finished_at_ms: null,
|
||||
duration_ms: null,
|
||||
usage: null,
|
||||
cost: null,
|
||||
queue_name: null,
|
||||
debug: false,
|
||||
has_error: status === 'error',
|
||||
};
|
||||
});
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
byStatus[item.status_group] = (byStatus[item.status_group] ?? 0) + 1;
|
||||
}
|
||||
const completed = byStatus.completed ?? 0;
|
||||
const failed = byStatus.failed ?? 0;
|
||||
const denominator = completed + failed;
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
limit: items.length,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
summary: {
|
||||
executions: {
|
||||
total: items.length,
|
||||
by_source: { agent: 0, pipeline: items.length },
|
||||
by_status: byStatus,
|
||||
completed,
|
||||
failed,
|
||||
running: byStatus.running ?? 0,
|
||||
queued: byStatus.queued ?? 0,
|
||||
cancelled: byStatus.cancelled ?? 0,
|
||||
ignored: byStatus.ignored ?? 0,
|
||||
waiting: 0,
|
||||
success_rate:
|
||||
denominator > 0
|
||||
? Math.round((completed / denominator) * 10000) / 100
|
||||
: null,
|
||||
denominator,
|
||||
p50_duration_ms: null,
|
||||
p95_duration_ms: null,
|
||||
duration_sample: 0,
|
||||
debug: 0,
|
||||
real: items.length,
|
||||
},
|
||||
tokens: { total_tokens: 0, calls: 0, calls_with_usage: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTokenStatistics() {
|
||||
return {
|
||||
summary: {
|
||||
@@ -610,7 +733,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
|
||||
if (path === '/api/v1/user/info') {
|
||||
return fulfillJson(route, {
|
||||
account_uuid: 'account-playwright',
|
||||
// Most tests exercise an unlinked local user. Assistant tests opt into
|
||||
// an account identity and mock its additional provider/conversation APIs.
|
||||
account_uuid: state.withAssistant ? 'account-playwright' : null,
|
||||
user: '[email protected]',
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
@@ -1163,6 +1288,76 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/executions') {
|
||||
return fulfillJson(route, buildExecutionList(state.monitoringData));
|
||||
}
|
||||
|
||||
const executionDetail = path.match(
|
||||
/^\/api\/v1\/monitoring\/executions\/(agent|pipeline|event|auto)\/(.+)$/,
|
||||
);
|
||||
if (executionDetail) {
|
||||
const [, , executionId] = executionDetail;
|
||||
const payload = state.monitoringData as {
|
||||
messages?: RawMonitoringMessage[];
|
||||
llmCalls?: RawMonitoringMessage[];
|
||||
toolCalls?: RawMonitoringMessage[];
|
||||
errors?: RawMonitoringMessage[];
|
||||
};
|
||||
const row = buildExecutionList(payload).items.find(
|
||||
(item) => item.id === executionId,
|
||||
);
|
||||
if (!row)
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
json: { code: 404, msg: 'Execution not found' },
|
||||
});
|
||||
const root = (payload.messages ?? []).find(
|
||||
(item) => item.id === executionId,
|
||||
)!;
|
||||
const content = (item: RawMonitoringMessage) => ({
|
||||
...item,
|
||||
content: item.message_content,
|
||||
timestamp_ms: Date.parse(String(item.timestamp)),
|
||||
origin: item.role === 'assistant' ? 'delivery' : 'input',
|
||||
});
|
||||
const pages = Object.fromEntries(
|
||||
Object.entries({
|
||||
inputs: [content(root)],
|
||||
outputs: [],
|
||||
deliveries: (payload.messages ?? [])
|
||||
.filter((item) => item.parent_message_id === executionId)
|
||||
.map(content),
|
||||
conversation: (payload.messages ?? [])
|
||||
.filter(
|
||||
(item) =>
|
||||
item.session_id === root.session_id &&
|
||||
item.bot_id === root.bot_id,
|
||||
)
|
||||
.map(content),
|
||||
related: [],
|
||||
events: [],
|
||||
llm_calls: (payload.llmCalls ?? []).filter(
|
||||
(item) => item.message_id === executionId,
|
||||
),
|
||||
tool_calls: (payload.toolCalls ?? []).filter(
|
||||
(item) => item.message_id === executionId,
|
||||
),
|
||||
errors: (payload.errors ?? []).filter(
|
||||
(item) => item.message_id === executionId,
|
||||
),
|
||||
}).map(([key, items]) => [
|
||||
key,
|
||||
{ items, next_offset: items.length, has_more: false },
|
||||
]),
|
||||
);
|
||||
return fulfillJson(route, {
|
||||
source: row.source,
|
||||
row,
|
||||
pages,
|
||||
legacy_context: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/token-statistics') {
|
||||
return fulfillJson(route, emptyTokenStatistics());
|
||||
}
|
||||
@@ -1267,6 +1462,7 @@ export async function installLangBotApiMocks(
|
||||
page: Page,
|
||||
options: {
|
||||
authenticated?: boolean;
|
||||
withAssistant?: boolean;
|
||||
language?: string;
|
||||
monitoringData?: unknown;
|
||||
monitoringSessions?: unknown[];
|
||||
@@ -1280,6 +1476,7 @@ export async function installLangBotApiMocks(
|
||||
) {
|
||||
const {
|
||||
authenticated = false,
|
||||
withAssistant = false,
|
||||
language = 'en-US',
|
||||
monitoringData,
|
||||
monitoringSessions,
|
||||
@@ -1292,6 +1489,7 @@ export async function installLangBotApiMocks(
|
||||
} = options;
|
||||
const state: LangBotApiMockState = {
|
||||
authenticated,
|
||||
withAssistant,
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
pipelineMetadata,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
const runner = 'plugin:langbot-team/LocalAgent/default';
|
||||
|
||||
async function setup(page: Page, kind: 'agent' | 'pipeline' = 'agent') {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
// Exercise the self-hosted form, without the unrelated account assistant overlay.
|
||||
await page.route('**/api/v1/user/info', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { account_uuid: null, user_id: 1, username: 'Form tester' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const metadata = pipelineMetadata();
|
||||
let pipelineSaved = false;
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'PUT' && r.url().endsWith('/pipelines/pipeline-draft'))
|
||||
pipelineSaved = true;
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/pipeline-draft', async (route) => {
|
||||
if (route.request().method() !== 'GET' || pipelineSaved)
|
||||
return route.fallback();
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'pipeline-draft',
|
||||
name: 'Pipeline draft',
|
||||
emoji: '⚙️',
|
||||
description: '',
|
||||
config: {
|
||||
ai: {
|
||||
runner: { id: runner },
|
||||
runner_config: {
|
||||
[runner]: {
|
||||
model: { primary: 'llm-valid', fallbacks: [] },
|
||||
instruction: 'Original instruction',
|
||||
advanced: false,
|
||||
detail: 'Original detail',
|
||||
},
|
||||
},
|
||||
},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
metadata.configs[0].stages[1].config.push(
|
||||
...([
|
||||
{
|
||||
name: 'instruction',
|
||||
label: { en_US: 'Instruction' },
|
||||
type: 'text',
|
||||
required: true,
|
||||
default: 'Original instruction',
|
||||
},
|
||||
{
|
||||
name: 'advanced',
|
||||
label: { en_US: 'Advanced' },
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
name: 'detail',
|
||||
label: { en_US: 'Detail' },
|
||||
type: 'text',
|
||||
default: 'Original detail',
|
||||
show_if: { field: 'advanced', operator: 'eq', value: true },
|
||||
},
|
||||
] as any),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/_/metadata`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data:
|
||||
kind === 'agent'
|
||||
? {
|
||||
runner_config: metadata.configs[0],
|
||||
kinds: [],
|
||||
event_processors: [],
|
||||
}
|
||||
: metadata,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
`/home/${kind === 'agent' ? 'agents' : 'pipelines'}?id=${kind}-draft`,
|
||||
);
|
||||
if (kind === 'pipeline')
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
const input = page.locator('textarea[name="instruction"]');
|
||||
await expect(input).toHaveValue('Original instruction');
|
||||
return {
|
||||
input,
|
||||
save: page.getByRole('button', { name: 'Save', exact: true }),
|
||||
endpoint: `/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/${kind}-draft`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const kind of ['agent', 'pipeline'] as const) {
|
||||
test(`${kind}: revert, conditional fields, immediate save and reload retain the latest draft`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page, kind);
|
||||
await expect(save).toBeDisabled();
|
||||
await input.fill('Changed');
|
||||
await expect(save).toBeEnabled();
|
||||
await input.fill('Original instruction');
|
||||
await expect(save).toBeDisabled();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
const detail = page.locator('textarea[name="detail"]');
|
||||
await detail.fill('Retained while hidden');
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
await input.pressSequentially(' final characters');
|
||||
const request = page.waitForRequest(
|
||||
(r) => new URL(r.url()).pathname === endpoint && r.method() === 'PUT',
|
||||
);
|
||||
await save.click();
|
||||
const payload = (await request).postDataJSON();
|
||||
const config = kind === 'agent' ? payload.config : payload.config.ai;
|
||||
expect(config.runner_config[runner]).toMatchObject({
|
||||
instruction: 'Original instruction final characters',
|
||||
detail: 'Retained while hidden',
|
||||
advanced: false,
|
||||
});
|
||||
await expect(save).toBeDisabled();
|
||||
await page.reload();
|
||||
if (kind === 'pipeline')
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
await expect(input).toHaveValue('Original instruction final characters');
|
||||
await expect(save).toBeDisabled();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
await expect(detail).toHaveValue('Retained while hidden');
|
||||
});
|
||||
}
|
||||
|
||||
test('agent: save completion does not absorb edits made while the request is pending', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(`**${endpoint}`, async (route) => {
|
||||
if (route.request().method() === 'PUT') await gate;
|
||||
await route.fallback();
|
||||
});
|
||||
await input.fill('First draft');
|
||||
const request = page.waitForRequest(
|
||||
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
|
||||
);
|
||||
await save.click();
|
||||
await request;
|
||||
await input.fill('Second draft typed during save');
|
||||
release();
|
||||
await expect(save).toBeEnabled();
|
||||
const next = page.waitForRequest(
|
||||
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
|
||||
);
|
||||
await save.click();
|
||||
expect(
|
||||
(await next).postDataJSON().config.runner_config[runner].instruction,
|
||||
).toBe('Second draft typed during save');
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test('agent: failed save keeps the draft, and debugging saves the latest value first', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page);
|
||||
let fail = true;
|
||||
const operations: string[] = [];
|
||||
await page.route(`**${endpoint}`, async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.fallback();
|
||||
if (fail)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'Deliberate save failure' },
|
||||
});
|
||||
operations.push('save');
|
||||
expect(
|
||||
route.request().postDataJSON().config.runner_config[runner].instruction,
|
||||
).toBe('Latest before debug');
|
||||
await route.fallback();
|
||||
});
|
||||
await input.fill('Unsaved draft');
|
||||
await save.click();
|
||||
await expect(page.getByText(/Deliberate save failure/)).toBeVisible();
|
||||
await expect(save).toBeEnabled();
|
||||
await expect(input).toHaveValue('Unsaved draft');
|
||||
fail = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/debug/stream')) operations.push('debug');
|
||||
});
|
||||
await page.getByRole('textbox', { name: 'Message content' }).fill('Hello');
|
||||
await input.fill('Latest before debug');
|
||||
await page.getByRole('button', { name: 'Save and run', exact: true }).click();
|
||||
await expect(page.getByText('Mock Agent response')).toBeVisible();
|
||||
expect(operations).toEqual(['save', 'debug']);
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test('agent: required-field status still reacts after unrelated keystrokes are isolated', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input } = await setup(page);
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
|
||||
await input.fill('');
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toHaveCount(0);
|
||||
await input.fill('Valid again');
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('knowledge: early edits are not absorbed into initialization, and save uses a submitted snapshot', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const engine = {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
name: { en_US: 'Test engine' },
|
||||
capabilities: ['text_retrieval'],
|
||||
creation_schema: [],
|
||||
retrieval_schema: [
|
||||
{
|
||||
name: 'instructions',
|
||||
type: 'text',
|
||||
label: { en_US: 'Retrieval instructions' },
|
||||
default: 'Original',
|
||||
},
|
||||
],
|
||||
};
|
||||
let base = {
|
||||
uuid: 'kb-draft',
|
||||
name: 'Knowledge draft',
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
initialized: true,
|
||||
knowledge_engine_plugin_id: engine.plugin_id,
|
||||
knowledge_engine: engine,
|
||||
creation_settings: {},
|
||||
retrieval_settings: { instructions: 'Original' },
|
||||
};
|
||||
await page.route('**/api/v1/knowledge/engines', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { engines: [engine] } } }),
|
||||
);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let saving = false;
|
||||
await page.route('**/api/v1/knowledge/bases/kb-draft', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
const payload = route.request().postDataJSON();
|
||||
saving = true;
|
||||
await gate;
|
||||
base = { ...base, ...payload };
|
||||
return route.fulfill({ json: { code: 0, data: { uuid: base.uuid } } });
|
||||
}
|
||||
return route.fulfill({ json: { code: 0, data: { base } } });
|
||||
});
|
||||
await page.goto('/home/knowledge?id=kb-draft');
|
||||
const input = page.locator('textarea[name="instructions"]');
|
||||
const save = page.getByRole('button', { name: 'Save', exact: true });
|
||||
await expect(input).toHaveValue('Original');
|
||||
await input.fill('Early edit');
|
||||
await expect(save).toBeEnabled();
|
||||
await input.fill('Original');
|
||||
await expect(save).toBeDisabled();
|
||||
await input.fill('Submitted value');
|
||||
await save.click();
|
||||
await expect.poll(() => saving).toBe(true);
|
||||
await input.fill('Typed while saving');
|
||||
release();
|
||||
await expect
|
||||
.poll(() => base.retrieval_settings.instructions)
|
||||
.toBe('Submitted value');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(() => base.retrieval_settings.instructions)
|
||||
.toBe('Typed while saving');
|
||||
await expect(save).toBeDisabled();
|
||||
await page.reload();
|
||||
await expect(input).toHaveValue('Typed while saving');
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
@@ -59,10 +59,17 @@ test.describe('authenticated app shell', () => {
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/monitoring$/);
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
// The unified execution view is the default entry point.
|
||||
await expect(page.getByRole('tab', { name: 'Executions' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Execution Success Rate').first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Message Records' }),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Token Monitoring' }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -20,7 +20,7 @@ test('local account login reaches the authenticated home shell', async ({
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
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('Execution Success Rate').first()).toBeVisible();
|
||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
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('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 })).toHaveCount(2);
|
||||
await expect(sheet.getByText('[Empty message]', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
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 })).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')).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')).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')).toBeVisible();
|
||||
await expect(sheet.getByText('Original input')).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 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.getByRole('combobox', { name: 'Processor', exact: true });
|
||||
for (const processor of processors) {
|
||||
await filter.click();
|
||||
const option = page.getByRole('option', {
|
||||
name: processor.name,
|
||||
exact: true,
|
||||
});
|
||||
await expect(
|
||||
option.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.get('pipelineId') === processor.uuid
|
||||
);
|
||||
});
|
||||
await option.click();
|
||||
await request;
|
||||
await expect(filter).toContainText(processor.name);
|
||||
await expect(
|
||||
filter.getByText(processor.emoji, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await filter.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'All Processors', exact: true })
|
||||
.click();
|
||||
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();
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
}
|
||||
await page.reload();
|
||||
for (const [id, label] of selections)
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test, Route } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
buildExecutionList,
|
||||
} from './fixtures/langbot-api';
|
||||
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
|
||||
import {
|
||||
ErrorLog,
|
||||
@@ -254,7 +257,15 @@ function rawMonitoringData() {
|
||||
success_rate: 100,
|
||||
active_sessions: 2,
|
||||
},
|
||||
messages: scenario.messages.map(rawMessage),
|
||||
messages: scenario.messages.map((item) => ({
|
||||
...rawMessage(item),
|
||||
parent_message_id:
|
||||
item.role === 'assistant'
|
||||
? item.id === 'agent-assistant-4'
|
||||
? 'agent-user-2'
|
||||
: 'agent-user-1'
|
||||
: undefined,
|
||||
})),
|
||||
llmCalls: scenario.llmCalls.map(rawLlmCall),
|
||||
toolCalls: scenario.toolCalls.map(rawToolCall),
|
||||
embeddingCalls: [],
|
||||
@@ -273,8 +284,13 @@ function rawMonitoringData() {
|
||||
|
||||
async function respond(route: Route, label: string) {
|
||||
const data = rawMonitoringData();
|
||||
data.messages = [rawMessage(message(label, 'user', 10, label))];
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
data.messages = [
|
||||
{
|
||||
...rawMessage(message(label, 'user', 10, label)),
|
||||
parent_message_id: undefined,
|
||||
},
|
||||
];
|
||||
await route.fulfill({ json: { code: 0, data: buildExecutionList(data) } });
|
||||
}
|
||||
|
||||
test.describe('monitoring request contracts', () => {
|
||||
@@ -283,7 +299,7 @@ test.describe('monitoring request contracts', () => {
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let failing = true;
|
||||
await page.route('**/api/v1/monitoring/data?*', async (route) => {
|
||||
await page.route('**/api/v1/monitoring/executions?*', async (route) => {
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
@@ -298,14 +314,14 @@ test.describe('monitoring request contracts', () => {
|
||||
else await respond(route, 'Recovered monitoring');
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Failed to load monitoring data',
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Failed to load execution trace'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('No message records')).toHaveCount(0);
|
||||
failing = false;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('Recovered monitoring', { exact: true }),
|
||||
page.getByText('Recovered monitoring', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
@@ -315,7 +331,7 @@ test.describe('monitoring request contracts', () => {
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const pending: Route[] = [];
|
||||
await page.route('**/api/v1/monitoring/data?*', (route) => {
|
||||
await page.route('**/api/v1/monitoring/executions?*', (route) => {
|
||||
pending.push(route);
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
@@ -324,8 +340,9 @@ test.describe('monitoring request contracts', () => {
|
||||
await page.getByRole('option', { name: /Last 7 days/i }).click();
|
||||
await expect.poll(() => pending.length).toBe(3);
|
||||
await respond(pending[2], 'Latest filter data');
|
||||
// The message-turn view now lives behind its own tab.
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
page.getByText('Latest filter data', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await respond(pending[0], 'Obsolete filter data');
|
||||
await respond(pending[1], 'Obsolete filter data');
|
||||
@@ -336,7 +353,7 @@ test.describe('monitoring request contracts', () => {
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
page.getByText('Latest filter data', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Refresh Data', exact: true })
|
||||
@@ -350,14 +367,14 @@ test.describe('monitoring request contracts', () => {
|
||||
await expect.poll(() => pending.length).toBe(5);
|
||||
await respond(pending[4], 'Current result');
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
page.getByText('Current result', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await pending[3].fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'old failure' },
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
page.getByText('Current result', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
@@ -386,7 +403,7 @@ test.describe('monitoring request contracts', () => {
|
||||
page.getByText(
|
||||
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
|
||||
),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Traffic range truncated. Choose a shorter time range.'),
|
||||
).toBeVisible();
|
||||
@@ -589,56 +606,39 @@ test.describe('monitoring conversation turn grouping', () => {
|
||||
expect(turns[0].totalToolDuration).toBe(45);
|
||||
});
|
||||
|
||||
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
|
||||
test('replaces the messages tab with full execution inputs, deliveries and calls', 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();
|
||||
page.getByRole('tab', { name: 'Message Records' }),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'Need deployment plan' })
|
||||
.click();
|
||||
const sheet = page.getByRole('dialog');
|
||||
await expect(sheet.getByText('Event / input (1)')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Final answer: deployment plan ready'),
|
||||
sheet.getByText('Need deployment plan', { exact: true }).first(),
|
||||
).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();
|
||||
await expect(sheet.getByText('Delivery records (3)')).toBeVisible();
|
||||
await expect(
|
||||
sheet.getByText('Final answer: deployment plan ready').first(),
|
||||
).toBeVisible();
|
||||
await sheet.getByText('Model calls (3)', { exact: true }).click();
|
||||
await expect(sheet.getByText('gpt-5.5').first()).toBeVisible();
|
||||
await sheet.getByText('Tool calls (2)', { exact: true }).click();
|
||||
await expect(
|
||||
sheet.locator('span.break-all').filter({ hasText: /^repo_search$/ }),
|
||||
).toBeVisible();
|
||||
await sheet.getByText('Errors (1)', { exact: true }).click();
|
||||
await expect(sheet.getByText('Tool retry failed').first()).toBeVisible();
|
||||
await sheet.getByText('Conversation context (6)', { exact: true }).click();
|
||||
await expect(sheet.getByText('Rollback plan ready')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const chat = {
|
||||
uuid: 'assistant-test',
|
||||
revision: 0,
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
pending: [],
|
||||
error: null,
|
||||
model_uuid: null,
|
||||
model_name: null,
|
||||
};
|
||||
|
||||
test('resizes from the top handle, keeps height on reopen, and fits small viewports', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
|
||||
}),
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/home/bots');
|
||||
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
const handle = panel.getByRole('separator', {
|
||||
name: 'Resize assistant height',
|
||||
});
|
||||
await expect(handle).toBeVisible();
|
||||
const initial = (await panel.boundingBox())!;
|
||||
const grip = (await handle.boundingBox())!;
|
||||
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
grip.x + grip.width / 2,
|
||||
grip.y + grip.height / 2 - 140,
|
||||
{ steps: 8 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBeCloseTo(initial.height + 140, 0);
|
||||
const expanded = (await panel.boundingBox())!;
|
||||
expect(expanded.y + expanded.height).toBeCloseTo(
|
||||
initial.y + initial.height,
|
||||
0,
|
||||
);
|
||||
await panel
|
||||
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
|
||||
.click();
|
||||
const dock = page.getByRole('button', {
|
||||
name: 'Expand workspace assistant',
|
||||
exact: true,
|
||||
});
|
||||
await expect(dock).toBeVisible();
|
||||
expect((await dock.boundingBox())!.height).toBe(21);
|
||||
await dock.click();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBeCloseTo(expanded.height, 0);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBeCloseTo(expanded.height, 0);
|
||||
await handle.focus();
|
||||
await page.keyboard.press('Home');
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(280);
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(304);
|
||||
await page.keyboard.press('End');
|
||||
const preferredHeight = page.viewportSize()!.height - 48;
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBe(page.viewportSize()!.height - 48);
|
||||
await page.setViewportSize({ width: 375, height: 580 });
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.y)
|
||||
.toBeGreaterThanOrEqual(48);
|
||||
await expect(panel.getByRole('textbox')).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('langbot-assistant-panel-height'),
|
||||
),
|
||||
).toBe(String(preferredHeight));
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBe(preferredHeight);
|
||||
const moveHandle = panel.getByRole('slider', {
|
||||
name: 'Move assistant horizontally',
|
||||
});
|
||||
const beforeMove = (await panel.boundingBox())!;
|
||||
const title = (await moveHandle.boundingBox())!;
|
||||
await page.mouse.move(title.x + title.width / 2, title.y + title.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
title.x + title.width / 2 + 180,
|
||||
title.y + title.height / 2,
|
||||
{ steps: 8 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(beforeMove.x + 180, 0);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(beforeMove.x + 180, 0);
|
||||
await moveHandle.focus();
|
||||
await page.keyboard.press('End');
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await panel.boundingBox())!;
|
||||
return box.x + box.width;
|
||||
})
|
||||
.toBe(1268);
|
||||
await page.keyboard.press('Home');
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
await page.setViewportSize({ width: 375, height: 580 });
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
expect((await panel.boundingBox())!.width).toBe(351);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
await panel
|
||||
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
|
||||
.click();
|
||||
await expect(dock).toBeVisible();
|
||||
const dockBox = (await dock.boundingBox())!;
|
||||
const startX = dockBox.x + dockBox.width / 2;
|
||||
const startY = dockBox.y + dockBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 150, startY, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect(dock).toBeVisible();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await dock.boundingBox())!.x)
|
||||
.toBeCloseTo(dockBox.x + 150, 0);
|
||||
await dock.click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(162, 0);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(162, 0);
|
||||
});
|
||||
|
||||
test('sending survives model labels appearing and disappearing in stream snapshots', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (/createPortal|removeChild|createRoot/.test(message.text()))
|
||||
errors.push(message.text());
|
||||
});
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
let current: Record<string, unknown> = chat;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data:
|
||||
route.request().method() === 'POST' ? chat : { conversations: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/assistant-test',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: current },
|
||||
}),
|
||||
);
|
||||
let turn = 0;
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
turn += 1;
|
||||
current = {
|
||||
...chat,
|
||||
revision: turn,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: turn === 2 ? 'Snapshot model' : null,
|
||||
messages: [{ role: 'assistant', content: `Reply ${turn}` }],
|
||||
};
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body:
|
||||
JSON.stringify({ kind: 'snapshot', data: current }) +
|
||||
'\n' +
|
||||
JSON.stringify({ kind: 'completed', data: current }) +
|
||||
'\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
const model = assistant.getByRole('combobox', { name: 'Select Model' });
|
||||
await expect(model).toContainText('Recommended model');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await assistant.getByRole('textbox').fill(`Hello ${i}`);
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText(`Reply ${i}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(model).toContainText(
|
||||
i === 2 ? 'Snapshot model' : 'Valid Mock Model',
|
||||
);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('selects the recommendation on entry and submits the visible model', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: chat } }),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
|
||||
const result = {
|
||||
...chat,
|
||||
revision: 1,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hello back' },
|
||||
],
|
||||
};
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body: JSON.stringify({ kind: 'completed', data: result }) + '\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await expect(
|
||||
assistant.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Recommended model');
|
||||
await assistant.getByRole('textbox').fill('Hello');
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText('Hello back', { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('late recommendation cannot overwrite manual selection, including request payload', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/api/v1/assistant/recommended-model', async (route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { uuid: 'recommended-other', name: 'Other recommendation' },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: chat } }),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body:
|
||||
JSON.stringify({
|
||||
kind: 'completed',
|
||||
data: {
|
||||
...chat,
|
||||
revision: 1,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Valid Mock Model',
|
||||
messages: [{ role: 'assistant', content: 'Manual model used' }],
|
||||
},
|
||||
}) + '\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await assistant.getByRole('combobox', { name: 'Select Model' }).click();
|
||||
await page.getByRole('option', { name: /Valid Mock Model/ }).click();
|
||||
release();
|
||||
await expect(assistant.getByText('Preparing assistant…')).toHaveCount(0);
|
||||
await expect(
|
||||
assistant.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Valid Mock Model');
|
||||
await assistant.getByRole('textbox').fill('Hello');
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText('Manual model used', { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders streaming text and tool progress before completion', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { createServer } = await import('node:http');
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const started = {
|
||||
...chat,
|
||||
revision: 1,
|
||||
status: 'running',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'user', content: 'Inspect resources' }],
|
||||
};
|
||||
let currentConversation: Record<string, unknown> = started;
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
req.headers['access-control-request-headers'] || '*',
|
||||
);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
|
||||
const emit = (kind: string, data: unknown) =>
|
||||
res.write(JSON.stringify({ kind, data }) + '\n');
|
||||
emit('snapshot', started);
|
||||
emit('phase', { phase: 'thinking', round: 1 });
|
||||
emit('text', { text: 'Inspecting your resources' });
|
||||
emit('phase', {
|
||||
phase: 'tool',
|
||||
tool: { name: 'list_resources', arguments: { kind: 'models' } },
|
||||
});
|
||||
await gate;
|
||||
currentConversation = {
|
||||
...started,
|
||||
status: 'ready',
|
||||
messages: [
|
||||
...started.messages,
|
||||
{
|
||||
role: 'tool',
|
||||
content: '{}',
|
||||
tool: {
|
||||
name: 'list_resources',
|
||||
arguments: { kind: 'models' },
|
||||
result: { total: 1, items: [] },
|
||||
},
|
||||
},
|
||||
{ role: 'assistant', content: 'Found your model' },
|
||||
],
|
||||
};
|
||||
emit('completed', currentConversation);
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
// The panel refreshes the persisted conversation after streaming finishes.
|
||||
// Return the same snapshot as the stream instead of the generic API fallback.
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/assistant-test',
|
||||
(route) =>
|
||||
route.fulfill({ json: { code: 0, data: currentConversation } }),
|
||||
);
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { uuid: 'llm-valid', name: 'Recommended model' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: chat } }),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
(route) =>
|
||||
route.continue({ url: `http://127.0.0.1:${address.port}/stream` }),
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await expect(
|
||||
assistant.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Recommended model');
|
||||
await assistant.getByRole('textbox').fill('Inspect resources');
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText('Inspecting your resources', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
assistant.getByRole('button', { name: /Find chat models Running/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
assistant.getByRole('button', { name: 'Stop task', exact: true }),
|
||||
).toBeEnabled();
|
||||
release();
|
||||
await expect(
|
||||
assistant.getByText('Found your model', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
assistant.getByText('Inspecting your resources', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
const bubble = assistant
|
||||
.locator('[class*="assistantMessage"]')
|
||||
.filter({ hasText: 'Found your model' });
|
||||
await expect(
|
||||
bubble.getByRole('button', { name: /Find chat models Completed/ }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
release();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('without providers shows setup only and rechecks after model settings close', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/requesters', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { requesters: [] } } }),
|
||||
);
|
||||
let configured = false;
|
||||
let recommendations = 0;
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
providers: configured
|
||||
? [
|
||||
{
|
||||
uuid: 'provider',
|
||||
name: 'Custom provider',
|
||||
requester: 'openai-chat-completions',
|
||||
config: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) => {
|
||||
recommendations++;
|
||||
return route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
});
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
const setup = page.getByRole('complementary', {
|
||||
name: 'Workspace assistant',
|
||||
});
|
||||
await expect(
|
||||
setup.getByRole('button', { name: 'Sign in to LangBot Account' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Workspace assistant' }),
|
||||
).toHaveCount(0);
|
||||
expect(recommendations).toBe(0);
|
||||
await setup.getByRole('button', { name: 'Configure models' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
configured = true;
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(
|
||||
page
|
||||
.getByRole('dialog', { name: 'Workspace assistant' })
|
||||
.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Recommended model');
|
||||
});
|
||||
|
||||
test('setup login starts the existing LangBot Account authorization flow', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [] } },
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url().includes('/space/') && request.url().includes('authorize'),
|
||||
);
|
||||
await page
|
||||
.getByRole('complementary', { name: 'Workspace assistant' })
|
||||
.getByRole('button', { name: 'Sign in to LangBot Account' })
|
||||
.click();
|
||||
expect((await request).url()).toContain('authorize');
|
||||
});
|
||||
|
||||
test('switches conversations, restores background progress after reload and stops only the selected task', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
const running = {
|
||||
...chat,
|
||||
uuid: 'running-chat',
|
||||
revision: 3,
|
||||
status: 'running',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'user', content: 'Background request' }],
|
||||
progress: {
|
||||
text: 'Working in the background',
|
||||
phase: 'thinking',
|
||||
round: 2,
|
||||
},
|
||||
};
|
||||
const ready = {
|
||||
...chat,
|
||||
uuid: 'ready-chat',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'assistant', content: 'Previous answer' }],
|
||||
};
|
||||
let stopped = false;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
conversations: [
|
||||
{
|
||||
uuid: running.uuid,
|
||||
title: 'Background request',
|
||||
status: stopped ? 'failed' : 'running',
|
||||
},
|
||||
{
|
||||
uuid: ready.uuid,
|
||||
title: 'Previous conversation',
|
||||
status: 'ready',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations/*', (route) => {
|
||||
const data = route.request().url().endsWith('ready-chat')
|
||||
? ready
|
||||
: stopped
|
||||
? {
|
||||
...running,
|
||||
status: 'failed',
|
||||
error: 'stopped',
|
||||
progress: undefined,
|
||||
}
|
||||
: running;
|
||||
return route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/running-chat/stop',
|
||||
(route) => {
|
||||
expect(route.request().postDataJSON()).toEqual({ revision: 3 });
|
||||
stopped = true;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
...running,
|
||||
status: 'failed',
|
||||
error: 'stopped',
|
||||
progress: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await assistant
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: '◌ Background request', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
assistant.getByText('Working in the background', { exact: true }),
|
||||
).toBeVisible();
|
||||
await assistant
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Previous conversation', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
assistant.getByText('Previous answer', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(stopped).toBe(false);
|
||||
await assistant
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: '◌ Background request', exact: true })
|
||||
.click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
assistant.getByText('Working in the background', { exact: true }),
|
||||
).toBeVisible();
|
||||
await assistant
|
||||
.getByRole('button', { name: 'Stop task', exact: true })
|
||||
.click();
|
||||
await expect(assistant.getByRole('alert')).toContainText('Stopped.');
|
||||
await expect(
|
||||
assistant.getByRole('button', { name: 'Stop task', exact: true }),
|
||||
).toHaveCount(0);
|
||||
await assistant
|
||||
.getByRole('button', { name: 'New conversation', exact: true })
|
||||
.click();
|
||||
await expect(assistant.getByRole('textbox')).toBeEmpty();
|
||||
});
|
||||
|
||||
test('keeps the reading position through background updates and resumes following at the bottom', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
|
||||
}),
|
||||
);
|
||||
let updates = 0;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
conversations: [
|
||||
{
|
||||
uuid: 'scroll-chat',
|
||||
title: 'Long conversation',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations/scroll-chat', (route) => {
|
||||
updates++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
...chat,
|
||||
uuid: 'scroll-chat',
|
||||
status: 'running',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Model',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => `Paragraph ${i}`,
|
||||
).join('\n\n'),
|
||||
},
|
||||
],
|
||||
progress: {
|
||||
text: Array.from({ length: updates }, (_, i) => `Update ${i}`).join(
|
||||
'\n\n',
|
||||
),
|
||||
phase: 'thinking',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await panel
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: '◌ Long conversation', exact: true })
|
||||
.click();
|
||||
const messages = panel.locator('[aria-live="polite"]');
|
||||
const gap = () =>
|
||||
messages.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
|
||||
await expect.poll(gap).toBeLessThan(2);
|
||||
await messages.hover();
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect.poll(gap).toBeGreaterThan(200);
|
||||
const position = await messages.evaluate((el) => el.scrollTop);
|
||||
const previousUpdates = updates;
|
||||
await expect.poll(() => updates).toBeGreaterThan(previousUpdates + 1);
|
||||
await expect
|
||||
.poll(() => messages.evaluate((el) => el.scrollTop))
|
||||
.toBeCloseTo(position, 0);
|
||||
await messages.hover();
|
||||
await page.mouse.wheel(0, 10000);
|
||||
await expect.poll(gap).toBeLessThan(2);
|
||||
const beforeFollow = updates;
|
||||
await expect.poll(() => updates).toBeGreaterThan(beforeFollow + 1);
|
||||
await expect.poll(gap).toBeLessThan(2);
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
// The floating assistant button docks against a viewport edge, collapses into a
|
||||
// short blue rail, and re-expands on hover. The transition rules are pure so
|
||||
// they can be pinned here: the race this guards against is a button that drops
|
||||
// under the cursor and never becomes visible again.
|
||||
const sourcePath = new URL(
|
||||
'../../src/app/home/components/assistant-dock.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
|
||||
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loaded = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('assistant-dock must stay dependency free');
|
||||
},
|
||||
loaded,
|
||||
loaded.exports,
|
||||
);
|
||||
const {
|
||||
ASSISTANT_BUTTON_SIZE,
|
||||
ASSISTANT_SNAP_THRESHOLD,
|
||||
ASSISTANT_RAIL_WIDTH,
|
||||
clampAssistantPosition,
|
||||
resolveAssistantEdge,
|
||||
shouldCollapseRail,
|
||||
shouldExpandRail,
|
||||
restingRailExpanded,
|
||||
} = loaded.exports;
|
||||
|
||||
const viewport = { width: 1280, height: 800 };
|
||||
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
|
||||
|
||||
test('clamping keeps the button fully inside the viewport', () => {
|
||||
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
|
||||
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
|
||||
x: maxX,
|
||||
y: viewport.height - ASSISTANT_BUTTON_SIZE,
|
||||
});
|
||||
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
|
||||
x: 400,
|
||||
y: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('a narrow viewport never produces a negative travel range', () => {
|
||||
assert.deepEqual(clampAssistantPosition(10, 10, { width: 20, height: 20 }), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('positions within the snap threshold dock to the nearest edge', () => {
|
||||
assert.equal(resolveAssistantEdge(0, viewport), 'left');
|
||||
assert.equal(
|
||||
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport),
|
||||
'left',
|
||||
);
|
||||
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
|
||||
assert.equal(
|
||||
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
|
||||
'right',
|
||||
);
|
||||
});
|
||||
|
||||
test('a centred position stays free floating', () => {
|
||||
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
|
||||
assert.equal(
|
||||
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the left edge wins ties so a centred drop is deterministic', () => {
|
||||
// A viewport sized so both gaps are inside the snap threshold at x = 0.
|
||||
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
|
||||
assert.equal(resolveAssistantEdge(0, tight), 'left');
|
||||
});
|
||||
|
||||
test('hovering a docked rail expands it, hovering a free button does not', () => {
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'right',
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: null,
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Already expanded: nothing to do, so no redundant state churn on every move.
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a drag in progress never expands or collapses the rail', () => {
|
||||
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
|
||||
assert.equal(shouldExpandRail(dragging), false);
|
||||
assert.equal(shouldCollapseRail(dragging), false);
|
||||
});
|
||||
|
||||
test('only an *expanded* docked button collapses when the pointer leaves', () => {
|
||||
// Regression: keying this on `!railExpanded` made a revealed button
|
||||
// impossible to collapse again, so it stayed open forever after one hover.
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: null,
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('expand then leave round-trips back to the collapsed rail', () => {
|
||||
const docked = { dockedEdge: 'right', railExpanded: false, dragging: false };
|
||||
// Hover: reveal the button.
|
||||
assert.equal(shouldExpandRail(docked), true);
|
||||
// Pointer leaves: the revealed button must collapse again.
|
||||
assert.equal(shouldCollapseRail({ ...docked, railExpanded: true }), true);
|
||||
// A second hover re-reveals it, so the cycle is repeatable.
|
||||
assert.equal(shouldExpandRail(docked), true);
|
||||
});
|
||||
|
||||
test('dropping always starts collapsed so the rail is never stuck open', () => {
|
||||
// The pointer is still over the button on drop; the next pointermove expands.
|
||||
assert.equal(restingRailExpanded(), false);
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'right',
|
||||
railExpanded: restingRailExpanded(),
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the rail strip stays thinner than the button it replaces', () => {
|
||||
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
|
||||
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/infra/hooks/useInstalledPluginIcon.ts',
|
||||
);
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
|
||||
// --- minimal React + backend mocks so the hook can run under node:test ---
|
||||
let stateSlots = [];
|
||||
let lastCleanup;
|
||||
|
||||
const reactMock = {
|
||||
useState(initial) {
|
||||
const value = typeof initial === 'function' ? initial() : initial;
|
||||
const index = stateSlots.length;
|
||||
stateSlots.push(value);
|
||||
return [
|
||||
value,
|
||||
(next) => {
|
||||
stateSlots[index] =
|
||||
typeof next === 'function' ? next(stateSlots[index]) : next;
|
||||
},
|
||||
];
|
||||
},
|
||||
useEffect(effect) {
|
||||
const cleanup = effect();
|
||||
if (typeof cleanup === 'function') lastCleanup = cleanup;
|
||||
},
|
||||
};
|
||||
|
||||
const httpMock = {
|
||||
httpClient: {
|
||||
getAuthenticatedPluginIconURL: () => {
|
||||
throw new Error('fetch not configured for this test');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sourceRequire = createRequire(sourcePath);
|
||||
|
||||
function loadHook() {
|
||||
const loadedModule = { exports: {} };
|
||||
const requireShim = (id) => {
|
||||
if (id === 'react') return reactMock;
|
||||
if (id === '@/app/infra/http') return httpMock;
|
||||
return sourceRequire(id);
|
||||
};
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
requireShim,
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.useInstalledPluginIcon;
|
||||
}
|
||||
|
||||
const revoked = [];
|
||||
globalThis.URL.revokeObjectURL = (url) => revoked.push(url);
|
||||
|
||||
function reset() {
|
||||
stateSlots = [];
|
||||
lastCleanup = undefined;
|
||||
revoked.length = 0;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('caches per author/name and revokes once the last subscriber leaves', async () => {
|
||||
let fetches = 0;
|
||||
let resolveFetch;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const first = useInstalledPluginIcon('acme', 'demo');
|
||||
const firstCleanup = lastCleanup;
|
||||
assert.equal(first, null);
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
resolveFetch('blob:acme/demo');
|
||||
await flush();
|
||||
|
||||
// Second subscriber reuses the cached object URL synchronously.
|
||||
const second = useInstalledPluginIcon('acme', 'demo');
|
||||
const secondCleanup = lastCleanup;
|
||||
assert.equal(second, 'blob:acme/demo');
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
firstCleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
secondCleanup();
|
||||
assert.deepEqual(revoked, ['blob:acme/demo']);
|
||||
|
||||
// Cache was dropped with the last subscriber, so a new mount refetches.
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
assert.equal(fetches, 2);
|
||||
});
|
||||
|
||||
test('de-duplicates concurrent subscribers and revokes a shared icon once', async () => {
|
||||
let fetches = 0;
|
||||
let resolveFetch;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
const firstCleanup = lastCleanup;
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
const secondCleanup = lastCleanup;
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
resolveFetch('blob:shared');
|
||||
await flush();
|
||||
|
||||
firstCleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
secondCleanup();
|
||||
assert.deepEqual(revoked, ['blob:shared']);
|
||||
});
|
||||
|
||||
test('caches nothing and revokes nothing when the authenticated fetch fails', async () => {
|
||||
let fetches = 0;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('not found'));
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const value = useInstalledPluginIcon('acme', 'missing');
|
||||
const cleanup = lastCleanup;
|
||||
assert.equal(value, null);
|
||||
|
||||
await flush();
|
||||
assert.equal(useInstalledPluginIcon('acme', 'missing'), null);
|
||||
assert.equal(fetches, 2); // failures are not negatively cached
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
});
|
||||
|
||||
test('returns a marketplace URL verbatim without fetching', () => {
|
||||
let fetches = 0;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return Promise.resolve('blob:unused');
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const value = useInstalledPluginIcon(
|
||||
'acme',
|
||||
'demo',
|
||||
'https://space.langbot.app/icon',
|
||||
);
|
||||
assert.equal(value, 'https://space.langbot.app/icon');
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lastCleanup, undefined);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
// The resolver is pure: recorded calls carry a time and a session, the execution
|
||||
// list carries the runs' windows, so the link is decided without any re-query.
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/app/home/monitoring/utils/callLinks.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = { exports: {} };
|
||||
new Function(
|
||||
'exports',
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
)(module.exports);
|
||||
const { findCallExecution, resolveCallTarget } = module.exports;
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
function run(overrides) {
|
||||
return {
|
||||
source: 'agent',
|
||||
id: 'run-1',
|
||||
session_id: 'session-a',
|
||||
started_at_ms: at('2026-10-07T10:00:00Z').getTime(),
|
||||
finished_at_ms: at('2026-10-07T10:00:30Z').getTime(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function call(overrides) {
|
||||
return {
|
||||
id: 'call-1',
|
||||
timestamp: at('2026-10-07T10:00:10Z'),
|
||||
sessionId: 'session-a',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('links a pipeline call to the execution carrying its message id', () => {
|
||||
const execution = run({
|
||||
source: 'pipeline',
|
||||
id: 'message-9',
|
||||
session_id: null,
|
||||
});
|
||||
assert.equal(
|
||||
findCallExecution(call({ messageId: 'message-9', sessionId: undefined }), [
|
||||
execution,
|
||||
]),
|
||||
execution,
|
||||
);
|
||||
});
|
||||
|
||||
test('links an agent call to the run whose window contains it', () => {
|
||||
const execution = run({});
|
||||
assert.equal(findCallExecution(call({}), [execution]), execution);
|
||||
});
|
||||
|
||||
test('prefers the run that was live when a session has consecutive runs', () => {
|
||||
const first = run({ id: 'run-1' });
|
||||
const second = run({
|
||||
id: 'run-2',
|
||||
started_at_ms: at('2026-10-07T10:05:00Z').getTime(),
|
||||
finished_at_ms: at('2026-10-07T10:05:30Z').getTime(),
|
||||
});
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T10:05:10Z') }), [
|
||||
first,
|
||||
second,
|
||||
]).id,
|
||||
'run-2',
|
||||
);
|
||||
});
|
||||
|
||||
test('an open run owns every later call of its session', () => {
|
||||
const execution = run({ finished_at_ms: null });
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T10:20:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
execution,
|
||||
);
|
||||
});
|
||||
|
||||
test('a call outside every window links to nothing', () => {
|
||||
const execution = run({});
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T11:00:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T09:00:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCallExecution(call({ sessionId: 'other' }), [execution]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a call keeps its message target when the execution list cannot resolve it', () => {
|
||||
assert.deepEqual(
|
||||
resolveCallTarget(
|
||||
call({ messageId: 'message-9', sessionId: 'other' }),
|
||||
[run({})],
|
||||
new Set(['message-9']),
|
||||
),
|
||||
{ kind: 'message', id: 'message-9' },
|
||||
);
|
||||
// An Agent call carries its run id in the same field: an id the message list
|
||||
// does not hold must not become a jump to the message tab.
|
||||
assert.equal(
|
||||
resolveCallTarget(
|
||||
call({ messageId: 'run-9', sessionId: 'other' }),
|
||||
[run({})],
|
||||
new Set(['message-9']),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveCallTarget(call({}), []), null);
|
||||
});
|
||||
Reference in New Issue
Block a user