mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
fix(pipeline): decouple Runner from extension permissions (LANG-54) (#2723)
* fix(pipeline): decouple runner from extension allowlist Co-authored-by: multica-agent <[email protected]> * fix(pipeline): complete runner diagnostics and real runtime coverage Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
5a6ddbff6f
commit
d9e4e7d9df
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = process.env.LANGBOT_RUNNER_WEB_PORT;
|
||||
if (!port || !process.env.LANGBOT_RUNNER_API_URL) {
|
||||
throw new Error(
|
||||
'Run through tests/e2e/test_runner_plugin_runtime.py with its isolated backend and SDK runtime',
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: 'runner-real-runtime.spec.ts',
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 120_000,
|
||||
reporter: 'list',
|
||||
outputDir: process.env.LANGBOT_RUNNER_BROWSER_OUTPUT,
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: 'retain-on-failure',
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
webServer: {
|
||||
command: `node node_modules/vite/bin/vite.js preview --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 60_000,
|
||||
env: { VITE_API_PROXY_TARGET: process.env.LANGBOT_RUNNER_API_URL },
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { Bot, Puzzle, ExternalLink, Loader2, Store } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { getCloudServiceClientSync, httpClient } from '@/app/infra/http';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
|
||||
import type { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
@@ -233,6 +233,45 @@ export default function RunnerSelect({
|
||||
useState<RunnerCatalog['installedPluginDescriptions']>({});
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [catalogError, setCatalogError] = useState(false);
|
||||
const [runnerProblem, setRunnerProblem] = useState<
|
||||
'missing' | 'disabled' | 'runtime' | null
|
||||
>(null);
|
||||
const [diagnosing, setDiagnosing] = useState(false);
|
||||
const diagnoseRunner = useCallback(async () => {
|
||||
const pluginId = runnerPluginId(value);
|
||||
if (!pluginId) return null;
|
||||
const status = await httpClient.getPluginSystemStatus();
|
||||
if (!status.is_enable || !status.is_connected) return 'runtime' as const;
|
||||
const { plugins } = await httpClient.getPlugins();
|
||||
const plugin = plugins.find((item) => {
|
||||
const metadata = item.manifest.manifest.metadata;
|
||||
return `${metadata.author}/${metadata.name}` === pluginId;
|
||||
});
|
||||
if (!plugin) return 'missing' as const;
|
||||
if (!plugin.enabled) return 'disabled' as const;
|
||||
return options.some((option) => option.name === value)
|
||||
? null
|
||||
: ('missing' as const);
|
||||
}, [value, options]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRunnerProblem(null);
|
||||
setDiagnosing(true);
|
||||
void diagnoseRunner()
|
||||
.then((problem) => {
|
||||
if (!cancelled) setRunnerProblem(problem);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRunnerProblem('runtime');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDiagnosing(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [diagnoseRunner]);
|
||||
const [pendingInstall, setPendingInstall] = useState(() =>
|
||||
readPendingRunnerInstall(installScope),
|
||||
);
|
||||
@@ -405,6 +444,8 @@ export default function RunnerSelect({
|
||||
option={selectedOption}
|
||||
eventProcessor={usage === 'event'}
|
||||
/>
|
||||
) : value ? (
|
||||
<span className="truncate">{value}</span>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
@@ -539,6 +580,24 @@ export default function RunnerSelect({
|
||||
</a>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!diagnosing && runnerProblem && (
|
||||
<div
|
||||
role="alert"
|
||||
className="space-y-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
<p>{t(`agents.runnerDiagnosis.${runnerProblem}`)}</p>
|
||||
<a
|
||||
href="/home/plugins"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 underline"
|
||||
>
|
||||
{t('agents.runnerDiagnosis.repair')}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
<p>{t('agents.runnerDiagnosis.draft')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
|
||||
@@ -353,6 +353,9 @@ export default function PipelineExtension({
|
||||
<div className="space-y-6">
|
||||
{/* Plugins Section */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelines.extensions.pluginScopeHint')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('pipelines.extensions.pluginsTitle')}
|
||||
|
||||
@@ -723,6 +723,9 @@ const PipelineFormComponent = forwardRef<
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('pipelines.extensions.runnerScopeHint')}
|
||||
</CardDescription>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
|
||||
@@ -1611,6 +1611,16 @@ const enUS = {
|
||||
restoringRunnerInstall:
|
||||
'Restoring the Runner plugin installation and waiting for the runner…',
|
||||
noInstalledRunners: 'No Runner extension is installed yet.',
|
||||
runnerDiagnosis: {
|
||||
missing:
|
||||
'The selected Runner or its plugin is missing. Install or restore the plugin.',
|
||||
disabled: 'The selected Runner plugin is disabled. Enable it in Plugins.',
|
||||
runtime:
|
||||
'The Plugin Runtime is unavailable or disabled. Restore its connection before running.',
|
||||
repair: 'Manage plugins and runtime',
|
||||
draft:
|
||||
'Repair opens in a new tab. Your Runner selection and unsaved draft stay here.',
|
||||
},
|
||||
installingRunner: 'Installing {{runner}}...',
|
||||
runnerInstallSuccess: '{{runner}} is installed and ready to select',
|
||||
selectedRunnerUnavailable: 'Selected runner is unavailable',
|
||||
@@ -2219,6 +2229,10 @@ const enUS = {
|
||||
addPlugin: 'Add Plugin',
|
||||
selectPlugins: 'Select Plugins',
|
||||
pluginsTitle: 'Plugins',
|
||||
pluginScopeHint:
|
||||
'Only controls event listeners, tools and commands for this pipeline; does not affect the Runner selected in AI settings.',
|
||||
runnerScopeHint:
|
||||
'Select the Runner independently here, regardless of extension plugin switches. Its plugin must still be installed and available.',
|
||||
mcpServersTitle: 'MCP Servers',
|
||||
mcpResourcesTitle: 'MCP Resources',
|
||||
noMCPServersSelected: 'No MCP servers selected',
|
||||
|
||||
@@ -1635,6 +1635,17 @@ const esES = {
|
||||
restoringRunnerInstall:
|
||||
'Restaurando la instalación del plugin y esperando al ejecutor…',
|
||||
noInstalledRunners: 'Aún no hay extensiones de ejecutor instaladas.',
|
||||
runnerDiagnosis: {
|
||||
missing:
|
||||
'No se encuentra el Runner seleccionado o su complemento. Instale o restaure el complemento.',
|
||||
disabled:
|
||||
'El complemento del Runner seleccionado está deshabilitado. Actívelo en Complementos.',
|
||||
runtime:
|
||||
'El entorno de ejecución de complementos no está disponible o está deshabilitado. Restablezca la conexión antes de ejecutar.',
|
||||
repair: 'Gestionar complementos y entorno de ejecución',
|
||||
draft:
|
||||
'La reparación se abre en otra pestaña. La selección del Runner y el borrador sin guardar se conservan aquí.',
|
||||
},
|
||||
installingRunner: 'Instalando {{runner}}...',
|
||||
runnerInstallSuccess:
|
||||
'{{runner}} está instalado y listo para seleccionarse',
|
||||
@@ -2253,6 +2264,10 @@ const esES = {
|
||||
addPlugin: 'Añadir plugin',
|
||||
selectPlugins: 'Seleccionar plugins',
|
||||
pluginsTitle: 'Plugins',
|
||||
pluginScopeHint:
|
||||
'Solo controla los escuchadores de eventos, herramientas y comandos de este pipeline; no afecta al Runner elegido en los ajustes de IA.',
|
||||
runnerScopeHint:
|
||||
'El Runner se elige aquí independientemente de los interruptores de plugins de extensiones. Su plugin debe estar instalado y disponible.',
|
||||
mcpServersTitle: 'Servidores MCP',
|
||||
mcpResourcesTitle: 'Recursos MCP',
|
||||
noMCPServersSelected: 'No hay servidores MCP seleccionados',
|
||||
|
||||
@@ -1618,6 +1618,17 @@ const jaJP = {
|
||||
restoringRunnerInstall:
|
||||
'Runner プラグインのインストールを復元し、ランナーを待機しています…',
|
||||
noInstalledRunners: 'Runner 拡張機能はまだインストールされていません。',
|
||||
runnerDiagnosis: {
|
||||
missing:
|
||||
'選択した Runner またはプラグインが見つかりません。プラグインをインストールまたは復元してください。',
|
||||
disabled:
|
||||
'選択した Runner プラグインは無効です。プラグイン画面で有効にしてください。',
|
||||
runtime:
|
||||
'プラグインランタイムが利用できないか無効です。接続を復旧してから実行してください。',
|
||||
repair: 'プラグインとランタイムを管理',
|
||||
draft:
|
||||
'修復画面は新しいタブで開きます。Runner の選択と未保存の下書きは保持されます。',
|
||||
},
|
||||
installingRunner: '{{runner}} をインストールしています...',
|
||||
runnerInstallSuccess:
|
||||
'{{runner}} をインストールしました。インストール済み一覧から選択できます',
|
||||
@@ -2228,6 +2239,10 @@ const jaJP = {
|
||||
addPlugin: 'プラグインを追加',
|
||||
selectPlugins: 'プラグインを選択',
|
||||
pluginsTitle: 'プラグイン',
|
||||
pluginScopeHint:
|
||||
'このパイプラインのイベントリスナー、ツール、コマンドのみを制御します。AI 設定で選択した Runner には影響しません。',
|
||||
runnerScopeHint:
|
||||
'Runner は拡張機能のプラグインスイッチと独立して選択します。対応するプラグインはインストール済みで利用可能である必要があります。',
|
||||
mcpServersTitle: 'MCPサーバー',
|
||||
mcpResourcesTitle: 'MCPリソース',
|
||||
noMCPServersSelected: 'MCPサーバーが選択されていません',
|
||||
|
||||
@@ -1632,6 +1632,17 @@ const ruRU = {
|
||||
restoringRunnerInstall:
|
||||
'Восстановление установки плагина и ожидание исполнителя…',
|
||||
noInstalledRunners: 'Расширения исполнителей ещё не установлены.',
|
||||
runnerDiagnosis: {
|
||||
missing:
|
||||
'Выбранный Runner или его плагин не найден. Установите или восстановите плагин.',
|
||||
disabled:
|
||||
'Плагин выбранного Runner отключён. Включите его на странице плагинов.',
|
||||
runtime:
|
||||
'Среда выполнения плагинов недоступна или отключена. Восстановите соединение перед запуском.',
|
||||
repair: 'Управление плагинами и средой выполнения',
|
||||
draft:
|
||||
'Исправление откроется в новой вкладке. Выбор Runner и несохранённый черновик останутся здесь.',
|
||||
},
|
||||
installingRunner: 'Установка {{runner}}...',
|
||||
runnerInstallSuccess: '{{runner}} установлен и доступен для выбора',
|
||||
selectedRunnerUnavailable: 'Выбранный исполнитель недоступен',
|
||||
@@ -2243,6 +2254,10 @@ const ruRU = {
|
||||
addPlugin: 'Добавить плагин',
|
||||
selectPlugins: 'Выберите плагины',
|
||||
pluginsTitle: 'Плагины',
|
||||
pluginScopeHint:
|
||||
'Управляет только обработчиками событий, инструментами и командами этого конвейера, но не Runner, выбранным в настройках ИИ.',
|
||||
runnerScopeHint:
|
||||
'Runner выбирается независимо от переключателей плагинов расширений. Его плагин должен быть установлен и доступен.',
|
||||
mcpServersTitle: 'MCP-серверы',
|
||||
mcpResourcesTitle: 'MCP-ресурсы',
|
||||
noMCPServersSelected: 'MCP-серверы не выбраны',
|
||||
|
||||
@@ -1577,6 +1577,17 @@ const thTH = {
|
||||
viewMarketplace: 'ดูตลาด',
|
||||
restoringRunnerInstall: 'กำลังกู้คืนการติดตั้งปลั๊กอินและรอรันเนอร์…',
|
||||
noInstalledRunners: 'ยังไม่ได้ติดตั้งส่วนขยายรันเนอร์',
|
||||
runnerDiagnosis: {
|
||||
missing:
|
||||
'ไม่พบ Runner ที่เลือกหรือปลั๊กอิน โปรดติดตั้งหรือกู้คืนปลั๊กอิน',
|
||||
disabled:
|
||||
'ปลั๊กอิน Runner ที่เลือกถูกปิดใช้งาน โปรดเปิดใช้งานในหน้าปลั๊กอิน',
|
||||
runtime:
|
||||
'รันไทม์ปลั๊กอินไม่พร้อมใช้งานหรือถูกปิด โปรดกู้คืนการเชื่อมต่อก่อนเรียกใช้',
|
||||
repair: 'จัดการปลั๊กอินและรันไทม์',
|
||||
draft:
|
||||
'หน้าซ่อมแซมจะเปิดในแท็บใหม่ การเลือก Runner และฉบับร่างที่ยังไม่บันทึกจะยังอยู่ที่นี่',
|
||||
},
|
||||
installingRunner: 'กำลังติดตั้ง {{runner}}...',
|
||||
runnerInstallSuccess: 'ติดตั้ง {{runner}} แล้ว พร้อมให้เลือก',
|
||||
selectedRunnerUnavailable: 'รันเนอร์ที่เลือกไม่พร้อมใช้งาน',
|
||||
@@ -2172,6 +2183,10 @@ const thTH = {
|
||||
addPlugin: 'เพิ่มปลั๊กอิน',
|
||||
selectPlugins: 'เลือกปลั๊กอิน',
|
||||
pluginsTitle: 'ปลั๊กอิน',
|
||||
pluginScopeHint:
|
||||
'ควบคุมเฉพาะตัวรับฟังเหตุการณ์ เครื่องมือ และคำสั่งของไปป์ไลน์นี้ ไม่ส่งผลต่อ Runner ที่เลือกในการตั้งค่า AI',
|
||||
runnerScopeHint:
|
||||
'เลือก Runner ได้อย่างอิสระจากสวิตช์ปลั๊กอินส่วนขยาย โดยปลั๊กอินของ Runner ต้องติดตั้งแล้วและพร้อมใช้งาน',
|
||||
mcpServersTitle: 'เซิร์ฟเวอร์ MCP',
|
||||
mcpResourcesTitle: 'ทรัพยากร MCP',
|
||||
noMCPServersSelected: 'ไม่ได้เลือกเซิร์ฟเวอร์ MCP',
|
||||
|
||||
@@ -1601,6 +1601,17 @@ const viVN = {
|
||||
viewMarketplace: 'Xem chợ tiện ích',
|
||||
restoringRunnerInstall: 'Đang khôi phục cài đặt plugin và chờ trình chạy…',
|
||||
noInstalledRunners: 'Chưa cài tiện ích trình chạy nào.',
|
||||
runnerDiagnosis: {
|
||||
missing:
|
||||
'Không tìm thấy Runner đã chọn hoặc tiện ích của nó. Hãy cài đặt hoặc khôi phục tiện ích.',
|
||||
disabled:
|
||||
'Tiện ích Runner đã chọn bị tắt. Hãy bật nó trong trang Tiện ích.',
|
||||
runtime:
|
||||
'Môi trường chạy tiện ích không khả dụng hoặc bị tắt. Hãy khôi phục kết nối trước khi chạy.',
|
||||
repair: 'Quản lý tiện ích và môi trường chạy',
|
||||
draft:
|
||||
'Trang sửa lỗi mở trong thẻ mới. Lựa chọn Runner và bản nháp chưa lưu vẫn được giữ ở đây.',
|
||||
},
|
||||
installingRunner: 'Đang cài {{runner}}...',
|
||||
runnerInstallSuccess: 'Đã cài {{runner}} và có thể chọn',
|
||||
selectedRunnerUnavailable: 'Trình chạy đã chọn không khả dụng',
|
||||
@@ -2205,6 +2216,10 @@ const viVN = {
|
||||
addPlugin: 'Thêm Plugin',
|
||||
selectPlugins: 'Chọn Plugin',
|
||||
pluginsTitle: 'Plugin',
|
||||
pluginScopeHint:
|
||||
'Chỉ kiểm soát trình lắng nghe sự kiện, công cụ và lệnh của pipeline này; không ảnh hưởng đến Runner được chọn trong cài đặt AI.',
|
||||
runnerScopeHint:
|
||||
'Chọn Runner độc lập tại đây, không phụ thuộc vào công tắc plugin tiện ích. Plugin của Runner vẫn phải được cài đặt và khả dụng.',
|
||||
mcpServersTitle: 'Máy chủ MCP',
|
||||
mcpResourcesTitle: 'Tài nguyên MCP',
|
||||
noMCPServersSelected: 'Chưa chọn máy chủ MCP nào',
|
||||
|
||||
@@ -1516,6 +1516,13 @@ const zhHans = {
|
||||
viewMarketplace: '查看市场',
|
||||
restoringRunnerInstall: '正在恢复运行器插件安装并等待运行器就绪…',
|
||||
noInstalledRunners: '尚未安装任何运行器扩展。',
|
||||
runnerDiagnosis: {
|
||||
missing: '所选运行器或其插件不存在,请安装或恢复插件。',
|
||||
disabled: '所选运行器插件已禁用,请在插件页面启用。',
|
||||
runtime: '插件运行时不可用或已禁用,请恢复连接后再运行。',
|
||||
repair: '管理插件和运行时',
|
||||
draft: '修复页面将在新标签页打开,当前运行器选择和未保存的草稿将保留。',
|
||||
},
|
||||
installingRunner: '正在安装 {{runner}}...',
|
||||
runnerInstallSuccess: '{{runner}} 已安装,可从已安装列表中选择',
|
||||
selectedRunnerUnavailable: '所选运行器不可用',
|
||||
@@ -2092,6 +2099,10 @@ const zhHans = {
|
||||
addPlugin: '添加插件',
|
||||
selectPlugins: '选择插件',
|
||||
pluginsTitle: '插件',
|
||||
pluginScopeHint:
|
||||
'此处仅控制本流水线使用的事件监听器、工具和命令组件,不影响 AI 设置中选择的运行器(Runner)。',
|
||||
runnerScopeHint:
|
||||
'运行器在此独立选择,不受扩展集成中的插件开关控制;其所属插件仍需已安装且可用。',
|
||||
mcpServersTitle: 'MCP 服务器',
|
||||
mcpResourcesTitle: 'MCP 资源',
|
||||
noMCPServersSelected: '未选择任何 MCP 服务器',
|
||||
|
||||
@@ -1514,6 +1514,13 @@ const zhHant = {
|
||||
viewMarketplace: '查看市場',
|
||||
restoringRunnerInstall: '正在恢復運行器插件安裝並等待運行器就緒…',
|
||||
noInstalledRunners: '尚未安裝任何運行器擴展。',
|
||||
runnerDiagnosis: {
|
||||
missing: '所選運行器或其外掛不存在,請安裝或還原外掛。',
|
||||
disabled: '所選運行器外掛已停用,請在外掛頁面啟用。',
|
||||
runtime: '外掛執行環境無法使用或已停用,請恢復連線後再執行。',
|
||||
repair: '管理外掛和執行環境',
|
||||
draft: '修復頁面將在新分頁開啟,目前的運行器選擇和未儲存的草稿將保留。',
|
||||
},
|
||||
installingRunner: '正在安裝 {{runner}}...',
|
||||
runnerInstallSuccess: '{{runner}} 已安裝,可從已安裝列表中選擇',
|
||||
selectedRunnerUnavailable: '所選運行器不可用',
|
||||
@@ -2089,6 +2096,10 @@ const zhHant = {
|
||||
addPlugin: '新增插件',
|
||||
selectPlugins: '選擇插件',
|
||||
pluginsTitle: '插件',
|
||||
pluginScopeHint:
|
||||
'此處僅控制本流水線使用的事件監聽器、工具和命令元件,不影響 AI 設定中選擇的執行器(Runner)。',
|
||||
runnerScopeHint:
|
||||
'執行器在此獨立選擇,不受擴充整合中的外掛開關控制;其所屬外掛仍須已安裝且可用。',
|
||||
mcpServersTitle: 'MCP 伺服器',
|
||||
mcpResourcesTitle: 'MCP 資源',
|
||||
noMCPServersSelected: '未選擇任何 MCP 伺服器',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
pipelineMetadata,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
test('independent Runner guidance remains visible with extensions disabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/pipelines/*/extensions', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
enable_all_plugins: false,
|
||||
enable_all_mcp_servers: false,
|
||||
enable_all_skills: false,
|
||||
bound_plugins: [],
|
||||
available_plugins: [],
|
||||
bound_mcp_servers: [],
|
||||
available_mcp_servers: [],
|
||||
bound_skills: [],
|
||||
available_skills: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=boundary');
|
||||
await page.locator('[data-guide="pipeline-section-ai"]').click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Select the Runner independently here, regardless of extension plugin switches. Its plugin must still be installed and available.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Extensions', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Only controls event listeners, tools and commands for this pipeline; does not affect the Runner selected in AI settings.',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
for (const problem of ['missing', 'disabled', 'runtime'] as const) {
|
||||
test(`Runner ${problem} diagnosis keeps the draft and opens repair separately`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const id = 'plugin:langbot-team/LocalAgent/default';
|
||||
const config = {
|
||||
ai: { runner: { id }, runner_config: { [id]: { hidden: null } } },
|
||||
};
|
||||
const writes: unknown[] = [];
|
||||
await page.route('**/api/v1/pipelines/diagnosis', async (route) => {
|
||||
if (route.request().method() === 'PUT')
|
||||
writes.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'diagnosis',
|
||||
name: 'Diagnosis',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/system/status/plugin-system', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
is_enable: true,
|
||||
is_connected: problem !== 'runtime',
|
||||
plugin_connector_error: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
plugins:
|
||||
problem === 'missing'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
enabled: problem !== 'disabled',
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: {
|
||||
author: 'langbot-team',
|
||||
name: 'LocalAgent',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const originalMetadata = pipelineMetadata();
|
||||
const metadata = {
|
||||
configs: originalMetadata.configs.map((tab) => ({
|
||||
...tab,
|
||||
stages: tab.stages.map((stage) => ({
|
||||
...stage,
|
||||
config:
|
||||
stage.name === id
|
||||
? [
|
||||
{
|
||||
name: 'draft',
|
||||
type: 'string',
|
||||
label: { en_US: 'Draft marker', zh_Hans: '草稿标记' },
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
]
|
||||
: stage.config.map((field) =>
|
||||
problem === 'missing' && field.name === 'id'
|
||||
? { ...field, options: [] }
|
||||
: field,
|
||||
),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: metadata } }),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=diagnosis');
|
||||
await page.locator('[data-guide="pipeline-section-ai"]').click();
|
||||
const draft = page
|
||||
.getByRole('region', { name: 'Configuration', exact: true })
|
||||
.getByRole('textbox');
|
||||
await draft.fill('unsaved diagnosis draft');
|
||||
const alert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: 'Manage plugins and runtime' });
|
||||
await expect(alert).toContainText(
|
||||
{
|
||||
missing: 'The selected Runner or its plugin is missing.',
|
||||
disabled: 'The selected Runner plugin is disabled.',
|
||||
runtime: 'The Plugin Runtime is unavailable or disabled.',
|
||||
}[problem],
|
||||
);
|
||||
const repair = alert.getByRole('link', {
|
||||
name: 'Manage plugins and runtime',
|
||||
});
|
||||
await expect(repair).toHaveAttribute('target', '_blank');
|
||||
const popup = page.waitForEvent('popup');
|
||||
await repair.click();
|
||||
await (await popup).close();
|
||||
await expect(page).toHaveURL(/id=diagnosis/);
|
||||
await expect(draft).toHaveValue('unsaved diagnosis draft');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeEnabled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.skip(
|
||||
!process.env.LANGBOT_RUNNER_PIPELINE_ID,
|
||||
'Requires the isolated real backend and SDK pytest fixture',
|
||||
);
|
||||
|
||||
test('real backend and SDK: save, copy and execute with extensions off', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const api = process.env.LANGBOT_RUNNER_API_URL!;
|
||||
const pipelineId = process.env.LANGBOT_RUNNER_PIPELINE_ID!;
|
||||
const token = process.env.LANGBOT_RUNNER_TOKEN!;
|
||||
const runnerId = 'plugin:e2e/agent-runner-qa/default';
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
await page.addInitScript(
|
||||
({ token }) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('userEmail', '[email protected]');
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
for (const key of [
|
||||
'langbot_sidebar_guide_v1',
|
||||
'langbot_pipeline_setup_guide_v1',
|
||||
]) {
|
||||
localStorage.setItem(key, 'completed');
|
||||
}
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
await page.goto(`/home/pipelines?id=${pipelineId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.getByRole('button', { name: 'Skip', exact: true }).click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Skip', exact: true }),
|
||||
).not.toBeVisible();
|
||||
await page.goto(`/home/pipelines?id=${pipelineId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.locator('[data-guide="pipeline-section-ai"]').click();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Runner', exact: true }),
|
||||
).toContainText('QA Echo Runner');
|
||||
const prefixInput = page
|
||||
.getByRole('region', { name: 'Configuration', exact: true })
|
||||
.getByRole('textbox');
|
||||
await prefixInput.fill('browser saved');
|
||||
const savedResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/api/v1/pipelines/${pipelineId}`) &&
|
||||
response.request().method() === 'PUT',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
expect((await (await savedResponse).json()).code).toBe(0);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.locator('[data-guide="pipeline-section-ai"]').click();
|
||||
await expect(prefixInput).toHaveValue('browser saved');
|
||||
await page.getByRole('button', { name: 'Management', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Copy', exact: true }).click();
|
||||
const copiedResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/api/v1/pipelines/${pipelineId}/copy`) &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Confirm', exact: true })
|
||||
.click();
|
||||
const copiedPayload = await (await copiedResponse).json();
|
||||
expect(copiedPayload.code).toBe(0);
|
||||
const copiedId = copiedPayload.data.uuid;
|
||||
for (const id of [pipelineId, copiedId]) {
|
||||
const response = await request.get(`${api}/api/v1/pipelines/${id}`, {
|
||||
headers,
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const stored = (await response.json()).data.pipeline;
|
||||
expect(stored.config.ai.runner.id).toBe(runnerId);
|
||||
expect(stored.config.ai.runner_config[runnerId]).toEqual({
|
||||
prefix: 'browser saved',
|
||||
hidden: { keep: [null, false, ' raw '] },
|
||||
});
|
||||
expect(stored.extensions_preferences.enable_all_plugins).toBe(false);
|
||||
expect(stored.extensions_preferences.plugins).toEqual([]);
|
||||
await page.goto(`/home/pipelines?id=${id}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
const input = page.getByPlaceholder('Send Private Chat message...');
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill(`Chromium ${id}`);
|
||||
await input.press('Enter');
|
||||
await expect(
|
||||
page.getByText(`browser saved: Chromium ${id}`, { exact: true }),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('Runner diagnosis and draft-safe repair have all eight complete catalogs', () => {
|
||||
const locales = [
|
||||
'en-US',
|
||||
'zh-Hans',
|
||||
'zh-Hant',
|
||||
'ja-JP',
|
||||
'es-ES',
|
||||
'ru-RU',
|
||||
'th-TH',
|
||||
'vi-VN',
|
||||
];
|
||||
const expected = ['disabled', 'draft', 'missing', 'repair', 'runtime'];
|
||||
for (const locale of locales) {
|
||||
const source = fs.readFileSync(
|
||||
new URL(`../../src/i18n/locales/${locale}.ts`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
`${locale}.ts`,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const found = [];
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
node.name.getText(ast) === 'runnerDiagnosis'
|
||||
) {
|
||||
assert.ok(ts.isObjectLiteralExpression(node.initializer), locale);
|
||||
const fields = node.initializer.properties;
|
||||
assert.deepEqual(
|
||||
fields.map((field) => field.name.getText(ast)).sort(),
|
||||
expected,
|
||||
locale,
|
||||
);
|
||||
for (const field of fields) {
|
||||
assert.ok(
|
||||
ts.isStringLiteral(field.initializer) &&
|
||||
field.initializer.text.trim(),
|
||||
locale,
|
||||
);
|
||||
assert.equal(
|
||||
field.initializer.text.match(/{{[^}]+}}/g),
|
||||
null,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
found.push(node);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
assert.equal(found.length, 1, locale);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user