fix(bots): warn about incomplete processor event support

This commit is contained in:
Hyu
2026-09-15 14:56:43 +08:00
parent 2e6fe63750
commit 1c17c4a0db
9 changed files with 395 additions and 51 deletions
@@ -0,0 +1,245 @@
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const warning = /This bot cannot fully cover these subscribed events:/;
const definitions = [
{ name: 'Messages only', patterns: ['message.received'] },
{
name: 'Community helper',
patterns: [
'message.received',
'group.member_joined',
'friend.request_received',
],
},
{ name: 'Group observer', patterns: ['group.*'] },
{ name: 'All observer', patterns: ['*'] },
];
async function setup(page: Page, events?: string[]) {
await installLangBotApiMocks(page, { authenticated: true });
const components = definitions.map(({ name, patterns }, index) => ({
id: `plugin:qa/events/component-${index}`,
label: { en_US: name },
supported_event_patterns: patterns,
config_schema: [],
plugin_author: 'qa',
plugin_name: 'events',
}));
const agents = components.map((component, index) => ({
uuid: `processor-${index}`,
name: definitions[index].name,
kind: 'event_processor',
component_ref: component.id,
supported_event_patterns: component.supported_event_patterns,
config: {},
}));
await page.route('**/api/v1/agents**', async (route) => {
const path = new URL(route.request().url()).pathname;
let data: unknown;
if (path.endsWith('/_/metadata')) data = { event_processors: components };
else if (path === '/api/v1/agents' && route.request().method() === 'POST') {
const payload = route.request().postDataJSON();
agents.push({
...payload,
uuid: 'processor-new',
supported_event_patterns: components.find(
(item) => item.id === payload.component_ref,
)!.supported_event_patterns,
});
data = { uuid: 'processor-new', kind: 'event_processor' };
} else if (path === '/api/v1/agents') data = { agents };
else return route.fallback();
await route.fulfill({ json: { code: 0, data } });
});
await page.route('**/api/v1/platform/adapters', (route) =>
route.fulfill({
json: {
code: 0,
data: {
adapters: [
{
name: 'playwright-adapter',
label: { en_US: 'Message adapter' },
description: { en_US: 'Messages' },
spec: {
config: [],
...(events ? { supported_events: events } : {}),
},
},
{
name: 'full-adapter',
label: { en_US: 'Community adapter' },
description: { en_US: 'Community events' },
spec: {
config: [],
supported_events: [
'message.received',
'group.member_joined',
'friend.request_received',
],
},
},
],
},
},
}),
);
await page.goto('/home/bots?id=compatibility-bot');
await expect(
page.getByRole('button', { name: 'Add plugin processor', exact: true }),
).toBeVisible();
}
test('partial support warns in existing choices and saved bindings, and changes with adapter', async ({
page,
}) => {
await setup(page, ['message.received']);
await page
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
const dialog = page.getByRole('dialog');
const community = dialog
.locator('label')
.filter({ hasText: 'Community helper' });
await expect(community.getByRole('status')).toContainText('Member joined');
await expect(community.getByRole('status')).toContainText('Friend request');
await expect(community.getByRole('status')).not.toContainText(
'Message received',
);
await expect(
dialog
.locator('label')
.filter({ hasText: 'Messages only' })
.getByRole('status'),
).toHaveCount(0);
await community.getByRole('checkbox').check();
await dialog
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
await expect(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.reload();
await expect(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1);
await page.getByRole('combobox').first().click();
await page
.getByRole('option', { name: 'Community adapter', exact: true })
.click();
await expect(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(0);
await page.getByRole('combobox').first().click();
await page
.getByRole('option', { name: 'Message adapter', exact: true })
.click();
await expect(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1);
});
test('new component choices warn for missing and wildcard events but allow creation and binding', async ({
page,
}, testInfo) => {
await setup(page);
await page
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
const dialog = page.getByRole('dialog');
await dialog
.getByRole('tab', { name: 'New configuration', exact: true })
.click();
for (const name of ['Community helper', 'Group observer', 'All observer']) {
await expect(
dialog
.getByRole('button', { name: new RegExp(name) })
.getByRole('status'),
).toContainText(warning);
}
await expect(
dialog.getByRole('button', { name: /Messages only/ }).getByRole('status'),
).toHaveCount(0);
await dialog.getByRole('button', { name: /Community helper/ }).click();
await dialog.screenshot({
path: testInfo.outputPath('compatibility-dialog.png'),
});
await dialog
.getByRole('button', { name: 'Create and bind', exact: true })
.click();
await expect(dialog).toHaveCount(0);
await expect(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1);
await page
.locator('section[aria-labelledby="plugin-subscriptions-title"]')
.screenshot({ path: testInfo.outputPath('compatibility-bindings.png') });
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.reload();
await expect(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1);
});
test('fully supported processor choices have no compatibility warning', async ({
page,
}) => {
await setup(page, [
'message.received',
'group.member_joined',
'friend.request_received',
]);
await page
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
const dialog = page.getByRole('dialog');
await expect(
dialog
.locator('label')
.filter({ hasText: 'Community helper' })
.getByRole('status'),
).toHaveCount(0);
await dialog
.getByRole('tab', { name: 'New configuration', exact: true })
.click();
await expect(
dialog.getByRole('button', { name: /Community helper/ }),
).toBeVisible();
await expect(
dialog
.getByRole('button', { name: /Community helper/ })
.getByRole('status'),
).toHaveCount(0);
});
for (const events of [
undefined,
['message.received'],
['message.received', 'message.edited'],
]) {
test(`other events entry follows adapter events: ${events?.join(',') ?? 'legacy'}`, async ({
page,
}) => {
await setup(page, events);
await page
.getByRole('button', { name: 'Add behavior', exact: true })
.click();
const entry = page.getByRole('menuitem', {
name: /Configure another event/,
});
if (events?.includes('message.edited')) {
await expect(entry).toBeVisible();
await entry.hover();
await expect(
page.getByRole('menuitem', { name: /Message edited/ }),
).toBeVisible();
} else {
await expect(entry).toHaveCount(0);
await expect(
page.getByRole('menuitem', { name: /Reply to messages/ }),
).toBeVisible();
}
});
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
new URL(
'../../src/app/home/components/event-patterns/event-pattern-groups.ts',
import.meta.url,
),
'utf8',
);
const exports = {};
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
)(exports);
for (const [supported, required, covered] of [
['message.received', 'message.received', true],
['message.received', 'group.member_joined', false],
['message.received', 'message.*', false],
['message.received', '*', false],
['group.*', 'group.member_joined', true],
['group.*', 'group.*', true],
['group.*', 'groupish.member_joined', false],
['group.*', '*', false],
['*', 'group.*', true],
['*', '*', true],
]) {
test(`${supported} ${covered ? 'covers' : 'does not cover'} ${required}`, () => {
assert.equal(exports.eventPatternCovers(supported, required), covered);
});
}