From 91e09af76a3ad174f8d77fa476891b9ec1380548 Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 10:41:29 +0800 Subject: [PATCH] fix(web): complete pluginized agent onboarding flows --- .../http/controller/groups/pipelines/embed.py | 12 +- src/langbot/templates/embed/widget.js | 8 + tests/integration/api/test_embed.py | 19 + .../home/agents/agent-runner-marketplace.ts | 178 ++++++ .../agents/components/AgentFormComponent.tsx | 15 + .../agents/components/AgentRunnerSelect.tsx | 308 ++++++++++ .../dynamic-form/DynamicFormComponent.tsx | 106 ++-- .../qrcode-login/QrCodeLoginDialog.tsx | 3 +- .../pipeline-form/PipelineFormComponent.tsx | 12 + web/src/app/infra/http/backendUrl.ts | 24 + web/src/app/wizard/page.tsx | 460 ++++++++------- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/ja-JP.ts | 5 + web/src/i18n/locales/zh-Hans.ts | 4 + web/tests/e2e/crud-smoke.spec.ts | 303 ++++++++++ .../e2e/wizard-platform-regressions.spec.ts | 529 ++++++++++++++++++ .../unit/qr-code-login-workspace.test.mjs | 12 + web/tests/unit/wizard-page-bot.test.mjs | 62 +- 18 files changed, 1784 insertions(+), 280 deletions(-) create mode 100644 web/src/app/home/agents/agent-runner-marketplace.ts create mode 100644 web/src/app/home/agents/components/AgentRunnerSelect.tsx create mode 100644 web/src/app/infra/http/backendUrl.ts create mode 100644 web/tests/e2e/wizard-platform-regressions.spec.ts diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py index cf19930ed..7696dfa4e 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -13,6 +13,7 @@ import logging import uuid import hmac import hashlib +import os import time import re import httpx @@ -29,6 +30,7 @@ _AUTH_TIMEOUT_SECONDS = 10.0 # Cache the widget template content _widget_template_cache: str | None = None +_widget_template_cache_mtime_ns: int | None = None _logo_bytes_cache: bytes | None = None @@ -37,12 +39,14 @@ def _is_valid_uuid(s: str) -> bool: def _get_widget_template() -> str: - """Load and cache the widget JS template.""" - global _widget_template_cache - if _widget_template_cache is None: - template_path = paths.get_resource_path('templates/embed/widget.js') + """Load the widget template and refresh the cache when the file changes.""" + global _widget_template_cache, _widget_template_cache_mtime_ns + template_path = paths.get_resource_path('templates/embed/widget.js') + template_mtime_ns = os.stat(template_path).st_mtime_ns + if _widget_template_cache is None or _widget_template_cache_mtime_ns != template_mtime_ns: with open(template_path, 'r', encoding='utf-8') as f: _widget_template_cache = f.read() + _widget_template_cache_mtime_ns = template_mtime_ns return _widget_template_cache diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js index a62c54a4c..57c0deb5a 100644 --- a/src/langbot/templates/embed/widget.js +++ b/src/langbot/templates/embed/widget.js @@ -10,6 +10,9 @@ var scriptTestNotice = scriptEl ? scriptEl.getAttribute("data-test-notice") : null; + var scriptAutoOpen = scriptEl + ? scriptEl.getAttribute("data-auto-open") === "true" + : false; // ========== i18n ========== var I18N = { @@ -1252,6 +1255,9 @@ } root.remove(); }; + root.langbotOpen = function () { + if (!state.isOpen) togglePanel(); + }; document.body.appendChild(root); var shadow = root.attachShadow({ mode: "open" }); @@ -1406,6 +1412,8 @@ panel.appendChild(inputArea); shadow.appendChild(panel); + + if (scriptAutoOpen) root.langbotOpen(); } // ========== Initialize ========== diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py index d54716b62..c4184d13a 100644 --- a/tests/integration/api/test_embed.py +++ b/tests/integration/api/test_embed.py @@ -9,6 +9,7 @@ Run: uv run pytest tests/integration/api/test_embed.py -q from __future__ import annotations import json +import os import pytest from unittest.mock import MagicMock, AsyncMock, Mock @@ -147,6 +148,24 @@ class TestEmbedWidgetEndpoint: assert 'javascript' in response.content_type fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc') + def test_widget_template_cache_reloads_after_file_change(self, monkeypatch, tmp_path): + """Development edits to widget.js take effect without restarting the backend.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + template_path = tmp_path / 'widget.js' + template_path.write_text('first version', encoding='utf-8') + monkeypatch.setattr(embed.paths, 'get_resource_path', lambda _: str(template_path)) + embed._widget_template_cache = None + embed._widget_template_cache_mtime_ns = None + + assert embed._get_widget_template() == 'first version' + + previous_mtime_ns = template_path.stat().st_mtime_ns + template_path.write_text('second version', encoding='utf-8') + os.utime(template_path, ns=(previous_mtime_ns + 1_000_000, previous_mtime_ns + 1_000_000)) + + assert embed._get_widget_template() == 'second version' + @pytest.mark.asyncio async def test_get_widget_js_invalid_uuid(self, quart_test_client): """GET widget.js with invalid UUID returns 400.""" diff --git a/web/src/app/home/agents/agent-runner-marketplace.ts b/web/src/app/home/agents/agent-runner-marketplace.ts new file mode 100644 index 000000000..37b174914 --- /dev/null +++ b/web/src/app/home/agents/agent-runner-marketplace.ts @@ -0,0 +1,178 @@ +import { httpClient } from '@/app/infra/http/HttpClient'; +import { getCloudServiceClient } from '@/app/infra/http'; +import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; +import type { PipelineConfigTab } from '@/app/infra/entities/pipeline'; +import type { PluginV4 } from '@/app/infra/entities/plugin'; + +export const RUNNER_COMPONENT_FILTER = 'AgentRunner'; + +const RUNNER_CATALOG_PAGE_SIZE = 100; +const RUNNER_INSTALL_TIMEOUT_MS = 120_000; +const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000; + +export type AgentRunnerMarketplaceErrorCode = + | 'version-unavailable' + | 'install-timeout' + | 'registration-timeout'; + +export class AgentRunnerMarketplaceError extends Error { + constructor(public readonly code: AgentRunnerMarketplaceErrorCode) { + super(code); + this.name = 'AgentRunnerMarketplaceError'; + } +} + +export interface AgentRunnerCatalog { + marketplaceRunners: PluginV4[]; + installedPluginIds: string[]; +} + +export interface InstalledAgentRunner { + configTab: PipelineConfigTab; + runner: IDynamicFormItemOption; +} + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function getErrorMessage(error: unknown): string { + if (error && typeof error === 'object') { + const value = error as { msg?: string; message?: string }; + return value.msg || value.message || ''; + } + return typeof error === 'string' ? error : ''; +} + +export function marketplacePluginId(plugin: Pick) { + return `${plugin.author}/${plugin.name}`; +} + +export function runnerPluginPrefix(plugin: Pick) { + return `plugin:${plugin.author}/${plugin.name}/`; +} + +export async function loadAgentRunnerCatalog(): Promise { + const cloudClient = await getCloudServiceClient(); + const [firstSearchResult, recommendationResult, installedResult] = + await Promise.all([ + cloudClient.searchMarketplaceExtensions({ + query: '', + page: 1, + page_size: RUNNER_CATALOG_PAGE_SIZE, + type_filter: 'plugin', + component_filter: RUNNER_COMPONENT_FILTER, + }), + cloudClient.getRecommendationLists().catch(() => ({ lists: [] })), + httpClient.getPlugins().catch(() => ({ plugins: [] })), + ]); + + const remainingPageCount = Math.max( + 0, + Math.ceil((firstSearchResult.total || 0) / RUNNER_CATALOG_PAGE_SIZE) - 1, + ); + const remainingResults = await Promise.all( + Array.from({ length: remainingPageCount }, (_, index) => + cloudClient.searchMarketplaceExtensions({ + query: '', + page: index + 2, + page_size: RUNNER_CATALOG_PAGE_SIZE, + type_filter: 'plugin', + component_filter: RUNNER_COMPONENT_FILTER, + }), + ), + ); + const catalogPlugins = [ + ...(firstSearchResult.plugins || []), + ...remainingResults.flatMap((result) => result.plugins || []), + ]; + + const recommendationOrder = new Map(); + let nextOrder = 0; + for (const list of recommendationResult.lists || []) { + for (const plugin of list.plugins || []) { + if (!plugin.components?.[RUNNER_COMPONENT_FILTER]) continue; + const id = marketplacePluginId(plugin); + if (!recommendationOrder.has(id)) { + recommendationOrder.set(id, nextOrder); + nextOrder += 1; + } + } + } + + const marketplaceRunners = catalogPlugins + .filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER]) + .sort((left, right) => { + const leftOrder = recommendationOrder.get(marketplacePluginId(left)); + const rightOrder = recommendationOrder.get(marketplacePluginId(right)); + if (leftOrder !== undefined && rightOrder !== undefined) { + return leftOrder - rightOrder; + } + if (leftOrder !== undefined) return -1; + if (rightOrder !== undefined) return 1; + return right.install_count - left.install_count; + }); + + return { + marketplaceRunners, + installedPluginIds: installedResult.plugins.map((plugin) => { + const metadata = plugin.manifest.manifest.metadata; + return `${metadata.author ?? ''}/${metadata.name}`; + }), + }; +} + +export async function installMarketplaceAgentRunner( + plugin: PluginV4, +): Promise { + if (!plugin.latest_version) { + throw new AgentRunnerMarketplaceError('version-unavailable'); + } + + const { task_id: taskId } = await httpClient.installPluginFromMarketplace( + plugin.author, + plugin.name, + plugin.latest_version, + ); + const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS; + let installCompleted = false; + while (Date.now() < installDeadline) { + const task = await httpClient.getAsyncTask(taskId); + if (task.runtime.done) { + if (task.runtime.exception) { + throw new Error(task.runtime.exception); + } + installCompleted = true; + break; + } + await wait(1000); + } + if (!installCompleted) { + throw new AgentRunnerMarketplaceError('install-timeout'); + } + + const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; + const prefix = runnerPluginPrefix(plugin); + while (Date.now() < registrationDeadline) { + const metadata = await httpClient.getGeneralPipelineMetadata(); + const configTab = metadata.configs.find((config) => config.name === 'ai'); + const runnerStage = configTab?.stages.find( + (stage) => stage.name === 'runner', + ); + const runnerOptions = + runnerStage?.config.find((item) => item.name === 'id')?.options ?? []; + const pluginRunnerOptions = runnerOptions.filter((option) => + option.name.startsWith(prefix), + ); + const runner = + pluginRunnerOptions.find((option) => option.name.endsWith('/default')) ?? + pluginRunnerOptions[0]; + + if (configTab && runner) { + return { configTab, runner }; + } + await wait(1000); + } + + throw new AgentRunnerMarketplaceError('registration-timeout'); +} diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 0c3c531a9..3486ae304 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -38,6 +38,7 @@ import { FormMessage, } from '@/components/ui/form'; import AgentEventPatternPicker from './AgentEventPatternPicker'; +import AgentRunnerSelect from './AgentRunnerSelect'; export interface AgentRunnerStatus { label: string; @@ -415,6 +416,20 @@ function AgentFormComponent( + config.name === 'id' ? ( + + ) : undefined + : undefined + } onSubmit={(values) => handleDynamicFormEmit( isRunnerSelector ? 'runner' : 'runner_config', diff --git a/web/src/app/home/agents/components/AgentRunnerSelect.tsx b/web/src/app/home/agents/components/AgentRunnerSelect.tsx new file mode 100644 index 000000000..da9b2f04f --- /dev/null +++ b/web/src/app/home/agents/components/AgentRunnerSelect.tsx @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Bot, Download, Loader2, Store } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +import { getCloudServiceClientSync, httpClient } from '@/app/infra/http'; +import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; +import type { PipelineConfigTab } from '@/app/infra/entities/pipeline'; +import type { PluginV4 } from '@/app/infra/entities/plugin'; +import { + AgentRunnerMarketplaceError, + getErrorMessage, + installMarketplaceAgentRunner, + loadAgentRunnerCatalog, + marketplacePluginId, + runnerPluginPrefix, +} from '@/app/home/agents/agent-runner-marketplace'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const MARKETPLACE_VALUE_PREFIX = '__agent_runner_marketplace__:'; + +function installErrorMessage( + error: unknown, + t: ReturnType['t'], +) { + if (error instanceof AgentRunnerMarketplaceError) { + if (error.code === 'version-unavailable') { + return t('wizard.aiEngine.versionUnavailable'); + } + if (error.code === 'install-timeout') { + return t('wizard.aiEngine.installTimeout'); + } + return t('wizard.aiEngine.registrationTimeout'); + } + return getErrorMessage(error) || t('wizard.aiEngine.installFailed'); +} + +function InstalledRunnerContent({ + option, +}: { + option: IDynamicFormItemOption; +}) { + const iconURL = option.name.startsWith('plugin:') + ? (() => { + const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/); + return match ? httpClient.getPluginIconURL(match[1], match[2]) : null; + })() + : null; + + return ( + + {iconURL ? ( + + ) : ( + + )} + {extractI18nObject(option.label)} + + ); +} + +function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) { + const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL( + plugin.type, + plugin.author, + plugin.name, + plugin.icon, + ); + const description = + extractI18nObject(plugin.description) || `${plugin.author}/${plugin.name}`; + + return ( + + + + {extractI18nObject(plugin.label) || plugin.name} + + + + {description} + + + ); +} + +export default function AgentRunnerSelect({ + options, + label, + value, + onValueChange, + onMetadataRefresh, +}: { + options: IDynamicFormItemOption[]; + label: string; + value: string; + onValueChange: (value: string) => void; + onMetadataRefresh: (configTab: PipelineConfigTab) => void; +}) { + const { t } = useTranslation(); + const [marketplaceRunners, setMarketplaceRunners] = useState([]); + const [installedPluginIds, setInstalledPluginIds] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogError, setCatalogError] = useState(false); + const [installingPlugin, setInstallingPlugin] = useState( + null, + ); + const [installError, setInstallError] = useState(null); + + const loadCatalog = useCallback(async () => { + setCatalogLoading(true); + setCatalogError(false); + try { + const catalog = await loadAgentRunnerCatalog(); + setMarketplaceRunners(catalog.marketplaceRunners); + setInstalledPluginIds(catalog.installedPluginIds); + } catch (error) { + console.error('Failed to load AgentRunner catalog', error); + setCatalogError(true); + } finally { + setCatalogLoading(false); + } + }, []); + + useEffect(() => { + void loadCatalog(); + }, [loadCatalog]); + + const marketplaceOptions = useMemo( + () => + marketplaceRunners.filter((plugin) => { + const pluginId = marketplacePluginId(plugin); + if (installedPluginIds.includes(pluginId)) return false; + return !options.some((option) => + option.name.startsWith(runnerPluginPrefix(plugin)), + ); + }), + [installedPluginIds, marketplaceRunners, options], + ); + + const selectedOption = options.find((option) => option.name === value); + + const handleValueChange = useCallback( + async (nextValue: string) => { + if (!nextValue.startsWith(MARKETPLACE_VALUE_PREFIX)) { + setInstallError(null); + onValueChange(nextValue); + return; + } + + const pluginId = nextValue.slice(MARKETPLACE_VALUE_PREFIX.length); + const plugin = marketplaceRunners.find( + (candidate) => marketplacePluginId(candidate) === pluginId, + ); + if (!plugin || installingPlugin) return; + + setInstallingPlugin(plugin); + setInstallError(null); + try { + const installed = await installMarketplaceAgentRunner(plugin); + onMetadataRefresh(installed.configTab); + onValueChange(installed.runner.name); + await loadCatalog(); + toast.success( + t('wizard.aiEngine.installSuccess', { + runner: extractI18nObject(plugin.label) || plugin.name, + }), + ); + } catch (error) { + const message = installErrorMessage(error, t); + setInstallError(message); + toast.error(message); + } finally { + setInstallingPlugin(null); + } + }, + [ + installingPlugin, + loadCatalog, + marketplaceRunners, + onMetadataRefresh, + onValueChange, + t, + ], + ); + + return ( +
+ + + {installError && ( +

+ {installError} +

+ )} +
+ ); +} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 0d79d42cb..3e607c1f8 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -4,6 +4,7 @@ import { DynamicFormItemType, } from '@/app/infra/entities/form/dynamic'; import { useForm } from 'react-hook-form'; +import type { ControllerRenderProps } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { @@ -22,7 +23,8 @@ import { import QrCodeLoginDialog, { QrLoginPlatform, } from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; @@ -460,6 +462,7 @@ export default function DynamicFormComponent({ externalDependentValues, systemContext, onValidate, + renderItem, }: { itemConfigList: IDynamicFormItemSchema[]; onSubmit?: (val: object) => unknown; @@ -473,6 +476,15 @@ export default function DynamicFormComponent({ /** Callback to expose validation function to parent component. * Parent can call this function to trigger validation and get validity state. */ onValidate?: (validateFn: () => Promise) => void; + /** Override a field control while retaining the DynamicForm label, + * description, validation, and value emission behavior. Return undefined + * to use the standard control for that item. */ + renderItem?: (args: { + config: IDynamicFormItemSchema; + field: ControllerRenderProps; + formValues: Record; + setFormValue: (name: string, value: unknown) => void; + }) => ReactNode | undefined; }) { const isInitialMount = useRef(true); const previousInitialValues = useRef(initialValues); @@ -539,15 +551,15 @@ export default function DynamicFormComponent({ }); // Expose validation function to parent component - const validate = async (): Promise => { + const validate = useCallback(async (): Promise => { // Trigger validation for all fields const result = await form.trigger(); return result; - }; + }, [form]); useEffect(() => { onValidate?.(validate); - }, [onValidate]); + }, [onValidate, validate]); // 当 initialValues 变化时更新表单值 // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 @@ -978,42 +990,56 @@ export default function DynamicFormComponent({ key={fieldKey} control={form.control} name={config.name as keyof FormValues} - render={({ field }) => ( - - - - {extractI18nObject(config.label)}{' '} - {config.required && ( - * - )} - - {renderDisabledTooltipIcon()} - - -
- } - onFileUploaded={onFileUploaded} - setFormValue={setFormValue} - systemContext={systemContext} - /> -
-
- {config.description && ( -

- {extractI18nObject(config.description)} -

- )} - -
- )} + render={({ field }) => { + const customItem = renderItem?.({ + config: normalizedConfig, + field, + formValues: watchedValues as Record, + setFormValue, + }); + return ( + + + + {extractI18nObject(config.label)}{' '} + {config.required && ( + * + )} + + {renderDisabledTooltipIcon()} + + +
+ {customItem !== undefined ? ( + customItem + ) : ( + + } + onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} + /> + )} +
+
+ {config.description && ( +

+ {extractI18nObject(config.description)} +

+ )} + +
+ ); + }} /> ); })} diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 439b99f9d..29459d416 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react'; import QRCode from 'qrcode'; import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; +import { getBackendBaseUrl } from '@/app/infra/http/backendUrl'; export type QrLoginPlatform = | 'feishu' @@ -213,7 +214,7 @@ export default function QrCodeLoginDialog({ const token = localStorage.getItem('token'); const workspaceUuid = getActiveWorkspaceUuid(); - const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; + const baseUrl = getBackendBaseUrl(); baseUrlRef.current = baseUrl; const cfg = platformConfigRef.current; diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 278a484fd..777a2474f 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -57,6 +57,7 @@ import { Copy, } from 'lucide-react'; import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension'; +import AgentRunnerSelect from '@/app/home/agents/components/AgentRunnerSelect'; interface PipelineFormComponentProps { pipelineId?: string; @@ -515,6 +516,17 @@ const PipelineFormComponent = forwardRef< ] || {} } systemContext={dynamicFormSystemContext} + renderItem={({ config, field }) => + config.name === 'id' ? ( + + ) : undefined + } onSubmit={(values) => { handleDynamicFormEmit(formName, stage.name, values); }} diff --git a/web/src/app/infra/http/backendUrl.ts b/web/src/app/infra/http/backendUrl.ts new file mode 100644 index 000000000..874075077 --- /dev/null +++ b/web/src/app/infra/http/backendUrl.ts @@ -0,0 +1,24 @@ +export function resolveBackendBaseUrl( + configuredBaseUrl: string | undefined, + origin: string, +): string { + const normalizedOrigin = origin.replace(/\/+$/, ''); + const configured = configuredBaseUrl?.trim() ?? ''; + + if (!configured || configured === '/') { + return normalizedOrigin; + } + + if (/^https?:\/\//i.test(configured)) { + return configured.replace(/\/+$/, ''); + } + + return `${normalizedOrigin}/${configured.replace(/^\/+|\/+$/g, '')}`; +} + +export function getBackendBaseUrl(): string { + return resolveBackendBaseUrl( + import.meta.env.VITE_API_BASE_URL, + window.location.origin, + ); +} diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 691190b09..3409f4764 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -32,7 +32,6 @@ import { systemInfo, bootstrapWorkspaceSession, initializeSystemInfo, - getCloudServiceClient, getCloudServiceClientSync, userInfo, } from '@/app/infra/http'; @@ -65,8 +64,17 @@ import { import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import i18n from 'i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; +import { + AgentRunnerMarketplaceError, + getErrorMessage, + installMarketplaceAgentRunner, + loadAgentRunnerCatalog, + marketplacePluginId, + runnerPluginPrefix, +} from '@/app/home/agents/agent-runner-marketplace'; import { ensureHttpBotSigningSecret, + isRequiredRunnerConfigComplete, isWebhookModeEnabled, } from '@/app/wizard/utils'; @@ -96,30 +104,6 @@ import { // --------------------------------------------------------------------------- const TOTAL_STEPS = 4; -const RUNNER_COMPONENT_FILTER = 'AgentRunner'; -const RUNNER_CATALOG_PAGE_SIZE = 100; -const RUNNER_INSTALL_TIMEOUT_MS = 120_000; -const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000; - -function wait(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function getErrorMessage(error: unknown): string { - if (error && typeof error === 'object') { - const value = error as { msg?: string; message?: string }; - return value.msg || value.message || ''; - } - return typeof error === 'string' ? error : ''; -} - -function marketplacePluginId(plugin: Pick) { - return `${plugin.author}/${plugin.name}`; -} - -function runnerPluginPrefix(plugin: Pick) { - return `plugin:${plugin.author}/${plugin.name}/`; -} type WizardScenarioId = | 'message_reply' @@ -205,6 +189,9 @@ export default function WizardPage() { ); const [runnerConfig, setRunnerConfig] = useState>({}); const [createdBotUuid, setCreatedBotUuid] = useState(null); + const [createdPipelineUuid, setCreatedPipelineUuid] = useState( + null, + ); const [webhookUrl, setWebhookUrl] = useState(''); const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); @@ -228,82 +215,16 @@ export default function WizardPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [isSavingBot, setIsSavingBot] = useState(false); const [botSaved, setBotSaved] = useState(false); + const [pageBotPreviewRequest, setPageBotPreviewRequest] = useState(0); const [messageReceived, setMessageReceived] = useState(false); const loadRunnerCatalog = useCallback(async () => { setIsRunnerCatalogLoading(true); setRunnerCatalogError(false); try { - const cloudClient = await getCloudServiceClient(); - const [firstSearchResult, recommendationResult, installedResult] = - await Promise.all([ - cloudClient.searchMarketplaceExtensions({ - query: '', - page: 1, - page_size: RUNNER_CATALOG_PAGE_SIZE, - type_filter: 'plugin', - component_filter: RUNNER_COMPONENT_FILTER, - }), - cloudClient.getRecommendationLists().catch(() => ({ lists: [] })), - httpClient.getPlugins().catch(() => ({ plugins: [] })), - ]); - - const remainingPageCount = Math.max( - 0, - Math.ceil((firstSearchResult.total || 0) / RUNNER_CATALOG_PAGE_SIZE) - - 1, - ); - const remainingResults = await Promise.all( - Array.from({ length: remainingPageCount }, (_, index) => - cloudClient.searchMarketplaceExtensions({ - query: '', - page: index + 2, - page_size: RUNNER_CATALOG_PAGE_SIZE, - type_filter: 'plugin', - component_filter: RUNNER_COMPONENT_FILTER, - }), - ), - ); - const catalogPlugins = [ - ...(firstSearchResult.plugins || []), - ...remainingResults.flatMap((result) => result.plugins || []), - ]; - - const recommendationOrder = new Map(); - let nextOrder = 0; - for (const list of recommendationResult.lists || []) { - for (const plugin of list.plugins || []) { - if (!plugin.components?.[RUNNER_COMPONENT_FILTER]) continue; - const id = marketplacePluginId(plugin); - if (!recommendationOrder.has(id)) { - recommendationOrder.set(id, nextOrder); - nextOrder += 1; - } - } - } - - const runners = catalogPlugins - .filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER]) - .sort((left, right) => { - const leftOrder = recommendationOrder.get(marketplacePluginId(left)); - const rightOrder = recommendationOrder.get( - marketplacePluginId(right), - ); - if (leftOrder !== undefined && rightOrder !== undefined) { - return leftOrder - rightOrder; - } - if (leftOrder !== undefined) return -1; - if (rightOrder !== undefined) return 1; - return right.install_count - left.install_count; - }); - - setMarketplaceRunners(runners); - setInstalledPluginIds( - installedResult.plugins.map((plugin) => { - const metadata = plugin.manifest.manifest.metadata; - return `${metadata.author ?? ''}/${metadata.name}`; - }), - ); + const catalog = await loadAgentRunnerCatalog(); + setMarketplaceRunners(catalog.marketplaceRunners); + setInstalledPluginIds(catalog.installedPluginIds); } catch (error) { console.error('Failed to load AgentRunner catalog', error); setRunnerCatalogError(true); @@ -333,6 +254,10 @@ export default function WizardPage() { overrides.created_bot_uuid !== undefined ? overrides.created_bot_uuid : createdBotUuid, + created_pipeline_uuid: + overrides.created_pipeline_uuid !== undefined + ? overrides.created_pipeline_uuid + : createdPipelineUuid, bot_saved: overrides.bot_saved ?? botSaved, message_received: overrides.message_received ?? messageReceived, selected_runner: @@ -349,6 +274,7 @@ export default function WizardPage() { selectedScenario, selectedAdapter, createdBotUuid, + createdPipelineUuid, botSaved, messageReceived, selectedRunner, @@ -406,6 +332,15 @@ export default function WizardPage() { 'message_reply', ); setCreatedBotUuid(progress.created_bot_uuid); + setCreatedPipelineUuid( + progress.created_pipeline_uuid ?? + botData.bot.event_bindings?.find( + (binding) => + binding.event_pattern === 'message.received' && + binding.target_type === 'pipeline', + )?.target_uuid ?? + null, + ); setBotSaved( configNeedsSave ? false : (progress.bot_saved ?? false), ); @@ -435,6 +370,7 @@ export default function WizardPage() { selected_scenario: null, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, message_received: false, selected_runner: null, @@ -527,6 +463,12 @@ export default function WizardPage() { ); }, [selectedRunnerConfigStage]); + const isRunnerConfigComplete = useMemo( + () => + isRequiredRunnerConfigComplete(selectedRunnerConfigItems, runnerConfig), + [selectedRunnerConfigItems, runnerConfig], + ); + // ---- Runner selection with progress saving ---- const handleSelectRunner = useCallback( (runner: string, configTab: PipelineConfigTab | null = aiConfigTab) => { @@ -555,74 +497,29 @@ export default function WizardPage() { setRunnerInstallError(null); try { - if (!plugin.latest_version) { - throw new Error(t('wizard.aiEngine.versionUnavailable')); - } - - const { task_id: taskId } = - await httpClient.installPluginFromMarketplace( - plugin.author, - plugin.name, - plugin.latest_version, - ); - const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS; - let installCompleted = false; - while (Date.now() < installDeadline) { - const task = await httpClient.getAsyncTask(taskId); - if (task.runtime.done) { - if (task.runtime.exception) { - throw new Error(task.runtime.exception); - } - installCompleted = true; - break; - } - await wait(1000); - } - if (!installCompleted) { - throw new Error(t('wizard.aiEngine.installTimeout')); - } - - const registrationDeadline = - Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; - const prefix = runnerPluginPrefix(plugin); - while (Date.now() < registrationDeadline) { - const metadata = await httpClient.getGeneralPipelineMetadata(); - const nextAiTab = - metadata.configs.find((config) => config.name === 'ai') ?? null; - const nextRunnerStage = nextAiTab?.stages.find( - (stage) => stage.name === 'runner', - ); - const nextRunnerOptions = - nextRunnerStage?.config.find((item) => item.name === 'id') - ?.options ?? []; - const pluginRunnerOptions = nextRunnerOptions.filter((option) => - option.name.startsWith(prefix), - ); - const preferredRunner = - pluginRunnerOptions.find((option) => - option.name.endsWith('/default'), - ) ?? pluginRunnerOptions[0]; - - if (nextAiTab && preferredRunner) { - setAiConfigTab(nextAiTab); - setInstalledPluginIds((current) => - current.includes(pluginId) ? current : [...current, pluginId], - ); - handleSelectRunner(preferredRunner.name, nextAiTab); - toast.success( - t('wizard.aiEngine.installSuccess', { - runner: extractI18nObject(plugin.label), - }), - ); - return; - } - await wait(1000); - } - - throw new Error(t('wizard.aiEngine.registrationTimeout')); + const installed = await installMarketplaceAgentRunner(plugin); + setAiConfigTab(installed.configTab); + setInstalledPluginIds((current) => + current.includes(pluginId) ? current : [...current, pluginId], + ); + handleSelectRunner(installed.runner.name, installed.configTab); + toast.success( + t('wizard.aiEngine.installSuccess', { + runner: extractI18nObject(plugin.label), + }), + ); } catch (error) { - const message = - getErrorMessage(error) || t('wizard.aiEngine.installFailed'); + let message = getErrorMessage(error); + if (error instanceof AgentRunnerMarketplaceError) { + const key = + error.code === 'version-unavailable' + ? 'wizard.aiEngine.versionUnavailable' + : error.code === 'install-timeout' + ? 'wizard.aiEngine.installTimeout' + : 'wizard.aiEngine.registrationTimeout'; + message = t(key); + } + message ||= t('wizard.aiEngine.installFailed'); setRunnerInstallError(message); toast.error(message); } finally { @@ -645,7 +542,7 @@ export default function WizardPage() { (selectedScenario !== 'message_reply' || messageReceived) ); case 2: - return selectedRunner !== null; + return selectedRunner !== null && isRunnerConfigComplete; default: return false; } @@ -657,6 +554,7 @@ export default function WizardPage() { botSaved, messageReceived, selectedRunner, + isRunnerConfigComplete, ]); const handleSelectScenario = useCallback( @@ -726,6 +624,7 @@ export default function WizardPage() { }; const resp = await httpClient.createBot(bot); setCreatedBotUuid(resp.uuid); + setCreatedPipelineUuid(null); // Fetch runtime info to get webhook URL(s) try { @@ -750,6 +649,7 @@ export default function WizardPage() { selected_scenario: selectedScenario, selected_adapter: selectedAdapter, created_bot_uuid: resp.uuid, + created_pipeline_uuid: null, bot_saved: false, message_received: false, selected_runner: null, @@ -770,6 +670,8 @@ export default function WizardPage() { const handleSaveBot = useCallback(async () => { if (!createdBotUuid || !selectedAdapter) return; setIsSavingBot(true); + let previewPipelineUuid = createdPipelineUuid; + let createdPreviewPipelineUuid: string | null = null; try { const configToSave = ensureHttpBotSigningSecret( @@ -778,14 +680,51 @@ export default function WizardPage() { ); setAdapterConfig(configToSave); - await httpClient.updateBot(createdBotUuid, { + if ( + selectedScenarioDefinition?.processorKind === 'pipeline' && + !previewPipelineUuid + ) { + const pipelineResp = await httpClient.createPipeline({ + name: `${botName} Pipeline`, + description: botDescription || '', + config: {}, + }); + previewPipelineUuid = pipelineResp.uuid; + createdPreviewPipelineUuid = pipelineResp.uuid; + } + + const botUpdate: Partial = { name: botName, description: botDescription || '', adapter: selectedAdapter, adapter_config: configToSave, enable: true, - }); + }; + if ( + selectedScenarioDefinition?.processorKind === 'pipeline' && + previewPipelineUuid + ) { + botUpdate.event_bindings = [ + { + event_pattern: selectedScenarioDefinition.eventType, + target_type: 'pipeline', + target_uuid: previewPipelineUuid, + filters: [], + priority: 0, + enabled: true, + description: '', + }, + ]; + } + + await httpClient.updateBot(createdBotUuid, botUpdate); + if (previewPipelineUuid !== createdPipelineUuid) { + setCreatedPipelineUuid(previewPipelineUuid); + } setBotSaved(true); + if (selectedAdapter === 'web_page_bot') { + setPageBotPreviewRequest((request) => request + 1); + } setMessageReceived(false); // Re-fetch runtime info to get updated webhook URL(s) @@ -803,8 +742,23 @@ export default function WizardPage() { } // Persist progress - saveProgress({ step: 1, bot_saved: true, message_received: false }); + saveProgress({ + step: 1, + created_pipeline_uuid: previewPipelineUuid, + bot_saved: true, + message_received: false, + }); } catch (err) { + if (createdPreviewPipelineUuid) { + try { + await httpClient.deletePipeline(createdPreviewPipelineUuid); + } catch (rollbackError) { + console.warn( + 'Failed to roll back wizard preview pipeline', + rollbackError, + ); + } + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -818,6 +772,8 @@ export default function WizardPage() { botName, botDescription, adapterConfig, + createdPipelineUuid, + selectedScenarioDefinition, t, saveProgress, ]); @@ -831,23 +787,33 @@ export default function WizardPage() { // ---- Create Pipeline & Link (Step 2 finish) ---- const handleFinish = useCallback(async () => { - if (!selectedRunner || !createdBotUuid || !selectedScenarioDefinition) + if ( + !selectedRunner || + !isRunnerConfigComplete || + !createdBotUuid || + !selectedScenarioDefinition + ) return; setIsSubmitting(true); let processorUuid = ''; + let processorCreatedThisAttempt = false; + let targetType: 'agent' | 'pipeline' | null = null; try { - let targetType: 'agent' | 'pipeline'; - if (selectedScenarioDefinition.processorKind === 'pipeline') { - const pipeline: Pipeline = { - name: `${botName} Pipeline`, - description: botDescription || '', - config: {}, - }; - const pipelineResp = await httpClient.createPipeline(pipeline); - processorUuid = pipelineResp.uuid; - const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); + targetType = 'pipeline'; + processorUuid = createdPipelineUuid ?? ''; + if (!processorUuid) { + const pipeline: Pipeline = { + name: `${botName} Pipeline`, + description: botDescription || '', + config: {}, + }; + const pipelineResp = await httpClient.createPipeline(pipeline); + processorUuid = pipelineResp.uuid; + processorCreatedThisAttempt = true; + } + const createdPipeline = await httpClient.getPipeline(processorUuid); const fullConfig = createdPipeline.pipeline.config as unknown as Record< string, unknown @@ -866,7 +832,7 @@ export default function WizardPage() { ? (fullAiConfig.runner_config as Record) : {}; - await httpClient.updatePipeline(pipelineResp.uuid, { + await httpClient.updatePipeline(processorUuid, { name: `${botName} Pipeline`, description: botDescription || '', config: { @@ -881,8 +847,8 @@ export default function WizardPage() { }, }, }); - targetType = 'pipeline'; } else { + targetType = 'agent'; const agentResp = await httpClient.createAgent({ kind: 'agent', name: `${botName} - ${t(selectedScenarioDefinition.labelKey)}`, @@ -896,7 +862,7 @@ export default function WizardPage() { supported_event_patterns: [selectedScenarioDefinition.eventType], }); processorUuid = agentResp.uuid; - targetType = 'agent'; + processorCreatedThisAttempt = true; } const botData = await httpClient.getBot(createdBotUuid); @@ -921,11 +887,21 @@ export default function WizardPage() { }); setCurrentStep(3); - saveProgress({ step: 3 }); + if (targetType === 'pipeline') { + setCreatedPipelineUuid(processorUuid); + } + saveProgress({ + step: 3, + created_pipeline_uuid: targetType === 'pipeline' ? processorUuid : null, + }); } catch (err) { - if (processorUuid) { + if (processorCreatedThisAttempt && processorUuid) { try { - await httpClient.deleteAgent(processorUuid); + if (targetType === 'pipeline') { + await httpClient.deletePipeline(processorUuid); + } else { + await httpClient.deleteAgent(processorUuid); + } } catch (rollbackError) { console.warn('Failed to roll back wizard processor', rollbackError); } @@ -939,7 +915,9 @@ export default function WizardPage() { } }, [ selectedRunner, + isRunnerConfigComplete, createdBotUuid, + createdPipelineUuid, selectedScenarioDefinition, botName, botDescription, @@ -965,6 +943,7 @@ export default function WizardPage() { selected_scenario: null, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, selected_runner: null, }); @@ -1095,6 +1074,7 @@ export default function WizardPage() { createdBotUuid={createdBotUuid} isSavingBot={isSavingBot} botSaved={botSaved} + pageBotPreviewRequest={pageBotPreviewRequest} messageReceived={messageReceived} requiresMessageVerification={selectedScenario === 'message_reply'} onMessageReceived={handleMessageReceived} @@ -1469,22 +1449,28 @@ function PageBotFloatingWidget({ botUuid, title, testNotice, + openRequest, }: { botUuid: string; title?: string; testNotice: string; + openRequest: number; }) { useEffect(() => { const script = document.createElement('script'); script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`; script.dataset.title = title || 'LangBot'; script.dataset.testNotice = testNotice; + script.dataset.autoOpen = 'true'; document.body.appendChild(script); return () => { script.remove(); const root = document.getElementById('langbot-widget-root') as - | (HTMLElement & { langbotDestroy?: () => void }) + | (HTMLElement & { + langbotDestroy?: () => void; + langbotOpen?: () => void; + }) | null; if (root?.langbotDestroy) { root.langbotDestroy(); @@ -1494,6 +1480,14 @@ function PageBotFloatingWidget({ }; }, [botUuid, testNotice, title]); + useEffect(() => { + if (openRequest <= 0) return; + const root = document.getElementById('langbot-widget-root') as + | (HTMLElement & { langbotOpen?: () => void }) + | null; + root?.langbotOpen?.(); + }, [openRequest]); + return null; } @@ -1506,6 +1500,7 @@ function StepBotConfig({ createdBotUuid, isSavingBot, botSaved, + pageBotPreviewRequest, messageReceived, requiresMessageVerification, onMessageReceived, @@ -1521,6 +1516,7 @@ function StepBotConfig({ createdBotUuid: string | null; isSavingBot: boolean; botSaved: boolean; + pageBotPreviewRequest: number; messageReceived: boolean; requiresMessageVerification: boolean; onMessageReceived: () => void; @@ -1592,6 +1588,7 @@ function StepBotConfig({ : undefined } testNotice={t('wizard.botConfig.pageBotTestNotice')} + openRequest={pageBotPreviewRequest} /> )} @@ -1709,51 +1706,51 @@ function StepBotConfig({
{/* Left column: Adapter config form */}
- {adapterConfigItems.length > 0 && ( - - -
- - {t('wizard.config.platformConfig', { - platform: adapterLabel, - })} - - {selectedAdapterName && - (() => { - const selectedAdapter = adapters.find( - (a) => a.name === selectedAdapterName, - ); - const docUrl = getAdapterDocUrl( - selectedAdapter?.spec.help_links, - i18n.language, - ); - return docUrl ? ( - - - {t('bots.viewAdapterDocs')} - - ) : null; - })()} -
- -
+ + +
+ + {t('wizard.config.platformConfig', { + platform: adapterLabel, + })} + + {selectedAdapterName && + (() => { + const selectedAdapter = adapters.find( + (a) => a.name === selectedAdapterName, + ); + const docUrl = getAdapterDocUrl( + selectedAdapter?.spec.help_links, + i18n.language, + ); + return docUrl ? ( + + + {t('bots.viewAdapterDocs')} + + ) : null; + })()} +
+ +
+ {adapterConfigItems.length > 0 && ( -
- )} + )} +
{/* Bot saved indicator */} {botSaved && !requiresMessageVerification && ( @@ -2182,6 +2179,7 @@ function StepDone() { selected_scenario: null, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, selected_runner: null, }); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index a0b48b837..397dbf28c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -770,6 +770,10 @@ const enUS = { noRunnersAvailable: 'No runners are available', noRunnersAvailableDescription: 'Install and enable an AgentRunner extension before configuring this Agent.', + installedRunners: 'Installed AgentRunners', + marketplaceRunners: 'AgentRunner Marketplace', + noInstalledRunners: 'No AgentRunner extension is installed yet.', + installingRunner: 'Installing {{runner}}...', selectedRunnerUnavailable: 'Selected runner is unavailable', selectedRunnerUnavailableDescription: '{{runner}} is not currently registered. Select another runner or restore its extension.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index fea6a6f9a..552940dfd 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -782,6 +782,11 @@ const jaJP = { noRunnersAvailable: '利用可能な Runner がありません', noRunnersAvailableDescription: 'この Agent を設定する前に AgentRunner 拡張機能をインストールして有効にしてください。', + installedRunners: 'インストール済み AgentRunner', + marketplaceRunners: 'AgentRunner マーケットプレイス', + noInstalledRunners: + 'AgentRunner 拡張機能はまだインストールされていません。', + installingRunner: '{{runner}} をインストールしています...', selectedRunnerUnavailable: '選択した Runner は利用できません', selectedRunnerUnavailableDescription: '{{runner}} は現在登録されていません。別の Runner を選択するか、対応する拡張機能を復元してください。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1262bf3c6..1f96869d1 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -738,6 +738,10 @@ const zhHans = { noRunnersAvailable: '没有可用的运行器', noRunnersAvailableDescription: '请先安装并启用 AgentRunner 扩展,再配置此 Agent。', + installedRunners: '已安装的 AgentRunner', + marketplaceRunners: 'AgentRunner 插件市场', + noInstalledRunners: '尚未安装任何 AgentRunner 扩展。', + installingRunner: '正在安装 {{runner}}...', selectedRunnerUnavailable: '所选运行器不可用', selectedRunnerUnavailableDescription: '{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。', diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index e7dc08cdc..057e4c1f5 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -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 | 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; + 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, }) => { diff --git a/web/tests/e2e/wizard-platform-regressions.spec.ts b/web/tests/e2e/wizard-platform-regressions.spec.ts new file mode 100644 index 000000000..19dbd78a9 --- /dev/null +++ b/web/tests/e2e/wizard-platform-regressions.spec.ts @@ -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(); + }); +}); diff --git a/web/tests/unit/qr-code-login-workspace.test.mjs b/web/tests/unit/qr-code-login-workspace.test.mjs index 5e7819373..32e890e49 100644 --- a/web/tests/unit/qr-code-login-workspace.test.mjs +++ b/web/tests/unit/qr-code-login-workspace.test.mjs @@ -9,8 +9,20 @@ const dialogPath = path.join( 'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx', ); const localeDir = path.join(root, 'src/i18n/locales'); +const backendUrlPath = path.join(root, 'src/app/infra/http/backendUrl.ts'); const dialogSource = fs.readFileSync(dialogPath, 'utf8'); +const backendUrlSource = fs.readFileSync(backendUrlPath, 'utf8'); + +test('QR login resolves a proxy-root API base against the current origin', () => { + assert.match(dialogSource, /const baseUrl = getBackendBaseUrl\(\)/); + assert.match(backendUrlSource, /!configured \|\| configured === '\/'/); + assert.match(backendUrlSource, /return normalizedOrigin/); + assert.doesNotMatch( + dialogSource, + /import\.meta\.env\.VITE_API_BASE_URL \|\| window\.location\.origin/, + ); +}); test('QR credential exchanges preserve the active Workspace scope', () => { assert.match(dialogSource, /getActiveWorkspaceUuid/); diff --git a/web/tests/unit/wizard-page-bot.test.mjs b/web/tests/unit/wizard-page-bot.test.mjs index 2af23b7bb..2865e3418 100644 --- a/web/tests/unit/wizard-page-bot.test.mjs +++ b/web/tests/unit/wizard-page-bot.test.mjs @@ -9,6 +9,13 @@ const wizardSource = fs.readFileSync( path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'), 'utf8', ); +const runnerMarketplaceSource = fs.readFileSync( + path.resolve( + currentDirectory, + '../../src/app/home/agents/agent-runner-marketplace.ts', + ), + 'utf8', +); const widgetSource = fs.readFileSync( path.resolve( currentDirectory, @@ -32,11 +39,58 @@ test('shows the test-only notice only when the wizard opts in', () => { assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/); }); +test('opens the Page Bot preview after every successful wizard save', () => { + assert.match(wizardSource, /script\.dataset\.autoOpen = 'true'/); + assert.match( + wizardSource, + /setPageBotPreviewRequest\(\(request\) => request \+ 1\)/, + ); + assert.match(wizardSource, /root\?\.langbotOpen\?\.\(\)/); + assert.match(widgetSource, /getAttribute\("data-auto-open"\) === "true"/); + assert.match(widgetSource, /root\.langbotOpen = function \(\)/); + assert.match(widgetSource, /if \(scriptAutoOpen\) root\.langbotOpen\(\)/); +}); + +test('binds every message-reply bot to its provisional pipeline before verification', () => { + assert.match( + wizardSource, + /selectedScenarioDefinition\?\.processorKind === 'pipeline'[\s\S]*?httpClient\.createPipeline\(/, + ); + assert.match( + wizardSource, + /event_pattern: selectedScenarioDefinition\.eventType,[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/, + ); + assert.doesNotMatch( + wizardSource, + /selectedAdapter === 'web_page_bot' && !previewPipelineUuid/, + ); +}); + test('keeps the 4.11 AgentRunner marketplace installation flow', () => { - assert.match(wizardSource, /RUNNER_COMPONENT_FILTER = 'AgentRunner'/); - assert.match(wizardSource, /installPluginFromMarketplace\(/); - assert.match(wizardSource, /runnerPluginPrefix\(plugin\)/); - assert.match(wizardSource, /registrationDeadline/); + assert.match(wizardSource, /loadAgentRunnerCatalog\(\)/); + assert.match(wizardSource, /installMarketplaceAgentRunner\(plugin\)/); + assert.match( + runnerMarketplaceSource, + /RUNNER_COMPONENT_FILTER = 'AgentRunner'/, + ); + assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/); + assert.match(runnerMarketplaceSource, /runnerPluginPrefix\(plugin\)/); + assert.match(runnerMarketplaceSource, /registrationDeadline/); +}); + +test('requires the selected AgentRunner mandatory configuration before finishing', () => { + assert.match( + wizardSource, + /isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/, + ); + assert.match( + wizardSource, + /return selectedRunner !== null && isRunnerConfigComplete/, + ); + assert.match( + wizardSource, + /!selectedRunner \|\|[\s\S]*?!isRunnerConfigComplete \|\|[\s\S]*?!createdBotUuid/, + ); }); test('requires an observed message only for the message-reply scenario', () => {