Merge remote-tracking branch 'origin/dev/4.11.x' into dev/4.11.x

# Conflicts:
#	uv.lock
#	web/src/i18n/locales/en-US.ts
#	web/src/i18n/locales/ja-JP.ts
#	web/src/i18n/locales/zh-Hans.ts
#	web/src/i18n/locales/zh-Hant.ts
This commit is contained in:
fdc310
2026-09-19 11:00:45 +08:00
357 changed files with 43727 additions and 6682 deletions
+231
View File
@@ -0,0 +1,231 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const run = (id: string, status = 'completed') => ({
run_id: id,
binding_id: `debug:agent-logs:plugin:qa/agent/default`,
runner_id: 'plugin:qa/agent/default',
status,
status_reason: status === 'failed' ? 'Model request timed out' : 'stop',
created_at: 1788000000,
started_at_ms: 1788000000000,
finished_at_ms: 1788000001500,
metadata: {
event_type: 'message.received',
source: 'webui',
input: { text: `Task ${id}` },
},
usage: { prompt_tokens: 30, completion_tokens: 12, total_tokens: 42 },
});
test('Agent logs show task execution and keep the debug draft when switching tabs', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
withRunnerToolSelector: true,
});
let listCalls = 0;
await page.route('**/api/v1/agents/agent-logs/runs**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/runs')) {
listCalls++;
return route.fulfill({
json: {
code: 0,
data: {
items: [run('done'), run('failed', 'failed')],
total: 2,
has_more: false,
next_cursor: null,
},
},
});
}
const failed = url.pathname.includes('/failed/');
return route.fulfill({
json: {
code: 0,
data: {
run: run(failed ? 'failed' : 'done', failed ? 'failed' : 'completed'),
items: failed
? []
: [
{
sequence: 1,
type: 'message.delta',
data: {
chunk: {
role: 'assistant',
content: 'Checking the weather.',
model: 'test-model',
},
},
},
{
sequence: 2,
type: 'tool.call.started',
data: {
tool_call_id: 'call-1',
tool_name: 'weather_lookup',
parameters: { city: 'Beijing' },
},
},
{
sequence: 3,
type: 'tool.call.completed',
data: {
tool_call_id: 'call-1',
tool_name: 'weather_lookup',
result: { forecast: 'Sunny' },
},
},
{
sequence: 4,
type: 'message.completed',
data: {
message: {
role: 'assistant',
content: 'It will be sunny.',
model: 'test-model',
},
},
},
],
has_more: false,
next_cursor: null,
},
},
});
});
await page.goto('/home/agents?id=agent-logs');
const debug = page.getByRole('region', { name: 'Event Debug', exact: true });
const draft = debug.getByRole('textbox', { name: 'Message content' });
await draft.fill('Keep this draft');
expect(listCalls).toBe(0);
await page.getByRole('tab', { name: 'Run logs', exact: true }).click();
const log = page.getByTestId('agent-monitoring');
await expect(
log.getByText('Task done', { exact: true }).last(),
).toBeVisible();
await expect(log.getByText('test-model', { exact: true })).toBeVisible();
await expect(log.getByText('weather_lookup', { exact: true })).toBeVisible();
await expect(
log.getByText('It will be sunny.', { exact: true }),
).toBeVisible();
await expect(log.getByText('Beijing', { exact: false })).not.toBeVisible();
await log.getByRole('button', { name: 'Arguments', exact: true }).click();
await expect(log.getByText(/"city": "Beijing"/)).toBeVisible();
await log.getByRole('button').filter({ hasText: 'Task failed' }).click();
await expect(log.getByText('Model request timed out')).toBeVisible();
await expect(log.getByText('It will be sunny.', { exact: true })).toHaveCount(
0,
);
await page
.getByRole('tab', { name: 'Configure & debug', exact: true })
.click();
await expect(draft).toHaveValue('Keep this draft');
await expect(page.getByTestId('agent-monitoring')).toHaveCount(0);
});
test('Agent log pagination retains selection and ignores a slow previous detail response', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let release: () => void = () => {};
const slow = new Promise<void>((resolve) => {
release = resolve;
});
let slowRequested = false;
await page.route('**/api/v1/agents/agent-logs/runs**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/runs'))
return route.fulfill({
json: {
code: 0,
data: {
items: url.searchParams.has('before_id')
? [run('older')]
: [run('slow'), run('fast')],
total: 3,
has_more: !url.searchParams.has('before_id'),
next_cursor: url.searchParams.has('before_id') ? null : 2,
},
},
});
const id = url.pathname.split('/').at(-2)!;
if (id === 'slow') {
slowRequested = true;
await slow;
}
const more = id === 'fast' && !url.searchParams.has('after_sequence');
return route.fulfill({
json: {
code: 0,
data: {
run: run(id),
items: [
{
sequence: more ? 1 : 2,
type: 'message.completed',
data: {
message: {
role: 'assistant',
content: more ? 'First step' : `Result ${id}`,
},
},
},
],
has_more: more,
next_cursor: more ? 1 : null,
},
},
});
});
await page.goto('/home/agents?id=agent-logs');
await page.getByRole('tab', { name: 'Run logs', exact: true }).click();
await expect.poll(() => slowRequested).toBe(true);
const log = page.getByTestId('agent-monitoring');
await log.getByRole('button').filter({ hasText: 'Task fast' }).click();
await expect(log.getByText('First step', { exact: true })).toBeVisible();
release();
await log
.getByRole('region', { name: 'Execution steps' })
.getByRole('button', { name: 'Load more' })
.click();
await expect(log.getByText('Result fast', { exact: true })).toBeVisible();
await expect(log.getByText('Result slow', { exact: true })).toHaveCount(0);
await log.getByRole('button', { name: 'Load more', exact: true }).click();
await expect(
log.getByRole('button').filter({ hasText: 'Task older' }),
).toBeVisible();
await expect(
log.getByRole('button').filter({ hasText: 'Task fast' }),
).toHaveAttribute('aria-pressed', 'true');
});
test('Agent logs show an empty state and retry request errors', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let fail = true;
await page.route('**/api/v1/agents/agent-logs/runs**', (route) =>
fail
? route.fulfill({ status: 500, json: { code: -1, msg: 'Unavailable' } })
: route.fulfill({
json: {
code: 0,
data: { items: [], total: 0, has_more: false, next_cursor: null },
},
}),
);
await page.goto('/home/agents?id=agent-logs');
await page.getByRole('tab', { name: 'Run logs', exact: true }).click();
const log = page.getByTestId('agent-monitoring');
await expect(log.getByRole('alert')).toBeVisible();
fail = false;
await log.getByRole('button', { name: 'Refresh', exact: false }).click();
await expect(log.getByRole('alert')).toHaveCount(0);
await expect(log.getByText('No runs yet.', { exact: false })).toBeVisible();
});
@@ -66,7 +66,381 @@ function toolCall(
};
}
test.describe('bot session request recovery', () => {
for (const failure of [
'initial list',
'list page',
'session switch',
'message page',
'analysis',
]) {
test(`${failure} failure is visible and retry recovers`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let failing = true;
await page.route('**/api/v1/monitoring/**', async (route) => {
const url = new URL(route.request().url());
const offset = Number(url.searchParams.get('offset') || 0);
const second = url.searchParams.get('sessionId') === 'person-second';
const list = url.pathname.endsWith('/sessions');
const message = url.pathname.endsWith('/messages');
const analysis = url.pathname.endsWith('/analysis');
if (!list && !message && !analysis) return route.fallback();
const fail =
failing &&
((list && failure === 'initial list') ||
(list && failure === 'list page' && offset > 0) ||
(message && failure === 'session switch' && second) ||
(message && failure === 'message page' && offset > 0) ||
(analysis && failure === 'analysis'));
if (fail)
return route.fulfill({
status: 500,
json: { code: 500, message: 'fixture failure' },
});
const data = list
? {
sessions: [sessionId, 'person-second'].map((id, i) => ({
session_id: id,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 51,
start_time: at(0),
last_activity: at(4),
is_active: true,
user_name: offset ? `Page two ${i}` : `Recovery user ${i}`,
})),
total: 21,
}
: message
? {
messages: [
sessionMessage(
'recovery-message',
'user',
0,
second
? 'Second session message'
: offset
? 'Second page message'
: 'Successful message',
),
],
total: 51,
}
: {
tool_calls: [
toolCall('recovery-tool', 1, 'recovered_tool', 40),
],
};
return route.fulfill({ json: { code: 0, data } });
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
if (failure === 'list page') {
await page.getByRole('button', { name: 'Next', exact: true }).click();
} else if (failure !== 'initial list') {
await page.getByRole('button', { name: /Recovery user 0/ }).click();
if (failure !== 'analysis') {
await expect(
page.getByText('Successful message', { exact: true }),
).toBeVisible();
if (failure === 'session switch')
await page.getByRole('button', { name: /Recovery user 1/ }).click();
else
await page
.getByRole('button', { name: 'Next', exact: true })
.last()
.click();
}
}
await expect(page.getByRole('alert')).toBeVisible();
await expect(
page.getByText('No sessions found', { exact: true }),
).toHaveCount(0);
if (failure === 'analysis') {
await expect(page.getByRole('alert')).toContainText(/Tool/i);
await expect(
page.getByText('Successful message', { exact: true }),
).toBeVisible();
} else {
await expect(
page.getByText('Successful message', { exact: true }),
).toHaveCount(0);
}
if (failure === 'list page')
await expect(
page.getByRole('button', { name: /Recovery user 0/ }),
).toHaveCount(0);
failing = false;
await page
.getByRole('alert')
.getByRole('button', { name: 'Retry', exact: true })
.click();
await expect(page.getByRole('alert')).toHaveCount(0);
if (failure === 'initial list' || failure === 'list page') {
await expect(
page.getByRole('button', {
name: failure === 'list page' ? /Page two 0/ : /Recovery user 0/,
}),
).toBeVisible();
} else {
await expect(
page.getByText(
failure === 'session switch'
? 'Second session message'
: failure === 'message page'
? 'Second page message'
: 'Successful message',
{ exact: true },
),
).toBeVisible();
await expect(
page.getByText('recovered_tool', { exact: true }),
).toBeVisible();
}
});
}
});
test.describe('bot session request races', () => {
for (const kind of ['messages', 'analysis', 'sessions']) {
for (const status of [200, 500]) {
test(`ignores stale ${kind} ${status} after switching`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let held = false;
let released = false;
await page.route('**/api/v1/monitoring/**', async (route) => {
const url = new URL(route.request().url());
const list = url.pathname.endsWith('/sessions');
const message = url.pathname.endsWith('/messages');
const analysis = url.pathname.endsWith('/analysis');
if (!list && !message && !analysis) return route.fallback();
const old =
kind === 'sessions'
? url.searchParams.get('userQuery') === 'old'
: message
? url.searchParams.get('sessionId') === sessionId
: url.pathname.includes(sessionId);
const isHeld = old && url.pathname.endsWith(`/${kind}`);
if (isHeld) {
held = true;
await gate;
if (status === 500) {
await route.fulfill({ status: 500, json: { code: 500 } });
released = true;
return;
}
}
const data = list
? {
sessions: [sessionId, 'person-new'].map((id, i) => ({
session_id: id,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 1,
start_time: at(0),
last_activity: at(4),
is_active: true,
user_name: isHeld ? 'Stale list' : `Race user ${i}`,
})),
total: 2,
}
: message
? {
messages: [
sessionMessage(
'race-message',
'user',
0,
old ? 'Old message' : 'Current message',
),
],
total: 1,
}
: {
tool_calls: [
toolCall(
'race-tool',
1,
old ? 'old_tool' : 'current_tool',
40,
),
],
};
await route.fulfill({ json: { code: 0, data } });
if (isHeld) released = true;
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
if (kind === 'sessions') {
await page
.getByRole('textbox', { name: 'User ID or name' })
.fill('old');
await page
.getByRole('textbox', { name: 'User ID or name' })
.press('Enter');
} else await page.getByRole('button', { name: /Race user 0/ }).click();
await expect.poll(() => held).toBe(true);
if (kind === 'sessions') {
await page
.getByRole('textbox', { name: 'User ID or name' })
.fill('new');
await page
.getByRole('textbox', { name: 'User ID or name' })
.press('Enter');
await expect(
page.getByRole('button', { name: /Race user 0/ }),
).toBeVisible();
} else {
await page.getByRole('button', { name: /Race user 1/ }).click();
await expect(
page.getByText('Current message', { exact: true }),
).toBeVisible();
}
release();
await expect.poll(() => released).toBe(true);
// Allow the released HTTP response and React's queued update to settle.
await page.waitForTimeout(200);
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
0,
);
if (kind !== 'sessions') {
await expect(
page.getByText('Current message', { exact: true }),
).toBeVisible();
await expect(
page.getByText('current_tool', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Old message', { exact: true }),
).toHaveCount(0);
await expect(page.getByText('old_tool', { exact: true })).toHaveCount(
0,
);
}
});
}
}
});
test.describe('bot session monitor tool timeline', () => {
test('isolates messages and analysis for two bots sharing a raw session id', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const requests: Array<{ bot: string; path: string }> = [];
await page.route('**/api/v1/monitoring/**', async (route) => {
const url = new URL(route.request().url());
const selectedBot = url.searchParams.get('botId');
if (
!url.pathname.endsWith('/sessions') &&
!url.pathname.endsWith('/messages') &&
!url.pathname.endsWith('/analysis')
) {
return route.fallback();
}
expect(['bot-shared-a', 'bot-shared-b']).toContain(selectedBot);
expect(route.request().headers().authorization).toBe(
'Bearer playwright-token',
);
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-playwright',
);
requests.push({ bot: selectedBot!, path: url.pathname });
const shared = {
session_id: sessionId,
bot_id: selectedBot,
bot_name: selectedBot,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 1,
start_time: at(0),
last_activity: at(4),
is_active: true,
platform: 'person',
user_id: 'shared-user',
user_name: 'Shared User',
};
const data = url.pathname.endsWith('/sessions')
? { sessions: [shared], total: 1 }
: url.pathname.endsWith('/messages')
? {
messages: [
{
...sessionMessage(
'shared-message',
'user',
0,
`Message for ${selectedBot}`,
),
bot_id: selectedBot,
},
],
total: 1,
}
: {
session_id: sessionId,
found: true,
tool_calls: [
{
...toolCall('shared-tool', 1, `tool_${selectedBot}`, 40),
bot_id: selectedBot,
},
],
};
if (url.pathname.endsWith('/messages'))
expect(url.searchParams.get('sessionId')).toBe(sessionId);
if (url.pathname.endsWith('/analysis'))
expect(decodeURIComponent(url.pathname)).toContain(
`/sessions/${sessionId}/analysis`,
);
await route.fulfill({ json: { code: 0, data } });
});
for (const selectedBot of ['bot-shared-a', 'bot-shared-b']) {
await page.goto(`/home/bots?id=${selectedBot}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
await page.getByRole('button', { name: /Shared User/ }).click();
await expect(
page.getByText(`Message for ${selectedBot}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`tool_${selectedBot}`, { exact: true }),
).toBeVisible();
const otherBot =
selectedBot === 'bot-shared-a' ? 'bot-shared-b' : 'bot-shared-a';
await expect(
page.getByText(`Message for ${otherBot}`, { exact: true }),
).toHaveCount(0);
await expect(
page.getByText(`tool_${otherBot}`, { exact: true }),
).toHaveCount(0);
expect(
requests.some(
(request) =>
request.bot === selectedBot && request.path.endsWith('/messages'),
),
).toBe(true);
expect(
requests.some(
(request) =>
request.bot === selectedBot && request.path.endsWith('/analysis'),
),
).toBe(true);
}
});
test('renders tool calls as left-side agent events interleaved with messages', async ({
page,
}) => {
@@ -117,11 +491,41 @@ test.describe('bot session monitor tool timeline', () => {
},
});
const monitoringRequests: import('@playwright/test').Request[] = [];
page.on('request', (request) => {
if (request.url().includes('/api/v1/monitoring/'))
monitoringRequests.push(request);
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
await page.getByRole('button', { name: /Timeline User/ }).click();
await expect(page.getByText('Need a timeline check')).toBeVisible();
await expect
.poll(() =>
monitoringRequests.some((request) =>
request.url().includes('/analysis?'),
),
)
.toBe(true);
for (const request of monitoringRequests.filter((request) =>
/\/messages\?|\/analysis\?/.test(request.url()),
)) {
const url = new URL(request.url());
expect(url.searchParams.get('botId')).toBe(botId);
if (url.pathname.endsWith('/analysis')) {
expect(url.searchParams.get('startTime')).toBe(at(0));
expect(url.searchParams.get('endTime')).toBe(at(4));
}
expect(request.headers().authorization).toBe('Bearer playwright-token');
expect(request.headers()['x-workspace-id']).toBe('workspace-playwright');
if (url.pathname.endsWith('/messages'))
expect(url.searchParams.get('sessionId')).toBe(sessionId);
else
expect(decodeURIComponent(url.pathname)).toContain(
`/sessions/${sessionId}/analysis`,
);
}
await expect(
page.getByText('repo_file_read', { exact: true }),
).toBeVisible();
+150
View File
@@ -0,0 +1,150 @@
import { expect, test, type Page } from '@playwright/test';
// Isolated real React/Radix fixture. No backend or OAuth requests are made.
async function mount(page: Page, mode: string) {
page.on('pageerror', (error) => console.error(error.message));
await page.route('**/copy-harness', (route) =>
route.fulfill({
contentType: 'text/html',
body: `
<div id="root"></div><script type="module">
import RefreshRuntime from '/@react-refresh';
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script><script type="module">
import React from '/node_modules/.vite/deps/react.js';
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
const {createRoot} = ReactDOM;
import i18n from '/node_modules/.vite/deps/i18next.js';
import {initReactI18next} from '/node_modules/.vite/deps/react-i18next.js';
import {Toaster} from '/src/components/ui/sonner.tsx';
import '/src/app/global.css';
import {Dialog, DialogContent, DialogTitle} from '/src/components/ui/dialog.tsx';
import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx';
await i18n.use(initReactI18next).init({lng:'en', resources:{en:{translation:{}}}, interpolation:{escapeValue:false}});
const root=createRoot(document.getElementById('root'));
window.renderCode=(code='FIXTURE-1234',attempt='attempt-1')=>root.render(React.createElement(Dialog,{open:true},
React.createElement(DialogContent,{},React.createElement(DialogTitle,{},'Copy fixture'),React.createElement(Section,{providerId:'fixture',login:{phase:'pending',device:{user_code:code,authorization_id:attempt,verification_uri:'https://example.invalid',expires_at:9999999999}}})),React.createElement(Toaster)));
window.renderCode();
</script>`,
}),
);
await page.addInitScript((mode) => {
const w = window as any;
w.copyEvents = [];
document.addEventListener('copy', () => {
const el = document.activeElement as HTMLTextAreaElement;
w.copyEvents.push({
tag: el.tagName,
selected: el.value?.slice(el.selectionStart, el.selectionEnd),
});
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value:
mode === 'unavailable'
? undefined
: {
writeText: (text: string) => {
if (mode === 'success') {
w.written = text;
return Promise.resolve();
}
if (mode === 'delayed')
return new Promise((resolve) => {
w.resolveCopy = resolve;
});
return Promise.reject(new Error('denied'));
},
},
});
if (mode === 'false') document.execCommand = () => false;
if (mode === 'throw')
document.execCommand = () => {
throw new Error('denied');
};
}, mode);
await page.goto('/copy-harness');
await expect(
page.getByRole('button', { name: 'models.codex.copyCode', exact: true }),
).toBeVisible();
}
const copy = (page: Page) =>
page.getByRole('button', { name: 'models.codex.copyCode', exact: true });
const copied = (page: Page) =>
page.getByRole('button', { name: 'models.codex.copied', exact: true });
test('Clipboard API success shows icon, toast and transient feedback', async ({
page,
}) => {
await mount(page, 'success');
await expect(copy(page).locator('svg.lucide-copy')).toBeVisible();
await copy(page).click();
await expect(copied(page).locator('svg.lucide-check')).toBeVisible();
await expect(
page.getByText('common.copySuccess', { exact: true }),
).toBeVisible();
expect(await page.evaluate(() => (window as any).written)).toBe(
'FIXTURE-1234',
);
await expect(copy(page)).toBeVisible({ timeout: 4000 });
});
for (const mode of ['unavailable', 'rejected'])
test(`${mode} API performs a real selected-text copy inside modal`, async ({
page,
}) => {
await mount(page, mode);
await copy(page).click();
await expect(copied(page)).toBeVisible();
expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([
{ tag: 'TEXTAREA', selected: 'FIXTURE-1234' },
]);
await expect(copied(page)).toBeFocused();
await expect(page.locator('textarea')).toHaveCount(0);
});
for (const mode of ['false', 'throw'])
test(`${mode} fallback reports failure and manual guidance`, async ({
page,
}) => {
await mount(page, mode);
await copy(page).click();
await expect(
page.getByText('common.copyFailed', { exact: true }),
).toBeVisible();
await expect(
page.getByText('models.codex.copyManually', { exact: true }),
).toBeVisible();
await expect(copy(page)).toBeVisible();
await expect(page.locator('textarea')).toHaveCount(0);
await expect(copy(page)).toBeFocused();
});
test('new code or attempt clears copied feedback', async ({ page }) => {
await mount(page, 'success');
await copy(page).click();
await expect(copied(page)).toBeVisible();
await page.evaluate(() =>
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
);
await expect(copy(page)).toBeVisible();
await copy(page).click();
await expect(copied(page)).toBeVisible();
await page.evaluate(() =>
(window as any).renderCode('FIXTURE-5678', 'attempt-3'),
);
await expect(copy(page)).toBeVisible();
});
test('completion from an old attempt cannot mark the new code copied', async ({
page,
}) => {
await mount(page, 'delayed');
await copy(page).click();
await page.evaluate(() =>
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
);
await expect(page.getByText('FIXTURE-5678')).toBeVisible();
await page.evaluate(() => (window as any).resolveCopy());
await expect(copy(page)).toBeVisible();
await expect(copied(page)).toHaveCount(0);
});
+341
View File
@@ -0,0 +1,341 @@
import { writeFileSync } from 'node:fs';
import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// All OAuth, provider and model responses here are explicit UI fixtures.
// These tests never authenticate with OpenAI or use a real subscription.
async function fixture(page: Page) {
await installLangBotApiMocks(page, { authenticated: true });
const state = {
providers: [] as Record<string, unknown>[],
creates: 0,
starts: 0,
polls: 0,
cancels: 0,
disconnects: 0,
connected: false,
failStart: false,
pollStatus: 'pending',
interval: 1,
expiresIn: 600,
};
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
const method = route.request().method();
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#555"/></svg>',
});
if (path.endsWith('/requesters'))
return ok(route, {
requesters: ['openai-codex', 'openai'].map((name) => ({
name,
label: {
en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API',
},
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [
{ name: 'base_url', default: 'https://api.openai.com/v1' },
],
},
})),
});
if (path.endsWith('/providers')) {
if (method === 'POST') {
state.creates++;
const provider = {
...route.request().postDataJSON(),
uuid: `provider-${state.creates}`,
};
state.providers.push(provider);
return ok(route, { uuid: provider.uuid });
}
return ok(route, { providers: state.providers });
}
if (path.endsWith('/codex/status'))
return ok(route, {
status: state.connected ? 'connected' : 'disconnected',
connected: state.connected,
expires_at: null,
});
if (path.endsWith('/codex/device') && method === 'POST') {
state.starts++;
if (state.failStart)
return route.fulfill({
status: 400,
json: { code: 400, msg: 'Fixture start failure' },
});
return ok(route, {
authorization_id: `attempt-${state.starts}`,
user_code: 'TEST-1234',
verification_uri: 'https://auth.openai.com/codex/device',
interval: state.interval,
expires_at: Date.now() / 1000 + state.expiresIn,
});
}
if (path.endsWith('/codex/device/poll')) {
state.polls++;
expect(route.request().postDataJSON()).toEqual({
authorization_id: `attempt-${state.starts}`,
});
if (state.pollStatus === 'connected') state.connected = true;
return ok(route, { status: state.pollStatus, interval: state.interval });
}
if (path.includes('/codex/device/') && method === 'DELETE') {
state.cancels++;
return ok(route, {});
}
if (path.endsWith('/codex/auth') && method === 'DELETE') {
state.disconnects++;
state.connected = false;
return ok(route, {});
}
if (/\/providers\/provider-\d+$/.test(path)) {
const provider = state.providers.find((p) =>
path.endsWith(String(p.uuid)),
);
if (method === 'PUT')
Object.assign(provider!, route.request().postDataJSON());
return ok(route, { provider });
}
if (path.includes('/models/')) return ok(route, { models: [] });
return ok(route, {});
});
return state;
}
async function openModels(page: Page) {
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
}
async function choose(page: Page, name: string) {
await page
.getByRole('button', { name: 'Select Provider Type', exact: true })
.click();
await page.getByRole('button', { name: new RegExp(name) }).click();
}
for (const width of [1280, 390, 320]) {
test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({
page,
}) => {
const state = await fixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await openModels(page);
await page.setViewportSize({ width, height: 900 });
await page.locator('input[name="name"]').fill('My Codex');
await choose(page, 'OpenAI Codex');
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
await expect(page.locator('input[name="base_url"]')).toHaveCount(0);
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Copied', exact: true }),
).toBeVisible();
await expect(
page.getByText('Copy Successfully', { exact: true }),
).toBeInViewport({ ratio: 1 });
expect(state.creates).toBe(1);
expect(state.providers[0]).toMatchObject({
requester: 'openai-codex',
api_keys: [],
base_url: 'https://chatgpt.com/backend-api/codex',
});
await expect(
page.getByRole('link', { name: 'Continue at OpenAI' }),
).toHaveAttribute('href', 'https://auth.openai.com/codex/device');
const geometry = await page.getByTestId('codex-account').evaluate((el) => {
const box = el.getBoundingClientRect();
return {
left: box.left,
right: box.right,
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(width);
expect(geometry.documentWidth).toBeLessThanOrEqual(width);
if (process.env.CODEX_EVIDENCE_DIR) {
await page.locator('[data-sonner-toast]').evaluate(async (el) => {
await Promise.all(
el
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined)),
);
});
const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`;
await page.screenshot({ path: screenshot, fullPage: true });
writeFileSync(
`${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`,
JSON.stringify(
{
evidence: 'UI fixture only; not live OpenAI sign-in',
viewport: { width, height: 900 },
geometry,
screenshot,
},
null,
2,
),
);
}
state.pollStatus = 'connected';
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await expect(page.getByText('My Codex', { exact: true })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Add Model', exact: true }),
).toBeVisible();
expect(state.creates).toBe(1);
expect(
await page.evaluate(() => JSON.stringify({ ...localStorage })),
).not.toContain('attempt-');
});
}
test('failed start retries reuse saved provider; cancellation refreshes list', async ({
page,
}) => {
const state = await fixture(page);
state.failStart = true;
await openModels(page);
await page.locator('input[name="name"]').fill('Retry Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
state.failStart = false;
await page.getByRole('button', { name: 'Try again', exact: true }).click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page
.getByRole('button', { name: 'Cancel sign-in', exact: true })
.click();
await expect.poll(() => state.cancels).toBe(1);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible();
expect(state.creates).toBe(1);
});
test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({
page,
}) => {
const state = await fixture(page);
state.pollStatus = 'connected';
await openModels(page);
await page.locator('input[name="name"]').fill('Managed Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
state.pollStatus = 'pending';
await page.getByRole('button', { name: 'Reconnect', exact: true }).click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page
.getByRole('button', { name: 'Cancel sign-in', exact: true })
.click();
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
expect(state.disconnects).toBe(0);
await page.getByRole('button', { name: 'Disconnect', exact: true }).click();
expect(state.disconnects).toBe(0);
await page
.getByRole('button', { name: 'Confirm disconnect', exact: true })
.click();
await expect(page.getByText('Not connected', { exact: true })).toBeVisible();
expect(state.disconnects).toBe(1);
expect(state.creates).toBe(1);
});
test('expiration permits retry without duplicate provider and closing cancels pending login', async ({
page,
}) => {
const state = await fixture(page);
state.expiresIn = 1;
await openModels(page);
await page.locator('input[name="name"]').fill('Expired Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(
page.getByText('Sign-in expired. Start again to get a new code.'),
).toBeVisible();
await expect.poll(() => state.cancels).toBe(1);
state.expiresIn = 600;
await page.getByRole('button', { name: 'Try again', exact: true }).click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page.keyboard.press('Escape');
await expect.poll(() => state.cancels).toBe(2);
await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible();
expect(state.creates).toBe(1);
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await page.locator('input[name="name"]').fill('Second Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByText('TEST-1234')).toBeVisible();
expect(state.creates).toBe(2);
expect(state.providers.map((provider) => provider.name)).toEqual([
'Expired Codex',
'Second Codex',
]);
await page.keyboard.press('Escape');
await expect.poll(() => state.cancels).toBe(3);
});
test('model test retains the connected provider identity', async ({ page }) => {
const state = await fixture(page);
state.connected = true;
state.providers.push({
uuid: 'provider-1',
name: 'Connected Codex',
requester: 'openai-codex',
base_url: 'https://chatgpt.com/backend-api/codex',
api_keys: [],
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page.getByRole('button', { name: 'Add Model', exact: true }).click();
await page
.getByPlaceholder('Model Name', { exact: true })
.fill('fixture-codex-model');
const requestPromise = page.waitForRequest('**/models/llm/_/test');
await page.getByRole('button', { name: 'Test', exact: true }).click();
const payload = (await requestPromise).postDataJSON();
expect(payload.provider_uuid).toBe('provider-1');
expect(payload.provider.uuid).toBe('provider-1');
expect(payload.provider.api_keys).toEqual([]);
});
test('ordinary API-key provider still saves and closes', async ({ page }) => {
const state = await fixture(page);
await openModels(page);
await page.locator('input[name="name"]').fill('My API');
await choose(page, 'OpenAI API');
await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real');
await page
.locator('input[name="base_url"]')
.fill('https://api.example.test/v1');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('My API', { exact: true })).toBeVisible();
expect(state.providers[0]).toMatchObject({
requester: 'openai',
api_keys: ['fixture-api-key-not-real'],
base_url: 'https://api.example.test/v1',
});
expect(state.starts).toBe(0);
});
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Dynamic form compatibility fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./dynamic-form.tsx"></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getBoxScopeContext } from '@/app/home/pipelines/components/pipeline-form/BoxScopeContext';
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import scope from '../../fixtures/sandbox-scope-schema.json';
import '@/app/global.css';
// Browser component fixture only. These legacy context keys are NOT supplied by
// PipelineForm in 4.11: actual execution scope remains owned by the Runner Host.
const params = new URLSearchParams(window.location.search);
const initialValues = {
[scope.name]: scope.default,
mode: 'live',
secret: '',
count: 3,
};
const fields = [
{ ...scope, id: 'scope' },
{
id: 'mode',
name: 'mode',
type: 'select',
label: { en_US: 'Mode' },
default: 'live',
options: [
{ name: 'live', label: { en_US: 'Live' } },
{ name: 'hidden', label: { en_US: 'Hidden' } },
],
},
{
id: 'secret',
name: 'secret',
type: 'secret',
label: { en_US: 'Plugin secret' },
default: '',
show_if: { field: 'mode', operator: 'eq', value: 'live' },
disable_if: { field: '__system.locked', operator: 'eq', value: true },
disabled_tooltip: { en_US: 'Locked by context' },
disabled_tooltip_overrides: [
{
when: { field: 'mode', operator: 'in', value: ['live'] },
tooltip: { en_US: 'Live reason wins' },
},
],
},
{
id: 'count',
name: 'count',
type: 'number',
label: { en_US: 'Plugin count' },
default: 3,
show_if: { field: 'mode', operator: 'neq', value: 'hidden' },
},
] as IDynamicFormItemSchema[];
function Fixture() {
const [context, setContext] = useState<Record<string, unknown>>({
...getBoxScopeContext(
params.get('available') === 'true',
params.get('forced') || '',
),
locked: false,
});
const [saved, setSaved] = useState({});
useEffect(() => {
const update = (event: Event) =>
setContext((current) => ({
...current,
...(event as CustomEvent<Record<string, unknown>>).detail,
}));
window.addEventListener('test-form-context', update);
return () => window.removeEventListener('test-form-context', update);
}, []);
return (
<main className="p-8 max-w-2xl">
<DynamicFormComponent
itemConfigList={fields}
initialValues={initialValues}
externalDependentValues={{ mode: 'hidden', '__system.locked': true }}
systemContext={context}
onSubmit={setSaved}
/>
<output data-testid="saved-values">{JSON.stringify(saved)}</output>
</main>
);
}
createRoot(document.getElementById('root')!).render(<Fixture />);
+1 -1
View File
@@ -302,7 +302,7 @@ function makePipeline(
};
}
function pipelineMetadata(withRunnerToolSelector = false) {
export function pipelineMetadata(withRunnerToolSelector = false) {
const runnerId = 'plugin:langbot-team/LocalAgent/default';
return {
configs: [
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
function ok(data: unknown) {
return {
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
};
}
test('shows an actionable OAuth-required state after a transient MCP test', async ({
page,
}, testInfo) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/mcp/servers/_/test', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(ok({ task_id: 2363 })),
});
});
await page.route('**/api/v1/system/tasks/2363', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
ok({
runtime: {
done: true,
exception: 'Connection failed',
state: 'error',
},
task_context: {
current_action: 'Testing MCP server',
log: '',
metadata: {
runtime_info: {
status: 'error',
error_phase: 'oauth_required',
retry_count: 1,
tool_count: 0,
tools: [],
resource_count: 0,
resources: [],
},
},
},
}),
),
});
});
await page.goto('/home/mcp?id=new');
await page.locator('input[name="name"]').fill('oauth-protected-mcp');
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/protected');
await page.getByRole('button', { name: /^Test$/ }).click();
await expect(page.getByText('OAuth authorization required')).toBeVisible();
await expect(
page.getByText(
'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.',
),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('oauth-required.png'),
fullPage: true,
});
});
+192 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, test, Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
@@ -271,7 +271,198 @@ function rawMonitoringData() {
};
}
async function respond(route: Route, label: string) {
const data = rawMonitoringData();
data.messages = [rawMessage(message(label, 'user', 10, label))];
await route.fulfill({ json: { code: 0, data } });
}
test.describe('monitoring request contracts', () => {
test('shows failures instead of empty success and retries with auth and Workspace headers', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let failing = true;
await page.route('**/api/v1/monitoring/data?*', async (route) => {
expect(route.request().headers().authorization).toBe(
'Bearer playwright-token',
);
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-playwright',
);
if (failing)
await route.fulfill({
status: 500,
json: { code: 500, msg: 'fixture database unavailable' },
});
else await respond(route, 'Recovered monitoring');
});
await page.goto('/home/monitoring');
await expect(page.getByRole('alert')).toContainText(
'Failed to load monitoring data',
);
await expect(page.getByText('No message records')).toHaveCount(0);
failing = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(
page.getByText('Recovered monitoring', { exact: true }),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('latest filter request wins over delayed data and delayed failures', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const pending: Route[] = [];
await page.route('**/api/v1/monitoring/data?*', (route) => {
pending.push(route);
});
await page.goto('/home/monitoring');
await expect.poll(() => pending.length).toBe(2);
await page.getByRole('combobox').last().click();
await page.getByRole('option', { name: /Last 7 days/i }).click();
await expect.poll(() => pending.length).toBe(3);
await respond(pending[2], 'Latest filter data');
await expect(
page.getByText('Latest filter data', { exact: true }),
).toBeVisible();
await respond(pending[0], 'Obsolete filter data');
await respond(pending[1], 'Obsolete filter data');
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await expect(
page.getByText('Latest filter data', { exact: true }),
).toBeVisible();
await page
.getByRole('button', { name: 'Refresh Data', exact: true })
.click();
await expect.poll(() => pending.length).toBe(4);
await expect(
page.getByText('Obsolete filter data', { exact: true }),
).toHaveCount(0);
await page.getByRole('combobox').last().click();
await page.getByRole('option', { name: /Last 24 hours/i }).click();
await expect.poll(() => pending.length).toBe(5);
await respond(pending[4], 'Current result');
await expect(
page.getByText('Current result', { exact: true }),
).toBeVisible();
await pending[3].fulfill({
status: 500,
json: { code: 500, msg: 'old failure' },
});
await expect(
page.getByText('Current result', { exact: true }),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('uses aggregate traffic rather than the sparse record page and discloses truncation', async ({
page,
}) => {
const data = rawMonitoringData();
data.totalCount.messages = 125;
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: {
...data,
traffic: {
bucket: 'hour',
truncated: true,
points: [
{ timestamp: time(0).toISOString(), messages: 125, llm_calls: 77 },
{ timestamp: time(1).toISOString(), messages: 0, llm_calls: 0 },
],
},
},
});
await page.goto('/home/monitoring');
await expect(
page.getByText(
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
),
).toBeVisible();
await expect(
page.getByText('Traffic range truncated. Choose a shorter time range.'),
).toBeVisible();
const chart = page.locator('.recharts-wrapper');
await expect(chart).toHaveCount(1);
await chart
.locator(':scope > .recharts-surface')
.hover({ position: { x: 70, y: 100 } });
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
'125',
);
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
'77',
);
});
test('does not invent traffic totals when aggregation is unavailable', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: rawMonitoringData(),
});
await page.goto('/home/monitoring');
await expect(
page.getByText('Traffic aggregation unavailable'),
).toBeVisible();
await expect(page.locator('.recharts-wrapper')).toHaveCount(0);
});
});
test.describe('monitoring conversation turn grouping', () => {
test('does not reassign explicitly linked activity outside the visible page', () => {
const turns = buildConversationTurns(
[message('visible', 'user', 10, 'Visible turn')],
[llmCall('older-call', 11, 'off-page', 10, 5, 40)],
[errorLog('older-error', 11, 'off-page')],
[toolCall('older-tool', 11, 'off-page', 'search', 40)],
);
expect(turns[0].llmCalls).toEqual([]);
expect(turns[0].toolCalls).toEqual([]);
expect(turns[0].errors).toEqual([]);
});
test('does not assign unlinked activity before the first visible turn', () => {
const turns = buildConversationTurns(
[message('visible', 'user', 10, 'Visible turn')],
[llmCall('older-call', 1, undefined, 10, 5, 40)],
[{ ...errorLog('older-error', 1, ''), messageId: undefined }],
[toolCall('older-tool', 1, undefined, 'search', 40)],
);
expect(turns[0].llmCalls).toEqual([]);
expect(turns[0].toolCalls).toEqual([]);
expect(turns[0].errors).toEqual([]);
});
test('isolates same-session messages and activity by bot identity', () => {
const first = message('first', 'user', 1, 'Bot one');
const other = {
...message('other', 'user', 2, 'Bot two'),
botId: 'other-bot',
};
const reply = message('reply', 'assistant', 3, 'Bot one reply');
const turns = buildConversationTurns(
[first, other, reply],
[llmCall('call', 3, undefined, 10, 5, 40)],
[errorLog('error', 3, first.id)],
[toolCall('tool', 3, undefined, 'search', 40)],
);
const own = turns.find((turn) => turn.id === first.id)!;
expect(own.assistantMessages.map((item) => item.id)).toEqual(['reply']);
expect(own.llmCalls.map((item) => item.id)).toEqual(['call']);
expect(own.toolCalls.map((item) => item.id)).toEqual(['tool']);
expect(turns.find((turn) => turn.id === other.id)?.totalTokens).toBe(0);
});
test('keeps a single user message as one observable turn', () => {
const userOnly = message(
'single-user-only',
+698
View File
@@ -0,0 +1,698 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFileSync } from 'node:fs';
const canonicalConfig = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
);
import type {
MigrationInstallation,
PipelineMigrationItem,
PipelineMigrationResult,
PipelineMigrationState,
} from '../../src/app/infra/entities/api/pipeline-migration';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
const root = '/api/v1/pipelines/_/migration';
const runner = 'plugin:langbot-team/DifyAgent/default';
const row = (
pipeline_uuid: string,
state: PipelineMigrationState = 'ready',
): PipelineMigrationItem => ({
pipeline_uuid,
name: `Legacy ${pipeline_uuid}`,
state,
legacy_runner: 'dify-service-api',
target_runner_id: runner,
target_plugin: {
author: 'langbot-team',
name: 'DifyAgent',
version: '1.0.0',
},
changed_paths: ['ai.runner.id'],
blockers: [],
warnings: [],
preview_token: state === 'ready' ? `token-${pipeline_uuid}` : null,
});
const reply = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
async function setup(
page: Page,
options: { viewer?: boolean; current?: boolean; cloud?: boolean } = {},
) {
const workspace = makeWorkspaceEntry(
'workspace-playwright',
'Migration workspace',
options.cloud ? 'cloud_projection' : 'local',
);
if (options.viewer)
workspace.permissions = ['resource.view', 'workspace.view'];
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'migration-test',
session_type: 'person',
}),
);
});
});
const state = {
items: [row('one'), row('two')],
posts: [] as unknown[],
writes: [] as unknown[],
previews: 0,
polls: 0,
installations: [] as MigrationInstallation[],
metadataReads: 0,
pipelineReads: 0,
executeStatus: 200,
pollLost: false,
done: true,
taskException: false,
results: [
{ pipeline_uuid: 'one', state: 'migrated', code: null },
{ pipeline_uuid: 'two', state: 'migrated', code: null },
] as PipelineMigrationResult[],
holdExecute: null as Promise<void> | null,
holdPreview: null as Promise<void> | null,
holdPoll: null as Promise<void> | null,
};
let config = options.current
? {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
runner_config: {},
},
}
: {
ai: {
runner: { runner: 'dify-service-api' },
'dify-service-api': { 'api-key': 'fixture-not-a-secret' },
},
};
await page.route(`**${root}/preview`, async (route) => {
state.previews++;
const items = structuredClone(state.items);
const scope =
route.request().headers()['x-workspace-id'] || 'workspace-playwright';
if (state.holdPreview) await state.holdPreview;
await reply(route, {
planner_version: '1',
workspace_uuid: scope,
items,
total: items.length,
});
});
await page.route(`**${root}/execute`, async (route) => {
state.posts.push(route.request().postDataJSON());
if (state.holdExecute) await state.holdExecute;
if (state.executeStatus !== 200)
return route.fulfill({
status: state.executeStatus,
json: { code: state.executeStatus, msg: 'unsafe-upstream-secret' },
});
await reply(route, {
task_id: 411,
pipeline_uuids: state.items
.filter((r) => !['already_current', 'not_legacy'].includes(r.state))
.map((r) => r.pipeline_uuid),
});
});
await page.route('**/api/v1/system/tasks/411', async (route) => {
state.polls++;
if (state.holdPoll) await state.holdPoll;
if (state.pollLost)
return route.fulfill({ status: 404, json: { code: 404 } });
if (
state.done &&
state.results.some(
(item) => item.pipeline_uuid === 'one' && item.state === 'migrated',
)
) {
config = {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
runner_config: {},
},
};
}
await reply(route, {
id: 411,
runtime: {
done: state.done,
exception: state.taskException ? 'unsafe-upstream-secret' : null,
},
task_context: {
metadata: {
kind: 'pipeline_migration',
results: state.results,
installations: state.installations,
},
},
});
});
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
state.pipelineReads++;
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
config,
is_default: false,
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
state.metadataReads++;
await route.fallback();
});
return state;
}
async function open(page: Page, detail = false) {
await page.goto(`/home/pipelines${detail ? '?id=one' : ''}`);
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
return page.getByRole('dialog', { name: 'Pipeline migration' });
}
test('legacy detail never mounts editable runner defaults or debug autosave, metadata stays available', async ({
page,
}) => {
const state = await setup(page);
await page.goto('/home/pipelines?id=one');
await expect(
page.getByText(
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
),
).toBeVisible();
await expect(page.getByText('Preserved metadata')).toBeVisible();
await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toHaveCount(0);
expect(state.writes).toEqual([]);
expect(state.posts).toEqual([]);
});
const installButton = (page: Page) =>
page.getByRole('button', {
name: 'Install plugins and migrate',
exact: true,
});
const dataButton = (page: Page) =>
page.getByRole('button', { name: 'Migrate data only', exact: true });
test('compact assistant hides details, has no checkboxes and does not execute on open or close', async ({
page,
}) => {
const state = await setup(page);
const dialog = await open(page);
await expect(dialog.getByRole('checkbox')).toHaveCount(0);
await expect(dialog.getByText('Legacy one', { exact: true })).toHaveCount(0);
await expect(installButton(page)).toBeEnabled();
await expect(dataButton(page)).toBeEnabled();
await dialog.getByRole('button', { name: 'View pipelines' }).click();
await expect(dialog.getByText('Legacy one', { exact: true })).toBeVisible();
await expect(dialog).not.toContainText('ai.runner.id');
await dialog
.getByRole('button', { name: 'Close', exact: true })
.first()
.click();
expect(state.posts).toEqual([]);
});
for (const install of [true, false]) {
test(`one click migrates all pipelines, install_plugins=${install}`, async ({
page,
}) => {
const state = await setup(page);
state.items.push(row('missing', 'needs_plugin'));
state.results.push({
pipeline_uuid: 'missing',
state: 'migrated',
code: install ? null : 'data_only',
});
const dialog = await open(page);
await (install ? installButton(page) : dataButton(page)).click();
await expect(dialog).toContainText('3 migrated; 0 need attention.');
expect(state.posts).toEqual([
{ confirmed: true, all: true, install_plugins: install },
]);
expect(state.writes).toEqual([]);
if (!install)
await expect(dialog).toContainText(
'Install the corresponding runner plugins yourself',
);
await expect(installButton(page)).toHaveCount(0);
await expect(dataButton(page)).toHaveCount(0);
await Promise.all([
page.waitForEvent('load'),
dialog.getByRole('button', { name: 'Done', exact: true }).click(),
]);
await expect(dialog).toHaveCount(0);
expect(state.posts).toHaveLength(1);
});
}
test('read-only users can inspect but cannot migrate', async ({ page }) => {
const state = await setup(page, { viewer: true });
await open(page);
await expect(installButton(page)).toBeDisabled();
await expect(dataButton(page)).toBeDisabled();
expect(state.posts).toEqual([]);
});
test('double clicks do not submit duplicate tasks; close does not cancel running task', async ({
page,
}) => {
const state = await setup(page);
state.done = false;
const dialog = await open(page);
await installButton(page).dblclick();
await expect(dialog.getByRole('status')).toContainText(
'Installing required plugins',
);
await expect(installButton(page)).toHaveCount(0);
await dialog
.getByRole('button', { name: 'Close', exact: true })
.first()
.click();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(dialog.getByRole('status')).toContainText(
'Installing required plugins',
);
expect(state.posts).toHaveLength(1);
state.done = true;
await expect(dialog).toContainText('2 migrated; 0 need attention.');
});
test('partial failures are reported without exposing upstream errors, and reopening allows retry', async ({
page,
}) => {
const state = await setup(page);
state.results = [
{ pipeline_uuid: 'one', state: 'migrated', code: null },
{ pipeline_uuid: 'two', state: 'blocked', code: 'plugin_install_failed' },
];
const dialog = await open(page);
await installButton(page).click();
await expect(dialog).toContainText('1 migrated; 1 need attention.');
await dialog.getByRole('button', { name: 'View pipelines' }).click();
await expect(dialog.getByTestId('migration-result-two')).toContainText(
'Plugin installation failed',
);
await expect(dialog).not.toContainText('unsafe-upstream-secret');
await expect(installButton(page)).toBeEnabled();
await expect(
dialog.getByRole('button', { name: 'Refresh preview' }),
).toHaveCount(0);
await dialog
.getByRole('button', { name: 'Close', exact: true })
.first()
.click();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(installButton(page)).toBeEnabled();
expect(state.posts).toHaveLength(1);
});
test('all pipelines including more than fifty can be migrated, expanded list scrolls without moving actions', async ({
page,
}) => {
await page.setViewportSize({ width: 1024, height: 650 });
const state = await setup(page);
state.items = Array.from({ length: 65 }, (_, i) => row(`many-${i}`));
state.results = state.items.map((r) => ({
pipeline_uuid: r.pipeline_uuid,
state: 'migrated',
code: null,
}));
const dialog = await open(page);
await dialog.getByRole('button', { name: 'View pipelines' }).click();
const viewport = dialog
.getByTestId('migration-scroll-area')
.locator('[data-slot="scroll-area-viewport"]');
const before = await installButton(page).boundingBox();
const bounds = await viewport.boundingBox();
await page.mouse.move(
bounds!.x + bounds!.width / 2,
bounds!.y + bounds!.height / 2,
);
await page.mouse.wheel(0, 20000);
await expect
.poll(() =>
viewport.evaluate(
(el) => el.scrollTop + el.clientHeight >= el.scrollHeight - 2,
),
)
.toBe(true);
await expect(
dialog.getByText('Legacy many-64', { exact: true }),
).toBeInViewport();
expect((await installButton(page).boundingBox())!.y).toBeCloseTo(
before!.y,
0,
);
await expect(installButton(page)).toBeInViewport();
await dataButton(page).click();
await expect(dialog).toContainText('65 migrated; 0 need attention.');
expect(state.posts).toEqual([
{ confirmed: true, all: true, install_plugins: false },
]);
});
for (const failure of [
'missing',
'extra',
'duplicate',
'invalid-state',
'pending',
'poll-lost',
'task-error',
]) {
test(`task result boundary: ${failure}`, async ({ page }) => {
const state = await setup(page);
if (failure === 'missing') state.results.pop();
if (failure === 'extra')
state.results.push({
pipeline_uuid: 'foreign',
state: 'migrated',
code: null,
});
if (failure === 'duplicate') state.results.push(state.results[0]);
if (failure === 'invalid-state')
state.results[0].state = 'bogus' as PipelineMigrationResult['state'];
if (failure === 'pending') state.results[0].state = 'pending';
if (failure === 'poll-lost') state.pollLost = true;
if (failure === 'task-error') state.taskException = true;
const dialog = await open(page);
await installButton(page).click();
await expect(dialog).toContainText(
failure === 'task-error' ? 'Task failed' : 'Task observation lost',
);
await expect(installButton(page)).toBeDisabled();
await expect(dialog).not.toContainText('unsafe-upstream-secret');
expect(state.posts).toHaveLength(1);
});
}
test('workspace changes discard previous task results and stop polling', async ({
page,
}) => {
const state = await setup(page);
let release!: () => void;
state.holdPoll = new Promise((resolve) => {
release = resolve;
});
await open(page);
await installButton(page).click();
await expect.poll(() => state.polls).toBe(1);
await page.evaluate(async () => {
const path = '/src/app/infra/http/currentWorkspaceStore.ts';
const store = await import(path);
const current = store.getCurrentWorkspaceSnapshot();
store.setCurrentWorkspaceSnapshot({ ...current, placement_generation: 2 });
});
release();
await expect(
page.getByRole('dialog', { name: 'Pipeline migration' }),
).toHaveCount(0);
expect(state.posts).toHaveLength(1);
});
for (const [language, title, review, action] of [
['zh-Hans', '流水线迁移', '检查迁移', '自动安装插件并迁移'],
[
'ja-JP',
'パイプライン移行',
'移行を確認',
'プラグインを自動インストールして移行',
],
]) {
test(`localized automatic assistant (${language})`, async ({ page }) => {
await setup(page);
await page.addInitScript(
(locale) => localStorage.setItem('langbot_language', locale),
language,
);
await page.goto('/home/pipelines');
await page.getByRole('button', { name: review, exact: true }).click();
const dialog = page.getByRole('dialog', { name: title });
await expect(
dialog.getByRole('button', { name: action, exact: true }),
).toBeEnabled();
await expect(dialog).not.toContainText('pipelineMigration.');
});
}
async function actualAgentRoute(page: Page) {
const state = await setup(page);
const agent = {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
kind: 'pipeline',
config: canonicalConfig,
};
let listReads = 0;
await page.route('**/api/v1/agents', async (route) => {
listReads++;
await reply(route, { agents: [agent] });
});
await page.route('**/api/v1/agents/one', (route) => reply(route, { agent }));
return { state, listReads: () => listReads };
}
async function clickSidebarPipeline(page: Page) {
const sidebar = page.locator('[data-slot="sidebar"]');
const entry = sidebar.getByText('Legacy one', { exact: true });
if (!(await entry.isVisible())) {
await sidebar.getByText('Processors', { exact: true }).click();
}
await entry.click();
await expect(page).toHaveURL(/\/home\/agents\?id=one$/);
}
test('SPEC canonical empty current detail allows selecting and saving a runner', async ({
page,
}) => {
const { state } = await actualAgentRoute(page);
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
config: canonicalConfig,
is_default: false,
},
});
});
await page.goto('/home/agents?id=one');
await expect(
page.getByRole('tab', { name: 'AI', exact: true }),
).toBeVisible();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
const selector = page.getByRole('combobox', { name: 'Runner', exact: true });
await selector.click();
await page.getByRole('option', { name: /Local Agent/ }).click();
const save = page.getByRole('button', { name: 'Save', exact: true });
await expect(save).toBeEnabled();
await save.click();
await expect
.poll(() => state.writes)
.toMatchObject([
{
config: {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
},
},
},
]);
expect(state.posts).toEqual([]);
});
test('SPEC actual legacy on second read remains guarded on the sidebar route', async ({
page,
}) => {
const { state } = await actualAgentRoute(page);
let reads = 0;
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
reads++;
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
config:
reads === 1
? canonicalConfig
: { ai: { runner: { runner: 'local-agent' } } },
},
});
});
await page.goto('/home');
await clickSidebarPipeline(page);
await expect.poll(() => reads).toBeGreaterThan(1);
await expect(
page.getByText(
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
),
).toBeVisible();
await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toHaveCount(0);
expect(state.writes).toEqual([]);
});
test('Box reuse notice stays concise and template validation has an actionable message', async ({
page,
}) => {
const state = await setup(page);
state.items[0].legacy_runner = 'local-agent';
state.items[0].warnings = [
{
code: 'local.box_state_reset',
field: 'ai.local-agent.box-session-id-template',
},
];
state.items[1].state = 'blocked';
state.items[1].blockers = [
{
code: 'local.box_template_invalid',
field: 'ai.local-agent.box-session-id-template',
},
];
const dialog = await open(page);
await expect(
dialog.getByText('Sandbox reuse settings are preserved.', { exact: false }),
).not.toBeVisible();
await dialog.getByRole('button', { name: 'View pipelines' }).click();
await expect(
dialog.getByText('Sandbox reuse settings are preserved.', { exact: false }),
).toBeVisible();
await expect(
dialog.getByText('Invalid sandbox reuse template.', { exact: false }),
).toBeVisible();
await expect(dialog).not.toContainText('ai.local-agent');
await expect(dialog.getByRole('checkbox')).toHaveCount(0);
});
test('installation progress remains inspectable during work and after a missing release failure', async ({
page,
}) => {
const state = await setup(page);
await page.route(
'**/api/v1/marketplace/plugins/langbot-team/DifyAgent',
(route) =>
reply(route, {
plugin: {
author: 'langbot-team',
name: 'DifyAgent',
label: { en_US: 'Dify Agent' },
description: { en_US: 'Connect your Dify workflows.' },
icon: 'https://market.test/dify.svg',
},
}),
);
await page.route('https://market.test/dify.svg', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32"/></svg>',
}),
);
state.done = false;
state.results = state.items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
state: 'pending',
code: null,
}));
state.installations = [
{
author: 'langbot-team',
name: 'DifyAgent',
version: '1.0.0',
status: 'installing',
stage: 'downloading',
code: null,
progress_percent: 15,
download_current: 1024,
download_total: 2048,
started_at: 100,
updated_at: 103,
finished_at: null,
steps: [
{ stage: 'checking', started_at: 100, finished_at: 101 },
{ stage: 'downloading', started_at: 101, finished_at: null },
],
},
];
const dialog = await open(page);
await installButton(page).click();
await expect(dialog.getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
'15',
);
await expect(dialog.getByText('Dify Agent', { exact: true })).toBeVisible();
await expect(
dialog.getByText('Connect your Dify workflows.', { exact: true }),
).toBeVisible();
await expect(dialog.getByRole('img', { name: 'Dify Agent' })).toBeVisible();
await dialog.getByRole('button', { name: 'Installation details' }).click();
await expect(dialog.getByText('Downloaded 1.0 KB / 2.0 KB')).toBeVisible();
await dialog.getByRole('button', { name: 'View pipelines' }).click();
await expect(dialog.getByText('Legacy one', { exact: true })).toBeVisible();
state.installations[0] = {
...state.installations[0],
status: 'failed',
code: 'plugin_version_unavailable',
finished_at: 104,
updated_at: 104,
};
state.results = state.items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
state: 'blocked',
code: 'plugin_version_unavailable',
}));
state.done = true;
await expect(
dialog
.getByTestId('migration-installations')
.getByText(
'The required plugin version is not available in the marketplace. Retry after it is published, or migrate data only.',
),
).toBeVisible();
await expect(dialog.getByRole('progressbar')).toHaveCount(0);
await expect(dialog.getByText('Downloaded 1.0 KB / 2.0 KB')).toBeVisible();
});
@@ -0,0 +1,340 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: no real provider/model deletion or subscription authentication.
async function fixture(page: Page, requester = 'openai', empty = false) {
await installLangBotApiMocks(page, { authenticated: true });
const provider = {
uuid: 'provider-delete-fixture',
name: 'Delete fixture provider',
requester,
base_url: 'https://example.test/v1',
api_keys: [],
llm_count: empty ? 0 : 1,
embedding_count: empty ? 0 : 1,
rerank_count: empty ? 0 : 1,
};
const state = {
deleted: false,
fail: false,
deletes: [] as string[],
reads: [] as string[],
release: undefined as (() => void) | undefined,
hold: false,
};
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
const method = route.request().method();
if (method === 'DELETE') {
state.deletes.push(path + url.search);
if (state.hold)
await new Promise<void>((resolve) => {
state.release = resolve;
});
if (state.fail)
return route.fulfill({
status: 409,
json: { code: 409, msg: 'Fixture deletion blocked; try again.' },
});
state.deleted = true;
return ok(route, {});
}
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
if (path.endsWith('/requesters'))
return ok(route, {
requesters: ['openai', 'openai-codex'].map((name) => ({
name,
label: { en_US: name },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm', 'embedding', 'rerank'],
config: [],
},
})),
});
if (method === 'GET') state.reads.push(path + url.search);
if (path.endsWith('/providers'))
return ok(route, { providers: state.deleted ? [] : [provider] });
if (path.endsWith('/codex/status'))
return ok(route, {
status: 'connected',
connected: true,
expires_at: null,
});
if (path.includes('/models/')) {
const type = path.split('/').pop();
return ok(route, {
models: state.deleted
? []
: [
{
uuid: `fixture-${type}`,
name: `Fixture ${type} model`,
provider_uuid: provider.uuid,
provider,
abilities: [],
extra_args: {},
},
],
});
}
if (path.endsWith(provider.uuid)) return ok(route, { provider });
return ok(route, {});
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
return state;
}
const editDialog = (page: Page) =>
page.locator('[role="dialog"]').filter({
has: page.locator('[data-slot="dialog-title"]', {
hasText: /^Edit Provider$/,
}),
});
async function edit(page: Page) {
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await card.getByRole('button', { name: 'Expand', exact: true }).click();
await expect(
card.getByText('Fixture llm model', { exact: true }),
).toBeVisible();
await card
.locator('button')
.filter({ has: page.locator('svg.lucide-settings') })
.click();
await expect(editDialog(page).locator('input[name="name"]')).toHaveValue(
'Delete fixture provider',
);
}
for (const width of [1280, 320]) {
test(`confirmation stays centered throughout entry (${width}px)`, async ({
page,
}) => {
const state = await fixture(page);
await edit(page);
await page.setViewportSize({ width, height: 900 });
// Trigger without Playwright's post-click wait so the browser animation is
// still live. Sample its actual keyframes, not only the final screenshot.
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.evaluate((el) => (el as HTMLButtonElement).click());
const confirmation = page.getByRole('alertdialog');
for (const phase of ['entry']) {
const samples = await confirmation.evaluate(async (el) => {
const animations = el.getAnimations();
if (!animations.length)
throw new Error('Expected the real dialog animation');
await Promise.all(animations.map((a) => a.ready));
animations.forEach((a) => a.pause());
const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => {
animations.forEach((a) => {
a.currentTime = Number(a.effect!.getTiming().duration) * fraction;
});
const r = el.getBoundingClientRect();
return {
x: r.x + r.width / 2,
y: r.y + r.height / 2,
left: r.left,
right: r.right,
};
});
animations.forEach((a) => a.finish());
return samples;
});
for (const sample of samples) {
expect(
Math.abs(sample.x - width / 2),
`${phase} horizontal center`,
).toBeLessThan(1);
expect(
Math.abs(sample.y - 450),
`${phase} vertical center`,
).toBeLessThan(1);
expect(sample.left).toBeGreaterThanOrEqual(0);
expect(sample.right).toBeLessThanOrEqual(width);
}
}
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(confirmation).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
}
for (const requester of ['openai', 'openai-codex']) {
for (const width of [1280, 320]) {
test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({
page,
}) => {
const state = await fixture(page, requester);
await edit(page);
await page.setViewportSize({ width, height: 900 });
const dialog = editDialog(page);
const footer = dialog.locator('[data-slot="dialog-footer"]');
const remove = footer.getByRole('button', {
name: 'Delete',
exact: true,
});
await expect(remove).toBeVisible();
for (const button of await footer.getByRole('button').all()) {
await expect(button).toBeInViewport({ ratio: 1 });
const box = await button.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
}
const left = await remove.boundingBox();
const cancel = await footer
.getByRole('button', { name: 'Cancel', exact: true })
.boundingBox();
expect(left!.x + left!.width).toBeLessThan(cancel!.x);
await remove.click();
const confirmation = page.getByRole('alertdialog');
await expect(confirmation).toContainText('this provider and ALL models');
await expect(confirmation).toContainText('cannot be undone');
await expect(confirmation).toBeInViewport({ ratio: 1 });
await confirmation.evaluate(async (element) => {
await Promise.all(
element.getAnimations().map((animation) => animation.finished),
);
});
const box = await confirmation.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(confirmation).toHaveCount(0);
await expect(dialog).toBeVisible();
expect(state.deletes).toEqual([]);
});
}
test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({
page,
}) => {
const state = await fixture(page, requester);
await edit(page);
state.hold = true;
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.click();
const confirmation = page.getByRole('alertdialog');
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect.poll(() => state.deletes.length).toBe(1);
await expect(
confirmation.getByRole('button', { name: 'Delete', exact: true }),
).toBeDisabled();
await expect(
confirmation.getByRole('button', { name: 'Cancel', exact: true }),
).toBeDisabled();
await expect(
editDialog(page).getByRole('button', {
name: requester === 'openai' ? 'Save' : 'Done',
exact: true,
includeHidden: true,
}),
).toBeDisabled();
await page.keyboard.press('Escape');
await expect(confirmation).toBeVisible();
state.reads = [];
state.release!();
await expect(editDialog(page)).toHaveCount(0);
await expect(
page.getByText('Delete fixture provider', { exact: true }),
).toHaveCount(0);
await expect(
page.getByText('Fixture llm model', { exact: true }),
).toHaveCount(0);
expect(state.deletes).toEqual([
'/api/v1/provider/providers/provider-delete-fixture?cascade=true',
]);
expect(state.reads).toContain('/api/v1/provider/providers');
});
}
test('failed cascade retains readable error and can retry', async ({
page,
}) => {
const state = await fixture(page);
await edit(page);
state.fail = true;
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.click();
const confirmation = page.getByRole('alertdialog');
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(confirmation.getByRole('alert')).toContainText(
'Fixture deletion blocked; try again.',
);
await expect(
confirmation.getByRole('button', { name: 'Delete', exact: true }),
).toBeEnabled();
await expect(editDialog(page)).toBeVisible();
state.fail = false;
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(editDialog(page)).toHaveCount(0);
expect(state.deletes).toHaveLength(2);
});
test('new providers do not expose footer deletion', async ({ page }) => {
const state = await fixture(page);
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await expect(
page
.getByRole('dialog', { name: 'Add Provider', exact: true })
.getByRole('button', { name: 'Delete', exact: true }),
).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
test('system-managed provider has no edit or delete entry', async ({
page,
}) => {
const state = await fixture(page, 'space-chat-completions');
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await expect(card).toBeVisible();
await expect(card.locator('svg.lucide-settings')).toHaveCount(0);
await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
test('existing empty-provider card delete keeps its non-cascade request', async ({
page,
}) => {
const state = await fixture(page, 'openai', true);
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await card
.locator('button')
.filter({ has: page.locator('svg.lucide-trash-2') })
.click();
await expect(
page.getByText('Are you sure you want to delete this provider?', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(card).toHaveCount(0);
expect(state.deletes).toEqual([
'/api/v1/provider/providers/provider-delete-fixture',
]);
});
+177
View File
@@ -0,0 +1,177 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: never authenticate or write a real provider.
test.use({ hasTouch: true });
for (const width of [1280, 390, 320]) {
test(`provider dropdown bounded without dialog growth (${width}px)`, async ({
page,
}, testInfo) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
const data = path.endsWith('/requesters')
? {
requesters: Array.from({ length: 30 }, (_, i) => ({
name: i === 0 ? 'openai-codex' : `provider-${i}`,
label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
config: [],
support_type: ['llm'],
},
})),
}
: { providers: [], models: [] };
await route.fulfill({ json: { code: 0, data } });
});
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page
.getByRole('button', { name: 'Add Provider', exact: true })
.click();
await page.setViewportSize({ width, height: 720 });
const trigger = page.getByRole('button', {
name: 'Select Provider Type',
exact: true,
});
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.locator('input[name="name"]') });
await trigger.scrollIntoViewIfNeeded();
const before = await dialog.evaluate((el) => ({
height: el.clientHeight,
scroll: el.scrollHeight,
}));
await trigger.click();
const search = page.getByPlaceholder('Search providers...');
await expect(search).toBeFocused();
const menu = search.locator('../..');
await expect(
page.getByRole('button', { name: 'Provider 29', exact: false }),
).toBeAttached();
await menu.evaluate(async (el) => {
await Promise.all(el.getAnimations().map((a) => a.finished));
});
const options = menu.locator(':scope > div').last();
await options.hover();
await page.mouse.wheel(0, 1200);
await expect
.poll(() => options.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
if (width < 1280) {
await page.mouse.wheel(0, -1200);
await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0);
const box = (await options.boundingBox())!;
const session = await page.context().newCDPSession(page);
const x = box.x + box.width / 2;
const y = box.y + box.height - 30;
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x, y }],
});
for (let step = 1; step <= 10; step++) {
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x, y: y - step * 18 }],
});
}
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await session.detach();
await expect
.poll(() => options.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
}
const geometry = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect();
const list = el.lastElementChild as HTMLElement;
const clipped: string[] = [];
for (
let parent = el.parentElement;
parent;
parent = parent.parentElement
) {
const bounds = parent.getBoundingClientRect();
if (
/(auto|scroll|hidden|clip)/.test(
getComputedStyle(parent).overflowY,
) &&
(rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1)
)
clipped.push(parent.tagName);
}
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
clipped,
listHeight: list.clientHeight,
listScroll: list.scrollHeight,
scrollTop: list.scrollTop,
documentWidth: document.documentElement.scrollWidth,
};
});
const after = await dialog.evaluate((el) => ({
height: el.clientHeight,
scroll: el.scrollHeight,
}));
const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir;
mkdirSync(dir, { recursive: true });
await page.screenshot({
path: `${dir}/dropdown-${width}.png`,
fullPage: true,
});
writeFileSync(
`${dir}/dropdown-${width}.json`,
JSON.stringify(
{ evidence: 'UI fixture only', width, before, after, geometry },
null,
2,
),
);
expect.soft(after).toEqual(before);
expect.soft(geometry.clipped).toEqual([]);
expect.soft(geometry.left).toBeGreaterThanOrEqual(0);
expect.soft(geometry.right).toBeLessThanOrEqual(width);
expect.soft(geometry.top).toBeGreaterThanOrEqual(0);
expect.soft(geometry.bottom).toBeLessThanOrEqual(720);
expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width);
expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight);
expect(geometry.scrollTop).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await expect(search).toBeHidden();
await expect(dialog).toBeVisible();
await expect(trigger).toBeFocused();
await trigger.click();
await search.fill('Provider 29');
await page.locator('input[name="name"]').click();
await expect(search).toBeHidden();
await expect(page.locator('input[name="name"]')).toBeFocused();
await trigger.click();
await expect(search).toHaveValue('');
await search.fill('Codex');
await page
.getByRole('button', { name: 'OpenAI Codex', exact: false })
.click();
await expect(search).toBeHidden();
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Save and sign in', exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'OpenAI Codex', exact: false }),
).toBeFocused();
});
}
+262
View File
@@ -0,0 +1,262 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// All API traffic is intercepted; no real provider secrets or mutations.
async function fixture(page: Page, requester = 'openai') {
await installLangBotApiMocks(page, { authenticated: true });
const providers = ['alpha', 'beta'].map((id) => ({
uuid: `loading-${id}`,
name: `Loading fixture ${id}`,
requester,
base_url: `https://${id}.example.test/v1`,
api_keys: [`fixture-key-${id}`],
llm_count: 0,
embedding_count: 0,
rerank_count: 0,
}));
const state = {
hold: '' as '' | 'detail' | 'requesters',
fail: '' as '' | 'detail' | 'requesters',
held: [] as { release: () => void; finished: Promise<void> }[],
reads: [] as string[],
mutations: [] as string[],
errors: [] as string[],
};
page.on('pageerror', (error) => state.errors.push(error.message));
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (route.request().method() !== 'GET') {
state.mutations.push(route.request().method() + ' ' + path);
return ok(route, {});
}
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
state.reads.push(path);
const provider = providers.find((p) => path.endsWith('/' + p.uuid));
const dependency = path.endsWith('/requesters')
? 'requesters'
: provider
? 'detail'
: '';
const fail = dependency && state.fail === dependency;
let finish: (() => void) | undefined;
if (dependency && state.hold === dependency) {
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
await new Promise<void>((release) =>
state.held.push({ release, finished }),
);
}
try {
if (fail)
return await route.fulfill({
status: 503,
json: { code: 503, msg: `Fixture ${dependency} unavailable` },
});
if (dependency === 'requesters')
return await ok(route, {
requesters: [
{
name: requester,
label: {
en_US:
requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture',
},
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [],
},
},
],
});
if (provider) return await ok(route, { provider });
if (path.endsWith('/providers')) return await ok(route, { providers });
if (path.endsWith('/codex/status'))
return await ok(route, {
status: 'connected',
connected: true,
expires_at: null,
});
return await ok(route, { models: [] });
} finally {
finish?.();
}
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await expect(
page.getByText(providers[0].name, { exact: true }),
).toBeVisible();
// Let the panel's independent requester-support read finish before gating the form.
await expect
.poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length)
.toBeGreaterThanOrEqual(1);
return state;
}
const dialog = (page: Page) =>
page.getByRole('dialog', { name: 'Edit Provider', exact: true });
const editButton = (page: Page, id = 'alpha') =>
page
.locator('[data-slot="card"]')
.filter({ hasText: `Loading fixture ${id}` })
.locator('button')
.filter({ has: page.locator('svg.lucide-settings') });
async function expectLoading(page: Page) {
const form = dialog(page);
await expect(form.getByRole('status')).toContainText('Loading...');
await expect(
form.getByRole('status').locator('svg.animate-spin'),
).toBeVisible();
await expect(form.locator('input')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Cancel', exact: true }),
).toBeEnabled();
}
async function expectReady(page: Page, id = 'alpha', requester = 'openai') {
const form = dialog(page);
await expect(form.locator('input[name="name"]')).toHaveValue(
`Loading fixture ${id}`,
);
await expect(
form.getByRole('status', { name: 'Loading...', exact: true }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Delete', exact: true }),
).toBeEnabled();
await expect(
form.getByRole('button', {
name: requester === 'openai' ? 'Save' : 'Done',
exact: true,
}),
).toBeEnabled();
if (requester === 'openai') {
await expect(form.locator('input[name="base_url"]')).toHaveValue(
`https://${id}.example.test/v1`,
);
await expect(form.locator('input[name="api_key"]')).toHaveValue(
`fixture-key-${id}`,
);
await expect(
form.getByRole('button', { name: /OpenAI fixture/ }),
).toBeVisible();
} else {
await expect(form.locator('input[name="api_key"]')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /Codex fixture/ }),
).toBeVisible();
}
}
for (const requester of ['openai', 'openai-codex']) {
for (const dependency of ['detail', 'requesters'] as const) {
test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({
page,
}) => {
const state = await fixture(page, requester);
state.hold = dependency;
await editButton(page).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
// Remain gated for the whole delay, not just the first render.
await page.waitForTimeout(250);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page, 'alpha', requester);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
}
for (const dependency of ['detail', 'requesters'] as const) {
test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({
page,
}) => {
const state = await fixture(page);
state.fail = dependency;
await editButton(page).click();
const form = dialog(page);
await expect(form.getByRole('alert')).toContainText('Failed to load data');
await expect(form.locator('input')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Retry', exact: true }),
).toBeEnabled();
await expect(
form.getByRole('button', { name: 'Cancel', exact: true }),
).toBeEnabled();
state.fail = '';
state.hold = dependency;
await form.getByRole('button', { name: 'Retry', exact: true }).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page);
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(form).toHaveCount(0);
state.fail = dependency;
await editButton(page).click();
await expect(form.getByRole('alert')).toBeVisible();
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(form).toHaveCount(0);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
for (const next of ['alpha', 'beta']) {
for (const staleFailure of [false, true]) {
test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({
page,
}) => {
const state = await fixture(page);
state.hold = 'detail';
state.fail = staleFailure ? 'detail' : '';
await editButton(page).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
const staleRequests = state.held.splice(0);
await dialog(page)
.getByRole('button', { name: 'Cancel', exact: true })
.click();
state.fail = '';
// Reopen during the closing animation, before Radix's retained content unmounts.
await editButton(page, next).dispatchEvent('click');
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page, next);
await dialog(page)
.locator('input[name="name"]')
.fill('Unsaved fixture edit');
staleRequests.forEach((request) => request.release());
await Promise.all(staleRequests.map((request) => request.finished));
await page.waitForTimeout(250);
await expect(dialog(page).locator('input[name="name"]')).toHaveValue(
'Unsaved fixture edit',
);
await expect(dialog(page).getByRole('alert')).toHaveCount(0);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
}
@@ -0,0 +1,332 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
const contract = JSON.parse(
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
);
import { installLangBotApiMocks } from './fixtures/langbot-api';
const canonical = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
);
const outputSchema = contract.output_schema;
async function setup(
page: Page,
plugin = 'weknora-agent',
name = 'WeKnoraAgent',
agentId: null | 'absent' = null,
converted?: typeof canonical,
reasoningEnabled = true,
) {
const schema = contract.plugins[plugin].component;
const id = `plugin:langbot-team/${name}/default`;
const raw: Record<string, unknown> = {
'api-key': ' fixture credential ',
'base-url': ' http://localhost:8080/api/v1 ',
'app-type': 'agent',
'enable-all-tools': false,
tools: ['tool-allowed'],
'knowledge-bases': ['kb-allowed'],
'mcp-resources': [{ server: 'private', uri: 'secret' }],
'mcp-resource-agent-read-enabled': false,
};
if (agentId === null) raw['agent-id'] = null;
if (plugin === 'LocalAgent') {
raw['advanced-settings'] = true;
raw.model = {
primary: 'llm-valid',
fallbacks: ['llm-fallback'],
reasoning: { 'llm-valid': 'high', 'llm-fallback': 'low' },
};
raw['remove-think'] = true;
}
let config = {
...structuredClone(canonical),
ai: { runner: { id }, runner_config: { [id]: raw } },
};
if (converted) config = structuredClone(converted);
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
const writes: any[] = [];
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'fixture',
session_type: 'person',
}),
);
});
});
await page.route('**/api/v1/pipelines/preserve', async (route) => {
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON();
writes.push(body);
if (body.config) config = body.config;
}
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'preserve',
name: 'Preservation',
description: '',
emoji: '⚙️',
config,
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: contract.ai_schema.stages[0].config.map(
(field: Record<string, unknown>) =>
field.name === 'id'
? {
...field,
default: id,
options: [
{ name: id, label: schema.metadata.label },
],
}
: field,
),
},
{
name: id,
label: schema.metadata.label,
config: schema.spec.config,
},
],
},
outputSchema,
],
},
},
}),
);
await page.route('**/api/v1/provider/models/llm', (route) =>
route.fulfill({
json: {
code: 0,
data: {
models: ['llm-valid', 'llm-fallback'].map((uuid) => ({
uuid,
name: uuid,
provider_uuid: 'provider-valid',
provider: { uuid: 'provider-valid', name: 'Mock Provider' },
abilities: reasoningEnabled ? ['reasoning'] : [],
reasoning: { level: 'high' },
reasoning_capabilities: {
supported: true,
levels: ['provider_default', 'low', 'high'],
},
})),
},
},
}),
);
await page.goto('/home/pipelines?id=preserve');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
return { writes, raw, id };
}
test('explicit reasoning choices persist independently for primary and fallback models', async ({
page,
}) => {
const { writes, raw, id } = await setup(page, 'LocalAgent', 'LocalAgent');
await page
.getByRole('button', { name: 'Reasoning level: High', exact: true })
.click();
await expect(
page.getByRole('button', { name: 'Use model setting', exact: true }),
).toHaveCount(0);
await page
.getByRole('button', { name: 'Use provider default', exact: true })
.click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai.runner_config[id]).toEqual({
...raw,
model: {
...(raw.model as object),
reasoning: { 'llm-valid': 'provider_default', 'llm-fallback': 'low' },
},
});
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('button', {
name: 'Reasoning level: Use provider default',
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
await page
.getByRole('button', { name: 'Reasoning level: Low', exact: true })
.click();
await page.getByRole('slider').press('End');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(2);
expect(writes[1].config.ai.runner_config[id].model.reasoning).toEqual({
'llm-valid': 'provider_default',
'llm-fallback': 'high',
});
expect(writes[1].config.output).toEqual(writes[0].config.output);
});
test('custom models without reasoning enabled hide budget controls despite inferred support', async ({
page,
}) => {
await setup(page, 'LocalAgent', 'LocalAgent', null, undefined, false);
await expect(page.getByRole('combobox').first()).toBeVisible();
await expect(
page.getByRole('button', { name: /^Reasoning level:/ }),
).toHaveCount(0);
});
test('standalone picker keeps its original levels and provider default API', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines');
await page.evaluate(async () => {
// Vite serves the actual component, not a test replacement.
const resources = performance
.getEntriesByType('resource')
.map((entry) => entry.name);
const reactPath = resources.find((url) =>
new URL(url).pathname.endsWith('/react.js'),
)!;
const domPath = resources.find((url) =>
new URL(url).pathname.endsWith('/react-dom_client.js'),
)!;
const pickerPath =
'/src/app/home/components/reasoning/ReasoningLevelPicker.tsx';
const reactModule = await import(reactPath);
const React = reactModule.default || reactModule;
const domModule = await import(domPath);
const { createRoot } = domModule.default || domModule;
const { default: Picker } = await import(pickerPath);
const host = document.createElement('div');
const app = document.getElementById('root');
if (app) app.style.display = 'none';
document.body.appendChild(host);
function Harness() {
const [value, setValue] = React.useState('provider_default');
return React.createElement(Picker, {
value,
levels: ['provider_default', 'high'],
onChange: (next: string) => {
host.dataset.value = next;
setValue(next);
},
});
}
host.id = 'standalone-reasoning';
createRoot(host).render(React.createElement(Harness));
});
await page
.getByRole('button', {
name: 'Reasoning level: Use provider default',
exact: true,
})
.click();
await expect(
page.getByRole('button', { name: 'Use model setting', exact: true }),
).toHaveCount(0);
await page.getByRole('slider').press('End');
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
'data-value',
'high',
);
await page.getByRole('slider').press('Home');
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
'data-value',
'provider_default',
);
});
test('LangBot Models reasoning checkbox matches the capability icon', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/user/info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
account_uuid: 'account-playwright',
user: '[email protected]',
account_type: 'space',
has_password: true,
},
},
}),
);
const provider = {
uuid: 'space-provider',
name: 'LangBot Models',
requester: 'space-chat-completions',
base_url: '',
api_keys: [],
llm_count: 1,
embedding_count: 0,
rerank_count: 0,
};
await page.route('**/api/v1/provider/**', (route) => {
const path = new URL(route.request().url()).pathname;
const ok = (data: unknown) => route.fulfill({ json: { code: 0, data } });
if (path.endsWith('/providers')) return ok({ providers: [provider] });
if (path.endsWith('/requesters')) return ok({ requesters: [] });
if (path.includes('/models/'))
return ok({
models: path.endsWith('/llm')
? [
{
uuid: 'space-reasoning',
name: 'Space reasoning model',
provider_uuid: provider.uuid,
provider,
abilities: ['vision', 'func_call'],
extra_args: {},
reasoning_capabilities: {
supported: true,
levels: ['provider_default', 'low', 'high'],
},
},
]
: [],
});
return ok({ provider });
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'LangBot Models' });
await card.getByText('Space reasoning model', { exact: true }).click();
const checkbox = page.getByRole('checkbox', {
name: 'Reasoning',
exact: true,
});
await expect(checkbox).toBeChecked();
await expect(checkbox).toBeDisabled();
});
+122
View File
@@ -0,0 +1,122 @@
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const resetEndpoint = '**/api/v1/user/reset-password';
const email = '[email protected]';
const newPassword = 'Regression-password-2026!';
const successMessage = 'Password reset successfully, please login';
const failureMessage =
'Password reset failed, please check your email and recovery key';
async function fillResetForm(page: Page, recoveryKey: string) {
await page.goto('/reset-password');
await page.getByPlaceholder('Enter email address').fill(email);
const recoveryInput = page.getByPlaceholder('Enter recovery key');
await recoveryInput.fill(recoveryKey);
await expect(recoveryInput).toHaveValue(recoveryKey);
await page.getByPlaceholder('Enter new password').fill(newPassword);
}
test.beforeEach(async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: false });
});
const recoveryKeys = [
{ name: 'eight-character recovery code', value: '2A3B4C5D' },
{ name: 'six-character legacy recovery key', value: 'ABC123' },
{
name: '43-character mixed-case base64url recovery key',
value: 'aB-_'.repeat(10) + 'xYz',
},
];
for (const { name, value } of recoveryKeys) {
test(`submits the ${name} verbatim and returns to login`, async ({
page,
}) => {
const requests: { method: string; body: unknown }[] = [];
await page.route(resetEndpoint, async (route) => {
requests.push({
method: route.request().method(),
body: route.request().postDataJSON(),
});
await route.fulfill({
status: 200,
json: { code: 0, msg: 'ok', data: { user: email } },
});
});
await fillResetForm(page, value);
await page
.getByRole('button', { name: 'Reset Password', exact: true })
.click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByText(successMessage, { exact: true })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Login with password', exact: true }),
).toBeVisible();
expect(requests).toEqual([
{
method: 'POST',
body: { user: email, recovery_key: value, new_password: newPassword },
},
]);
await expect(page.getByText(failureMessage, { exact: true })).toHaveCount(
0,
);
});
}
test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({
page,
}) => {
const recoveryKey = '2A3B4C5D';
const requests: { method: string; body: unknown }[] = [];
let releaseResponse!: () => void;
const responseGate = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
await page.route(resetEndpoint, async (route) => {
requests.push({
method: route.request().method(),
body: route.request().postDataJSON(),
});
await responseGate;
await route.fulfill({
status: 429,
json: { code: -1, msg: 'Too many attempts, try again later' },
});
});
await fillResetForm(page, recoveryKey);
const submit = page.locator('button[type="submit"]');
await submit.click();
try {
await expect.poll(() => requests.length).toBe(1);
await expect(submit).toBeDisabled();
await expect(submit).toHaveText('Resetting...');
} finally {
releaseResponse();
}
await expect(page.getByText(failureMessage, { exact: true })).toBeVisible();
await expect(submit).toBeEnabled();
await expect(submit).toHaveText('Reset Password');
await expect(page).toHaveURL(/\/reset-password$/);
await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0);
await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue(
recoveryKey,
);
expect(requests).toEqual([
{
method: 'POST',
body: {
user: email,
recovery_key: recoveryKey,
new_password: newPassword,
},
},
]);
});
@@ -0,0 +1,212 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
const contract = JSON.parse(
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
);
import { installLangBotApiMocks } from './fixtures/langbot-api';
const canonical = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
);
const outputSchema = contract.output_schema;
const switchField = (page: Page, label: string) =>
page
.locator('label')
.filter({ hasText: label })
.locator('../..')
.getByRole('switch');
async function setup(
page: Page,
plugin = 'weknora-agent',
name = 'WeKnoraAgent',
agentId: null | 'absent' = null,
converted?: typeof canonical,
) {
const schema = contract.plugins[plugin].component;
const id = `plugin:langbot-team/${name}/default`;
const raw: Record<string, unknown> = {
'api-key': ' fixture credential ',
'base-url': ' http://localhost:8080/api/v1 ',
'app-type': 'agent',
'enable-all-tools': false,
tools: ['tool-allowed'],
'knowledge-bases': ['kb-allowed'],
'mcp-resources': [{ server: 'private', uri: 'secret' }],
'mcp-resource-agent-read-enabled': false,
};
if (agentId === null) raw['agent-id'] = null;
if (plugin === 'LocalAgent') {
raw['advanced-settings'] = true;
raw.model = {
primary: 'llm-valid',
fallbacks: [],
reasoning: { 'llm-valid': 'provider_default' },
};
raw['remove-think'] = true;
}
let config = {
...structuredClone(canonical),
ai: { runner: { id }, runner_config: { [id]: raw } },
};
if (converted) config = structuredClone(converted);
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
const writes: any[] = [];
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'fixture',
session_type: 'person',
}),
);
});
});
await page.route('**/api/v1/pipelines/preserve', async (route) => {
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON();
writes.push(body);
if (body.config) config = body.config;
}
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'preserve',
name: 'Preservation',
description: '',
emoji: '⚙️',
config,
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: contract.ai_schema.stages[0].config.map(
(field: Record<string, unknown>) =>
field.name === 'id'
? {
...field,
default: id,
options: [
{ name: id, label: schema.metadata.label },
],
}
: field,
),
},
{
name: id,
label: schema.metadata.label,
config: schema.spec.config,
},
],
},
outputSchema,
],
},
},
}),
);
await page.goto('/home/pipelines?id=preserve');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
return { writes, raw, id };
}
for (const candidate of contract.cases) {
test(`actual converter output stays editable and unchanged for ${candidate.legacy_runner}`, async ({
page,
}) => {
const name = candidate.plan.target_plugin.name;
const folder = Object.keys(contract.plugins).find(
(key) => contract.plugins[key].manifest.metadata.name === name,
)!;
const { writes } = await setup(
page,
folder,
name,
null,
candidate.plan.config,
);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
expect(writes).toHaveLength(0);
await page.getByRole('tab', { name: 'Output', exact: true }).click();
await switchField(page, 'Quote Origin Message').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai).toEqual(candidate.plan.config.ai);
expect(writes[0].config.output.misc['quote-origin']).toBe(
!candidate.plan.config.output.misc['quote-origin'],
);
});
}
for (const agentId of [null, 'absent'] as const) {
test(`mounted real WeKnora schema preserves ${agentId} agent ID and hidden policy`, async ({
page,
}) => {
const { writes, raw, id } = await setup(
page,
'weknora-agent',
'WeKnoraAgent',
agentId,
);
await expect(page.getByText('Agent ID', { exact: true })).toBeVisible();
expect(writes).toEqual([]);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
await switchField(page, 'Enable Web Search').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai.runner_config[id]).toEqual({
...raw,
'web-search-enabled': true,
});
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(switchField(page, 'Enable Web Search')).toBeChecked();
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
expect(writes).toHaveLength(1);
});
}
test('remove-think is synchronized only after intentional output or runner toggles', async ({
page,
}) => {
const { writes, id } = await setup(page, 'LocalAgent', 'LocalAgent');
const runnerThink = switchField(page, 'Remove');
await expect(runnerThink).toBeChecked();
expect(writes).toEqual([]);
await page.getByRole('tab', { name: 'Output', exact: true }).click();
const outputThink = switchField(page, 'Remove CoT');
await expect(outputThink).not.toBeChecked();
await outputThink.click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.output.misc['remove-think']).toBe(true);
expect(writes[0].config.ai.runner_config[id]['remove-think']).toBe(true);
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await runnerThink.click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(2);
expect(writes[1].config.output.misc['remove-think']).toBe(false);
expect(writes[1].config.ai.runner_config[id]['remove-think']).toBe(false);
});
+268
View File
@@ -0,0 +1,268 @@
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import { getBoxScopeContext } from '../../src/app/home/pipelines/components/pipeline-form/BoxScopeContext';
const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const forcedHint = '已强制使用全局沙箱,无法修改作用域。';
const customHint = '已强制使用固定沙箱作用域,无法修改作用域。';
// Exercise the real generic renderer, not a retired local-agent page. Host
// execution scope is not a plugin configuration field; see fixtures/README.md.
async function openConditionForm(page: Page, available: boolean, forced = '') {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'zh-Hans' },
});
const params = new URLSearchParams({ available: String(available), forced });
await page.goto(`/tests/e2e/fixtures/dynamic-form.html?${params}`);
const scope = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('沙箱作用域', { exact: true }) })
.getByRole('combobox');
await expect(scope).toBeVisible();
return scope;
}
async function updateContext(page: Page, context: Record<string, unknown>) {
await page.evaluate((detail) => {
window.dispatchEvent(new CustomEvent('test-form-context', { detail }));
}, context);
}
async function expectWarning(page: Page, hint: string) {
const warning = page.getByRole('button', { name: hint, exact: true });
await expect(warning).toBeVisible();
await warning.hover();
await expect(page.getByRole('tooltip')).toHaveText(hint);
}
async function expectNoWarning(page: Page) {
for (const hint of [unavailableHint, forcedHint, customHint]) {
await expect(page.getByRole('button', { name: hint })).toHaveCount(0);
}
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
test.describe('sandbox condition renderer compatibility (UI fixture only)', () => {
for (const scenario of [
{ name: 'Box disabled', forced: '' },
{ name: 'Box disconnected', forced: '' },
{
name: 'unavailable Box takes precedence over forced global',
forced: '{global}',
},
{
name: 'unavailable Box takes precedence over forced fixed',
forced: '{pipeline_id}',
},
]) {
test(scenario.name, async ({ page }) => {
const scope = await openConditionForm(page, false, scenario.forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(
0,
);
});
}
for (const forced of ['{global}', ' {global} ', '{pipeline_id}']) {
test(`available context explains the restriction without rewriting config (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openConditionForm(page, true, forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
// The generic renderer must not coerce values based on a Host policy.
await expect(scope).toHaveText('每个会话(推荐)');
await expectWarning(
page,
forced.trim() === '{global}' ? forcedHint : customHint,
);
await expect(
page.getByRole('button', { name: unavailableHint }),
).toHaveCount(0);
await expect(page.getByTestId('saved-values')).toContainText(
'"box-session-id-template":"{launcher_type}_{launcher_id}"',
);
});
}
for (const forced of ['', ' ']) {
test(`available unforced context is editable (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openConditionForm(page, true, forced);
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expect(scope).toHaveText('每个会话(推荐)');
await expectNoWarning(page);
await scope.click();
await page
.getByRole('option', { name: '全局(所有人共享)', exact: true })
.click();
await expect(scope).toHaveText('全局(所有人共享)');
await expectNoWarning(page);
});
}
for (const forced of ['', '{global}']) {
test(`caller context updates the warning without remounting (${forced || 'unforced'})`, async ({
page,
}) => {
const scope = await openConditionForm(page, false, forced);
const original = await scope.elementHandle();
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await page.mouse.move(0, 0);
await updateContext(page, getBoxScopeContext(true, forced));
if (forced) {
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, forcedHint);
} else {
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expectNoWarning(page);
}
await page.mouse.move(0, 0);
await updateContext(page, getBoxScopeContext(false, forced));
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
expect(await original!.evaluate((element) => element.isConnected)).toBe(
true,
);
});
}
test('manifest secret and number aliases retain live show/disable conditions', async ({
page,
}) => {
await openConditionForm(page, true);
const secret = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('Plugin secret', { exact: true }) })
.locator('input');
await expect(secret).toHaveAttribute('type', 'password');
await expect(secret).toHaveCSS('pointer-events', 'auto');
await expect(page.getByRole('spinbutton')).toHaveValue('3');
await secret.fill('fixture-only-not-a-credential');
await updateContext(page, { locked: true });
await expect(secret).toHaveCSS('pointer-events', 'none');
await expectWarning(page, 'Live reason wins');
await page.mouse.move(0, 0);
await page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('Mode', { exact: true }) })
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Hidden', exact: true }).click();
await expect(secret).toHaveCount(0);
await expect(page.getByRole('spinbutton')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Live reason wins' }),
).toHaveCount(0);
});
});
test('pipeline plugin schema round-trips without resurrecting Core sandbox ownership', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'en-US' },
});
const runnerId = 'plugin:qa/conditions/default';
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI Feature' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: [
{
name: 'id',
type: 'select',
label: { en_US: 'Runner' },
default: runnerId,
options: [
{ name: runnerId, label: { en_US: 'Fixture runner' } },
],
},
],
},
{
name: runnerId,
label: { en_US: 'Plugin settings' },
config: [
{
name: 'note',
type: 'string',
label: { en_US: 'Plugin note' },
default: '',
show_if: {
field: '__system.pipeline_id',
operator: 'eq',
value: 'pipeline-scope-fixture',
},
},
],
},
],
},
],
},
},
}),
);
const pipeline = {
uuid: 'pipeline-scope-fixture',
name: 'Plugin scope fixture',
description: '',
emoji: '⚙️',
is_default: false,
config: {
ai: {
runner: { id: runnerId },
runner_config: { [runnerId]: { note: 'original' } },
},
trigger: {},
safety: {},
output: {},
},
};
let saved: typeof pipeline | undefined;
await page.route(
'**/api/v1/pipelines/pipeline-scope-fixture',
async (route) => {
if (route.request().method() === 'PUT') {
saved = route.request().postDataJSON();
await route.fulfill({ json: { code: 0, data: {} } });
} else {
await route.fulfill({ json: { code: 0, data: { pipeline } } });
}
},
);
await page.goto('/home/pipelines?id=pipeline-scope-fixture');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('region', { name: 'Configuration' }).getByRole('textbox'),
).toHaveValue('original');
await expect(page.getByText('Sandbox Scope', { exact: true })).toHaveCount(0);
await page
.getByRole('region', { name: 'Configuration' })
.getByRole('textbox')
.fill('round-trip');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect
.poll(() => saved?.config.ai)
.toEqual({
runner: { id: runnerId },
runner_config: { [runnerId]: { note: 'round-trip' } },
});
});
@@ -0,0 +1,233 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
const contract = JSON.parse(
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
);
import { installLangBotApiMocks } from './fixtures/langbot-api';
const canonical = contract.cases[0].plan.config;
const outputSchema = contract.output_schema;
const switchField = (page: Page, label: string) =>
page
.locator('label')
.filter({ hasText: label })
.locator('../..')
.getByRole('switch');
async function setup(
page: Page,
plugin = 'weknora-agent',
name = 'WeKnoraAgent',
agentId: null | 'absent' = null,
converted?: typeof canonical,
) {
const schema = contract.plugins[plugin].component;
const id = `plugin:langbot-team/${name}/default`;
const raw: Record<string, unknown> = {
'api-key': ' fixture credential ',
'base-url': ' http://localhost:8080/api/v1 ',
'app-type': 'agent',
'enable-all-tools': false,
tools: ['tool-allowed'],
'knowledge-bases': ['kb-allowed'],
'mcp-resources': [{ server: 'private', uri: 'secret' }],
'mcp-resource-agent-read-enabled': false,
};
if (agentId === null) raw['agent-id'] = null;
if (plugin === 'LocalAgent') {
raw['advanced-settings'] = true;
raw.model = {
primary: 'llm-valid',
fallbacks: [],
reasoning: { 'llm-valid': 'provider_default' },
};
raw['remove-think'] = true;
}
let config = {
...structuredClone(canonical),
ai: { runner: { id }, runner_config: { [id]: raw } },
};
if (converted) config = structuredClone(converted);
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
const writes: any[] = [];
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'fixture',
session_type: 'person',
}),
);
});
});
await page.route('**/api/v1/pipelines/preserve', async (route) => {
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON();
writes.push(body);
if (body.config) config = body.config;
}
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'preserve',
name: 'Preservation',
description: '',
emoji: '⚙️',
config,
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: contract.ai_schema.stages[0].config.map(
(field: Record<string, unknown>) =>
field.name === 'id'
? {
...field,
default: id,
options: [
{ name: id, label: schema.metadata.label },
],
}
: field,
),
},
{
name: id,
label: schema.metadata.label,
config: schema.spec.config,
},
],
},
outputSchema,
],
},
},
}),
);
await page.goto('/home/pipelines?id=preserve');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
return { writes, raw, id };
}
for (const kind of ['json', 'prompt', 'prompt-omitted'] as const) {
test(`canonical ${kind} edits preserve types and metadata, reject invalid drafts`, async ({
page,
}) => {
const candidate = contract.cases.find(
(c: any) =>
c.legacy_runner === (kind === 'json' ? 'langflow-api' : 'local-agent'),
);
const config = structuredClone(candidate.plan.config);
const id = candidate.plan.target_runner_id;
const field = kind === 'json' ? 'tweaks' : 'prompt';
const initial =
kind === 'json'
? {
node: {
enabled: false,
count: 0,
missing: null,
list: [false, 0, null],
},
}
: kind === 'prompt-omitted'
? [
{
role: 'assistant',
tool_calls: [],
name: 'original',
provider_specific_fields: { cache: false, count: 0 },
},
]
: [
{
role: 'user',
content: [{ type: 'text', text: 'original' }],
name: 'alice',
metadata: { flag: false, count: 0 },
},
{
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call1',
type: 'function',
function: { name: 'foo', arguments: '{}' },
},
],
},
{ role: 'tool', content: [], tool_call_id: 'call1' },
];
config.ai.runner_config[id][field] = initial;
config.ai.runner_config[id]['advanced-settings'] = true;
const { writes } = await setup(
page,
kind === 'json' ? 'langflow-agent' : 'LocalAgent',
candidate.plan.target_plugin.name,
null,
config,
);
const editor = page.getByTestId(`structured-editor-${field}`);
await expect(editor).toBeVisible();
expect(JSON.parse(await editor.inputValue())).toEqual(initial);
expect(writes).toHaveLength(0);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
const edited = structuredClone(initial) as any;
if (kind === 'json') edited.node.count = 7;
else if (kind === 'prompt-omitted') edited[0].name = 'edited';
else edited[0].content[0].text = 'edited';
await editor.fill(JSON.stringify(edited, null, 2));
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai.runner_config[id][field]).toEqual(edited);
if (kind === 'prompt-omitted')
expect(
Object.hasOwn(
writes[0].config.ai.runner_config[id][field][0],
'content',
),
).toBe(false);
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
expect(JSON.parse(await editor.inputValue())).toEqual(edited);
await editor.fill('{ invalid');
await expect(
page.getByRole('alert').filter({ hasText: `${field} (JSON)` }),
).toBeVisible();
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
// Make another valid edit so Save remains available: invalid JSON must
// block the entire save, not silently persist the previous value.
await page.getByRole('tab', { name: 'Output', exact: true }).click();
await switchField(page, 'Quote Origin Message').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.waitForTimeout(400);
expect(writes).toHaveLength(1);
await page.getByRole('tab', { name: 'AI', exact: true }).click();
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
await expect(editor).toHaveValue('{ invalid');
await editor.fill(JSON.stringify(edited));
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(2);
expect(writes[1].config.ai.runner_config[id][field]).toEqual(edited);
});
}
+560
View File
@@ -0,0 +1,560 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
pipelineMetadata,
} from './fixtures/langbot-api';
const runnerId = 'plugin:langbot-team/LocalAgent/default';
const ok = (data: unknown) => JSON.stringify({ code: 0, message: 'ok', data });
for (const failsFirst of [false, true]) {
test(`prepares Local Agent before enabling the bot${failsFirst ? ' after an installation retry' : ''}`, async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
let installed = false;
let completeInstall = false;
let installCount = 0;
let modelCount = 0;
let messageReceived = false;
const order: string[] = [];
const configs: Record<string, any>[] = [];
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
const metadata = pipelineMetadata();
if (!installed) metadata.configs[0].stages = [];
else {
const model = metadata.configs[0].stages.find(
(stage) => stage.name === runnerId,
)!.config[0];
model.default = { primary: '', fallbacks: [] } as never;
}
await route.fulfill({
contentType: 'application/json',
body: ok(metadata),
});
});
await page.route(
'**/api/v1/marketplace/plugins/langbot-team/LocalAgent',
async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
plugin: {
author: 'langbot-team',
name: 'LocalAgent',
label: { en_US: 'Local Agent' },
latest_version: '0.1.7',
},
}),
});
},
);
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
installCount += 1;
expect(route.request().postDataJSON()).toEqual({
plugin_author: 'langbot-team',
plugin_name: 'LocalAgent',
plugin_version: '0.1.7',
});
order.push('install');
await route.fulfill({
contentType: 'application/json',
body: ok({ task_id: installCount }),
});
});
await page.route('**/api/v1/system/tasks/*', async (route) => {
const failed = failsFirst && installCount === 1;
installed = !failed && completeInstall;
await route.fulfill({
contentType: 'application/json',
body: ok({
id: installCount,
name: 'plugin-install-marketplace',
label: 'LocalAgent',
runtime: {
done: failed || completeInstall,
exception: failed ? 'Download failed: connection reset' : null,
},
task_context: {
current_action: 'Downloading package',
metadata: { progress_percent: 25 },
},
}),
});
});
await page.route(
'**/api/v1/system/wizard/recommended-model',
async (route) => {
modelCount += 1;
order.push('model');
await route.fulfill({
contentType: 'application/json',
body: ok({ uuid: 'recommended-model', name: 'Recommended' }),
});
},
);
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
logs: messageReceived
? [
{
seq_id: 1,
timestamp: Date.now() / 1000,
level: 'info',
text: 'Received message',
images: [],
message_session_id: 'person_123',
},
]
: [],
total_count: messageReceived ? 1 : 0,
}),
});
});
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'PUT' &&
path === '/api/v1/pipelines/pipeline-1'
) {
configs.push(request.postDataJSON().config);
order.push('configure');
}
if (
request.method() === 'PUT' &&
path === '/api/v1/platform/bots/bot-1' &&
request.postDataJSON().enable
)
order.push('enable');
});
await page.goto('/wizard');
await page.getByText('Playwright Adapter', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
if (failsFirst) {
await expect(
page.getByText(/Download failed: connection reset/),
).toBeVisible();
expect(order).toEqual(['install']);
await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
}
const preparing = page.getByRole('button', {
name: /Preparing default AI.*23%/,
});
await expect(preparing).toBeDisabled();
await expect(preparing.locator('span[aria-hidden="true"]')).toHaveAttribute(
'style',
'width: 23%;',
);
await expect(page.getByRole('progressbar')).toHaveCount(0);
completeInstall = true;
await expect(
page.getByRole('button', { name: 'Re-save Configuration' }),
).toBeVisible();
expect(order.slice(-4)).toEqual([
'install',
'model',
'configure',
'enable',
]);
expect(configs[0].ai.runner.id).toBe(runnerId);
expect(configs[0].ai.runner_config[runnerId].model.primary).toBe(
'recommended-model',
);
await page.getByRole('button', { name: 'Re-save Configuration' }).click();
await expect.poll(() => configs.length).toBe(2);
expect(installCount).toBe(failsFirst ? 2 : 1);
expect(modelCount).toBe(1);
messageReceived = true;
await page.getByRole('button', { name: 'Next' }).click();
await expect(
page.getByText('Use the default setup', { exact: true }),
).toBeVisible();
await expect(page.getByText('Local Agent', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Done', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
expect(configs.length).toBe(2);
});
}
test('uses a custom model in the existing Local Agent pipeline', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
logs: [
{
seq_id: 1,
timestamp: Date.now() / 1000,
level: 'info',
text: 'Received message',
images: [],
message_session_id: 'person_123',
},
],
total_count: 1,
},
},
});
});
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/requesters'))
return route.fulfill({
json: {
code: 0,
data: {
requesters: [
{
name: 'openai',
label: { en_US: 'OpenAI API' },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [
{ name: 'base_url', default: 'https://api.openai.com/v1' },
],
},
},
],
},
},
});
if (path.endsWith('/providers') && route.request().method() === 'POST')
return route.fulfill({
json: { code: 0, data: { uuid: 'own-provider' } },
});
if (path.endsWith('/scan-models'))
return route.fulfill({ json: { code: 0, data: { models: [] } } });
if (path.endsWith('/models/llm') && route.request().method() === 'POST')
return route.fulfill({ json: { code: 0, data: { uuid: 'own-model' } } });
await route.fallback();
});
await page.goto('/wizard');
await page.getByText('Playwright Adapter', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByText('Use My Own Model', { exact: true }).click();
await expect(
page.getByRole('button', { name: 'Use Selected Model & Finish' }),
).toBeDisabled();
await page.locator('input[name="name"]').fill('Wizard custom provider');
await page
.getByRole('button', { name: 'Select Provider Type', exact: true })
.click();
await page.getByRole('button', { name: /OpenAI API/ }).click();
await page.locator('input[name="api_key"]').fill('fixture-key');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByLabel('Model ID', { exact: false }).fill('test-model');
const update = page.waitForRequest(
(request) =>
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1',
);
await page
.getByRole('button', { name: 'Use Selected Model & Finish' })
.click();
const config = (await update).postDataJSON().config;
expect(config.ai.runner.id).toBe(runnerId);
expect(config.ai.runner_config[runnerId].model.primary).toBe('own-model');
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
});
test('only labels debugging Runners even when the marketplace is offline', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
version: 'fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
wizard_status: 'none',
wizard_progress: {
step: 2,
created_bot_uuid: 'bot-1',
created_pipeline_uuid: 'pipeline-1',
selected_adapter: 'test',
bot_saved: true,
message_received: true,
},
},
},
});
});
const plugins = [
{ name: 'local', label: 'Local Runner', debug: false },
{ name: 'debug', label: 'Debug Runner', debug: true },
];
await page.route('**/api/v1/plugins', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
plugins: plugins.map((plugin) => ({
manifest: {
manifest: {
metadata: {
author: 'qa',
name: plugin.name,
description: { en_US: `${plugin.label} description` },
},
},
},
debug: plugin.debug,
})),
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
const metadata = pipelineMetadata();
const selector = metadata.configs[0].stages[0].config[0];
if (!('options' in selector))
throw new Error('Missing Runner options in fixture');
const options = selector.options;
for (const plugin of plugins) {
options.push({
name: `plugin:qa/${plugin.name}/default`,
label: { en_US: plugin.label, zh_Hans: plugin.label },
});
}
await route.fulfill({ json: { code: 0, data: metadata } });
});
await page.route('**/api/v1/marketplace/**', async (route) => {
await route.fulfill({
status: 503,
json: { code: 503, msg: 'Marketplace offline' },
});
});
await page.goto('/wizard');
await page
.getByRole('radio', { name: 'Connect an External Agent', exact: true })
.click();
const local = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Local Runner' });
const debug = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Debug Runner' });
await expect(local.getByText('Installed', { exact: true })).toHaveCount(0);
await expect(debug.getByText('Debugging', { exact: true })).toBeVisible();
await expect(debug.getByText('Installed', { exact: true })).toHaveCount(0);
await debug.getByRole('button', { name: 'Use This Runner' }).click();
await expect(
page
.locator('[data-slot="card"]')
.filter({ hasText: 'Debug Runner' })
.getByText('Debugging', { exact: true }),
).toBeVisible();
});
test('shows installation progress inside the matching card button and recovers after failure', async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
version: 'fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
wizard_status: 'none',
wizard_progress: {
step: 2,
created_bot_uuid: 'bot-1',
created_pipeline_uuid: 'pipeline-1',
selected_adapter: 'test',
bot_saved: true,
message_received: true,
},
},
},
});
});
const plugin = (name: string) => ({
name,
author: 'qa',
label: { en_US: name },
description: { en_US: 'Test Runner' },
components: { Runner: 1 },
runner_usages: ['agent'],
latest_version: '1.0.0',
install_count: 0,
type: 'plugin',
});
await page.route(
'**/api/v1/marketplace/extensions/search**',
async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
extensions: [plugin('ProgressRunner'), plugin('OtherRunner')],
total: 2,
},
},
});
},
);
let attempt = 0;
let phase: 'download' | 'deps' | 'failed' | 'done' = 'download';
let registered = false;
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
attempt += 1;
phase = 'download';
await route.fulfill({ json: { code: 0, data: { task_id: attempt } } });
});
await page.route('**/api/v1/system/tasks/*', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
id: attempt,
name: 'plugin-install-marketplace',
label: 'ProgressRunner',
runtime: {
done: phase === 'done' || phase === 'failed',
exception:
phase === 'failed' ? 'Dependency installation failed' : null,
},
task_context: {
current_action:
phase === 'download'
? 'Downloading package'
: 'Installing dependencies',
metadata: { progress_percent: phase === 'download' ? 24 : 64 },
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
const metadata = pipelineMetadata();
const selector = metadata.configs[0].stages[0].config[0];
if ('options' in selector)
selector.options.push({
name: 'plugin:qa/ExistingRunner/default',
label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' },
});
metadata.configs[0].stages.push({
name: 'plugin:qa/ExistingRunner/default',
label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' },
config: [
{
name: 'api-key',
label: { en_US: 'API Key', zh_Hans: 'API Key' },
type: 'string',
required: true,
default: '',
},
],
} as (typeof metadata.configs)[0]['stages'][number]);
if (registered && 'options' in selector)
selector.options.push({
name: 'plugin:qa/ProgressRunner/default',
label: { en_US: 'ProgressRunner', zh_Hans: 'ProgressRunner' },
});
await route.fulfill({ json: { code: 0, data: metadata } });
});
await page.goto('/wizard');
await page
.getByRole('radio', { name: 'Connect an External Agent', exact: true })
.click();
const card = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('ProgressRunner', { exact: true }) });
const other = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('OtherRunner', { exact: true }) });
const back = page.getByRole('button', {
name: /Back to (options|list)/,
exact: true,
});
await expect(back).toBeVisible();
const pickerBounds = await back.boundingBox();
await page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('ExistingRunner', { exact: true }) })
.getByRole('button', { name: 'Use This Runner' })
.click();
await expect(page.getByText('ExistingRunner Configuration')).toBeVisible();
await expect
.poll(async () => (await back.boundingBox())?.x)
.toBeCloseTo(pickerBounds!.x, 0);
await expect
.poll(async () => (await back.boundingBox())?.y)
.toBeCloseTo(pickerBounds!.y, 0);
await page.getByRole('textbox').fill('unsaved-test-key');
await page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('ExistingRunner', { exact: true }) })
.getByRole('button', { name: 'Use This Runner' })
.click();
await expect(page.getByRole('textbox')).toHaveValue('unsaved-test-key');
await card
.getByRole('button', { name: 'Install & Continue', exact: true })
.click();
await expect(
card.getByRole('button', { name: /Downloading.*24%/ }),
).toBeDisabled();
await expect(page.getByRole('progressbar')).toHaveCount(0);
await expect(
other.getByRole('button', { name: 'Install & Continue', exact: true }),
).toBeDisabled();
phase = 'deps';
await expect(
card.getByRole('button', { name: /Installing Dependencies.*64%/ }),
).toBeDisabled();
phase = 'failed';
await card
.getByRole('button', { name: 'Failed · Retry', exact: true })
.click();
await expect(
card.getByRole('button', { name: /Downloading.*24%/ }),
).toBeDisabled();
phase = 'done';
await expect(
card.getByRole('button', {
name: /Starting and refreshing components.*95%/,
}),
).toBeDisabled();
registered = true;
await expect(
card.getByRole('button', { name: 'Use This Runner' }),
).toBeEnabled();
await expect(
card.getByRole('button', { name: 'Use This Runner' }),
).toHaveAttribute('aria-pressed', 'true');
await expect(
other.getByRole('button', { name: 'Install & Continue' }),
).toBeEnabled();
expect(attempt).toBe(2);
});
@@ -91,15 +91,123 @@ async function createDraftBot(page: Page, adapterLabel: string) {
}
test.describe('wizard and QR platform regressions', () => {
test('opens the Page Bot test panel after the first and every later save', async ({
test('starts with message platforms directly and keeps legacy adapters available', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const adapter = adapterWithQrLogin(
'message-adapter',
'Message Adapter',
'feishu',
);
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
adapters: [
adapter,
adapter,
{
...adapter,
name: 'events-only',
label: { en_US: 'Events Only' },
spec: {
...adapter.spec,
supported_events: ['group.member_joined'],
},
},
{
...adapter,
name: 'legacy',
label: { en_US: 'Legacy Message Adapter' },
spec: {
...adapter.spec,
legacy: true,
supported_events: undefined,
},
},
],
}),
});
});
await page.goto('/wizard');
await expect(
page.getByRole('heading', { name: 'Select a Platform' }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: /Reply to messages|Welcome new members/,
}),
).toHaveCount(0);
await expect(
page.getByText('Message Adapter', { exact: true }),
).toHaveCount(1);
await expect(page.getByText('Events Only', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: /Legacy Adapters/i }).click();
await expect(
page.getByText('Legacy Message Adapter', { exact: true }),
).toBeVisible();
await page.getByText('Message Adapter', { exact: true }).click();
await expect(
page.getByRole('button', { name: 'Confirm, Create Bot' }),
).toBeEnabled();
});
test('old non-message drafts start fresh without modifying their Agent or bot', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
version: 'frontend-smoke',
edition: 'community',
wizard_status: 'none',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
wizard_progress: {
step: 2,
selected_scenario: 'welcome_members',
created_bot_uuid: 'old-bot',
selected_adapter: 'test',
},
}),
});
});
const resourceRequests: string[] = [];
page.on('request', (request) => {
if (
/\/api\/v1\/(agents|pipelines|platform\/bots)(\/[^_]|$)/.test(
new URL(request.url()).pathname,
) &&
request.method() !== 'GET'
) {
resourceRequests.push(request.url());
}
});
await page.goto('/wizard');
await expect(
page.getByRole('heading', { name: 'Select a Platform' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Confirm, Create Bot' }),
).toBeDisabled();
expect(resourceRequests).toEqual([]);
});
test('loads the Page Bot from the API origin, retries failures, and reopens after every save', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let pipelineCreateCount = 0;
let boundPipelineUuid: string | null = null;
let backendOrigin = '';
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname === '/api/v1/platform/adapters')
backendOrigin = url.origin;
if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') {
pipelineCreateCount += 1;
}
@@ -160,8 +268,10 @@ test.describe('wizard and QR platform regressions', () => {
path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'),
'utf8',
);
let widgetAvailable = false;
await page.route('**/api/v1/embed/*/widget.js?*', async (route) => {
if (!boundPipelineUuid || pipelineCreateCount === 0) {
expect(new URL(route.request().url()).origin).toBe(backendOrigin);
if (!widgetAvailable || !boundPipelineUuid || pipelineCreateCount === 0) {
await route.fulfill({
status: 404,
contentType: 'application/javascript',
@@ -184,7 +294,6 @@ test.describe('wizard and QR platform regressions', () => {
});
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();
@@ -196,6 +305,15 @@ test.describe('wizard and QR platform regressions', () => {
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
await expect(
page.getByText(
'Failed to load the test chat. Please save the configuration again to retry.',
{ exact: true },
),
).toBeVisible();
widgetAvailable = true;
await saveButton.click();
const widgetRoot = page.locator('#langbot-widget-root');
await expect(widgetRoot).toBeAttached();
await expect
@@ -337,7 +455,6 @@ test.describe('wizard and QR platform regressions', () => {
);
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
@@ -354,13 +471,44 @@ test.describe('wizard and QR platform regressions', () => {
await expect.poll(() => inboundTestCount).toBe(1);
});
test('blocks deployment until required Runner configuration is real', async ({
test('creates only a message pipeline after message verification and valid Runner configuration', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const processorRequests: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'POST' &&
['/api/v1/pipelines', '/api/v1/agents'].includes(path)
) {
processorRequests.push(path);
}
});
let messageReceived = false;
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
logs: messageReceived
? [
{
seq_id: 1,
timestamp: Date.now() / 1000,
level: 'info',
text: 'Received message',
images: [],
message_session_id: 'person_123',
},
]
: [],
total_count: messageReceived ? 1 : 0,
}),
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
await route.fulfill({
status: 200,
@@ -371,6 +519,17 @@ test.describe('wizard and QR platform regressions', () => {
name: 'ai',
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
stages: [
{
name: 'plugin:langbot-team/LocalAgent/default',
config: [
{
name: 'model',
type: 'model-fallback-selector',
required: true,
default: { primary: 'llm-valid', fallbacks: [] },
},
],
},
{
name: 'runner',
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
@@ -418,17 +577,41 @@ test.describe('wizard and QR platform regressions', () => {
});
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 expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
messageReceived = true;
await page.getByRole('button', { name: 'Next' }).click();
await page.getByText('External Runner', { exact: true }).click();
await expect(
page.getByText('Use the default setup', { exact: true }),
).toBeVisible();
await page.getByText('Connect an External Agent', { exact: true }).click();
await page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('External Runner', { exact: true }) })
.getByRole('button', { name: 'Use This Runner' })
.click();
const deployButton = page.getByRole('button', { name: 'Create & Deploy' });
await expect(deployButton).toBeDisabled();
await page.getByRole('textbox').fill('app-real-api-key');
await expect(deployButton).toBeEnabled();
const bindingRequest = page.waitForRequest(
(request) =>
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1',
);
await deployButton.click();
const body = (await bindingRequest).postDataJSON();
expect(body.config.ai.runner.id).toBe('external-runner');
expect(body.config.ai.runner_config['external-runner']['api-key']).toBe(
'app-real-api-key',
);
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
expect(processorRequests).toEqual(['/api/v1/pipelines']);
});
for (const qrPlatform of qrPlatforms) {
+17
View File
@@ -0,0 +1,17 @@
# Sandbox condition compatibility fixture
`sandbox-scope-schema.json` preserves the sandbox field from master
`9b7ba0d64708496ace30a82866f6dbc185f089dc` (`templates/metadata/pipeline/ai.yaml`)
solely as regression input for the generic dynamic-form condition renderer.
It is **not** shipped pipeline metadata, an installed plugin manifest, or an
assertion that 4.11 plugins may select the Host's execution sandbox scope.
The old local-agent page is retired. Its unit matrix (availability precedence,
trimmed forced scope, ordered overrides, locale parity and live/external/system
resolution) remains covered. Browser coverage now mounts the real
`DynamicFormComponent` and updates caller context without remounting, rather
than expecting a removed PipelineForm Box poller or scope-value coercion.
A separate real-pipeline browser regression verifies plugin `runner_config`
round-tripping and absence of an injected Core sandbox selector.
The fixture uses no real Box, provider credentials, or production resources.
+149
View File
@@ -0,0 +1,149 @@
{
"name": "box-session-id-template",
"label": {
"en_US": "Sandbox Scope",
"zh_Hans": "沙箱作用域",
"zh_Hant": "沙箱作用域",
"ja_JP": "サンドボックススコープ",
"vi_VN": "Phạm vi Sandbox",
"th_TH": "ขอบเขต Sandbox",
"es_ES": "Alcance del Sandbox",
"ru_RU": "Область песочницы"
},
"description": {
"en_US": "Determines how sandbox environments are shared across messages.",
"zh_Hans": "决定沙箱环境在不同消息间的共享方式。",
"zh_Hant": "決定沙箱環境在不同訊息間的共享方式。",
"ja_JP": "メッセージ間でサンドボックス環境を共有する方法を決定します。",
"vi_VN": "Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.",
"th_TH": "กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ",
"es_ES": "Determina cómo se comparten los entornos sandbox entre mensajes.",
"ru_RU": "Определяет, как песочницы используются совместно между сообщениями."
},
"disable_if": {
"field": "__system.box_scope_editable",
"operator": "eq",
"value": false
},
"disabled_tooltip": {
"en_US": "Sandbox is unavailable. Enable Box and check its connection before changing the scope.",
"zh_Hans": "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。",
"zh_Hant": "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。",
"ja_JP": "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。",
"vi_VN": "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi.",
"th_TH": "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต",
"es_ES": "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance.",
"ru_RU": "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
},
"disabled_tooltip_overrides": [
{
"when": {
"field": "__system.box_scope_forced_global",
"operator": "eq",
"value": true
},
"tooltip": {
"en_US": "A global sandbox is enforced; the scope cannot be changed.",
"zh_Hans": "已强制使用全局沙箱,无法修改作用域。",
"zh_Hant": "已強制使用全域沙箱,無法修改作用域。",
"ja_JP": "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。",
"vi_VN": "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi.",
"th_TH": "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้",
"es_ES": "Se impone un sandbox global; no se puede cambiar el alcance.",
"ru_RU": "Принудительно используется глобальная песочница; изменить область нельзя."
}
},
{
"when": {
"field": "__system.box_scope_forced",
"operator": "eq",
"value": true
},
"tooltip": {
"en_US": "A fixed sandbox scope is enforced; the scope cannot be changed.",
"zh_Hans": "已强制使用固定沙箱作用域,无法修改作用域。",
"zh_Hant": "已強制使用固定沙箱作用域,無法修改作用域。",
"ja_JP": "固定のサンドボックススコープが強制されているため、スコープを変更できません。",
"vi_VN": "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi.",
"th_TH": "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้",
"es_ES": "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance.",
"ru_RU": "Принудительно задана фиксированная область песочницы; изменить её нельзя."
}
}
],
"type": "select",
"required": false,
"default": "{launcher_type}_{launcher_id}",
"options": [
{
"name": "{global}",
"label": {
"en_US": "Global (shared by all)",
"zh_Hans": "全局(所有人共享)",
"zh_Hant": "全域(所有人共用)",
"ja_JP": "グローバル(全員共有)",
"vi_VN": "Toàn cục (chia sẻ cho tất cả)",
"th_TH": "ทั่วไป (แชร์ทั้งหมด)",
"es_ES": "Global (compartido por todos)",
"ru_RU": "Глобальный (общий для всех)"
}
},
{
"name": "{launcher_type}_{launcher_id}",
"label": {
"en_US": "Per chat (Recommended)",
"zh_Hans": "每个会话(推荐)",
"zh_Hant": "每個會話(推薦)",
"ja_JP": "チャットごと(推奨)",
"vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)",
"th_TH": "ต่อแชท (แนะนำ)",
"es_ES": "Por chat (Recomendado)",
"ru_RU": "По чату (Рекомендуется)"
}
},
{
"name": "{launcher_type}_{launcher_id}_{sender_id}",
"label": {
"en_US": "Per user in chat",
"zh_Hans": "会话中每个用户",
"zh_Hant": "會話中每個用戶",
"ja_JP": "チャット内のユーザーごと",
"vi_VN": "Mỗi người dùng trong cuộc trò chuyện",
"th_TH": "ต่อผู้ใช้ในแชท",
"es_ES": "Por usuario en chat",
"ru_RU": "По пользователю в чате"
}
},
{
"name": "{launcher_type}_{launcher_id}_{conversation_id}",
"label": {
"en_US": "Per conversation context",
"zh_Hans": "每个对话上下文",
"zh_Hant": "每個對話上下文",
"ja_JP": "会話コンテキストごと",
"vi_VN": "Mỗi ngữ cảnh hội thoại",
"th_TH": "ต่อบริบทการสนทนา",
"es_ES": "Por contexto de conversación",
"ru_RU": "По контексту разговора"
}
},
{
"name": "{query_id}",
"label": {
"en_US": "Per message (isolated)",
"zh_Hans": "每条消息(完全隔离)",
"zh_Hant": "每條訊息(完全隔離)",
"ja_JP": "メッセージごと(隔離)",
"vi_VN": "Mỗi tin nhắn (cách ly)",
"th_TH": "ต่อข้อความ (แยกส่วน)",
"es_ES": "Por mensaje (aislado)",
"ru_RU": "По сообщению (изолированно)"
}
}
],
"show_if": {
"field": "__system.is_wizard",
"operator": "neq",
"value": true
}
}
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(
new URL('../../src/app/login/page.tsx', import.meta.url),
'utf8',
);
test('normal Cloud login uses the standard Space OAuth callback path', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
});
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
});
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function loadCatalog(url) {
const compiled = ts.transpileModule(fs.readFileSync(url, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const module = { exports: {} };
const require = (specifier) => {
assert.ok(specifier.startsWith('./'), 'Locale imports must be relative');
return loadCatalog(new URL(`${specifier}.ts`, url));
};
new Function('module', 'exports', 'require', compiled)(
module,
module.exports,
require,
);
return module.exports;
}
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
const directory = new URL('../../src/i18n/locales/', import.meta.url);
const files = fs
.readdirSync(directory)
.filter((file) => file.endsWith('.ts'));
assert.equal(files.length, 8);
let expected;
for (const file of files) {
const exports = loadCatalog(new URL(file, directory));
const catalog = (exports.default || Object.values(exports)[0]).models.codex;
const keys = Object.keys(catalog).sort();
expected ??= keys;
assert.deepEqual(keys, expected, file);
assert.equal(keys.length, 26, file);
assert.ok(catalog.expiresAt.includes('{{time}}'), file);
}
});
function policy() {
const source = fs.readFileSync(
new URL(
'../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts',
import.meta.url,
),
'utf8',
);
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const module = { exports: {} };
new Function('module', 'exports', compiled)(module, module.exports);
return module.exports;
}
test('Codex payload discards previously entered API credentials and URL', () => {
const { providerPayload } = policy();
assert.deepEqual(
providerPayload({
name: 'Subscription',
requester: 'openai-codex',
base_url: 'https://proxy.invalid',
api_key: 'fixture-only',
}),
{
name: 'Subscription',
requester: 'openai-codex',
base_url: 'https://chatgpt.com/backend-api/codex',
api_keys: [],
},
);
});
test('ordinary providers preserve API key and base URL behavior', () => {
assert.deepEqual(
policy().providerPayload({
name: 'API',
requester: 'openai',
base_url: 'https://api.example.test/v1',
api_key: 'fixture-only',
}),
{
name: 'API',
requester: 'openai',
base_url: 'https://api.example.test/v1',
api_keys: ['fixture-only'],
},
);
});
test('poll delay honors upstream minimum and transient backoff', () => {
const { pollDelay } = policy();
assert.equal(pollDelay(5, 0), 5000);
assert.equal(pollDelay(10, 2), 40000);
assert.equal(pollDelay(120, 3), 120000);
assert.equal(pollDelay(NaN, 0), 5000);
assert.equal(pollDelay(-1, 0), 5000);
});
test('only the contracted OpenAI device authorization URL can be opened', () => {
const { isCodexVerificationUri } = policy();
assert.equal(
isCodexVerificationUri('https://auth.openai.com/codex/device'),
true,
);
for (const url of [
'javascript:alert(1)',
'https://auth.openai.com.evil.test/codex/device',
'https://evil.test',
'https://[email protected]/codex/device',
]) {
assert.equal(isCodexVerificationUri(url), false);
}
});
@@ -66,6 +66,7 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
fallbacks: ['fallback-model'],
reasoning: {
'primary-model': 'high',
'fallback-model': 'provider_default',
},
},
});
@@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const widgetPath = path.resolve(
testDirectory,
'../../../src/langbot/templates/embed/widget.js',
);
const widgetSource = fs.readFileSync(widgetPath, 'utf8');
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.className = '';
this.dataset = {};
this.style = {};
this.listeners = {};
this._innerHTML = '';
}
appendChild(child) {
this.children.push(child);
return child;
}
setAttribute(name, value) {
this[name] = String(value);
}
addEventListener(event, listener) {
this.listeners[event] = listener;
}
click() {
this.listeners.click?.({});
}
attachShadow() {
this.shadowRoot = new FakeElement('shadow-root');
return this.shadowRoot;
}
get classList() {
return {
add: (...names) => {
const classes = `${this.className} ${names.join(' ')}`
.trim()
.split(/\s+/);
this.className = [...new Set(classes)].join(' ');
},
};
}
set textContent(value) {
this._innerHTML = String(value ?? '');
}
set innerHTML(value) {
this._innerHTML = String(value ?? '');
}
get innerHTML() {
return this._innerHTML;
}
querySelectorAll(selector) {
const matches = [];
for (const child of this.children) {
if (
selector.startsWith('.') &&
child.className.split(/\s+/).includes(selector.slice(1))
) {
matches.push(child);
}
matches.push(...child.querySelectorAll(selector));
}
return matches;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
}
class FakeDocument {
constructor() {
this.body = new FakeElement('body');
this.head = new FakeElement('head');
this.readyState = 'complete';
this.currentScript = { getAttribute: () => null };
}
createElement(tagName) {
return new FakeElement(tagName);
}
getElementById(id) {
const find = (element) => {
if (element.id === id) return element;
for (const child of element.children) {
const match = find(child);
if (match) return match;
}
return null;
};
return find(this.body) ?? find(this.head);
}
}
class FakeWebSocket {
static OPEN = 1;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.OPEN;
FakeWebSocket.instances.push(this);
}
send() {}
}
function launchWidget() {
FakeWebSocket.instances = [];
const document = new FakeDocument();
const window = {
crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' },
sessionStorage: { getItem: () => null, setItem: () => {} },
};
const context = vm.createContext({
document,
window,
navigator: { clipboard: { writeText: () => Promise.resolve() } },
WebSocket: FakeWebSocket,
fetch: () => new Promise(() => {}),
requestAnimationFrame: () => 0,
setTimeout: () => 0,
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
});
vm.runInContext(widgetSource, context, { filename: widgetPath });
const root = document.getElementById('langbot-widget-root');
assert.ok(root, 'widget should initialize');
root.shadowRoot.querySelector('.lb-bubble').click();
const socket = FakeWebSocket.instances.at(-1);
assert.ok(socket, 'opening widget should connect its WebSocket');
return {
receive(message) {
socket.onmessage({
data: JSON.stringify({ type: 'response', data: message }),
});
},
assistantMessages() {
return root.shadowRoot.querySelectorAll('.lb-msg-assistant');
},
};
}
function assistant(id, content) {
return { id, role: 'assistant', content, is_final: true };
}
test('renders a non-empty reply after an empty assistant frame', () => {
const widget = launchWidget();
widget.receive(assistant('thought', ''));
widget.receive(assistant('answer', 'visible answer'));
const messages = widget.assistantMessages();
assert.equal(messages.length, 2);
assert.equal(
messages[1].querySelector('.lb-msg-bubble').innerHTML,
'visible answer',
);
});
test('still drops a duplicate non-empty assistant frame', () => {
const widget = launchWidget();
widget.receive(assistant('answer-1', 'same answer'));
widget.receive(assistant('answer-2', 'same answer'));
assert.equal(widget.assistantMessages().length, 1);
});
test('still keeps two distinct non-empty assistant frames', () => {
const widget = launchWidget();
widget.receive(assistant('answer-1', 'first answer'));
widget.receive(assistant('answer-2', 'second answer'));
assert.equal(widget.assistantMessages().length, 2);
});
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts',
);
function loadVisibilityPolicy() {
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('N8nAuthFieldVisibility must not have runtime imports');
},
loadedModule,
loadedModule.exports,
);
return loadedModule.exports;
}
test('shows response handling with the other common n8n fields', () => {
const { shouldShowN8nConfigField } = loadVisibilityPolicy();
for (const field of [
'webhook-url',
'auth-type',
'timeout',
'output-key',
'response-handling',
]) {
assert.equal(shouldShowN8nConfigField(field, 'none'), true, field);
}
});
test('shows only fields for the selected n8n authentication method', () => {
const { shouldShowN8nConfigField } = loadVisibilityPolicy();
assert.equal(shouldShowN8nConfigField('basic-username', 'basic'), true);
assert.equal(shouldShowN8nConfigField('basic-password', 'jwt'), false);
assert.equal(shouldShowN8nConfigField('jwt-secret', 'jwt'), true);
assert.equal(shouldShowN8nConfigField('header-name', 'header'), true);
assert.equal(shouldShowN8nConfigField('unrelated-field', 'none'), false);
});
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import test from 'node:test';
import ts from 'typescript';
const source = readFileSync(
'src/app/home/pipelines/pipeline-config-safety.ts',
'utf8',
);
const context = { exports: {} };
vm.runInNewContext(
ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const { isCurrentPipelineConfig } = context.exports;
const plugin = 'plugin:langbot-team/LocalAgent/default';
test('canonical current empty runner remains editable', () => {
const config = JSON.parse(
readFileSync(
'../src/langbot/templates/default-pipeline-config.json',
'utf8',
),
);
assert.equal(isCurrentPipelineConfig(config), true);
});
test('configured current binding remains editable', () => {
assert.equal(
isCurrentPipelineConfig({
ai: { runner: { id: plugin }, runner_config: {} },
}),
true,
);
});
for (const [name, ai] of Object.entries({
legacy: { runner: { runner: 'local-agent' } },
mixedSelector: { runner: { id: plugin, runner: 'local-agent' } },
mixedLegacySection: {
runner: { id: plugin },
runner_config: {},
'local-agent': {},
},
emptyMixedLegacySection: {
runner: { id: '' },
runner_config: {},
'dify-service-api': {},
},
emptyMissingContainer: { runner: { id: '' } },
emptyMalformedContainer: { runner: { id: '' }, runner_config: [] },
emptyAmbiguousContainer: {
runner: { id: '' },
runner_config: { orphan: {} },
},
emptyAmbiguousSelector: {
runner: { id: '', legacy: 'local-agent' },
runner_config: {},
},
missingId: { runner: {}, runner_config: {} },
nonPlugin: { runner: { id: 'local-agent' }, runner_config: {} },
})) {
test(`${name} does not hydrate current editor defaults`, () => {
assert.equal(isCurrentPipelineConfig({ ai }), false);
});
}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
function load(path) {
const context = { exports: {} };
vm.runInNewContext(
ts.transpileModule(readFileSync(path, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
return context.exports;
}
const warnings = [
...new Set(
[
...readFileSync(
'../src/langbot/pkg/pipeline/legacy_config_migration.py',
'utf8',
).matchAll(/_warn\(result,\s*'([^']+)'/g),
].map((match) => match[1]),
),
];
test('every converter warning has a specific explanation in all eight locales', () => {
const { migrationIssueKey } = load(
'src/app/home/pipelines/pipeline-migration-issues.ts',
);
const base = 'src/i18n/locales/pipeline-migration';
const locales = readdirSync(base).filter((name) => name.endsWith('.ts'));
assert.equal(locales.length, 8);
assert.ok(
warnings.length > 10,
'warning discovery must not silently go empty',
);
for (const file of locales) {
const catalog = load(`${base}/${file}`).default;
for (const code of warnings) {
const key = migrationIssueKey(code);
assert.ok(key, `${file}: missing mapping for ${code}`);
assert.equal(typeof catalog.notices?.[key], 'string', `${file}: ${code}`);
assert.ok(catalog.notices[key].length > 10);
}
assert.ok(catalog.notices.pluginVersion);
assert.ok(catalog.notices.pendingInteraction);
for (const code of ['plugin_runtime_unavailable', 'migration_failed']) {
const key = migrationIssueKey(code);
assert.ok(key);
assert.equal(typeof catalog.notices[key], 'string');
assert.notEqual(catalog.notices[key], catalog.blockerFallback);
}
}
});
test('unknown backend issue codes never become translation keys or displayed messages', () => {
const { migrationIssueKey } = load(
'src/app/home/pipelines/pipeline-migration-issues.ts',
);
for (const code of [
'unknown',
'__proto__',
'constructor',
'secret-token.example',
]) {
assert.equal(migrationIssueKey(code), undefined);
}
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
'src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx',
'utf8',
);
// Exercise the actual field callback without mocking React's form lifecycle.
const callback = source.slice(
source.indexOf('const updateModelReasoning ='),
source.indexOf('const replaceModel ='),
);
function edit(reasoning, uuid, level) {
let result;
const code = ts.transpileModule(
callback + '\nupdateModelReasoning(uuid, level);',
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
).outputText;
new Function('modelValue', 'updateValue', 'uuid', 'level', code)(
{ reasoning },
(value) => {
result = value.reasoning;
},
uuid,
level,
);
return result;
}
test('provider default is an explicit override, not inheritance', () => {
assert.deepEqual(edit({ a: 'high', b: 'low' }, 'a', 'provider_default'), {
a: 'provider_default',
b: 'low',
});
});
test('changing one model preserves other overrides without mutating input', () => {
const input = Object.freeze({ a: 'provider_default', b: 'high' });
const output = edit(input, 'a', 'low');
assert.deepEqual(output, { a: 'low', b: 'high' });
assert.deepEqual(input, { a: 'provider_default', b: 'high' });
});
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function load(relative) {
const source = fs.readFileSync(new URL(relative, import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
const module = { exports: {} };
new Function('module', 'exports', compiled)(module, module.exports);
return module.exports;
}
const helper =
'../../src/app/home/pipelines/components/pipeline-form/RunnerConfigPreservation.ts';
test('mount emission is not a persisted edit', () => {
const { preserveRunnerConfig } = load(helper);
const raw = {
'api-key': ' secret ',
'agent-id': null,
'enable-all-tools': false,
};
assert.deepEqual(
preserveRunnerConfig(raw, undefined, {
'api-key': 'secret',
'agent-id': '',
mode: 'chat',
}),
raw,
);
});
test('unrelated edits preserve hidden Host policy, absent/null and credentials', () => {
const { preserveRunnerConfig } = load(helper);
const raw = {
'api-key': ' secret ',
'agent-id': null,
'enable-all-tools': false,
tools: ['one'],
'knowledge-bases': ['kb'],
'mcp-resources': [{ uri: 'private' }],
'mcp-resource-agent-read-enabled': false,
};
const first = {
'api-key': 'secret',
'agent-id': '',
mode: 'chat',
absent: 'default',
};
assert.deepEqual(
preserveRunnerConfig(raw, first, { ...first, mode: 'agent' }),
{ ...raw, mode: 'agent' },
);
});
test('intentional edits, removals and nested edits win without normalizing untouched siblings', () => {
const { preserveRunnerConfig } = load(helper);
const raw = {
'api-key': ' secret ',
model: {
primary: 'one',
fallbacks: ['two'],
reasoning: { one: 'provider_default', two: 'high' },
},
};
const first = { 'api-key': 'secret', model: raw.model };
assert.deepEqual(
preserveRunnerConfig(raw, first, {
'api-key': 'new',
model: {
primary: 'one',
fallbacks: [],
reasoning: { one: 'provider_default' },
},
}),
{
'api-key': 'new',
model: {
primary: 'one',
fallbacks: [],
reasoning: { one: 'provider_default' },
},
},
);
});
test('save normalization preserves explicit selected per-model provider_default', () => {
const { normalizeDynamicFormValuesForSave } = load(
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
);
const value = {
primary: 'one',
fallbacks: ['two'],
reasoning: { one: 'provider_default', two: 'high', removed: 'high' },
};
assert.deepEqual(
normalizeDynamicFormValuesForSave(
[{ name: 'model', type: 'model-fallback-selector' }],
{ model: value },
).model.reasoning,
{ one: 'provider_default', two: 'high' },
);
});
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const read = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8');
test('dynamic form combines manifest normalization and shared condition helpers', () => {
const source = read(
'../../src/app/home/components/dynamic-form/DynamicFormComponent.tsx',
);
const parsed = ts.createSourceFile(
'DynamicFormComponent.tsx',
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
assert.equal(
parsed.parseDiagnostics.length,
0,
'Dynamic form must parse after the merge',
);
const imports = parsed.statements.filter(ts.isImportDeclaration);
const importedNames = (module) =>
imports
.filter((item) => item.moduleSpecifier.text === module)
.flatMap(
(item) =>
item.importClause?.namedBindings?.elements?.map(
(entry) => entry.name.text,
) ?? [],
);
assert.ok(
importedNames('./DynamicFormItemConfig').includes(
'parseDynamicFormItemType',
),
);
assert.deepEqual(importedNames('./DynamicFormConditions').sort(), [
'resolveDisabledState',
'resolveShowIfValue',
]);
assert.equal(
parsed.statements.filter(
(item) =>
ts.isFunctionDeclaration(item) &&
item.name?.text === 'resolveShowIfValue',
).length,
0,
);
});
test('pipeline defaults retain plugin runner containers, not retired Core runners', () => {
const config = JSON.parse(
read('../../../src/langbot/templates/default-pipeline-config.json'),
);
assert.deepEqual(config.ai, {
runner: { id: '', 'expire-time': 0 },
runner_config: {},
});
const metadata = read(
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
);
assert.doesNotMatch(
metadata,
/^\s+- name: (?:local-agent|n8n-service-api|langflow-api)$/m,
);
assert.match(metadata, /RunnerRegistry/);
});
test('pipeline UI does not restore host-owned Box scope as local-agent config', () => {
const source = read(
'../../src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
);
assert.doesNotMatch(
source,
/getBoxScopeContext|force_box_session_id_template|N8nAuthFormComponent/,
);
assert.match(source, /ai\.runner_config/);
assert.match(source, /systemContext=\{dynamicFormSystemContext\}/);
});
test('backend client preserves complementary Codex and runner debug API imports', () => {
const source = read('../../src/app/infra/http/BackendClient.ts');
const parsed = ts.createSourceFile(
'BackendClient.ts',
source,
ts.ScriptTarget.Latest,
true,
);
assert.equal(
parsed.parseDiagnostics.length,
0,
'API client must parse after the merge',
);
const imports = parsed.statements
.filter(ts.isImportDeclaration)
.flatMap(
(item) =>
item.importClause?.namedBindings?.elements?.map(
(entry) => entry.name.text,
) ?? [],
);
for (const name of [
'CodexAuthStatus',
'CodexDeviceAuthorization',
'CodexDevicePoll',
'DebugExecutionEvent',
]) {
assert.ok(imports.includes(name), name);
}
});
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// Compatibility fixture, not Core metadata: 4.11 owns sandbox scope in the
// Runner Host. Keep the original condition/locale matrix without reintroducing
// a local-agent configuration stage or claiming plugins control Host scope.
const scope = JSON.parse(
fs.readFileSync(
new URL('../fixtures/sandbox-scope-schema.json', import.meta.url),
'utf8',
),
);
const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const globalForced = '已强制使用全局沙箱,无法修改作用域。';
const customForced = '已强制使用固定沙箱作用域,无法修改作用域。';
function loadSource(relativePath) {
const filename = new URL(`../../src/${relativePath}`, import.meta.url);
assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`);
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === '@/app/infra/entities/form/dynamic')
return loadSource('app/infra/entities/form/dynamic.ts');
throw new Error(`Unexpected runtime import: ${name}`);
},
loaded,
loaded.exports,
);
return loaded.exports;
}
function policies() {
return {
...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'),
...loadSource(
'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts',
),
};
}
function scopeState(available, forcedTemplate) {
const { getBoxScopeContext, resolveDisabledState } = policies();
return resolveDisabledState(
scope,
{},
undefined,
getBoxScopeContext(available, forcedTemplate),
);
}
test('sandbox default tooltip explains only unavailability', () => {
assert.equal(scope.disabled_tooltip.zh_Hans, unavailable);
});
for (const [name, available, template, expected] of [
['Box disabled', false, '', unavailable],
['Box disconnected', false, undefined, unavailable],
[
'unavailable takes precedence over forced global',
false,
'{global}',
unavailable,
],
[
'unavailable takes precedence over forced custom',
false,
'{pipeline_id}',
unavailable,
],
['available forced global', true, '{global}', globalForced],
['available padded forced global', true, ' {global} ', globalForced],
['available whitespace-only editable', true, ' ', undefined],
['available forced custom', true, '{pipeline_id}', customForced],
['available forced literal', true, 'tenant-sandbox', customForced],
['available editable', true, '', undefined],
['available without limitation', true, undefined, undefined],
]) {
test(name, () => {
const state = scopeState(available, template);
assert.equal(state.isDisabledByCondition, expected !== undefined);
assert.equal(state.disabledTooltip?.zh_Hans, expected);
});
}
test('reason follows availability and forced-scope transitions without mutating metadata', () => {
const snapshot = structuredClone(scope);
for (const [available, template, expected] of [
[false, '{global}', unavailable],
[true, '{global}', globalForced],
[true, '{pipeline_id}', customForced],
[true, '', undefined],
[false, '', unavailable],
[true, '', undefined],
]) {
assert.equal(
scopeState(available, template).disabledTooltip?.zh_Hans,
expected,
);
}
assert.deepEqual(scope, snapshot);
});
test('all sandbox reason variants preserve the eight metadata locales', () => {
const locales = [
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'vi_VN',
'th_TH',
'es_ES',
'ru_RU',
].sort();
assert.equal(scope.disabled_tooltip_overrides?.length, 2);
const messages = [
scope.disabled_tooltip,
...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip),
];
for (const message of messages) {
assert.deepEqual(Object.keys(message).sort(), locales);
for (const locale of locales) assert.ok(message[locale].trim(), locale);
}
for (const locale of locales) {
assert.equal(
new Set(messages.map((message) => message[locale])).size,
3,
locale,
);
assert.equal(
scopeState(false, '{global}').disabledTooltip[locale],
messages[0][locale],
);
assert.equal(
scopeState(true, '{global}').disabledTooltip[locale],
messages[1][locale],
);
assert.equal(
scopeState(true, '{pipeline_id}').disabledTooltip[locale],
messages[2][locale],
);
}
});
test('ordinary static disabled tooltip remains compatible', () => {
const { resolveDisabledState } = policies();
const tooltip = { en_US: 'Read only' };
const config = {
disable_if: { field: 'locked', operator: 'eq', value: true },
disabled_tooltip: tooltip,
};
assert.deepEqual(resolveDisabledState(config, { locked: true }), {
isDisabledByCondition: true,
disabledTooltip: tooltip,
});
assert.deepEqual(resolveDisabledState(config, { locked: false }), {
isDisabledByCondition: false,
disabledTooltip: undefined,
});
assert.equal(
resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState({ disable_if: config.disable_if }, { locked: true })
.disabledTooltip,
undefined,
);
});
test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => {
const { matchesFormCondition, resolveDisabledState } = policies();
const watched = { mode: 'live', empty: null, '__system.locked': false };
const external = { mode: 'external', fallback: 3, empty: 'external' };
const system = { locked: true };
for (const [condition, expected] of [
[{ field: 'mode', operator: 'eq', value: 'live' }, true],
[{ field: 'mode', operator: 'eq', value: 'external' }, false],
[{ field: 'fallback', operator: 'neq', value: 4 }, true],
[{ field: 'fallback', operator: 'in', value: [2, 3] }, true],
[{ field: 'fallback', operator: 'in', value: '3' }, false],
[{ field: 'fallback', operator: 'eq', value: '3' }, false],
[{ field: 'empty', operator: 'eq', value: null }, true],
[{ field: '__system.locked', operator: 'eq', value: true }, true],
[{ field: 'absent', operator: 'eq', value: true }, false],
])
assert.equal(
matchesFormCondition(condition, watched, external, system),
expected,
);
const config = {
disable_if: { field: '__system.locked', operator: 'eq', value: true },
disabled_tooltip: { en_US: 'Default' },
disabled_tooltip_overrides: [
{
when: { field: 'mode', operator: 'eq', value: 'external' },
tooltip: { en_US: 'Wrong' },
},
{
when: { field: 'fallback', operator: 'in', value: [3] },
tooltip: { en_US: 'First match' },
},
{
when: { field: 'mode', operator: 'neq', value: 'external' },
tooltip: { en_US: 'Later match' },
},
],
};
assert.equal(
resolveDisabledState(config, watched, external, system).disabledTooltip
.en_US,
'First match',
);
assert.equal(
resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US,
'Later match',
);
assert.equal(
resolveDisabledState(config, watched, external, { locked: false })
.disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState(
{ ...config, disabled_tooltip_overrides: [] },
watched,
external,
system,
).disabledTooltip.en_US,
'Default',
);
const unmatched = {
...config,
disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]],
};
assert.equal(
resolveDisabledState(unmatched, watched, external, system).disabledTooltip
.en_US,
'Default',
);
});
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const repoRoot = path.resolve(root, '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8');
const includes = (source, token, message) =>
assert.ok(source.includes(token), message);
test('session list request supports a server-side page and operator filters', () => {
const client = read('src/app/infra/http/BackendClient.ts');
includes(client, 'startTime?: string', 'client accepts a start date');
includes(client, 'endTime?: string', 'client accepts an end date');
includes(client, 'userQuery?: string', 'client accepts a user query');
includes(
client,
"queryParams.append('offset', options.offset.toString())",
'client sends the requested session offset',
);
includes(
client,
"queryParams.append('userQuery', options.userQuery)",
'client sends the user query',
);
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'SESSION_PAGE_SIZE',
'sessionTotal',
'sessionPage',
'startDate',
'endDate',
'userQuery',
]) {
includes(monitor, token, `monitor includes ${token}`);
}
});
test('session detail requests and renders a bounded message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'MESSAGE_PAGE_SIZE',
'messageTotal',
'messagePage',
'page * MESSAGE_PAGE_SIZE',
]) {
includes(monitor, token, `message pagination includes ${token}`);
}
});
test('backend filters sessions by user id or user name in the existing endpoint', () => {
const controller = readRepo(
'src/langbot/pkg/api/http/controller/groups/monitoring.py',
);
const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py');
includes(
controller,
"quart.request.args.get('userQuery')",
'route accepts userQuery',
);
includes(controller, 'user_query=user_query', 'route forwards userQuery');
includes(
service,
'user_query: str | None = None',
'service accepts userQuery',
);
includes(
service,
'MonitoringSession.user_id.ilike',
'service searches user ids',
);
includes(
service,
'MonitoringSession.user_name.ilike',
'service searches user names',
);
});
test('stale session and message page responses cannot overwrite the latest page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'sessionRequestIdRef',
'messageRequestIdRef',
'requestId !== sessionRequestIdRef.current',
'requestId !== messageRequestIdRef.current',
'messageRequestIdRef.current += 1',
]) {
includes(monitor, token, `stale response guard includes ${token}`);
}
});
test('changing the session page or filters clears the selected detail', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared');
includes(
monitor,
'[appliedUserQuery, botId, endDate, sessionPage, startDate]',
'page and filters invalidate the selected session',
);
});
test('date filters use the operator local calendar day', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(
monitor,
'localDateBoundaryToISOString(startDate, false)',
'local start-of-day conversion',
);
includes(
monitor,
'localDateBoundaryToISOString(endDate, true)',
'local end-of-day conversion',
);
});
test('session tool calls are bounded to the visible message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, 'startTime: sorted[0]?.timestamp', 'analysis page start');
includes(
monitor,
'endTime: sorted[sorted.length - 1]?.timestamp',
'analysis page end',
);
includes(monitor, 'sessionId, botId, {', 'bot-scoped analysis');
const client = read('src/app/infra/http/BackendClient.ts');
includes(
client,
"queryParams.set('startTime', options.startTime)",
'analysis start query',
);
includes(
client,
"queryParams.set('endTime', options.endTime)",
'analysis end query',
);
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const file = 'src/app/home/components/dynamic-form/StructuredFieldValue.ts';
function load() {
const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const m = { exports: {} };
new Function('module', 'exports', compiled)(m, m.exports);
return m.exports;
}
test('JSON parsing preserves nested false, zero, null and arrays as typed values', () => {
const { parseStructuredDraft } = load();
const value = { n: { a: false, b: 0, c: null, d: [false, 0, null] } };
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), false), value);
});
test('prompt parsing preserves structured content, null, empty arrays, metadata and first role', () => {
const { parseStructuredDraft } = load();
const value = [
{ role: 'user', content: [{ type: 'text', text: 'x' }], name: 'alice' },
{ role: 'assistant', content: null, tool_calls: [] },
{ role: 'tool', content: [], tool_call_id: 'x' },
];
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), true), value);
});
test('prompt metadata edits preserve omitted content without inserting null', () => {
const { parseStructuredDraft, isPromptValue } = load();
const value = [
{
role: 'assistant',
tool_calls: [],
name: 'edited',
provider_specific_fields: { cache: false },
},
];
assert.equal(isPromptValue(value), true);
const result = parseStructuredDraft(JSON.stringify(value), true);
assert.deepEqual(result, value);
assert.equal(Object.hasOwn(result[0], 'content'), false);
for (const content of [false, 0, {}, undefined])
assert.equal(isPromptValue([{ ...value[0], content }]), false);
});
test('invalid JSON and non-message prompt shapes reject rather than silently retain old values', () => {
const { parseStructuredDraft } = load();
for (const raw of ['{broken', '', 'undefined'])
assert.throws(() => parseStructuredDraft(raw, false));
for (const raw of [
'{}',
'null',
'[{"role":0,"content":"x"}]',
'[{"role":"user","content":false}]',
])
assert.throws(() => parseStructuredDraft(raw, true));
});
test('friendly prompt editor is restricted to losslessly represented roles and string content', () => {
const { isSimplePrompt } = load();
assert.equal(
isSimplePrompt([
{ role: 'system', content: '' },
{ role: 'user', content: 'x' },
]),
true,
);
for (const value of [
[{ role: 'user', content: 'x' }],
[{ role: 'system', content: null }],
[{ role: 'system', content: [] }],
[{ role: 'system', content: 'x', name: 'a' }],
[],
])
assert.equal(isSimplePrompt(value), false);
});
+19 -10
View File
@@ -28,6 +28,7 @@ function loadWizardUtils() {
}
const {
LOCAL_AGENT_RUNNER_ID,
configureLocalAgentPrimaryModel,
ensureHttpBotSigningSecret,
findDefaultPipeline,
@@ -81,23 +82,31 @@ test('configures the selected model as the Local Agent primary model', () => {
const config = {
trigger: { prefix: '!' },
ai: {
runner: { runner: 'plugin:external', timeout: 30 },
'local-agent': {
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
tools: { enabled: true },
runner: { id: 'plugin:external', timeout: 30 },
runner_config: {
[LOCAL_AGENT_RUNNER_ID]: {
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
tools: { enabled: true },
},
},
},
};
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
assert.equal(updated.ai.runner.runner, 'local-agent');
assert.equal(updated.ai.runner.id, LOCAL_AGENT_RUNNER_ID);
assert.equal(updated.ai.runner.timeout, 30);
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
'fallback-model',
]);
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
assert.equal(
updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.primary,
'selected-model',
);
assert.deepEqual(
updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.fallbacks,
['fallback-model'],
);
assert.deepEqual(updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].tools, {
enabled: true,
});
assert.deepEqual(updated.trigger, { prefix: '!' });
});
+9 -15
View File
@@ -45,20 +45,20 @@ test('opens the Page Bot preview after every successful wizard save', () => {
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', () => {
test('binds every wizard bot to its provisional pipeline before verification', () => {
assert.match(
wizardSource,
/selectedScenarioDefinition\?\.processorKind === 'pipeline'[\s\S]*?httpClient\.createPipeline\(/,
/if \(!previewPipelineUuid\) \{[\s\S]*?httpClient\.createPipeline\(/,
);
assert.match(
wizardSource,
/event_pattern: selectedScenarioDefinition\.eventType,[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/,
/event_pattern: 'message\.received',[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/,
);
assert.doesNotMatch(
wizardSource,
@@ -89,25 +89,19 @@ test('requires the selected Runner mandatory configuration before finishing', ()
wizardSource,
/isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/,
);
assert.match(wizardSource, /aiChoice === 'external'/);
assert.match(
wizardSource,
/return selectedRunner !== null && isRunnerConfigComplete/,
);
assert.match(
wizardSource,
/!selectedRunner \|\|[\s\S]*?!isRunnerConfigComplete \|\|[\s\S]*?!createdBotUuid/,
/!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/,
);
});
test('requires an observed message only for the message-reply scenario', () => {
test('requires an observed message before configuring the runner', () => {
assert.match(
wizardSource,
/selectedScenario !== 'message_reply' \|\| messageReceived/,
);
assert.match(
wizardSource,
/requiresMessageVerification=\{selectedScenario === 'message_reply'\}/,
/createdBotUuid !== null && botSaved && messageReceived/,
);
assert.match(wizardSource, /requiresMessageVerification/);
assert.match(wizardSource, /onMessageReceived=\{handleMessageReceived\}/);
});