mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(web): complete pluginized agent onboarding flows
This commit is contained in:
@@ -836,6 +836,309 @@ test.describe('pipeline advanced flows', () => {
|
||||
});
|
||||
|
||||
test.describe('agent runner resource selectors', () => {
|
||||
test('installs an AgentRunner from the grouped empty selector and refreshes it', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
const runnerId = 'plugin:qa/MarketplaceRunner/default';
|
||||
let installed = false;
|
||||
let installRequests = 0;
|
||||
let taskPolls = 0;
|
||||
let marketplaceSearchBody: Record<string, unknown> | undefined;
|
||||
|
||||
const apiResponse = (data: unknown) =>
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const runnerConfig = () => ({
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
|
||||
config: [
|
||||
{
|
||||
name: 'id',
|
||||
label: { en_US: 'Runner', zh_Hans: '运行器' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: '',
|
||||
options: installed
|
||||
? [
|
||||
{
|
||||
name: runnerId,
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
...(installed
|
||||
? [
|
||||
{
|
||||
name: runnerId,
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
config: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/agents/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
runner_config: runnerConfig(),
|
||||
kinds: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/agents/agent-empty', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
agent: {
|
||||
uuid: 'agent-empty',
|
||||
name: 'Empty Runner Agent',
|
||||
description: '',
|
||||
emoji: 'A',
|
||||
kind: 'agent',
|
||||
config: {
|
||||
runner: { id: '', 'expire-time': 0 },
|
||||
runner_config: {},
|
||||
},
|
||||
supported_event_patterns: ['*'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({ configs: [runnerConfig()] }),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
plugins: installed
|
||||
? [
|
||||
{
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: { author: 'qa', name: 'MarketplaceRunner' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins/install/marketplace', (route) => {
|
||||
installRequests += 1;
|
||||
installed = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({ task_id: 77 }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/77', (route) => {
|
||||
taskPolls += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
id: 77,
|
||||
name: 'plugin-install-marketplace',
|
||||
label: 'Marketplace Runner',
|
||||
runtime: { done: true, exception: null },
|
||||
task_context: { current_action: 'complete', metadata: {} },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
'https://space.langbot.app/api/v1/marketplace/extensions/search',
|
||||
async (route) => {
|
||||
marketplaceSearchBody = JSON.parse(
|
||||
route.request().postData() || '{}',
|
||||
) as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
extensions: [
|
||||
{
|
||||
id: 1,
|
||||
plugin_id: 'qa/MarketplaceRunner',
|
||||
author: 'qa',
|
||||
name: 'MarketplaceRunner',
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Runner used by the grouped selector test.',
|
||||
zh_Hans: '用于分组选择器测试的运行器。',
|
||||
},
|
||||
icon: '',
|
||||
repository: 'https://example.test/runner',
|
||||
tags: [],
|
||||
install_count: 12,
|
||||
latest_version: '1.0.0',
|
||||
components: { AgentRunner: 1 },
|
||||
status: 'live',
|
||||
type: 'plugin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto('/home/agents?id=agent-empty');
|
||||
await page.getByRole('tab', { name: /^Runner$/ }).click();
|
||||
|
||||
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
|
||||
const triggerBox = await runnerSelect.boundingBox();
|
||||
await runnerSelect.click();
|
||||
await expect(page.getByText('Installed AgentRunners')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No AgentRunner extension is installed yet.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('AgentRunner Marketplace')).toBeVisible();
|
||||
const selectorPopup = page.locator('[data-slot="select-content"]');
|
||||
await expect(
|
||||
selectorPopup.getByText('Runner used by the grouped selector test.', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const popupBox = await selectorPopup.boundingBox();
|
||||
expect(triggerBox?.width).toBeLessThanOrEqual(353);
|
||||
expect(popupBox?.width ?? 0).toBeLessThanOrEqual(
|
||||
(triggerBox?.width ?? 0) + 1,
|
||||
);
|
||||
expect(popupBox?.width ?? 0).toBeGreaterThan((triggerBox?.width ?? 0) - 20);
|
||||
await expect
|
||||
.poll(() => marketplaceSearchBody)
|
||||
.toMatchObject({
|
||||
component_filter: 'AgentRunner',
|
||||
type_filter: 'plugin',
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'Marketplace Runner' })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => installRequests).toBe(1);
|
||||
await expect.poll(() => taskPolls).toBeGreaterThan(0);
|
||||
await expect(runnerSelect).toContainText('Marketplace Runner');
|
||||
|
||||
await runnerSelect.click();
|
||||
await expect(
|
||||
page.getByRole('option').filter({ hasText: runnerId }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Runner used by the grouped selector test.', {
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('uses the compact AgentRunner marketplace selector in pipeline AI settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const apiResponse = (data: unknown) =>
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await page.route(
|
||||
'https://space.langbot.app/api/v1/marketplace/extensions/search',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
extensions: [
|
||||
{
|
||||
id: 2,
|
||||
plugin_id: 'qa/PipelineMarketplaceRunner',
|
||||
author: 'qa',
|
||||
name: 'PipelineMarketplaceRunner',
|
||||
label: {
|
||||
en_US: 'Pipeline Marketplace Runner',
|
||||
zh_Hans: '流水线市场运行器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'A marketplace runner shown inside pipeline settings.',
|
||||
zh_Hans: '展示在流水线配置中的市场运行器。',
|
||||
},
|
||||
icon: '',
|
||||
repository: 'https://example.test/pipeline-runner',
|
||||
tags: [],
|
||||
install_count: 9,
|
||||
latest_version: '1.0.0',
|
||||
components: { AgentRunner: 1 },
|
||||
status: 'live',
|
||||
type: 'plugin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-runner-selector');
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
|
||||
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
|
||||
await runnerSelect.click();
|
||||
await expect(page.getByText('Installed AgentRunners')).toBeVisible();
|
||||
await expect(page.getByText('AgentRunner Marketplace')).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="select-content"]')
|
||||
.getByText('A marketplace runner shown inside pipeline settings.', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'Pipeline Marketplace Runner' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('uses the global catalog and preserves temporarily unavailable tools', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
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('opens the Page Bot test panel after the first and every later save', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
let pipelineCreateCount = 0;
|
||||
let boundPipelineUuid: string | null = null;
|
||||
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 {
|
||||
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',
|
||||
);
|
||||
await page.route('**/api/v1/embed/*/widget.js?*', async (route) => {
|
||||
if (!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.getByRole('button', { name: /Reply to messages/ }).click();
|
||||
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');
|
||||
|
||||
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.getByRole('button', { name: /Reply to messages/ }).click();
|
||||
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('blocks deployment until required AgentRunner configuration is real', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
});
|
||||
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.getByRole('button', { name: /Welcome new members/ }).click();
|
||||
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 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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user