Files
LangBot/web/tests/e2e/wizard-platform-regressions.spec.ts
T

701 lines
22 KiB
TypeScript

import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const qrPlatforms = [
{
platform: 'feishu',
adapterName: 'qr-feishu',
label: 'Feishu QR Adapter',
apiBase: '/api/v1/platform/adapters/lark/create-app',
},
{
platform: 'weixin',
adapterName: 'qr-weixin',
label: 'Weixin QR Adapter',
apiBase: '/api/v1/platform/adapters/weixin/login',
},
{
platform: 'dingtalk',
adapterName: 'qr-dingtalk',
label: 'DingTalk QR Adapter',
apiBase: '/api/v1/platform/adapters/dingtalk/create-app',
},
{
platform: 'wecombot',
adapterName: 'qr-wecombot',
label: 'WeCom QR Adapter',
apiBase: '/api/v1/platform/adapters/wecombot/create-bot',
},
{
platform: 'qqofficial',
adapterName: 'qr-qqofficial',
label: 'QQ Official QR Adapter',
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
},
] as const;
function ok(data: unknown) {
return JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
}
function adapterWithQrLogin(
adapterName: string,
label: string,
platform: string,
) {
return {
name: adapterName,
label: { en_US: label, zh_Hans: label },
description: {
en_US: 'Exercises the QR-assisted setup flow.',
zh_Hans: '验证扫码辅助创建流程。',
},
spec: {
categories: ['testing'],
supported_events: ['message.received'],
config: [
{
id: 'qr-login',
name: 'qr-login',
type: 'qr-code-login',
label: { en_US: 'Create with QR code', zh_Hans: '扫码创建' },
description: {
en_US: 'Scan to finish creating this adapter.',
zh_Hans: '扫码完成适配器创建。',
},
required: false,
default: '',
login_platform: platform,
},
],
},
};
}
test.describe('wizard and QR platform regressions', () => {
test('starts with message platforms directly and keeps legacy adapters available', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const adapter = adapterWithQrLogin(
'message-adapter',
'Message Adapter',
'feishu',
);
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
adapters: [
adapter,
adapter,
{
...adapter,
name: 'events-only',
label: { en_US: 'Events Only' },
spec: {
...adapter.spec,
supported_events: ['group.member_joined'],
},
},
{
...adapter,
name: 'legacy',
label: { en_US: 'Legacy Message Adapter' },
spec: {
...adapter.spec,
legacy: true,
supported_events: undefined,
},
},
],
}),
});
});
await page.goto('/wizard');
await expect(
page.getByRole('heading', { name: 'Select a Platform' }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: /Reply to messages|Welcome new members/,
}),
).toHaveCount(0);
await expect(
page.getByText('Message Adapter', { exact: true }),
).toHaveCount(1);
await expect(page.getByText('Events Only', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: /Legacy Adapters/i }).click();
await expect(
page.getByText('Legacy Message Adapter', { exact: true }),
).toBeVisible();
await page.getByText('Message Adapter', { exact: true }).click();
await expect(
page.getByRole('button', { name: 'Confirm, Create Bot' }),
).toBeEnabled();
});
test('old non-message drafts start fresh without modifying their Agent or bot', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
version: 'frontend-smoke',
edition: 'community',
wizard_status: 'none',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
wizard_progress: {
step: 2,
selected_scenario: 'welcome_members',
created_bot_uuid: 'old-bot',
selected_adapter: 'test',
},
}),
});
});
const resourceRequests: string[] = [];
page.on('request', (request) => {
if (
/\/api\/v1\/(agents|pipelines|platform\/bots)(\/[^_]|$)/.test(
new URL(request.url()).pathname,
) &&
request.method() !== 'GET'
) {
resourceRequests.push(request.url());
}
});
await page.goto('/wizard');
await expect(
page.getByRole('heading', { name: 'Select a Platform' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Confirm, Create Bot' }),
).toBeDisabled();
expect(resourceRequests).toEqual([]);
});
test('loads the Page Bot from the API origin, retries failures, and reopens after every save', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let pipelineCreateCount = 0;
let boundPipelineUuid: string | null = null;
let backendOrigin = '';
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname === '/api/v1/platform/adapters')
backendOrigin = url.origin;
if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') {
pipelineCreateCount += 1;
}
if (
request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1'
) {
const body = request.postDataJSON() as {
event_bindings?: Array<{
event_pattern?: string;
target_type?: string;
target_uuid?: string;
}>;
};
const binding = body.event_bindings?.find(
(item) =>
item.event_pattern === 'message.received' &&
item.target_type === 'pipeline',
);
boundPipelineUuid = binding?.target_uuid ?? null;
}
});
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
{
name: 'web_page_bot',
label: { en_US: 'Page Bot', zh_Hans: '页面机器人' },
description: {
en_US: 'An embeddable page bot.',
zh_Hans: '可嵌入网页的机器人。',
},
spec: {
categories: ['web'],
supported_events: ['message.received'],
config: [
{
id: 'title',
name: 'title',
type: 'string',
label: { en_US: 'Title', zh_Hans: '标题' },
required: false,
default: 'Wizard Page Bot',
},
],
},
},
],
}),
});
});
const widgetTemplate = fs.readFileSync(
path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'),
'utf8',
);
let widgetAvailable = false;
await page.route('**/api/v1/embed/*/widget.js?*', async (route) => {
expect(new URL(route.request().url()).origin).toBe(backendOrigin);
if (!widgetAvailable || !boundPipelineUuid || pipelineCreateCount === 0) {
await route.fulfill({
status: 404,
contentType: 'application/javascript',
body: '// Bot not found or not available',
});
return;
}
const origin = new URL(route.request().url()).origin;
const source = widgetTemplate
.replaceAll('__LANGBOT_LOCALE__', 'en_US')
.replaceAll('__LANGBOT_BOT_UUID__', 'bot-1')
.replaceAll('__LANGBOT_BASE_URL__', origin)
.replaceAll('__LANGBOT_TURNSTILE_SITE_KEY__', '')
.replaceAll('__LANGBOT_BUBBLE_ICON__', 'chat');
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: source,
});
});
await page.goto('/wizard');
await page.getByText('Page Bot', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
const saveButton = page.getByRole('button', {
name: /^(Save & Enable Bot|Re-save Configuration)$/,
});
await saveButton.click();
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
await expect(
page.getByText(
'Failed to load the test chat. Please save the configuration again to retry.',
{ exact: true },
),
).toBeVisible();
widgetAvailable = true;
await saveButton.click();
const widgetRoot = page.locator('#langbot-widget-root');
await expect(widgetRoot).toBeAttached();
await expect
.poll(() =>
widgetRoot.evaluate((root) =>
root.shadowRoot
?.querySelector('.lb-panel')
?.classList.contains('lb-visible'),
),
)
.toBe(true);
await expect
.poll(() =>
widgetRoot.evaluate(
(root) =>
root.shadowRoot?.querySelector('.lb-test-notice')?.textContent,
),
)
.toContain('For testing only');
await widgetRoot.evaluate((root) => {
const minimize = root.shadowRoot?.querySelector(
'.lb-header-btn[aria-label="Minimize"]',
) as HTMLButtonElement | null;
minimize?.click();
});
await expect
.poll(() =>
widgetRoot.evaluate((root) =>
root.shadowRoot
?.querySelector('.lb-panel')
?.classList.contains('lb-visible'),
),
)
.toBe(false);
await saveButton.click();
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
await expect
.poll(() =>
widgetRoot.evaluate((root) =>
root.shadowRoot
?.querySelector('.lb-panel')
?.classList.contains('lb-visible'),
),
)
.toBe(true);
});
test('binds HTTP Bot before its signed inbound verification request', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let pipelineCreateCount = 0;
let inboundTestCount = 0;
let boundPipelineUuid: string | null = null;
let savedInboundSecret = '';
page.on('request', (request) => {
const url = new URL(request.url());
if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') {
pipelineCreateCount += 1;
}
if (
request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1'
) {
const body = request.postDataJSON() as {
adapter_config?: { inbound_secret?: string };
event_bindings?: Array<{
event_pattern?: string;
target_type?: string;
target_uuid?: string;
}>;
};
savedInboundSecret = body.adapter_config?.inbound_secret ?? '';
const binding = body.event_bindings?.find(
(item) =>
item.event_pattern === 'message.received' &&
item.target_type === 'pipeline',
);
boundPipelineUuid = binding?.target_uuid ?? null;
}
});
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
{
name: 'http_bot',
label: { en_US: 'HTTP Bot', zh_Hans: 'HTTP 机器人' },
description: {
en_US: 'Receives signed HTTP messages.',
zh_Hans: '接收签名的 HTTP 消息。',
},
spec: {
categories: ['web'],
supported_events: ['message.received'],
config: [
{
id: 'signature-required',
name: 'signature_required',
type: 'boolean',
label: { en_US: 'Require signature', zh_Hans: '要求签名' },
required: false,
default: true,
},
{
id: 'webhook-url',
name: 'webhook_url',
type: 'webhook-url',
label: { en_US: 'Webhook URL', zh_Hans: 'Webhook 地址' },
required: false,
default: '',
},
],
},
},
],
}),
});
});
await page.route(
'**/api/v1/platform/bots/bot-1/test-inbound',
async (route) => {
inboundTestCount += 1;
expect(route.request().method()).toBe('POST');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({ accepted: true }),
});
},
);
await page.goto('/wizard');
await page.getByText('HTTP Bot', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
await page
.getByRole('button', {
name: /^(Save & Enable Bot|Re-save Configuration)$/,
})
.click();
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
await expect.poll(() => savedInboundSecret).toMatch(/^[a-f0-9]{64}$/);
await page.getByRole('button', { name: 'Send Test' }).click();
await expect.poll(() => inboundTestCount).toBe(1);
});
test('creates only a message pipeline after message verification and valid Runner configuration', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const processorRequests: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'POST' &&
['/api/v1/pipelines', '/api/v1/agents'].includes(path)
) {
processorRequests.push(path);
}
});
let messageReceived = false;
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
logs: messageReceived
? [
{
seq_id: 1,
timestamp: Date.now() / 1000,
level: 'info',
text: 'Received message',
images: [],
message_session_id: 'person_123',
},
]
: [],
total_count: messageReceived ? 1 : 0,
}),
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
configs: [
{
name: 'ai',
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
stages: [
{
name: 'runner',
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
config: [
{
name: 'id',
label: { en_US: 'Runner', zh_Hans: '运行器' },
type: 'select',
required: true,
default: 'external-runner',
options: [
{
name: 'external-runner',
label: {
en_US: 'External Runner',
zh_Hans: '外部运行器',
},
},
],
},
],
},
{
name: 'external-runner',
label: {
en_US: 'External Runner',
zh_Hans: '外部运行器',
},
config: [
{
id: 'api-key',
name: 'api-key',
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
type: 'secret',
required: true,
default: 'your-api-key',
},
],
},
],
},
],
}),
});
});
await page.goto('/wizard');
await page.getByText('Playwright Adapter', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
messageReceived = true;
await page.getByRole('button', { name: 'Next' }).click();
await page.getByText('External Runner', { exact: true }).click();
const deployButton = page.getByRole('button', { name: 'Create & Deploy' });
await expect(deployButton).toBeDisabled();
await page.getByRole('textbox').fill('app-real-api-key');
await expect(deployButton).toBeEnabled();
const bindingRequest = page.waitForRequest(
(request) =>
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/v1/platform/bots/bot-1',
);
await deployButton.click();
const body = (await bindingRequest).postDataJSON();
expect(body.event_bindings).toEqual([
{
event_pattern: 'message.received',
target_type: 'pipeline',
target_uuid: 'pipeline-1',
filters: [],
priority: 0,
enabled: true,
description: '',
},
]);
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
expect(processorRequests).toEqual(['/api/v1/pipelines']);
});
for (const qrPlatform of qrPlatforms) {
test(`${qrPlatform.label} requests and displays its QR code`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
adapterWithQrLogin(
qrPlatform.adapterName,
qrPlatform.label,
qrPlatform.platform,
),
],
}),
});
});
let cleanupRequestSeen = false;
await page.route(`**${qrPlatform.apiBase}`, async (route) => {
expect(route.request().method()).toBe('POST');
expect(new URL(route.request().url()).origin).toBe(
'http://127.0.0.1:4173',
);
expect(route.request().headers()['authorization']).toBe(
'Bearer playwright-token',
);
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-playwright',
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
session_id: `session-${qrPlatform.platform}`,
qr_url: `https://example.test/qr/${qrPlatform.platform}`,
expire_at: Math.floor(Date.now() / 1000) + 120,
}),
});
});
await page.route(`**${qrPlatform.apiBase}/**`, async (route) => {
if (route.request().method() === 'DELETE') {
cleanupRequestSeen = true;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({ status: 'pending' }),
});
});
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: qrPlatform.label }).click();
await page.getByRole('button', { name: /^Start$/ }).click();
const qrImage = page.getByRole('img', { name: 'QR Code' });
await expect(qrImage).toBeVisible();
await expect(qrImage).toHaveAttribute('src', /^data:image\/png;base64,/);
await page.keyboard.press('Escape');
await expect(qrImage).toHaveCount(0);
await expect.poll(() => cleanupRequestSeen).toBe(true);
});
}
test('a failed QR request remains cancellable instead of trapping the form', async ({
page,
}) => {
const qrPlatform = qrPlatforms[0];
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
adapterWithQrLogin(
qrPlatform.adapterName,
qrPlatform.label,
qrPlatform.platform,
),
],
}),
});
});
await page.route(`**${qrPlatform.apiBase}`, async (route) => {
await route.fulfill({ status: 503, body: 'unavailable' });
});
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: qrPlatform.label }).click();
await page.getByRole('button', { name: /^Start$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('HTTP 503')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Retry' })).toBeEnabled();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible();
});
});