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 PluginSnapshotResponse { eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][]; requirements: PluginRequirementResponse['requirement'][]; serverId: string; } interface SocketMessage { [key: string]: unknown; type?: string; } interface TestSocket { close: () => Promise; 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(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(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(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 refuses arbitrary server persistence', async () => { const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { data: { actorUserId: OWNER_USER_ID, schemaVersion: 1, scope: 'server', value: { enabled: true, pluginVersion: manifest.version } } }), 410); expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED'); const listed = await expectJson<{ errorCode: string }>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, { params: { key: 'settings', scope: 'server', userId: OWNER_USER_ID } }), 410); expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED'); const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { data: { actorUserId: OWNER_USER_ID, scope: 'server' } }), 410); expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED'); }); 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(await request.get(pluginsApi)); expect(snapshot.eventDefinitions).toEqual([]); expect(snapshot.requirements).toEqual([]); }); }); }); async function createServer( request: APIRequestContext, baseUrl: string, serverName: string ): Promise { 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(response, 201); } async function upsertEventDefinition( request: APIRequestContext, pluginsApi: string, eventDefinition: ReturnType ): Promise { return await expectJson(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(response: APIResponse, status = 200): Promise { expect(response.status()).toBe(status); return await response.json() as T; } async function openTestSocket(baseUrl: string): Promise { 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((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((resolve) => { socket.once('close', () => resolve()); socket.close(); }); }, messages, send: (message: SocketMessage) => { socket.send(JSON.stringify(message)); } }; } async function waitForSocketMessage( socket: Pick, predicate: (message: SocketMessage) => boolean, timeoutMs = 10_000 ): Promise { 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); }); }