mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 17:35:19 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 />);
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+17
@@ -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
@@ -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\)/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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: '!' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user