mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
test(web): exercise marketplace installs and wizard contracts
This commit is contained in:
+300
-224
@@ -865,253 +865,329 @@ test.describe('pipeline advanced flows', () => {
|
||||
});
|
||||
|
||||
test.describe('agent runner resource selectors', () => {
|
||||
test('installs an Runner from the grouped empty selector and refreshes it', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
for (const kind of ['agent', 'pipeline'] as const) {
|
||||
test(`installs an Runner from the grouped empty selector and refreshes it in ${kind}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
const runnerId = 'plugin:qa/MarketplaceRunner/default';
|
||||
let installed = false;
|
||||
let installRequests = 0;
|
||||
let taskPolls = 0;
|
||||
let marketplaceSearchBody: Record<string, unknown> | undefined;
|
||||
const runnerId = 'plugin:qa/MarketplaceRunner/default';
|
||||
let installed = false;
|
||||
let finished = false;
|
||||
let installRequests = 0;
|
||||
let taskPolls = 0;
|
||||
let marketplaceSearchBody: Record<string, unknown> | undefined;
|
||||
|
||||
const apiResponse = (data: unknown) =>
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const runnerConfig = () => ({
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
|
||||
config: [
|
||||
{
|
||||
name: 'id',
|
||||
label: { en_US: 'Runner', zh_Hans: '运行器' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: '',
|
||||
options: installed
|
||||
? [
|
||||
{
|
||||
name: runnerId,
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
...(installed
|
||||
? [
|
||||
const apiResponse = (data: unknown) =>
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const runnerConfig = () => ({
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
|
||||
config: [
|
||||
{
|
||||
name: runnerId,
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
config: [],
|
||||
name: 'id',
|
||||
label: { en_US: 'Runner', zh_Hans: '运行器' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: '',
|
||||
options: installed
|
||||
? [
|
||||
{
|
||||
name: runnerId,
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/agents/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
runner_config: runnerConfig(),
|
||||
kinds: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/agents/agent-empty', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
agent: {
|
||||
uuid: 'agent-empty',
|
||||
name: 'Empty Runner Agent',
|
||||
description: '',
|
||||
emoji: 'A',
|
||||
kind: 'agent',
|
||||
config: {
|
||||
runner: { id: '', 'expire-time': 0 },
|
||||
runner_config: {},
|
||||
},
|
||||
supported_event_patterns: ['*'],
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({ configs: [runnerConfig()] }),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
plugins: installed
|
||||
...(installed
|
||||
? [
|
||||
{
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: { author: 'qa', name: 'MarketplaceRunner' },
|
||||
},
|
||||
name: runnerId,
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
config: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins/install/marketplace', (route) => {
|
||||
installRequests += 1;
|
||||
installed = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({ task_id: 77 }),
|
||||
: []),
|
||||
],
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/77', (route) => {
|
||||
taskPolls += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
id: 77,
|
||||
name: 'plugin-install-marketplace',
|
||||
label: 'Marketplace Runner',
|
||||
runtime: { done: true, exception: null },
|
||||
task_context: { current_action: 'complete', metadata: {} },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
'https://space.langbot.app/api/v1/marketplace/extensions/search',
|
||||
async (route) => {
|
||||
marketplaceSearchBody = JSON.parse(
|
||||
route.request().postData() || '{}',
|
||||
) as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
|
||||
await page.route('**/api/v1/agents/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
extensions: [
|
||||
{
|
||||
id: 1,
|
||||
plugin_id: 'qa/MarketplaceRunner',
|
||||
author: 'qa',
|
||||
name: 'MarketplaceRunner',
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Runner used by the grouped selector test.',
|
||||
zh_Hans: '用于分组选择器测试的运行器。',
|
||||
},
|
||||
icon: '',
|
||||
repository: 'https://example.test/runner',
|
||||
tags: [],
|
||||
install_count: 12,
|
||||
latest_version: '1.0.0',
|
||||
components: { Runner: 1 },
|
||||
runner_usages: ['agent'],
|
||||
status: 'live',
|
||||
type: 'plugin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
runner_config: runnerConfig(),
|
||||
kinds: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/agents/agent-empty', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
agent: {
|
||||
uuid: 'agent-empty',
|
||||
name: 'Empty Runner Agent',
|
||||
description: '',
|
||||
emoji: 'A',
|
||||
kind: 'agent',
|
||||
config: {
|
||||
runner: { id: '', 'expire-time': 0 },
|
||||
runner_config: {},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
supported_event_patterns: ['*'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({ configs: [runnerConfig()] }),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
plugins: installed
|
||||
? [
|
||||
{
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: {
|
||||
author: 'qa',
|
||||
name: 'MarketplaceRunner',
|
||||
description: {
|
||||
en_US: 'Installed plugin description',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins/install/marketplace', (route) => {
|
||||
installRequests += 1;
|
||||
installed = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({ task_id: 77 }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/77', (route) => {
|
||||
taskPolls += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
id: 77,
|
||||
name: 'plugin-install-marketplace',
|
||||
label: 'Marketplace Runner',
|
||||
runtime: { done: finished, exception: null },
|
||||
task_context: { current_action: 'complete', metadata: {} },
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto('/home/agents?id=agent-empty');
|
||||
await page.getByRole('tab', { name: /^Runner$/ }).click();
|
||||
|
||||
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
|
||||
const triggerBox = await runnerSelect.boundingBox();
|
||||
await runnerSelect.click();
|
||||
await expect(page.getByText('Installed Runners')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No Runner extension is installed yet.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible();
|
||||
const selectorPopup = page.locator('[data-slot="select-content"]');
|
||||
await expect(
|
||||
selectorPopup.getByText('Runner used by the grouped selector test.', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const popupBox = await selectorPopup.boundingBox();
|
||||
expect(triggerBox?.width).toBeLessThanOrEqual(353);
|
||||
expect(popupBox?.width ?? 0).toBeLessThanOrEqual(
|
||||
(triggerBox?.width ?? 0) + 1,
|
||||
);
|
||||
expect(popupBox?.width ?? 0).toBeGreaterThan((triggerBox?.width ?? 0) - 20);
|
||||
await expect
|
||||
.poll(() => marketplaceSearchBody)
|
||||
.toMatchObject({
|
||||
component_filter: 'Runner',
|
||||
type_filter: 'plugin',
|
||||
});
|
||||
await page.route(
|
||||
'https://space.langbot.app/api/v1/marketplace/extensions/search',
|
||||
async (route) => {
|
||||
marketplaceSearchBody = JSON.parse(
|
||||
route.request().postData() || '{}',
|
||||
) as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: apiResponse({
|
||||
extensions: [
|
||||
{
|
||||
id: 1,
|
||||
plugin_id: 'qa/MarketplaceRunner',
|
||||
author: 'qa',
|
||||
name: 'MarketplaceRunner',
|
||||
label: {
|
||||
en_US: 'Marketplace Runner',
|
||||
zh_Hans: '市场运行器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Runner used by the grouped selector test.',
|
||||
zh_Hans: '用于分组选择器测试的运行器。',
|
||||
},
|
||||
icon: '',
|
||||
repository: 'https://example.test/runner',
|
||||
tags: [],
|
||||
install_count: 12,
|
||||
latest_version: '1.0.0',
|
||||
components: { Runner: 1 },
|
||||
runner_usages: ['agent'],
|
||||
status: 'live',
|
||||
type: 'plugin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
author: 'qa',
|
||||
name: 'SecondRunner',
|
||||
label: { en_US: 'A much longer second Runner label' },
|
||||
description: { en_US: 'Second runner description' },
|
||||
latest_version: '1.0.0',
|
||||
components: { Runner: 1 },
|
||||
runner_usages: ['agent'],
|
||||
install_count: 1,
|
||||
type: 'plugin',
|
||||
},
|
||||
].filter(
|
||||
(plugin) => !installed || plugin.name !== 'MarketplaceRunner',
|
||||
),
|
||||
total: installed ? 1 : 2,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Install Marketplace Runner', exact: true })
|
||||
.click();
|
||||
await page.route('**/api/v1/pipelines/agent-empty', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'agent-empty',
|
||||
kind: 'pipeline',
|
||||
name: 'Empty Runner Pipeline',
|
||||
description: '',
|
||||
emoji: 'P',
|
||||
is_default: false,
|
||||
config: {
|
||||
ai: {
|
||||
runner: { id: '', 'expire-time': 0 },
|
||||
runner_config: {},
|
||||
},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
kind === 'agent'
|
||||
? '/home/agents?id=agent-empty'
|
||||
: '/home/pipelines?id=agent-empty',
|
||||
);
|
||||
await page
|
||||
.getByRole('tab', { name: kind === 'agent' ? /^Runner$/ : /^AI$/ })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => installRequests).toBe(1);
|
||||
await expect.poll(() => taskPolls).toBeGreaterThan(0);
|
||||
await expect(
|
||||
page.getByRole('option', {
|
||||
name: 'Marketplace Runner Runner used by the grouped selector test.',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Marketplace Runner Runner used by the grouped selector test.',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(runnerSelect).toContainText('Marketplace Runner');
|
||||
const runnerSelect = page.getByRole('combobox', {
|
||||
name: 'Runner',
|
||||
includeHidden: true,
|
||||
});
|
||||
const triggerBox = await runnerSelect.boundingBox();
|
||||
await runnerSelect.click();
|
||||
await expect(page.getByText('Installed Runners')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No Runner extension is installed yet.'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Runner plugins in Marketplace'),
|
||||
).toBeVisible();
|
||||
const selectorPopup = page.locator('[data-slot="select-content"]');
|
||||
await expect(
|
||||
selectorPopup.getByText('Runner used by the grouped selector test.', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const popupBox = await selectorPopup.boundingBox();
|
||||
expect(triggerBox?.width).toBeLessThanOrEqual(353);
|
||||
expect(popupBox?.width ?? 0).toBeLessThanOrEqual(
|
||||
(triggerBox?.width ?? 0) + 1,
|
||||
);
|
||||
expect(popupBox?.width ?? 0).toBeGreaterThan(
|
||||
(triggerBox?.width ?? 0) - 20,
|
||||
);
|
||||
await expect
|
||||
.poll(() => marketplaceSearchBody)
|
||||
.toMatchObject({
|
||||
component_filter: 'Runner',
|
||||
type_filter: 'plugin',
|
||||
});
|
||||
|
||||
await runnerSelect.click();
|
||||
await expect(
|
||||
page.getByRole('option', {
|
||||
name: 'Marketplace Runner Runner used by the grouped selector test.',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
const install = page.getByRole('button', {
|
||||
name: 'Install Marketplace Runner',
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
const otherInstall = page.getByRole('button', {
|
||||
name: 'Install A much longer second Runner label',
|
||||
exact: true,
|
||||
});
|
||||
const positions = await Promise.all([
|
||||
install.boundingBox(),
|
||||
otherInstall.boundingBox(),
|
||||
]);
|
||||
expect(positions.every(Boolean)).toBe(true);
|
||||
expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
Math.abs(positions[0]!.width - positions[1]!.width),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await install.click();
|
||||
await expect(install).toContainText('%');
|
||||
await expect(otherInstall).toBeDisabled();
|
||||
await expect(runnerSelect).not.toContainText('Marketplace Runner');
|
||||
finished = true;
|
||||
|
||||
await expect.poll(() => installRequests).toBe(1);
|
||||
await expect.poll(() => taskPolls).toBeGreaterThan(0);
|
||||
await expect(
|
||||
page.getByRole('option', {
|
||||
name: 'Marketplace Runner Installed plugin description',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(runnerSelect).not.toContainText('Marketplace Runner');
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Install Marketplace Runner',
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expect(otherInstall).toBeEnabled();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Marketplace Runner Installed plugin description',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(runnerSelect).toContainText('Marketplace Runner');
|
||||
});
|
||||
}
|
||||
|
||||
test('uses the compact Runner marketplace selector in pipeline AI settings', async ({
|
||||
page,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
for (const reload of [false, true]) {
|
||||
test(`knowledge engine installation keeps fields and requires explicit selection${reload ? ' after reload' : ''}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let finished = false,
|
||||
installs = 0;
|
||||
const searches: Record<string, unknown>[] = [];
|
||||
const engine = {
|
||||
plugin_id: 'qa/engine',
|
||||
name: { en_US: 'Installed QA Engine' },
|
||||
description: { en_US: 'Registered knowledge engine description' },
|
||||
creation_schema: [],
|
||||
retrieval_schema: [],
|
||||
capabilities: [],
|
||||
};
|
||||
await page.route('**/api/v1/knowledge/engines', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { engines: finished ? [engine] : [] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/marketplace/**/search', async (route) => {
|
||||
searches.push(route.request().postDataJSON());
|
||||
const plugin = (name: string, label: string) => ({
|
||||
author: 'qa',
|
||||
name,
|
||||
label: { en_US: label },
|
||||
description: { en_US: `${label} description` },
|
||||
latest_version: '2.0.0',
|
||||
type: 'plugin',
|
||||
components: { KnowledgeEngine: 1 },
|
||||
install_count: 10,
|
||||
});
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
total: 2,
|
||||
extensions: [
|
||||
plugin('engine', 'QA Engine'),
|
||||
plugin('long-engine', 'A much longer engine display label'),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
|
||||
installs++;
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
plugin_author: 'qa',
|
||||
plugin_name: 'engine',
|
||||
plugin_version: '2.0.0',
|
||||
});
|
||||
await route.fulfill({ json: { code: 0, data: { task_id: 42 } } });
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/42', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
id: 42,
|
||||
name: 'plugin-install-marketplace',
|
||||
runtime: { done: finished, exception: null },
|
||||
task_context: {
|
||||
current_action: 'Downloading',
|
||||
metadata: { progress_percent: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/knowledge?id=new');
|
||||
await page.locator('input[name="name"]').fill('Engine choice test');
|
||||
await expect(page.locator('input[name="description"]')).toBeVisible();
|
||||
const selector = page.getByRole('combobox', {
|
||||
name: 'Knowledge Engine',
|
||||
exact: true,
|
||||
});
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('Knowledge engine is required', { exact: true }),
|
||||
).toBeVisible();
|
||||
await selector.click();
|
||||
const first = page.getByRole('button', {
|
||||
name: 'Install QA Engine',
|
||||
exact: true,
|
||||
});
|
||||
const second = page.getByRole('button', {
|
||||
name: 'Install A much longer engine display label',
|
||||
exact: true,
|
||||
});
|
||||
await expect(first).toBeVisible();
|
||||
const positions = await Promise.all([
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
expect(positions.every(Boolean)).toBe(true);
|
||||
expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(positions[0]!.width - positions[1]!.width),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await first.click();
|
||||
await expect(first).toContainText('%');
|
||||
await expect(second).toBeDisabled();
|
||||
if (reload) {
|
||||
await expect.poll(() => installs).toBe(1);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Recovered engine choice');
|
||||
await expect(selector).toHaveText('Select Knowledge Engine');
|
||||
}
|
||||
finished = true;
|
||||
await expect(
|
||||
page.getByText('QA Engine is installed and ready to select', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(selector).toHaveText('Select Knowledge Engine');
|
||||
if (reload)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('Knowledge engine is required', { exact: true }),
|
||||
).toBeVisible();
|
||||
await selector.click();
|
||||
const option = page.getByRole('option', { name: /Installed QA Engine/ });
|
||||
await expect(
|
||||
option.getByText('Registered knowledge engine description'),
|
||||
).toBeVisible();
|
||||
await option.click();
|
||||
await expect(selector).toHaveText('Installed QA Engine');
|
||||
await expect(
|
||||
page.getByText('Knowledge engine is required', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
const selectedBox = await selector.boundingBox();
|
||||
expect(selectedBox!.height).toBeLessThanOrEqual(40);
|
||||
expect(installs).toBe(1);
|
||||
expect(searches.length).toBeGreaterThan(0);
|
||||
for (const request of searches)
|
||||
expect(request).toMatchObject({
|
||||
component_filter: 'KnowledgeEngine',
|
||||
type_filter: 'plugin',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
test('marketplace defaults to hot sorting and updates the same browser like on its card', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const searches: Record<string, unknown>[] = [],
|
||||
updates: unknown[] = [];
|
||||
let fingerprint = '';
|
||||
await page.route('**/api/v1/marketplace/extensions/search', async (route) => {
|
||||
searches.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
total: 1,
|
||||
extensions: [
|
||||
{
|
||||
author: 'qa',
|
||||
name: 'liked',
|
||||
label: { en_US: 'QA Likes' },
|
||||
description: { en_US: 'Like test plugin' },
|
||||
latest_version: '1.0.0',
|
||||
type: 'plugin',
|
||||
status: 'live',
|
||||
tags: [],
|
||||
components: {},
|
||||
install_count: 5,
|
||||
like_count: 12,
|
||||
hot_score: 17,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
'**/api/v1/marketplace/extensions/likes/status',
|
||||
async (route) => {
|
||||
fingerprint = route.request().postDataJSON().fingerprint;
|
||||
expect(fingerprint).toEqual(expect.any(String));
|
||||
expect(fingerprint.length).toBeGreaterThan(0);
|
||||
await route.fulfill({ json: { code: 0, data: { extensions: [] } } });
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/marketplace/extensions/plugin/qa/liked/like',
|
||||
async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
updates.push(body);
|
||||
expect(route.request().method()).toBe('PUT');
|
||||
expect(body.fingerprint).toBe(fingerprint);
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
type: 'plugin',
|
||||
extension_id: 'qa/liked',
|
||||
liked: body.liked,
|
||||
like_count: body.liked ? 13 : 12,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/add-extension');
|
||||
await expect(page.getByText('QA Likes', { exact: true })).toBeVisible();
|
||||
await expect.poll(() => fingerprint).not.toBe('');
|
||||
const like = page.getByRole('button', { name: 'Like', exact: true });
|
||||
await expect(like).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(like).toHaveText('12');
|
||||
expect(searches.length).toBeGreaterThan(0);
|
||||
expect(searches[0]).toMatchObject({
|
||||
sort_by: 'hot_score',
|
||||
sort_order: 'DESC',
|
||||
});
|
||||
await like.click();
|
||||
const unlike = page.getByRole('button', { name: 'Unlike', exact: true });
|
||||
await expect(unlike).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(unlike).toHaveText('13');
|
||||
await unlike.click();
|
||||
await expect(like).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(like).toHaveText('12');
|
||||
expect(updates).toEqual([
|
||||
{ fingerprint, liked: true },
|
||||
{ fingerprint, liked: false },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
for (const entry of ['list', 'sidebar'] as const) {
|
||||
test(`plugin upgrade from ${entry} recovers its operation and progress after reload`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let upgrades = 0,
|
||||
phase: 'checking' | 'validating' | 'launching' | 'done' | 'failed' =
|
||||
'checking';
|
||||
const task = () => ({
|
||||
id: 80,
|
||||
name: 'plugin-upgrade-qa/upgradable',
|
||||
label: 'qa/upgradable',
|
||||
runtime: {
|
||||
done: phase === 'done' || phase === 'failed',
|
||||
exception: phase === 'failed' ? 'Signature rejected fixture' : null,
|
||||
},
|
||||
task_context: {
|
||||
current_action: {
|
||||
checking: 'Checking update',
|
||||
validating: 'Validating package',
|
||||
launching: 'Waiting for plugin to become ready',
|
||||
done: 'Plugin updated',
|
||||
failed: 'Validating package',
|
||||
}[phase],
|
||||
metadata: { plugin_name: 'qa/upgradable', operation: 'upgrade' },
|
||||
},
|
||||
});
|
||||
const plugin = {
|
||||
install_source: 'marketplace',
|
||||
install_info: {},
|
||||
debug: false,
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: {
|
||||
author: 'qa',
|
||||
name: 'upgradable',
|
||||
label: { en_US: 'Upgrade Fixture' },
|
||||
version: '1.0.0',
|
||||
description: { en_US: 'Upgrade fixture plugin' },
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
await page.route('**/api/v1/plugins', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { plugins: [plugin] } } }),
|
||||
);
|
||||
await page.route('**/api/v1/extensions', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { extensions: [{ type: 'plugin', plugin }] } },
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/plugins/qa/upgradable/upgrade',
|
||||
async (route) => {
|
||||
upgrades++;
|
||||
expect(route.request().method()).toBe('POST');
|
||||
await route.fulfill({ json: { code: 0, data: { task_id: 80 } } });
|
||||
},
|
||||
);
|
||||
await page.route('**/api/v1/system/tasks?**', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { tasks: upgrades ? [task()] : [] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/system/tasks/80', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: task() } }),
|
||||
);
|
||||
await page.goto('/home/extensions');
|
||||
if (entry === 'list') {
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ has: page.getByText('Upgrade Fixture', { exact: true }) });
|
||||
await card.getByRole('button').last().click();
|
||||
} else {
|
||||
const item = page
|
||||
.locator('[data-slot="sidebar-menu-sub-item"]')
|
||||
.filter({ has: page.getByRole('link', { name: /Upgrade Fixture/ }) });
|
||||
await item.hover();
|
||||
await item.getByRole('button').click();
|
||||
}
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Update Plugin', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Confirm Update', exact: true })
|
||||
.click();
|
||||
let dialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
dialog.getByRole('heading', {
|
||||
name: 'Updating qa/upgradable',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => upgrades).toBe(1);
|
||||
phase = 'validating';
|
||||
await expect(dialog.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
'45',
|
||||
);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Plugin Tasks/ }).click();
|
||||
await page.getByText('qa/upgradable', { exact: true }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
dialog.getByRole('heading', {
|
||||
name: 'Updating qa/upgradable',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
'45',
|
||||
);
|
||||
phase = 'launching';
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Number(
|
||||
await dialog.getByRole('progressbar').getAttribute('aria-valuenow'),
|
||||
),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(88);
|
||||
phase = entry === 'list' ? 'done' : 'failed';
|
||||
if (phase === 'done') {
|
||||
await expect(
|
||||
dialog.getByText('Plugin updated successfully', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
'100',
|
||||
);
|
||||
} else
|
||||
await expect(
|
||||
dialog.getByText('Signature rejected fixture', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(upgrades).toBe(1);
|
||||
});
|
||||
}
|
||||
@@ -364,6 +364,39 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('a normal Page Bot embed has no wizard notice and stays closed until opened', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto('/login');
|
||||
const source = fs
|
||||
.readFileSync(
|
||||
path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'),
|
||||
'utf8',
|
||||
)
|
||||
.replaceAll('__LANGBOT_LOCALE__', 'en_US')
|
||||
.replaceAll('__LANGBOT_BOT_UUID__', 'bot-1')
|
||||
.replaceAll('__LANGBOT_BASE_URL__', 'https://embed.test')
|
||||
.replaceAll('__LANGBOT_TURNSTILE_SITE_KEY__', '')
|
||||
.replaceAll('__LANGBOT_BUBBLE_ICON__', 'chat');
|
||||
await page.addScriptTag({ content: source });
|
||||
const root = page.locator('#langbot-widget-root');
|
||||
await expect(root).toBeAttached();
|
||||
expect(
|
||||
await root.evaluate(
|
||||
(element) =>
|
||||
element.shadowRoot?.querySelectorAll('.lb-test-notice').length,
|
||||
),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await root.evaluate((element) =>
|
||||
element.shadowRoot
|
||||
?.querySelector('.lb-panel')
|
||||
?.classList.contains('lb-visible'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('binds HTTP Bot before its signed inbound verification request', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -446,6 +479,16 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
async (route) => {
|
||||
inboundTestCount += 1;
|
||||
expect(route.request().method()).toBe('POST');
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
message: 'Explicit HTTP verification',
|
||||
});
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
expect(route.request().headers()['x-workspace-id']).toBe(
|
||||
'workspace-playwright',
|
||||
);
|
||||
expect(boundPipelineUuid).toBe('pipeline-1');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -467,152 +510,186 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
|
||||
await expect.poll(() => savedInboundSecret).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Send Test Message', exact: true })
|
||||
.locator('..')
|
||||
.locator('input')
|
||||
.fill(' Explicit HTTP verification ');
|
||||
await page.getByRole('button', { name: 'Send Test' }).click();
|
||||
await expect.poll(() => inboundTestCount).toBe(1);
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
for (const accountType of ['local', 'space'] as const) {
|
||||
test(`creates only a message pipeline after message verification and valid Runner configuration for ${accountType}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: ok({
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
|
||||
stages: [
|
||||
{
|
||||
name: '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: '运行方式' },
|
||||
config: [
|
||||
{
|
||||
name: 'id',
|
||||
label: { en_US: 'Runner', zh_Hans: '运行器' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'external-runner',
|
||||
options: [
|
||||
{
|
||||
name: 'external-runner',
|
||||
label: {
|
||||
en_US: 'External Runner',
|
||||
zh_Hans: '外部运行器',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'external-runner',
|
||||
label: {
|
||||
en_US: 'External Runner',
|
||||
zh_Hans: '外部运行器',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'api-key',
|
||||
name: 'api-key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
type: 'secret',
|
||||
required: true,
|
||||
default: 'your-api-key',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
await page.route('**/api/v1/user/info', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
account_uuid:
|
||||
accountType === 'space' ? 'account-playwright' : null,
|
||||
user: '[email protected]',
|
||||
account_type: accountType,
|
||||
has_password: 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,
|
||||
contentType: 'application/json',
|
||||
body: ok({
|
||||
configs: [
|
||||
{
|
||||
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: '运行方式' },
|
||||
config: [
|
||||
{
|
||||
name: 'id',
|
||||
label: { en_US: 'Runner', zh_Hans: '运行器' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'external-runner',
|
||||
options: [
|
||||
{
|
||||
name: 'external-runner',
|
||||
label: {
|
||||
en_US: 'External Runner',
|
||||
zh_Hans: '外部运行器',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'external-runner',
|
||||
label: {
|
||||
en_US: 'External Runner',
|
||||
zh_Hans: '外部运行器',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'api-key',
|
||||
name: 'api-key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
type: 'secret',
|
||||
required: true,
|
||||
default: 'your-api-key',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/wizard');
|
||||
await page.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 expect(
|
||||
page.getByRole('button', { name: 'Next', exact: true }),
|
||||
).toBeEnabled();
|
||||
const localWarning = page.getByText(
|
||||
'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.',
|
||||
{ exact: true },
|
||||
);
|
||||
if (accountType === 'local') await expect(localWarning).toBeVisible();
|
||||
else await expect(localWarning).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Next' }).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']);
|
||||
});
|
||||
|
||||
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 expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
|
||||
messageReceived = true;
|
||||
await page.getByRole('button', { name: 'Next' }).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) {
|
||||
test(`${qrPlatform.label} requests and displays its QR code`, async ({
|
||||
|
||||
@@ -1,146 +1,264 @@
|
||||
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';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(currentDirectory, '../..');
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||
function setup({
|
||||
tasks = [{ runtime: { done: true } }],
|
||||
engines = [{ engines: [] }],
|
||||
cloud,
|
||||
} = {}) {
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../../src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
const storage = new Map(),
|
||||
calls = [];
|
||||
let workspace = 'alpha',
|
||||
now = 0,
|
||||
taskIndex = 0,
|
||||
engineIndex = 0;
|
||||
const http = {
|
||||
getPlugins: async () => ({
|
||||
plugins: [
|
||||
{
|
||||
manifest: {
|
||||
manifest: { metadata: { author: 'qa', name: 'existing' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
installPluginFromMarketplace: async (...args) => {
|
||||
calls.push(['install', ...args]);
|
||||
return { task_id: 42 };
|
||||
},
|
||||
getAsyncTask: async (id) => {
|
||||
calls.push(['task', id]);
|
||||
return tasks[Math.min(taskIndex++, tasks.length - 1)];
|
||||
},
|
||||
getKnowledgeEngines: async () => {
|
||||
calls.push(['engines']);
|
||||
return engines[Math.min(engineIndex++, engines.length - 1)];
|
||||
},
|
||||
};
|
||||
const deps = {
|
||||
'@/app/infra/http': { getCloudServiceClient: async () => cloud },
|
||||
'@/app/infra/http/HttpClient': { httpClient: http },
|
||||
'@/app/infra/http/workspaceContext': {
|
||||
getActiveWorkspaceUuid: () => workspace,
|
||||
},
|
||||
};
|
||||
const mod = { exports: {} };
|
||||
new Function(
|
||||
'require',
|
||||
'module',
|
||||
'exports',
|
||||
'window',
|
||||
'sessionStorage',
|
||||
'CustomEvent',
|
||||
'Date',
|
||||
'setTimeout',
|
||||
code,
|
||||
)(
|
||||
(name) => {
|
||||
assert.ok(name in deps, name);
|
||||
return deps[name];
|
||||
},
|
||||
mod,
|
||||
mod.exports,
|
||||
new EventTarget(),
|
||||
{
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
},
|
||||
CustomEvent,
|
||||
{ now: () => now },
|
||||
(callback, ms) => {
|
||||
now += ms;
|
||||
queueMicrotask(callback);
|
||||
},
|
||||
);
|
||||
return {
|
||||
api: mod.exports,
|
||||
storage,
|
||||
calls,
|
||||
setWorkspace: (value) => {
|
||||
workspace = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
const plugin = {
|
||||
author: 'qa',
|
||||
name: 'engine',
|
||||
label: { en_US: 'QA Engine' },
|
||||
latest_version: '2.0.0',
|
||||
};
|
||||
const engine = { plugin_id: 'qa/engine', name: 'Registered QA Engine' };
|
||||
|
||||
const kbFormSource = readSource(
|
||||
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
|
||||
);
|
||||
const selectSource = readSource(
|
||||
'src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx',
|
||||
);
|
||||
const runnerSelectSource = readSource(
|
||||
'src/app/home/agents/components/RunnerSelect.tsx',
|
||||
);
|
||||
const agentFormSource = readSource(
|
||||
'src/app/home/agents/components/AgentFormComponent.tsx',
|
||||
);
|
||||
const pipelineFormSource = readSource(
|
||||
'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
|
||||
);
|
||||
const runnerMarketplaceSource = readSource(
|
||||
'src/app/home/agents/runner-marketplace.ts',
|
||||
);
|
||||
const marketplaceInstallButtonSource = readSource(
|
||||
'src/app/home/components/MarketplaceInstallButton.tsx',
|
||||
);
|
||||
const marketplaceSource = readSource(
|
||||
'src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts',
|
||||
);
|
||||
const taskContextSource = readSource(
|
||||
'src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx',
|
||||
);
|
||||
const progressDialogSource = readSource(
|
||||
'src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx',
|
||||
);
|
||||
const installedPluginsSource = readSource(
|
||||
'src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx',
|
||||
);
|
||||
const homeSidebarSource = readSource(
|
||||
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||
);
|
||||
|
||||
test('keeps the knowledge-base fields visible without an installed engine', () => {
|
||||
assert.match(kbFormSource, /<KnowledgeEngineSelect/);
|
||||
assert.match(kbFormSource, /name="name"/);
|
||||
assert.match(kbFormSource, /name="description"/);
|
||||
assert.doesNotMatch(kbFormSource, /if \(ragEngines\.length === 0\)/);
|
||||
});
|
||||
|
||||
test('offers KnowledgeEngine marketplace plugins inside the selector', () => {
|
||||
assert.match(
|
||||
marketplaceSource,
|
||||
/KNOWLEDGE_ENGINE_COMPONENT_FILTER = 'KnowledgeEngine'/,
|
||||
test('catalog loads every page with the engine filter and excludes unrelated components', async () => {
|
||||
const searches = [];
|
||||
const { api } = setup({
|
||||
cloud: {
|
||||
searchMarketplaceExtensions: async (args) => {
|
||||
searches.push(args);
|
||||
return {
|
||||
total: 201,
|
||||
plugins:
|
||||
args.page === 1
|
||||
? [
|
||||
{
|
||||
...plugin,
|
||||
components: { KnowledgeEngine: 1 },
|
||||
install_count: 5,
|
||||
},
|
||||
]
|
||||
: args.page === 2
|
||||
? [
|
||||
{
|
||||
name: 'runner',
|
||||
components: { Runner: 1 },
|
||||
install_count: 999,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
...plugin,
|
||||
name: 'popular',
|
||||
components: { KnowledgeEngine: 1 },
|
||||
install_count: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await api.loadKnowledgeEngineCatalog();
|
||||
assert.deepEqual(
|
||||
searches.map((value) => value.page),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.match(marketplaceSource, /installPluginFromMarketplace\(/);
|
||||
assert.match(marketplaceSource, /getKnowledgeEngines\(\)/);
|
||||
assert.match(marketplaceSource, /sessionStorage\.setItem\(/);
|
||||
assert.match(selectSource, /resumePendingKnowledgeEngineInstall\(/);
|
||||
assert.match(selectSource, /onInstalled\(installed\)/);
|
||||
assert.match(selectSource, /component=KnowledgeEngine/);
|
||||
});
|
||||
|
||||
test('tracks plugin upgrades as recoverable multistep async tasks', () => {
|
||||
assert.match(taskContextSource, /name\.startsWith\('plugin-upgrade-'\)/);
|
||||
assert.match(taskContextSource, /operation: PluginTaskOperation/);
|
||||
assert.match(taskContextSource, /computeStageProgress/);
|
||||
assert.match(taskContextSource, /INSTALL_PROGRESS_CAP/);
|
||||
assert.match(progressDialogSource, /InstallStage\.CHECKING/);
|
||||
assert.match(progressDialogSource, /InstallStage\.VALIDATING/);
|
||||
assert.match(progressDialogSource, /InstallStage\.LAUNCHING/);
|
||||
assert.match(progressDialogSource, /plugins\.installProgress\.updateTitle/);
|
||||
for (const source of [installedPluginsSource, homeSidebarSource]) {
|
||||
assert.match(
|
||||
source,
|
||||
/upgradePlugin\([\s\S]*?add(?:Plugin)?Task\([\s\S]*?operation: 'upgrade'/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/pluginTaskKey\(res\.task_id, 'marketplace', 'upgrade'\)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps marketplace install actions on one fixed vertical column', () => {
|
||||
for (const source of [selectSource, runnerSelectSource]) {
|
||||
assert.match(source, /grid-cols-\[1\.75rem_minmax\(0,1fr\)_4rem\]/);
|
||||
assert.match(source, /<MarketplaceInstallButton/);
|
||||
}
|
||||
assert.match(marketplaceInstallButtonSource, /justify-self-end/);
|
||||
});
|
||||
|
||||
test('uses a compact selected engine layout and clears stale required errors', () => {
|
||||
assert.match(selectSource, /function SelectedEngineContent/);
|
||||
assert.match(
|
||||
selectSource,
|
||||
/selectedEngine \? \([\s\S]*?<SelectedEngineContent engine=\{selectedEngine\}/,
|
||||
for (const args of searches)
|
||||
assert.deepEqual(args, {
|
||||
query: '',
|
||||
page: args.page,
|
||||
page_size: 100,
|
||||
type_filter: 'plugin',
|
||||
component_filter: 'KnowledgeEngine',
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.marketplaceEngines.map((value) => value.name),
|
||||
['popular', 'engine'],
|
||||
);
|
||||
assert.match(
|
||||
selectSource,
|
||||
/flex min-w-0 flex-1 items-center gap-2 text-left/,
|
||||
);
|
||||
assert.match(kbFormSource, /form\.clearErrors\('ragEngineId'\)/);
|
||||
assert.match(kbFormSource, /form\.trigger\('ragEngineId'\)/);
|
||||
assert.doesNotMatch(kbFormSource, /field\.onChange\(value\)/);
|
||||
assert.deepEqual(result.installedPluginIds, ['qa/existing']);
|
||||
});
|
||||
|
||||
test('installs marketplace components from inline progress buttons without selecting them', () => {
|
||||
for (const source of [selectSource, runnerSelectSource]) {
|
||||
assert.match(source, /const handleInstall = useCallback/);
|
||||
assert.match(source, /installing=\{activePluginId === pluginId\}/);
|
||||
assert.match(source, /progress=\{installProgress\}/);
|
||||
assert.doesNotMatch(source, /MARKETPLACE_VALUE_PREFIX/);
|
||||
}
|
||||
assert.match(marketplaceInstallButtonSource, /<Progress/);
|
||||
assert.match(marketplaceInstallButtonSource, /onPointerDown=/);
|
||||
assert.match(kbFormSource, /suppressNextAutoSelectRef\.current = true/);
|
||||
assert.doesNotMatch(
|
||||
kbFormSource,
|
||||
/handleEngineInstalled[\s\S]*?handleEngineChange\(engine\.plugin_id/,
|
||||
);
|
||||
for (const source of [agentFormSource, pipelineFormSource]) {
|
||||
const callback = source.match(
|
||||
/const applyInstalledRunner = useCallback\(([\s\S]*?)\n \);/,
|
||||
);
|
||||
assert.ok(callback);
|
||||
assert.doesNotMatch(callback[1], /form\.setValue/);
|
||||
}
|
||||
test('installation persists a scoped task before polling and waits for component registration', async () => {
|
||||
const { api, storage, calls } = setup({
|
||||
tasks: [{ runtime: { done: false } }, { runtime: { done: true } }],
|
||||
engines: [{ engines: [{ plugin_id: 'qa/other' }] }, { engines: [engine] }],
|
||||
});
|
||||
let created;
|
||||
const installed = await api.installMarketplaceKnowledgeEngine(plugin, {
|
||||
scope: 'kb:new',
|
||||
onTaskCreated: (id) => {
|
||||
created = id;
|
||||
assert.equal(api.readPendingKnowledgeEngineInstall('kb:new').taskId, 42);
|
||||
},
|
||||
});
|
||||
assert.equal(installed, engine);
|
||||
assert.equal(created, 42);
|
||||
assert.deepEqual(calls, [
|
||||
['install', 'qa', 'engine', '2.0.0'],
|
||||
['task', 42],
|
||||
['task', 42],
|
||||
['engines'],
|
||||
['engines'],
|
||||
]);
|
||||
assert.equal(storage.size, 0);
|
||||
});
|
||||
|
||||
test('shows plugin descriptions for installed Runner entries', () => {
|
||||
assert.match(runnerMarketplaceSource, /installedPluginDescriptions/);
|
||||
assert.match(runnerMarketplaceSource, /metadata\.description/);
|
||||
assert.match(runnerSelectSource, /installedRunnerDescription\(/);
|
||||
assert.match(runnerSelectSource, /function InstalledRunnerOptionContent/);
|
||||
assert.match(runnerSelectSource, /marketplacePlugin\?\.description/);
|
||||
assert.match(runnerSelectSource, /grid-cols-\[1\.75rem_minmax\(0,1fr\)\]/);
|
||||
assert.doesNotMatch(runnerSelectSource, /description=\{option\.name\}/);
|
||||
test('a pending engine install resumes only in its original workspace and form scope', async () => {
|
||||
const { api, storage, calls, setWorkspace } = setup({
|
||||
engines: [{ engines: [engine] }],
|
||||
});
|
||||
storage.set(
|
||||
'langbot-knowledge-engine-install:alpha:kb:new',
|
||||
JSON.stringify({
|
||||
taskId: 42,
|
||||
pluginId: 'qa/engine',
|
||||
pluginAuthor: 'qa',
|
||||
pluginName: 'engine',
|
||||
pluginLabel: 'QA Engine',
|
||||
scope: 'kb:new',
|
||||
startedAt: 0,
|
||||
}),
|
||||
);
|
||||
setWorkspace('beta');
|
||||
assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:new'), null);
|
||||
setWorkspace('alpha');
|
||||
assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:other'), null);
|
||||
assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:new'), engine);
|
||||
assert.deepEqual(calls, [['task', 42], ['engines']]);
|
||||
assert.equal(storage.size, 0);
|
||||
});
|
||||
|
||||
test('failed tasks clear their intent and never query component registration', async () => {
|
||||
const { api, calls, storage } = setup({
|
||||
tasks: [{ runtime: { done: true, exception: 'Package invalid' } }],
|
||||
});
|
||||
await assert.rejects(
|
||||
api.installMarketplaceKnowledgeEngine(plugin, { scope: 'kb:new' }),
|
||||
/Package invalid/,
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['install', 'qa', 'engine', '2.0.0'],
|
||||
['task', 42],
|
||||
]);
|
||||
assert.equal(storage.size, 0);
|
||||
});
|
||||
|
||||
test('an unfinished task times out with a recoverable intent, while missing registration clears it', async () => {
|
||||
const pending = setup({ tasks: [{ runtime: { done: false } }] });
|
||||
await assert.rejects(
|
||||
pending.api.installMarketplaceKnowledgeEngine(plugin, { scope: 'kb:new' }),
|
||||
(error) => error.code === 'install-timeout',
|
||||
);
|
||||
assert.equal(
|
||||
pending.api.readPendingKnowledgeEngineInstall('kb:new').taskId,
|
||||
42,
|
||||
);
|
||||
assert.equal(
|
||||
pending.calls.some((value) => value[0] === 'engines'),
|
||||
false,
|
||||
);
|
||||
const unregistered = setup();
|
||||
await assert.rejects(
|
||||
unregistered.api.installMarketplaceKnowledgeEngine(plugin, {
|
||||
scope: 'kb:new',
|
||||
}),
|
||||
(error) => error.code === 'registration-timeout',
|
||||
);
|
||||
assert.equal(unregistered.storage.size, 0);
|
||||
});
|
||||
|
||||
test('a plugin without a version never starts an install task', async () => {
|
||||
const { api, calls } = setup();
|
||||
await assert.rejects(
|
||||
api.installMarketplaceKnowledgeEngine(
|
||||
{ ...plugin, latest_version: '' },
|
||||
{ scope: 'kb:new' },
|
||||
),
|
||||
(error) => error.code === 'version-unavailable',
|
||||
);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -1,86 +1,157 @@
|
||||
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';
|
||||
import ts from 'typescript';
|
||||
|
||||
const webRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
const read = (relativePath) =>
|
||||
fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||
function load(relative, deps, globals = {}) {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/' + relative, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
const mod = { exports: {} };
|
||||
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(
|
||||
(name) => {
|
||||
assert.ok(name in deps, name);
|
||||
return deps[name];
|
||||
},
|
||||
mod,
|
||||
mod.exports,
|
||||
...Object.values(globals),
|
||||
);
|
||||
return mod.exports;
|
||||
}
|
||||
|
||||
test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => {
|
||||
const market = read(
|
||||
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
|
||||
test('likes reuse the browser fingerprint, update cached state and notify only until unsubscribed', async () => {
|
||||
const calls = [],
|
||||
events = [];
|
||||
const client = {
|
||||
getMarketplaceLikedExtensions: async (fingerprint) => {
|
||||
calls.push(['status', fingerprint]);
|
||||
return {
|
||||
extensions: [{ type: 'plugin', extension_id: 'qa/already-liked' }],
|
||||
};
|
||||
},
|
||||
setMarketplaceExtensionLike: async (...args) => {
|
||||
calls.push(['toggle', ...args]);
|
||||
return { liked: args[4], like_count: args[4] ? 8 : 7 };
|
||||
},
|
||||
};
|
||||
const api = load(
|
||||
'app/home/plugins/components/plugin-market/marketplace-likes.ts',
|
||||
{
|
||||
'@fingerprintjs/fingerprintjs': {
|
||||
default: {
|
||||
load: async (options) => {
|
||||
calls.push(['fingerprint', options]);
|
||||
return { get: async () => ({ visitorId: 'browser-id' }) };
|
||||
},
|
||||
},
|
||||
},
|
||||
'@/app/infra/http': { getCloudServiceClient: async () => client },
|
||||
},
|
||||
{ window: new EventTarget(), CustomEvent },
|
||||
);
|
||||
const card = read(
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
|
||||
assert.equal(
|
||||
await api.getMarketplaceExtensionLiked(undefined, 'qa', 'already-liked'),
|
||||
true,
|
||||
);
|
||||
const cardVO = read(
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts',
|
||||
assert.equal(
|
||||
await api.getMarketplaceExtensionLiked('skill', 'qa', 'already-liked'),
|
||||
false,
|
||||
);
|
||||
const entity = read('src/app/infra/entities/plugin/index.ts');
|
||||
const client = read('src/app/infra/http/CloudServiceClient.ts');
|
||||
const likes = read(
|
||||
'src/app/home/plugins/components/plugin-market/marketplace-likes.ts',
|
||||
const unsubscribe = api.subscribeMarketplaceLikeChanges((change) =>
|
||||
events.push(change),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
market,
|
||||
/hot_score_desc/,
|
||||
'market must expose and default to hot-score sorting',
|
||||
assert.deepEqual(
|
||||
await api.toggleMarketplaceExtensionLike(undefined, 'qa', 'new', true),
|
||||
{ key: 'plugin:qa/new', liked: true, likeCount: 8 },
|
||||
);
|
||||
assert.match(
|
||||
market,
|
||||
/sortBy:\s*['"]hot_score['"]/,
|
||||
'hot option must use the shared API field',
|
||||
assert.equal(
|
||||
await api.getMarketplaceExtensionLiked('plugin', 'qa', 'new'),
|
||||
true,
|
||||
);
|
||||
assert.match(
|
||||
entity,
|
||||
/like_count\??:\s*number/,
|
||||
'marketplace extension responses must carry like counts',
|
||||
);
|
||||
assert.match(
|
||||
cardVO,
|
||||
/likeCount:\s*number/,
|
||||
'market cards must carry like counts',
|
||||
);
|
||||
assert.match(card, /\bHeart\b/, 'market cards must render a like affordance');
|
||||
assert.match(
|
||||
card,
|
||||
/toggleMarketplaceExtensionLike/,
|
||||
'market cards must support liking and unliking',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/marketplace\/extensions\/likes/,
|
||||
'client must load browser likes',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/setMarketplaceExtensionLike\(/,
|
||||
'client must update likes through the shared API',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/,
|
||||
'older Space servers must fall back from hot score to install sorting',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/@fingerprintjs\/fingerprintjs/,
|
||||
'anonymous likes must use browser fingerprinting',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/FingerprintJS\.load\(\{/,
|
||||
'fingerprint agent must be initialized with options',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/monitoring:\s*false/,
|
||||
'fingerprinting must not send vendor monitoring requests',
|
||||
await api.toggleMarketplaceExtensionLike('plugin', 'qa', 'new', false);
|
||||
assert.equal(
|
||||
await api.getMarketplaceExtensionLiked('plugin', 'qa', 'new'),
|
||||
false,
|
||||
);
|
||||
unsubscribe();
|
||||
await api.toggleMarketplaceExtensionLike('skill', 'qa', 'new', true);
|
||||
assert.deepEqual(events, [
|
||||
{ key: 'plugin:qa/new', liked: true, likeCount: 8 },
|
||||
{ key: 'plugin:qa/new', liked: false, likeCount: 7 },
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
['fingerprint', { monitoring: false }],
|
||||
['status', 'browser-id'],
|
||||
['toggle', 'plugin', 'qa', 'new', 'browser-id', true],
|
||||
['toggle', 'plugin', 'qa', 'new', 'browser-id', false],
|
||||
['toggle', 'skill', 'qa', 'new', 'browser-id', true],
|
||||
]);
|
||||
});
|
||||
|
||||
test('legacy marketplace search converts hot sorting while preserving filters', async () => {
|
||||
const calls = [];
|
||||
class BaseHttpClient {
|
||||
async post(path, body) {
|
||||
calls.push([path, body]);
|
||||
if (path === '/api/v1/marketplace/extensions/search')
|
||||
throw new Error('Legacy server');
|
||||
return {
|
||||
plugins: [{ author: 'qa', name: 'legacy', type: 'plugin' }],
|
||||
total: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
const { CloudServiceClient } = load('app/infra/http/CloudServiceClient.ts', {
|
||||
'./BaseHttpClient': { BaseHttpClient },
|
||||
'@/app/infra/entities/plugin': {},
|
||||
});
|
||||
const result = await new CloudServiceClient().searchMarketplaceExtensions({
|
||||
query: 'needle',
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
sort_by: 'hot_score',
|
||||
sort_order: 'DESC',
|
||||
type_filter: 'plugin',
|
||||
tags_filter: ['qa'],
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.plugins.map((value) => value.name),
|
||||
['legacy'],
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'/api/v1/marketplace/extensions/search',
|
||||
{
|
||||
query: 'needle',
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
sort_by: 'hot_score',
|
||||
sort_order: 'DESC',
|
||||
type_filter: 'plugin',
|
||||
tags_filter: ['qa'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'/api/v1/marketplace/plugins/search',
|
||||
{
|
||||
query: 'needle',
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
sort_by: 'install_count',
|
||||
sort_order: 'DESC',
|
||||
component_filter: undefined,
|
||||
tags_filter: ['qa'],
|
||||
type_filter: 'plugin',
|
||||
runner_usage: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,126 +1,120 @@
|
||||
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';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const wizardSource = fs.readFileSync(
|
||||
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const runnerMarketplaceSource = fs.readFileSync(
|
||||
path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/agents/runner-marketplace.ts',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const widgetSource = fs.readFileSync(
|
||||
path.resolve(
|
||||
currentDirectory,
|
||||
'../../../src/langbot/templates/embed/widget.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
function load(source, deps = {}, globals = {}) {
|
||||
const code = ts.transpileModule(
|
||||
fs.readFileSync(new URL('../../src/' + source, import.meta.url), 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
const mod = { exports: {} };
|
||||
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(
|
||||
(id) => {
|
||||
assert.ok(id in deps, id);
|
||||
return deps[id];
|
||||
},
|
||||
mod,
|
||||
mod.exports,
|
||||
...Object.values(globals),
|
||||
);
|
||||
return mod.exports;
|
||||
}
|
||||
|
||||
test('shows the test-only notice only when the wizard opts in', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/,
|
||||
test('wizard Runner recovery waits for the matching plugin registration without installing twice', async () => {
|
||||
const scope = 'wizard-ai-runner';
|
||||
const pending = {
|
||||
scope,
|
||||
taskId: 12,
|
||||
pluginId: 'qa/runner',
|
||||
pluginAuthor: 'qa',
|
||||
pluginName: 'runner',
|
||||
pluginLabel: 'QA Runner',
|
||||
startedAt: -1000000,
|
||||
};
|
||||
const storage = new Map([
|
||||
[`langbot-runner-install:alpha:${scope}`, JSON.stringify(pending)],
|
||||
]);
|
||||
const calls = [],
|
||||
progress = [];
|
||||
let workspace = 'beta',
|
||||
tick = 0,
|
||||
polls = 0;
|
||||
const option = {
|
||||
name: 'plugin:qa/runner/default',
|
||||
label: { en_US: 'Registered Runner' },
|
||||
};
|
||||
const configTab = {
|
||||
name: 'ai',
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
config: [
|
||||
{
|
||||
name: 'id',
|
||||
options: [{ name: 'plugin:qa/runner-neighbor/default' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const httpClient = {
|
||||
getAsyncTask: async (id) => {
|
||||
calls.push(['task', id]);
|
||||
return { runtime: { done: ++polls >= 2 } };
|
||||
},
|
||||
getGeneralPipelineMetadata: async () => {
|
||||
calls.push(['metadata']);
|
||||
if (calls.filter((value) => value[0] === 'metadata').length === 2)
|
||||
configTab.stages[0].config[0].options.push(option);
|
||||
return { configs: [configTab] };
|
||||
},
|
||||
installPluginFromMarketplace: async () =>
|
||||
assert.fail('recovery must not reinstall'),
|
||||
};
|
||||
const api = load(
|
||||
'app/home/agents/runner-marketplace.ts',
|
||||
{
|
||||
'@/app/infra/http/HttpClient': { httpClient },
|
||||
'@/app/infra/http': {},
|
||||
'@/app/infra/http/workspaceContext': {
|
||||
getActiveWorkspaceUuid: () => workspace,
|
||||
},
|
||||
'@/app/infra/entities/plugin': {},
|
||||
},
|
||||
{
|
||||
window: new EventTarget(),
|
||||
CustomEvent,
|
||||
sessionStorage: {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
removeItem: (key) => storage.delete(key),
|
||||
},
|
||||
Date: { now: () => tick },
|
||||
setTimeout: (callback, ms) => {
|
||||
tick += ms;
|
||||
queueMicrotask(callback);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/,
|
||||
assert.equal(await api.resumePendingRunnerInstall(scope), null);
|
||||
workspace = 'alpha';
|
||||
assert.equal(await api.resumePendingRunnerInstall('other'), null);
|
||||
const recovered = await api.resumePendingRunnerInstall(scope, (task) =>
|
||||
progress.push(task.runtime.done),
|
||||
);
|
||||
assert.match(widgetSource, /getAttribute\("data-test-notice"\)/);
|
||||
assert.match(widgetSource, /if \(scriptTestNotice\)/);
|
||||
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
|
||||
});
|
||||
|
||||
test('opens the Page Bot preview after every successful wizard save', () => {
|
||||
assert.match(wizardSource, /script\.dataset\.autoOpen = 'true'/);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/setPageBotPreviewRequest\(\(request\) => request \+ 1\)/,
|
||||
);
|
||||
|
||||
assert.match(widgetSource, /getAttribute\("data-auto-open"\) === "true"/);
|
||||
assert.match(widgetSource, /root\.langbotOpen = function \(\)/);
|
||||
assert.match(widgetSource, /if \(scriptAutoOpen\) root\.langbotOpen\(\)/);
|
||||
});
|
||||
|
||||
test('binds every wizard bot to its provisional pipeline before verification', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/if \(!previewPipelineUuid\) \{[\s\S]*?httpClient\.createPipeline\(/,
|
||||
);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/event_pattern: 'message\.received',[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
wizardSource,
|
||||
/selectedAdapter === 'web_page_bot' && !previewPipelineUuid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the 4.11 Runner marketplace installation flow', () => {
|
||||
assert.match(wizardSource, /loadRunnerCatalog\(\)/);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/installMarketplaceRunner\(plugin, \{[\s\S]*?scope: WIZARD_RUNNER_INSTALL_SCOPE/,
|
||||
);
|
||||
assert.match(wizardSource, /resumePendingRunnerInstall\(/);
|
||||
assert.match(runnerMarketplaceSource, /RUNNER_COMPONENT_FILTER = 'Runner'/);
|
||||
assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/);
|
||||
assert.match(
|
||||
runnerMarketplaceSource,
|
||||
/const prefix = runnerPluginPrefix\(\{/,
|
||||
);
|
||||
assert.match(runnerMarketplaceSource, /option\.name\.startsWith\(prefix\)/);
|
||||
assert.match(runnerMarketplaceSource, /registrationDeadline/);
|
||||
assert.match(runnerMarketplaceSource, /sessionStorage\.setItem\(/);
|
||||
});
|
||||
|
||||
test('requires the selected Runner mandatory configuration before finishing', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/,
|
||||
);
|
||||
assert.match(wizardSource, /aiChoice === 'external'/);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires an observed message before configuring the runner', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/createdBotUuid !== null && botSaved && messageReceived/,
|
||||
);
|
||||
assert.match(wizardSource, /requiresMessageVerification/);
|
||||
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/);
|
||||
assert.equal(recovered.runner, option);
|
||||
assert.equal(recovered.configTab, configTab);
|
||||
assert.deepEqual(progress, [false, true]);
|
||||
assert.deepEqual(calls, [
|
||||
['task', 12],
|
||||
['task', 12],
|
||||
['metadata'],
|
||||
['metadata'],
|
||||
]);
|
||||
assert.equal(storage.size, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user