mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
Merge remote-tracking branch 'origin/master' into dev/4.11.x
# Conflicts: # src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py # src/langbot/pkg/api/http/service/bot.py # src/langbot/pkg/provider/runners/localagent.py # src/langbot/templates/metadata/pipeline/ai.yaml # tests/unit_tests/api/service/test_bot_service.py # tests/unit_tests/provider/runners/test_difysvapi_runner.py # tests/unit_tests/utils/test_safe_regex.py # web/src/app/infra/entities/adapter-categories.ts # web/src/app/wizard/page.tsx # web/src/i18n/locales/en-US.ts # web/src/i18n/locales/ja-JP.ts # web/src/i18n/locales/zh-Hans.ts # web/tests/e2e/plugin-page-auth.spec.ts
This commit is contained in:
@@ -63,12 +63,28 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||
|
||||
let authenticatedAssetRequests = 0;
|
||||
let pageSdkRequests = 0;
|
||||
let pageApiRequests = 0;
|
||||
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
|
||||
pageSdkRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: 'window.langbot = { onReady(callback) { callback(); } };',
|
||||
body: `window.langbot = {
|
||||
onReady(callback) { callback(); },
|
||||
api(endpoint, body, method) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = 'request-' + Date.now();
|
||||
const handler = (event) => {
|
||||
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve(event.data.data);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
@@ -78,28 +94,48 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `<!doctype html><html><body><main></main>
|
||||
body: `<!doctype html><html><body><main></main><button id="save">Save</button>
|
||||
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
|
||||
<script>
|
||||
langbot.onReady(() => {
|
||||
document.querySelector('main').innerHTML = '<h1>LangRAG Observability</h1>';
|
||||
document.querySelector('#save').addEventListener('click', async () => {
|
||||
await window.langbot.api('/settings', { enabled: true }, 'POST');
|
||||
document.body.dataset.saved = 'true';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body></html>`,
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
|
||||
async (route) => {
|
||||
pageApiRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({ saved: true }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||
);
|
||||
|
||||
const pluginFrame = page.frameLocator('iframe');
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('iframe')
|
||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||
).toBeVisible();
|
||||
await pluginFrame.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(pluginFrame.locator('body')).toHaveAttribute(
|
||||
'data-saved',
|
||||
'true',
|
||||
);
|
||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||
expect(pageSdkRequests).toBeGreaterThan(0);
|
||||
expect(pageSdkRequests).toBe(1);
|
||||
expect(pageApiRequests).toBe(1);
|
||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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/infra/entities/adapter-categories.ts',
|
||||
);
|
||||
|
||||
function loadCategoryHelpers(language = 'zh-Hans') {
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
esModuleInterop: true,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) => {
|
||||
if (name === 'i18next') return { language };
|
||||
throw new Error(`Unexpected runtime import: ${name}`);
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
test('places an adapter only once when metadata repeats a category', () => {
|
||||
const { groupByCategory } = loadCategoryHelpers();
|
||||
const adapter = { name: 'http_bot', categories: ['popular', 'popular'] };
|
||||
|
||||
assert.deepEqual(groupByCategory([adapter]), [
|
||||
{ categoryId: 'popular', items: [adapter] },
|
||||
]);
|
||||
});
|
||||
@@ -24,11 +24,11 @@ function loadNormalizer() {
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
||||
const { normalizeDynamicFormValuesForSave } = loadNormalizer();
|
||||
const specs = [
|
||||
{ name: 'single-line', type: 'string', default: '' },
|
||||
{ name: 'multiline', type: 'text', default: '' },
|
||||
@@ -70,3 +70,29 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes missing dynamic form defaults into controlled values', () => {
|
||||
const { normalizeDynamicFormFieldValue } = loadNormalizer();
|
||||
|
||||
assert.equal(
|
||||
normalizeDynamicFormFieldValue(
|
||||
{ name: 'api-key', type: 'string', default: undefined },
|
||||
undefined,
|
||||
),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDynamicFormFieldValue(
|
||||
{ name: 'enabled', type: 'boolean', default: undefined },
|
||||
undefined,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeDynamicFormFieldValue(
|
||||
{ name: 'items', type: 'array[string]', default: undefined },
|
||||
undefined,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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 utilsPath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/monitoring/utils.ts',
|
||||
);
|
||||
const componentPath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/monitoring/components/TokenMonitoring.tsx',
|
||||
);
|
||||
|
||||
function loadMonitoringUtils() {
|
||||
const source = fs.readFileSync(utilsPath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('Monitoring utils must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
const { getErrorMessage } = loadMonitoringUtils();
|
||||
|
||||
test('token monitoring extracts messages from structured API errors', () => {
|
||||
assert.equal(
|
||||
getErrorMessage({
|
||||
code: 500,
|
||||
msg: 'SQLite aggregation failed',
|
||||
data: null,
|
||||
}),
|
||||
'SQLite aggregation failed',
|
||||
);
|
||||
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
|
||||
assert.equal(getErrorMessage('Request failed'), 'Request failed');
|
||||
});
|
||||
|
||||
test('token monitoring uses the structured API error helper', () => {
|
||||
const source = fs.readFileSync(componentPath, 'utf8');
|
||||
assert.match(source, /import \{ getErrorMessage \} from '\.\.\/utils';/);
|
||||
assert.match(source, /setError\(getErrorMessage\(e\)\)/);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
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/wizard/utils.ts',
|
||||
);
|
||||
|
||||
function loadWizardUtils() {
|
||||
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('Wizard utils must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
const {
|
||||
configureLocalAgentPrimaryModel,
|
||||
ensureHttpBotSigningSecret,
|
||||
findDefaultPipeline,
|
||||
getErrorMessage,
|
||||
isRequiredRunnerConfigComplete,
|
||||
isWebhookModeEnabled,
|
||||
} = loadWizardUtils();
|
||||
|
||||
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
|
||||
const config = ensureHttpBotSigningSecret('http_bot', {
|
||||
signature_required: true,
|
||||
inbound_secret: '',
|
||||
});
|
||||
|
||||
assert.match(config.inbound_secret, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('preserves existing or intentionally disabled HTTP Bot signing config', () => {
|
||||
const existing = { signature_required: true, inbound_secret: 'keep-me' };
|
||||
const disabled = { signature_required: false, inbound_secret: '' };
|
||||
|
||||
assert.equal(ensureHttpBotSigningSecret('http_bot', existing), existing);
|
||||
assert.equal(ensureHttpBotSigningSecret('http_bot', disabled), disabled);
|
||||
});
|
||||
|
||||
test('does not add signing config to other adapters', () => {
|
||||
const config = {};
|
||||
|
||||
assert.equal(ensureHttpBotSigningSecret('web_page_bot', config), config);
|
||||
});
|
||||
|
||||
test('extracts the backend message from structured API errors', () => {
|
||||
assert.equal(
|
||||
getErrorMessage({ code: 400, msg: 'Signing secret is required' }),
|
||||
'Signing secret is required',
|
||||
);
|
||||
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
|
||||
});
|
||||
|
||||
test('selects only a usable Workspace default pipeline', () => {
|
||||
const pipelines = [
|
||||
{ uuid: 'recent-pipeline', is_default: false },
|
||||
{ uuid: '', is_default: true },
|
||||
{ uuid: 'default-pipeline', is_default: true },
|
||||
];
|
||||
|
||||
assert.equal(findDefaultPipeline(pipelines)?.uuid, 'default-pipeline');
|
||||
});
|
||||
|
||||
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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
|
||||
|
||||
assert.equal(updated.ai.runner.runner, 'local-agent');
|
||||
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.deepEqual(updated.trigger, { prefix: '!' });
|
||||
});
|
||||
|
||||
test('shows webhook guidance only when the adapter webhook mode is active', () => {
|
||||
const dualModeFields = [
|
||||
{
|
||||
name: 'webhook_url',
|
||||
show_if: { field: 'enable-webhook', operator: 'eq', value: true },
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': false }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(isWebhookModeEnabled([{ name: 'webhook_url' }], {}), true);
|
||||
assert.equal(isWebhookModeEnabled([], {}), false);
|
||||
});
|
||||
|
||||
test('requires real values for required external runner configuration', () => {
|
||||
const fields = [
|
||||
{ name: 'base-url', required: true, default: 'https://api.dify.ai/v1' },
|
||||
{ name: 'api-key', required: true, default: 'your-api-key' },
|
||||
{ name: 'optional', required: false, default: '' },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
isRequiredRunnerConfigComplete(fields, {
|
||||
'base-url': 'https://api.dify.ai/v1',
|
||||
'api-key': 'your-api-key',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRequiredRunnerConfigComplete(fields, {
|
||||
'base-url': 'https://api.dify.ai/v1',
|
||||
'api-key': 'app-real-key',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
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 currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const wizardSource = fs.readFileSync(
|
||||
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const widgetSource = fs.readFileSync(
|
||||
path.resolve(
|
||||
currentDirectory,
|
||||
'../../../src/langbot/templates/embed/widget.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('shows the test-only notice only when the wizard opts in', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/,
|
||||
);
|
||||
assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/,
|
||||
);
|
||||
assert.match(widgetSource, /getAttribute\("data-test-notice"\)/);
|
||||
assert.match(widgetSource, /if \(scriptTestNotice\)/);
|
||||
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
|
||||
});
|
||||
|
||||
test('keeps the 4.11 AgentRunner marketplace installation flow', () => {
|
||||
assert.match(wizardSource, /RUNNER_COMPONENT_FILTER = 'AgentRunner'/);
|
||||
assert.match(wizardSource, /installPluginFromMarketplace\(/);
|
||||
assert.match(wizardSource, /runnerPluginPrefix\(plugin\)/);
|
||||
assert.match(wizardSource, /registrationDeadline/);
|
||||
});
|
||||
|
||||
test('requires an observed message only for the message-reply scenario', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/selectedScenario !== 'message_reply' \|\| messageReceived/,
|
||||
);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/requiresMessageVerification=\{selectedScenario === 'message_reply'\}/,
|
||||
);
|
||||
assert.match(wizardSource, /onMessageReceived=\{handleMessageReceived\}/);
|
||||
});
|
||||
|
||||
test('warns local-account users after the bot receives an IM message', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/messageReceived && userInfo\?\.account_type !== 'space'/,
|
||||
);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/wizard\.botConfig\.messageReceivedLocalAccountWarning/,
|
||||
);
|
||||
assert.match(wizardSource, /<AlertTriangle className="size-3 text-white"/);
|
||||
});
|
||||
|
||||
test('offers the HTTP Bot test through the signed inbound API', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/testHttpBotInbound\(createdBotUuid, testMessage\.trim\(\)\)/,
|
||||
);
|
||||
assert.match(wizardSource, /wizard\.botConfig\.sendHttpTest/);
|
||||
});
|
||||
Reference in New Issue
Block a user