@rimori/playwright-testing 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,498 @@
1
+ # @rimori/playwright
2
+
3
+ Playwright testing utilities for Rimori plugins. This package provides a complete testing environment that simulates how plugins run within the Rimori application, including MessageChannel communication, API mocking, and event handling.
4
+
5
+ ## Overview
6
+
7
+ The `@rimori/playwright` package enables end-to-end testing of Rimori plugins by:
8
+
9
+ - **Simulating iframe environment**: Makes plugins think they're running in an iframe (not standalone mode)
10
+ - **MessageChannel simulation**: Mimics the parent-iframe communication used in production
11
+ - **API mocking**: Provides mock handlers for Supabase and backend endpoints
12
+ - **Event handling**: Simulates Rimori events like main panel actions and sidebar actions
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install --save-dev @rimori/playwright @playwright/test
18
+ # or
19
+ pnpm add -D @rimori/playwright @playwright/test
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```typescript
25
+ import { test, expect } from '@playwright/test';
26
+ import { RimoriTestEnvironment } from '@rimori/playwright';
27
+
28
+ const pluginId = 'pl7720512027';
29
+ const pluginUrl = 'http://localhost:3009';
30
+
31
+ test.describe('My Plugin', () => {
32
+ let env: RimoriTestEnvironment;
33
+
34
+ test.beforeEach(async ({ page }) => {
35
+ env = new RimoriTestEnvironment({ page, pluginId });
36
+
37
+ // Set up mocks
38
+ env.ai.mockGetObject({ result: 'data' });
39
+
40
+ // Initialize the test environment
41
+ await env.setup();
42
+ await page.goto(`${pluginUrl}/#/my-page`);
43
+ });
44
+
45
+ test('should work correctly', async ({ page }) => {
46
+ await expect(page.getByText('Hello')).toBeVisible();
47
+ });
48
+ });
49
+ ```
50
+
51
+ ## Core Concepts
52
+
53
+ ### MessageChannel Simulation
54
+
55
+ Plugins communicate with the Rimori parent application via MessageChannel. The `RimoriTestEnvironment` automatically sets up a MessageChannel simulation. This ensures plugins run in iframe mode, not standalone mode, matching production behavior.
56
+
57
+ ### Test Environment Setup
58
+
59
+ The test environment:
60
+
61
+ - Sets default handlers for common routes (plugin_settings, etc.)
62
+ - Initializes MessageChannel communication
63
+ - Provides default RimoriInfo with test credentials
64
+ - Routes requests to appropriate mock handlers
65
+
66
+ ## API Reference
67
+
68
+ ### RimoriTestEnvironment
69
+
70
+ Main test environment class that provides mocking capabilities and MessageChannel simulation.
71
+
72
+ #### Constructor
73
+
74
+ ```typescript
75
+ new RimoriTestEnvironment({
76
+ page: Page,
77
+ pluginId: string,
78
+ queryParams?: Record<string, string>,
79
+ userInfo?: Record<string, unknown>,
80
+ installedPlugins?: Plugin[],
81
+ guildOverrides?: Record<string, unknown>
82
+ })
83
+ ```
84
+
85
+ **Example:**
86
+
87
+ ```typescript
88
+ const env = new RimoriTestEnvironment({
89
+ page,
90
+ pluginId: 'pl1234567890',
91
+ queryParams: { applicationMode: 'sidebar' },
92
+ });
93
+ ```
94
+
95
+ #### Methods
96
+
97
+ ##### `setup(): Promise<void>`
98
+
99
+ Initializes the test environment. Must be called before navigating to the plugin page.
100
+
101
+ ```typescript
102
+ await env.setup();
103
+ await page.goto(pluginUrl);
104
+ ```
105
+
106
+ ### AI Mocking (`env.ai`)
107
+
108
+ Mock AI/LLM backend endpoints.
109
+
110
+ #### `mockGetText(values: unknown, options?: MockOptions)`
111
+
112
+ Mocks a non-streaming text generation response.
113
+
114
+ ```typescript
115
+ env.ai.mockGetText({ result: 'Generated text' });
116
+ ```
117
+
118
+ #### `mockGetSteamedText(text: string, options?: MockOptions)`
119
+
120
+ Mocks a streaming text response formatted as SSE (Server-Sent Events).
121
+
122
+ **Note**: Due to Playwright's `route.fulfill()` limitations, all SSE chunks are sent at once (no visible delays). The client will still parse it correctly as SSE.
123
+
124
+ ```typescript
125
+ env.ai.mockGetSteamedText('This is the streaming response text.');
126
+ ```
127
+
128
+ #### `mockGetObject(value: unknown, options?: MockOptions)`
129
+
130
+ Mocks structured object generation (e.g., translation results).
131
+
132
+ ```typescript
133
+ env.ai.mockGetObject(
134
+ {
135
+ type: 'noun',
136
+ translation_swedish: 'träd',
137
+ translation_mother_tongue: 'tree',
138
+ },
139
+ {
140
+ matcher: (request) => {
141
+ const body = request.postDataJSON();
142
+ return body?.instructions?.includes('Look up the word') ?? false;
143
+ },
144
+ },
145
+ );
146
+ ```
147
+
148
+ #### `mockGetVoice(values: Buffer, options?: MockOptions)`
149
+
150
+ Mocks text-to-speech voice generation.
151
+
152
+ #### `mockGetTextFromVoice(text: string, options?: MockOptions)`
153
+
154
+ Mocks speech-to-text transcription.
155
+
156
+ ### Plugin Settings (`env.plugin`)
157
+
158
+ Mock plugin settings endpoints.
159
+
160
+ #### `mockGetSettings(settingsRow, options?)`
161
+
162
+ Mocks GET request for plugin settings.
163
+
164
+ ```typescript
165
+ // Return existing settings
166
+ env.plugin.mockGetSettings({
167
+ id: 'settings-id',
168
+ plugin_id: pluginId,
169
+ guild_id: 'guild-id',
170
+ settings: { theme: 'dark' },
171
+ is_guild_setting: false,
172
+ });
173
+
174
+ // Return null to simulate no settings (triggers INSERT flow)
175
+ env.plugin.mockGetSettings(null);
176
+ ```
177
+
178
+ #### `mockSetSettings(response?, options?)`
179
+
180
+ Mocks PATCH request for updating settings. Returns empty array by default (triggers INSERT).
181
+
182
+ #### `mockInsertSettings(response?, options?)`
183
+
184
+ Mocks POST request for inserting new settings.
185
+
186
+ ### Event Handling (`env.event`)
187
+
188
+ Simulate Rimori events and actions.
189
+
190
+ #### `triggerOnSidePanelAction(payload: MainPanelAction)`
191
+
192
+ Triggers a side panel action event. Sets up a listener that responds when the plugin calls `onSidePanelAction()`.
193
+
194
+ **Important**: Call this BEFORE navigating to the page, so the listener is ready when the plugin initializes.
195
+
196
+ ```typescript
197
+ await env.event.triggerOnSidePanelAction({
198
+ plugin_id: pluginId,
199
+ action_key: 'translate',
200
+ action: 'translate',
201
+ text: 'tree',
202
+ });
203
+
204
+ await page.goto(`${pluginUrl}/#/sidebar/translate`);
205
+ ```
206
+
207
+ #### `triggerOnMainPanelAction(payload: MainPanelAction)`
208
+
209
+ Triggers a main panel action event. Sets up a listener that responds when the plugin calls `onMainPanelAction()`.
210
+
211
+ ```typescript
212
+ await env.event.triggerOnMainPanelAction({
213
+ plugin_id: pluginId,
214
+ action_key: 'open',
215
+ action: 'open',
216
+ });
217
+ ```
218
+
219
+ ### Mock Options
220
+
221
+ All mock methods accept an optional `MockOptions` parameter:
222
+
223
+ ```typescript
224
+ interface MockOptions {
225
+ // Delay before response (milliseconds)
226
+ delay?: number;
227
+
228
+ // Request matcher function
229
+ matcher?: (request: Request) => boolean;
230
+
231
+ // HTTP method override
232
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
233
+
234
+ // Custom response headers
235
+ headers?: Record<string, string>;
236
+
237
+ // Simulate network error
238
+ error?: 'aborted' | 'connectionfailed' | 'timedout' | /* ... */;
239
+ }
240
+ ```
241
+
242
+ **Example with matcher:**
243
+
244
+ ```typescript
245
+ env.ai.mockGetObject(
246
+ { result: 'data' },
247
+ {
248
+ matcher: (request) => {
249
+ const body = request.postDataJSON();
250
+ return body?.instructions?.includes('specific text') ?? false;
251
+ },
252
+ delay: 500, // Simulate network delay
253
+ },
254
+ );
255
+ ```
256
+
257
+ ## Common Patterns
258
+
259
+ ### Testing Settings Flow
260
+
261
+ The plugin settings flow involves GET → PATCH → POST:
262
+
263
+ 1. **GET** - Check if settings exist (returns null if not found)
264
+ 2. **PATCH** - Try to update (returns empty array if no rows updated)
265
+ 3. **POST** - Insert new settings
266
+
267
+ The test environment sets up default handlers for all three, but you can override them:
268
+
269
+ ```typescript
270
+ // Override to return existing settings
271
+ env.plugin.mockGetSettings({
272
+ id: 'existing-id',
273
+ plugin_id: pluginId,
274
+ settings: { existing: 'data' },
275
+ });
276
+
277
+ // Override to simulate successful update (don't trigger INSERT)
278
+ env.plugin.mockSetSettings([{ id: 'updated-id' }]);
279
+ ```
280
+
281
+ ### Testing Action Events
282
+
283
+ Action events work differently for main panel vs sidebar:
284
+
285
+ **Side Panel Action:**
286
+
287
+ ```typescript
288
+ // Plugin is on a sidebar page, uses onSidePanelAction()
289
+ await env.event.triggerOnSidePanelAction({
290
+ plugin_id: pluginId,
291
+ action_key: 'translate',
292
+ action: 'translate',
293
+ text: 'word',
294
+ });
295
+ ```
296
+
297
+ **Main Panel Action:**
298
+
299
+ ```typescript
300
+ // Plugin is on a main panel page, uses onMainPanelAction()
301
+ await env.event.triggerOnMainPanelAction({
302
+ plugin_id: pluginId,
303
+ action_key: 'open',
304
+ action: 'open',
305
+ });
306
+ ```
307
+
308
+ ### Mocking Multiple Responses for Same Endpoint
309
+
310
+ Use matchers to provide different responses for the same endpoint:
311
+
312
+ ```typescript
313
+ // First request - word lookup
314
+ env.ai.mockGetObject(
315
+ { type: 'noun', translation: 'hund' },
316
+ {
317
+ matcher: (req) => {
318
+ return req.postDataJSON()?.instructions?.includes('Look up') ?? false;
319
+ },
320
+ },
321
+ );
322
+
323
+ // Second request - example sentence
324
+ env.ai.mockGetObject(
325
+ { example_sentence: { target_language: 'Jag har en hund.' } },
326
+ {
327
+ matcher: (req) => {
328
+ return req.postDataJSON()?.instructions?.includes('example sentence') ?? false;
329
+ },
330
+ delay: 1000, // Simulate slower response
331
+ },
332
+ );
333
+ ```
334
+
335
+ ## Examples
336
+
337
+ ### Complete Translation Plugin Test
338
+
339
+ ```typescript
340
+ import { test, expect } from '@playwright/test';
341
+ import { RimoriTestEnvironment } from '@rimori/playwright';
342
+
343
+ const pluginId = 'pl7720512027';
344
+ const pluginUrl = 'http://localhost:3009';
345
+
346
+ test.describe('Translator Plugin', () => {
347
+ let env: RimoriTestEnvironment;
348
+
349
+ test.beforeEach(async ({ page }) => {
350
+ env = new RimoriTestEnvironment({ page, pluginId });
351
+
352
+ // Mock translation lookup
353
+ env.ai.mockGetObject(
354
+ {
355
+ gramatically_corrected_input_text: 'tree',
356
+ detected_language: 'English',
357
+ text_type: 'noun',
358
+ translation_swedish: 'träd',
359
+ translation_mother_tongue: 'tree',
360
+ en_ett_word: 'ett',
361
+ },
362
+ {
363
+ matcher: (req) => {
364
+ return req.postDataJSON()?.instructions?.includes('Look up') ?? false;
365
+ },
366
+ },
367
+ );
368
+
369
+ // Mock example sentence (with delay)
370
+ env.ai.mockGetObject(
371
+ {
372
+ example_sentence: {
373
+ target_language: 'Jag ser ett träd.',
374
+ english: 'I see a tree.',
375
+ },
376
+ explanation: 'A tall perennial plant.',
377
+ },
378
+ {
379
+ delay: 1000,
380
+ matcher: (req) => {
381
+ return req.postDataJSON()?.instructions?.includes('example') ?? false;
382
+ },
383
+ },
384
+ );
385
+
386
+ await env.setup();
387
+ await page.goto(`${pluginUrl}/#/sidebar/translate`);
388
+ });
389
+
390
+ test('translates word correctly', async ({ page }) => {
391
+ await page.getByRole('textbox').fill('tree');
392
+ await page.getByRole('button', { name: 'Look up word' }).click();
393
+
394
+ await expect(page.getByText('träd')).toBeVisible();
395
+ await expect(page.getByText('ett')).toBeVisible();
396
+ });
397
+ });
398
+ ```
399
+
400
+ ### Testing with Side Panel Actions
401
+
402
+ ```typescript
403
+ test('handles side panel action', async ({ page }) => {
404
+ // Set up action BEFORE navigating
405
+ await env.event.triggerOnSidePanelAction({
406
+ plugin_id: pluginId,
407
+ action_key: 'translate',
408
+ action: 'translate',
409
+ text: 'tree',
410
+ });
411
+
412
+ await page.goto(`${pluginUrl}/#/sidebar/translate`);
413
+
414
+ // Plugin receives the action and starts translation
415
+ await expect(page.getByText('träd')).toBeVisible();
416
+ });
417
+ ```
418
+
419
+ ### Testing Streaming Responses
420
+
421
+ ```typescript
422
+ test('handles streaming chat responses', async ({ page }) => {
423
+ // Mock streaming response for chat
424
+ env.ai.mockGetSteamedText('This is the AI response that will be streamed.');
425
+
426
+ await env.setup();
427
+ await page.goto(`${pluginUrl}/#/sidebar/translate`);
428
+
429
+ // Type a question
430
+ await page.getByRole('textbox', { name: 'Ask questions...' }).fill('Explain this');
431
+ await page.keyboard.press('Enter');
432
+
433
+ // Response should appear (formatted as SSE)
434
+ await expect(page.getByText('This is the AI response')).toBeVisible();
435
+ });
436
+ ```
437
+
438
+ ## Default Behavior
439
+
440
+ The test environment automatically provides:
441
+
442
+ - **Default RimoriInfo**: Test credentials, guild info, user profile
443
+ - **Default route handlers**:
444
+ - `GET /plugin_settings` → returns `null` (no settings)
445
+ - `PATCH /plugin_settings` → returns `[]` (no rows updated, triggers INSERT)
446
+ - `POST /plugin_settings` → returns success response
447
+ - **MessageChannel communication**: Fully set up and ready
448
+
449
+ You can override any of these defaults by calling the appropriate mock methods.
450
+
451
+ ## Limitations
452
+
453
+ ### Streaming Responses
454
+
455
+ Due to Playwright's `route.fulfill()` requiring a complete response body, streaming responses (via `mockGetSteamedText`) send all SSE chunks at once. The client will parse them correctly as SSE, but incremental timing/delays won't be visible in the UI.
456
+
457
+ For true streaming with visible delays, use a real HTTP server instead of route mocking.
458
+
459
+ ### Standalone Mode
460
+
461
+ The test environment forces iframe mode (not standalone). Plugins that rely on standalone mode behavior may need different test setups.
462
+
463
+ ## Troubleshooting
464
+
465
+ ### "No route handler found"
466
+
467
+ If you see this error, add a mock for the missing route:
468
+
469
+ ```typescript
470
+ env.plugin.mockGetSettings(null); // or env.ai.mockGetObject(...), etc.
471
+ ```
472
+
473
+ ### Plugin not receiving events
474
+
475
+ Make sure to call `triggerOnSidePanelAction` or `triggerOnMainPanelAction` BEFORE navigating:
476
+
477
+ ```typescript
478
+ // ✅ Correct
479
+ await env.event.triggerOnSidePanelAction(payload);
480
+ await page.goto(pluginUrl);
481
+
482
+ // ❌ Wrong - listener not ready
483
+ await page.goto(pluginUrl);
484
+ await env.event.triggerOnSidePanelAction(payload);
485
+ ```
486
+
487
+ ### Settings not being saved
488
+
489
+ The default flow is: GET → PATCH (empty) → POST. If your test expects different behavior, override the handlers:
490
+
491
+ ```typescript
492
+ // Simulate settings already exist
493
+ env.plugin.mockGetSettings({ id: 'existing', settings: {...} });
494
+ ```
495
+
496
+ ## License
497
+
498
+ Apache License 2.0
File without changes
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ // import { expect, type Page } from "@playwright/test";
3
+ // import type { RimoriTestEnvironment } from "../core/RimoriTestEnvironment";
4
+ // import type { EventHandler } from "../types/event-bus";
5
+ // export async function waitForEvent(
6
+ // environment: RimoriTestEnvironment,
7
+ // topic: string,
8
+ // timeout = 10000
9
+ // ): Promise<unknown> {
10
+ // return new Promise((resolve, reject) => {
11
+ // let timer: NodeJS.Timeout | undefined;
12
+ // const handler: EventHandler = ({ data, event }) => {
13
+ // if (event.topic !== topic) {
14
+ // return;
15
+ // }
16
+ // if (timer) {
17
+ // clearTimeout(timer);
18
+ // }
19
+ // environment.offEvent(topic, handler);
20
+ // resolve(data);
21
+ // };
22
+ // timer = setTimeout(() => {
23
+ // environment.offEvent(topic, handler);
24
+ // reject(new Error(`Timed out waiting for event ${topic}`));
25
+ // }, timeout);
26
+ // environment.onEvent(topic, handler);
27
+ // });
28
+ // }
@@ -0,0 +1,132 @@
1
+ import type { Page } from '@playwright/test';
2
+ type Language = {
3
+ code: string;
4
+ name: string;
5
+ native: string;
6
+ capitalized: string;
7
+ uppercase: string;
8
+ };
9
+ type StudyBuddy = {
10
+ id: string;
11
+ name: string;
12
+ description: string;
13
+ avatarUrl: string;
14
+ voiceId: string;
15
+ aiPersonality: string;
16
+ };
17
+ export type UserInfo = {
18
+ mother_tongue: Language;
19
+ target_language: Language;
20
+ skill_level_reading: string;
21
+ skill_level_writing: string;
22
+ skill_level_grammar: string;
23
+ skill_level_speaking: string;
24
+ skill_level_listening: string;
25
+ skill_level_understanding: string;
26
+ goal_longterm: string;
27
+ goal_weekly: string;
28
+ study_buddy: StudyBuddy;
29
+ story_genre: string;
30
+ study_duration: number;
31
+ motivation_type: string;
32
+ onboarding_completed: boolean;
33
+ context_menu_on_select: boolean;
34
+ user_name?: string;
35
+ target_country: string;
36
+ target_city?: string;
37
+ };
38
+ type RimoriGuild = {
39
+ id: string;
40
+ longTermGoalOverride: string;
41
+ allowUserPluginSettings: boolean;
42
+ };
43
+ type PluginInfo = {
44
+ id: string;
45
+ title: string;
46
+ description: string;
47
+ logo: string;
48
+ url: string;
49
+ };
50
+ type RimoriInfo = {
51
+ url: string;
52
+ key: string;
53
+ backendUrl: string;
54
+ token: string;
55
+ expiration: Date;
56
+ tablePrefix: string;
57
+ pluginId: string;
58
+ guild: RimoriGuild;
59
+ installedPlugins: PluginInfo[];
60
+ profile: UserInfo;
61
+ mainPanelPlugin?: PluginInfo;
62
+ sidePanelPlugin?: PluginInfo;
63
+ };
64
+ type EventBusMessage = {
65
+ timestamp: string;
66
+ sender: string;
67
+ topic: string;
68
+ data: unknown;
69
+ debug: boolean;
70
+ eventId?: number;
71
+ };
72
+ type MessageChannelSimulatorArgs = {
73
+ page: Page;
74
+ pluginId: string;
75
+ queryParams?: Record<string, string>;
76
+ rimoriInfo?: RimoriInfo;
77
+ };
78
+ type EventListener = (event: EventBusMessage) => void | Promise<void>;
79
+ export declare class MessageChannelSimulator {
80
+ private readonly page;
81
+ private readonly pluginId;
82
+ private readonly queryParams;
83
+ private readonly baseUserInfo;
84
+ private readonly providedInfo?;
85
+ private readonly listeners;
86
+ private readonly autoResponders;
87
+ private readonly pendingOutbound;
88
+ private currentUserInfo;
89
+ private currentRimoriInfo;
90
+ private isReady;
91
+ private instanceId;
92
+ /**
93
+ * Creates a simulator that mimics the Rimori host for plugin tests.
94
+ * @param param
95
+ * @param param.page - Playwright page hosting the plugin iframe.
96
+ * @param param.pluginId - Target plugin identifier.
97
+ * @param param.queryParams - Query parameters forwarded to the plugin init.
98
+ */
99
+ constructor({ page, pluginId, queryParams, rimoriInfo }: MessageChannelSimulatorArgs);
100
+ get defaultUserInfo(): UserInfo;
101
+ get userInfo(): UserInfo;
102
+ /**
103
+ * Injects the handshake shims so the plugin talks to this simulator.
104
+ */
105
+ initialize(): Promise<void>;
106
+ /**
107
+ * Sends an event into the plugin as though the Rimori parent emitted it.
108
+ */
109
+ emit(topic: string, data: unknown, sender?: string): Promise<void>;
110
+ /**
111
+ * Registers a handler for events emitted from the plugin.
112
+ */
113
+ on(topic: string, handler: EventListener): () => void;
114
+ /**
115
+ * Overrides the default profile returned by the auto responders.
116
+ */
117
+ setUserInfo(overrides: Partial<UserInfo>): void;
118
+ getRimoriInfo(): RimoriInfo | null;
119
+ private setupMessageChannel;
120
+ private sendToPlugin;
121
+ private flushPending;
122
+ private handlePortMessage;
123
+ private dispatchEvent;
124
+ private maybeRespond;
125
+ private buildRimoriInfo;
126
+ private serializeRimoriInfo;
127
+ private cloneUserInfo;
128
+ private mergeUserInfo;
129
+ private registerAutoResponders;
130
+ private cloneRimoriInfo;
131
+ }
132
+ export {};