test(web): exercise marketplace installs and wizard contracts

This commit is contained in:
huanghuoguoguo
2026-10-17 16:09:23 +08:00
parent c05aac538f
commit 8e447dae09
8 changed files with 1400 additions and 685 deletions
+300 -224
View File
@@ -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',
});
});
}
+89
View File
@@ -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);
});
}
+214 -137
View File
@@ -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, []);
});
+144 -73
View File
@@ -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,
},
],
]);
});
+112 -118
View File
@@ -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);
});