feat: plugins v1
This commit is contained in:
405
e2e/tests/plugins/plugin-support-api.spec.ts
Normal file
405
e2e/tests/plugins/plugin-support-api.spec.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
||||
import WebSocket from 'ws';
|
||||
import { expect, test } from '../../fixtures/multi-client';
|
||||
import {
|
||||
getPluginApiTestEvent,
|
||||
readPluginApiTestManifest,
|
||||
TEST_PLUGIN_ID,
|
||||
TEST_PLUGIN_P2P_EVENT,
|
||||
TEST_PLUGIN_RELAY_EVENT
|
||||
} from '../../helpers/plugin-api-test-fixture';
|
||||
|
||||
const OWNER_USER_ID = 'plugin-api-owner';
|
||||
|
||||
interface CreatedServerResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface PluginRequirementResponse {
|
||||
requirement: {
|
||||
pluginId: string;
|
||||
reason?: string;
|
||||
status: string;
|
||||
versionRange?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PluginEventDefinitionResponse {
|
||||
eventDefinition: {
|
||||
direction: string;
|
||||
eventName: string;
|
||||
maxPayloadBytes: number;
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PluginDataResponse {
|
||||
record: {
|
||||
key: string;
|
||||
ownerId?: string;
|
||||
pluginId: string;
|
||||
schemaVersion: number;
|
||||
scope: string;
|
||||
value: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface PluginDataListResponse {
|
||||
records: PluginDataResponse['record'][];
|
||||
}
|
||||
|
||||
interface PluginSnapshotResponse {
|
||||
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
|
||||
requirements: PluginRequirementResponse['requirement'][];
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface SocketMessage {
|
||||
[key: string]: unknown;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface TestSocket {
|
||||
close: () => Promise<void>;
|
||||
messages: SocketMessage[];
|
||||
send: (message: SocketMessage) => void;
|
||||
}
|
||||
|
||||
test.describe('Plugin support API', () => {
|
||||
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
|
||||
const manifest = await readPluginApiTestManifest();
|
||||
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
|
||||
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
|
||||
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
|
||||
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
|
||||
|
||||
await test.step('Initial snapshot is empty', async () => {
|
||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||
|
||||
expect(snapshot).toEqual(expect.objectContaining({
|
||||
eventDefinitions: [],
|
||||
requirements: [],
|
||||
serverId: server.id
|
||||
}));
|
||||
});
|
||||
|
||||
await test.step('Requirement API enforces server management permission', async () => {
|
||||
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||
data: {
|
||||
actorUserId: 'not-the-owner',
|
||||
status: 'required'
|
||||
}
|
||||
});
|
||||
const body = await expectJson<{ errorCode: string }>(response, 403);
|
||||
|
||||
expect(body.errorCode).toBe('NOT_AUTHORIZED');
|
||||
});
|
||||
|
||||
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
|
||||
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||
data: {
|
||||
actorUserId: OWNER_USER_ID,
|
||||
reason: manifest.description,
|
||||
status: 'required',
|
||||
versionRange: `^${manifest.version}`
|
||||
}
|
||||
}));
|
||||
|
||||
expect(requirement.requirement).toEqual(expect.objectContaining({
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
reason: manifest.description,
|
||||
status: 'required',
|
||||
versionRange: `^${manifest.version}`
|
||||
}));
|
||||
|
||||
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
|
||||
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
|
||||
|
||||
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
|
||||
direction: 'serverRelay',
|
||||
eventName: TEST_PLUGIN_RELAY_EVENT,
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
scope: 'server'
|
||||
}));
|
||||
|
||||
expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({
|
||||
direction: 'p2pHint',
|
||||
eventName: TEST_PLUGIN_P2P_EVENT,
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
scope: 'user'
|
||||
}));
|
||||
|
||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||
|
||||
expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]);
|
||||
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
|
||||
});
|
||||
|
||||
await test.step('Plugin data API stores, lists, and deletes server scoped data', async () => {
|
||||
const stored = await expectJson<PluginDataResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||
data: {
|
||||
actorUserId: OWNER_USER_ID,
|
||||
schemaVersion: 1,
|
||||
scope: 'server',
|
||||
value: {
|
||||
enabled: true,
|
||||
pluginVersion: manifest.version
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
expect(stored.record).toEqual(expect.objectContaining({
|
||||
key: 'settings',
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
schemaVersion: 1,
|
||||
scope: 'server',
|
||||
value: {
|
||||
enabled: true,
|
||||
pluginVersion: manifest.version
|
||||
}
|
||||
}));
|
||||
|
||||
const listed = await expectJson<PluginDataListResponse>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
|
||||
params: {
|
||||
key: 'settings',
|
||||
scope: 'server',
|
||||
userId: OWNER_USER_ID
|
||||
}
|
||||
}));
|
||||
|
||||
expect(listed.records).toHaveLength(1);
|
||||
expect(listed.records[0]?.value).toEqual({
|
||||
enabled: true,
|
||||
pluginVersion: manifest.version
|
||||
});
|
||||
|
||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||
data: {
|
||||
actorUserId: OWNER_USER_ID,
|
||||
scope: 'server'
|
||||
}
|
||||
}));
|
||||
|
||||
const afterDelete = await expectJson<PluginDataListResponse>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
|
||||
params: {
|
||||
key: 'settings',
|
||||
scope: 'server',
|
||||
userId: OWNER_USER_ID
|
||||
}
|
||||
}));
|
||||
|
||||
expect(afterDelete.records).toEqual([]);
|
||||
});
|
||||
|
||||
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
|
||||
const alice = await openTestSocket(testServer.url);
|
||||
const bob = await openTestSocket(testServer.url);
|
||||
|
||||
try {
|
||||
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
|
||||
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
|
||||
alice.send({ type: 'join_server', serverId: server.id });
|
||||
bob.send({ type: 'join_server', serverId: server.id });
|
||||
|
||||
const aliceSnapshot = await waitForSocketMessage(alice, (message) => message.type === 'plugin_requirements');
|
||||
const bobSnapshot = await waitForSocketMessage(bob, (message) => message.type === 'plugin_requirements');
|
||||
const bobEventNames = (bobSnapshot['snapshot'] as PluginSnapshotResponse).eventDefinitions
|
||||
.map((entry) => entry.eventName)
|
||||
.sort();
|
||||
|
||||
expect((aliceSnapshot['snapshot'] as PluginSnapshotResponse).requirements[0]?.pluginId).toBe(TEST_PLUGIN_ID);
|
||||
expect(bobEventNames).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
|
||||
|
||||
alice.send({
|
||||
type: 'plugin_event',
|
||||
eventId: 'relay-event-1',
|
||||
eventName: TEST_PLUGIN_RELAY_EVENT,
|
||||
payload: { message: 'hello from fixture plugin' },
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
serverId: server.id,
|
||||
sourcePluginUserId: 'fixture-plugin-user'
|
||||
});
|
||||
|
||||
const relayedEvent = await waitForSocketMessage(bob, (message) => message.type === 'plugin_event');
|
||||
|
||||
expect(relayedEvent).toEqual(expect.objectContaining({
|
||||
eventId: 'relay-event-1',
|
||||
eventName: TEST_PLUGIN_RELAY_EVENT,
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
serverId: server.id,
|
||||
sourcePluginUserId: 'fixture-plugin-user',
|
||||
sourceUserId: OWNER_USER_ID
|
||||
}));
|
||||
|
||||
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
|
||||
expect(typeof relayedEvent['emittedAt']).toBe('number');
|
||||
|
||||
alice.send({
|
||||
type: 'plugin_event',
|
||||
eventId: 'p2p-event-1',
|
||||
eventName: TEST_PLUGIN_P2P_EVENT,
|
||||
payload: { hint: true },
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
serverId: server.id
|
||||
});
|
||||
|
||||
const p2pError = await waitForSocketMessage(
|
||||
alice,
|
||||
(message) => message.type === 'plugin_error' && message['eventId'] === 'p2p-event-1'
|
||||
);
|
||||
|
||||
expect(p2pError['code']).toBe('PLUGIN_EVENT_NOT_RELAYABLE');
|
||||
|
||||
alice.send({
|
||||
type: 'plugin_event',
|
||||
eventId: 'missing-event-1',
|
||||
eventName: 'e2e:missing',
|
||||
payload: {},
|
||||
pluginId: TEST_PLUGIN_ID,
|
||||
serverId: server.id
|
||||
});
|
||||
|
||||
const missingError = await waitForSocketMessage(
|
||||
alice,
|
||||
(message) => message.type === 'plugin_error' && message['eventId'] === 'missing-event-1'
|
||||
);
|
||||
|
||||
expect(missingError['code']).toBe('PLUGIN_EVENT_NOT_REGISTERED');
|
||||
} finally {
|
||||
await Promise.all([alice.close(), bob.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Delete APIs remove event definitions and requirements', async () => {
|
||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
|
||||
data: { actorUserId: OWNER_USER_ID }
|
||||
}));
|
||||
|
||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
|
||||
data: { actorUserId: OWNER_USER_ID }
|
||||
}));
|
||||
|
||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||
data: { actorUserId: OWNER_USER_ID }
|
||||
}));
|
||||
|
||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||
|
||||
expect(snapshot.eventDefinitions).toEqual([]);
|
||||
expect(snapshot.requirements).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createServer(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
serverName: string
|
||||
): Promise<CreatedServerResponse> {
|
||||
const response = await request.post(`${baseUrl}/api/servers`, {
|
||||
data: {
|
||||
channels: [
|
||||
{
|
||||
id: 'general-text',
|
||||
name: 'general',
|
||||
position: 0,
|
||||
type: 'text'
|
||||
}
|
||||
],
|
||||
description: 'Server for plugin API E2E coverage',
|
||||
id: `plugin-api-${Date.now()}`,
|
||||
isPrivate: false,
|
||||
name: serverName,
|
||||
ownerId: OWNER_USER_ID,
|
||||
ownerPublicKey: 'plugin-api-owner-public-key',
|
||||
tags: ['plugins']
|
||||
}
|
||||
});
|
||||
|
||||
return await expectJson<CreatedServerResponse>(response, 201);
|
||||
}
|
||||
|
||||
async function upsertEventDefinition(
|
||||
request: APIRequestContext,
|
||||
pluginsApi: string,
|
||||
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
|
||||
): Promise<PluginEventDefinitionResponse> {
|
||||
return await expectJson<PluginEventDefinitionResponse>(await request.put(
|
||||
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
|
||||
{
|
||||
data: {
|
||||
actorUserId: OWNER_USER_ID,
|
||||
direction: eventDefinition.direction,
|
||||
maxPayloadBytes: eventDefinition.maxPayloadBytes,
|
||||
schemaJson: '{"type":"object"}',
|
||||
scope: eventDefinition.scope
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
|
||||
expect(response.status()).toBe(status);
|
||||
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
|
||||
const socketUrl = baseUrl.replace(/^http/, 'ws');
|
||||
const socket = new WebSocket(socketUrl);
|
||||
const messages: SocketMessage[] = [];
|
||||
|
||||
socket.on('message', (data) => {
|
||||
messages.push(JSON.parse(data.toString()) as SocketMessage);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('open', () => resolve());
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected');
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
if (socket.readyState === WebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.once('close', () => resolve());
|
||||
socket.close();
|
||||
});
|
||||
},
|
||||
messages,
|
||||
send: (message: SocketMessage) => {
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSocketMessage(
|
||||
socket: Pick<TestSocket, 'messages'>,
|
||||
predicate: (message: SocketMessage) => boolean,
|
||||
timeoutMs = 10_000
|
||||
): Promise<SocketMessage> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
const message = socket.messages.find(predicate);
|
||||
|
||||
if (message) {
|
||||
clearInterval(interval);
|
||||
resolve(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Timed out waiting for websocket message'));
|
||||
}
|
||||
}, 25);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user