@rimori/playwright-testing 0.2.2 → 0.2.3-next.2

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.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RimoriTestEnvironment = void 0;
4
4
  const default_user_info_1 = require("../fixtures/default-user-info");
5
5
  const MessageChannelSimulator_1 = require("./MessageChannelSimulator");
6
+ const SettingsStateManager_1 = require("./SettingsStateManager");
6
7
  class RimoriTestEnvironment {
7
8
  constructor(options) {
8
9
  this.backendRoutes = {};
@@ -10,57 +11,45 @@ class RimoriTestEnvironment {
10
11
  this.messageChannelSimulator = null;
11
12
  this.plugin = {
12
13
  /**
13
- * Mocks PATCH request for updating plugin_settings.
14
- * @param response - The response for PATCH. Defaults to empty array (no rows updated).
15
- * Should return array with updated row(s) like [{ id: '...' }] if update succeeds.
14
+ * Manually set the settings state (useful for test setup).
15
+ * This directly modifies the internal settings state.
16
+ * @param settings - The settings to set, or null to clear settings
16
17
  */
17
- mockSetSettings: (response, options) => {
18
- console.log('Mocking set settings for mockSetSettings', response, options);
19
- console.warn('mockSetSettings is not tested');
20
- // PATCH request returns array of updated rows
21
- // Empty array means no rows matched (will trigger INSERT)
22
- // Array with items means update succeeded
23
- const defaultResponse = response ?? [];
24
- this.addSupabaseRoute('plugin_settings', defaultResponse, { ...options, method: 'PATCH' });
18
+ setSettings: (settings) => {
19
+ this.settingsManager.setSettings(settings);
20
+ },
21
+ /**
22
+ * Get the current settings state (useful for assertions).
23
+ * @returns The current settings or null if no settings exist
24
+ */
25
+ getSettings: () => {
26
+ return this.settingsManager.getSettings();
25
27
  },
26
28
  /**
27
- * Mocks GET request for fetching plugin_settings.
28
- * @param settingsRow - The full row object from plugin_settings table, or null if not found.
29
- * Should include: { id, plugin_id, guild_id, settings, is_guild_setting, user_id }.
30
- * If null, simulates no settings exist (triggers INSERT flow).
29
+ * Override the GET handler for plugin_settings (rarely needed).
30
+ * By default, GET returns the current state from SettingsStateManager.
31
31
  */
32
32
  mockGetSettings: (settingsRow, options) => {
33
- console.log('Mocking get settings for mockGetSettings', settingsRow, options);
34
- console.warn('mockGetSettings is not tested');
35
- // GET request returns the full row or null (from maybeSingle())
36
- // null means no settings exist, which triggers setSettings() -> INSERT
37
- this.addSupabaseRoute('plugin_settings', settingsRow, options);
33
+ this.addSupabaseRoute('plugin_settings', settingsRow, { ...options, method: 'GET' });
34
+ },
35
+ /**
36
+ * Override the PATCH handler for plugin_settings (rarely needed).
37
+ * By default, PATCH updates the state in SettingsStateManager.
38
+ */
39
+ mockSetSettings: (response, options) => {
40
+ this.addSupabaseRoute('plugin_settings', response, { ...options, method: 'PATCH' });
38
41
  },
39
42
  /**
40
- * Mocks POST request for inserting plugin_settings.
41
- * @param response - The response for POST. Defaults to success response with inserted row.
43
+ * Override the POST handler for plugin_settings (rarely needed).
44
+ * By default, POST inserts/updates the state in SettingsStateManager.
42
45
  */
43
46
  mockInsertSettings: (response, options) => {
44
- console.log('Mocking insert settings for mockInsertSettings', response, options);
45
- console.warn('mockInsertSettings is not tested');
46
- // TODO this function should not exist and possibly be combined with the mockSetSettings function
47
- // POST request returns the inserted row or success response
48
- // Default to an object representing successful insert
49
- const defaultResponse = response ?? {
50
- id: 'mock-settings-id',
51
- plugin_id: this.pluginId,
52
- guild_id: this.rimoriInfo.guild.id,
53
- };
54
- this.addSupabaseRoute('plugin_settings', defaultResponse, { ...options, method: 'POST' });
47
+ this.addSupabaseRoute('plugin_settings', response, { ...options, method: 'POST' });
55
48
  },
56
49
  mockGetUserInfo: (userInfo, options) => {
57
- console.log('Mocking get user info for mockGetUserInfo', userInfo, options);
58
- console.warn('mockGetUserInfo is not tested');
59
50
  this.addSupabaseRoute('/user-info', { ...this.rimoriInfo.profile, ...userInfo }, { ...options, delay: 0 });
60
51
  },
61
52
  mockGetPluginInfo: (pluginInfo, options) => {
62
- console.log('Mocking get plugin info for mockGetPluginInfo', pluginInfo, options);
63
- console.warn('mockGetPluginInfo is not tested');
64
53
  this.addSupabaseRoute('/plugin-info', pluginInfo, options);
65
54
  },
66
55
  };
@@ -81,21 +70,87 @@ class RimoriTestEnvironment {
81
70
  * @param options - Mock options including HTTP method (defaults to 'GET' if not specified)
82
71
  */
83
72
  mockFrom: (tableName, value, options) => {
84
- console.log('Mocking db.from for table:', tableName, 'method:', options?.method ?? 'GET', value, options);
73
+ // console.log('Mocking db.from for table:', tableName, 'method:', options?.method ?? 'GET', value, options);
85
74
  const fullTableName = `${this.pluginId}_${tableName}`;
86
75
  this.addSupabaseRoute(fullTableName, value, options);
87
76
  },
88
- mockTable: () => { },
89
77
  };
90
78
  this.event = {
91
- mockEmit: () => { },
92
- mockRequest: () => { },
93
- mockOn: () => { },
79
+ /**
80
+ * Emit an event into the plugin as if it came from Rimori main or another plugin.
81
+ *
82
+ * Note: This does NOT currently reach worker listeners such as those in
83
+ * `worker/listeners/decks.ts` or `worker/listeners/flascards.ts` – those run in a
84
+ * separate process. This helper is intended for UI‑side events only.
85
+ */
86
+ mockEmit: async (topic, data, sender = 'test') => {
87
+ if (!this.messageChannelSimulator) {
88
+ throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
89
+ }
90
+ await this.messageChannelSimulator.emit(topic, data, sender);
91
+ },
92
+ /**
93
+ * Registers a one-time auto-responder for request/response style events.
94
+ *
95
+ * When the plugin calls `plugin.event.request(topic, data)`, this registered responder
96
+ * will automatically return the provided response value. The responder is automatically
97
+ * removed after the first request, ensuring it only responds once.
98
+ *
99
+ * Example:
100
+ * ```ts
101
+ * // Register a responder that will return deck summaries when requested
102
+ * env.event.mockRequest('deck.requestOpenToday', [
103
+ * { id: 'deck-1', name: 'My Deck', total_new: 5, total_learning: 2, total_review: 10 }
104
+ * ]);
105
+ *
106
+ * // Now when the plugin calls: plugin.event.request('deck.requestOpenToday', {})
107
+ * // It will receive the deck summaries array above
108
+ * ```
109
+ *
110
+ * @param topic - The event topic to respond to (e.g., 'deck.requestOpenToday')
111
+ * @param response - The response value to return, or a function that receives the event and returns the response
112
+ * @returns A function to manually remove the responder before it's used
113
+ */
114
+ mockRequest: (topic, response) => {
115
+ if (!this.messageChannelSimulator) {
116
+ throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
117
+ }
118
+ return this.messageChannelSimulator.respondOnce(topic, response);
119
+ },
120
+ /**
121
+ * Listen for events emitted by the plugin.
122
+ * @param topic - The event topic to listen for (e.g., 'global.accomplishment.triggerMicro')
123
+ * @param handler - The handler function that receives the event data
124
+ * @returns A function to unsubscribe from the event
125
+ */
126
+ on: (topic, handler) => {
127
+ if (!this.messageChannelSimulator) {
128
+ throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
129
+ }
130
+ return this.messageChannelSimulator.on(topic, (event) => {
131
+ handler(event.data);
132
+ });
133
+ },
94
134
  mockOnce: () => { },
95
135
  mockRespond: () => { },
96
136
  mockEmitAccomplishment: () => { },
97
137
  mockOnAccomplishment: () => { },
98
- mockEmitSidebarAction: () => { },
138
+ /**
139
+ * Emits a sidebar action event into the plugin as if Rimori main had triggered it.
140
+ * This is useful for testing sidebar-driven flows like flashcard creation from selected text.
141
+ *
142
+ * It sends a message on the 'global.sidebar.triggerAction' topic, which plugins can listen to via:
143
+ * plugin.event.on<{ action: string; text: string }>('global.sidebar.triggerAction', ...)
144
+ *
145
+ * @param payload - The payload forwarded to the plugin, typically including an `action` key and optional `text`.
146
+ */
147
+ triggerSidebarAction: async (payload) => {
148
+ if (!this.messageChannelSimulator) {
149
+ throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
150
+ }
151
+ // Simulate Rimori main emitting the sidebar trigger event towards the plugin
152
+ await this.messageChannelSimulator.emit('global.sidebar.triggerAction', payload, 'sidebar');
153
+ },
99
154
  /**
100
155
  * Triggers a side panel action event as the parent application would.
101
156
  * This simulates how rimori-main's SidebarPluginHandler responds to plugin's 'action.requestSidebar' events.
@@ -156,24 +211,137 @@ class RimoriTestEnvironment {
156
211
  * @param options - Optional mock options.
157
212
  */
158
213
  mockGetSteamedText: (text, options) => {
159
- console.log('Mocking get steamed text for mockGetSteamedText', text, options);
160
214
  this.addBackendRoute('/ai/llm', text, { ...options, isStreaming: true });
161
215
  },
162
216
  mockGetVoice: (values, options) => {
163
- console.log('Mocking get voice for mockGetVoice', values, options);
164
- console.warn('mockGetVoice is not tested');
165
217
  this.addBackendRoute('/voice/tts', values, options);
166
218
  },
167
219
  mockGetTextFromVoice: (text, options) => {
168
- console.log('Mocking get text from voice for mockGetTextFromVoice', text, options);
169
- console.warn('mockGetTextFromVoice is not tested');
170
220
  this.addBackendRoute('/voice/stt', text, options);
171
221
  },
172
222
  mockGetObject: (value, options) => {
173
- console.log('Mocking get object for mockGetObject', value, options);
174
223
  this.addBackendRoute('/ai/llm-object', value, { ...options, method: 'POST' });
175
224
  },
176
225
  };
226
+ /**
227
+ * Helpers for tracking browser audio playback in tests.
228
+ *
229
+ * This is useful for components like the AudioPlayer in @rimori/react-client which:
230
+ * 1) Fetch audio data from the backend (mocked via `env.ai.mockGetVoice`)
231
+ * 2) Create `new Audio(url)` and call `.play()`
232
+ *
233
+ * With tracking enabled you can assert how many times audio playback was attempted:
234
+ *
235
+ * ```ts
236
+ * await env.audio.enableTracking();
237
+ * await env.ai.mockGetVoice(Buffer.from('dummy'), { method: 'POST' });
238
+ * await env.setup();
239
+ * // ...navigate and trigger audio...
240
+ * const counts = await env.audio.getPlayCounts();
241
+ * expect(counts.mediaPlayCalls).toBeGreaterThan(0);
242
+ * ```
243
+ *
244
+ * **Counter Types:**
245
+ * - `mediaPlayCalls`: Tracks calls to `.play()` on any `HTMLMediaElement` instance
246
+ * (including `<audio>`, `<video>` elements, or any element that inherits from `HTMLMediaElement`).
247
+ * This counter increments whenever `HTMLMediaElement.prototype.play()` is invoked.
248
+ * - `audioPlayCalls`: Tracks calls to `.play()` specifically on instances created via the `Audio` constructor
249
+ * (e.g., `new Audio(url).play()`). This is a subset of `mediaPlayCalls` but provides more specific
250
+ * tracking for programmatically created audio elements.
251
+ *
252
+ * **Note**: Since `Audio` instances are also `HTMLMediaElement` instances, calling `.play()` on an
253
+ * `Audio` object will increment **both** counters. For most use cases, checking `mediaPlayCalls`
254
+ * is sufficient as it captures all audio playback attempts.
255
+ */
256
+ this.audio = {
257
+ /**
258
+ * Injects tracking hooks for HTMLMediaElement.play and the Audio constructor.
259
+ * Must be called before the plugin code runs (ideally before env.setup()).
260
+ */
261
+ enableTracking: async () => {
262
+ await this.page.addInitScript(() => {
263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
+ const w = window;
265
+ if (!w.__rimoriAudio) {
266
+ w.__rimoriAudio = {
267
+ mediaPlayCalls: 0,
268
+ audioPlayCalls: 0,
269
+ };
270
+ }
271
+ const proto = (w.HTMLMediaElement && w.HTMLMediaElement.prototype) || undefined;
272
+ if (proto && !proto.__rimoriPatched) {
273
+ const originalPlay = proto.play;
274
+ proto.play = function (...args) {
275
+ w.__rimoriAudio.mediaPlayCalls += 1;
276
+ return originalPlay.apply(this, args);
277
+ };
278
+ Object.defineProperty(proto, '__rimoriPatched', {
279
+ value: true,
280
+ configurable: false,
281
+ enumerable: false,
282
+ writable: false,
283
+ });
284
+ }
285
+ const OriginalAudio = w.Audio;
286
+ if (OriginalAudio && !OriginalAudio.__rimoriPatched) {
287
+ const PatchedAudio = function (...args) {
288
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
+ const audio = new OriginalAudio(...args);
290
+ const originalPlay = audio.play.bind(audio);
291
+ audio.play = () => {
292
+ w.__rimoriAudio.audioPlayCalls += 1;
293
+ return originalPlay();
294
+ };
295
+ return audio;
296
+ };
297
+ PatchedAudio.prototype = OriginalAudio.prototype;
298
+ Object.defineProperty(PatchedAudio, '__rimoriPatched', {
299
+ value: true,
300
+ configurable: false,
301
+ enumerable: false,
302
+ writable: false,
303
+ });
304
+ w.Audio = PatchedAudio;
305
+ }
306
+ });
307
+ },
308
+ /**
309
+ * Returns current audio play counters from the browser context.
310
+ *
311
+ * @returns An object with two counters:
312
+ * - `mediaPlayCalls`: Total number of `.play()` calls on any `HTMLMediaElement` (includes all audio/video elements)
313
+ * - `audioPlayCalls`: Number of `.play()` calls on instances created via `new Audio()` (subset of `mediaPlayCalls`)
314
+ *
315
+ * **Note**: Since `Audio` extends `HTMLMediaElement`, calling `.play()` on an `Audio` instance increments both counters.
316
+ * For general audio playback tracking, use `mediaPlayCalls` as it captures all playback attempts.
317
+ */
318
+ getPlayCounts: async () => {
319
+ return this.page.evaluate(() => {
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ const w = window;
322
+ if (!w.__rimoriAudio) {
323
+ return { mediaPlayCalls: 0, audioPlayCalls: 0 };
324
+ }
325
+ return {
326
+ mediaPlayCalls: Number(w.__rimoriAudio.mediaPlayCalls || 0),
327
+ audioPlayCalls: Number(w.__rimoriAudio.audioPlayCalls || 0),
328
+ };
329
+ });
330
+ },
331
+ /**
332
+ * Resets the audio play counters to zero.
333
+ */
334
+ resetPlayCounts: async () => {
335
+ await this.page.evaluate(() => {
336
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
337
+ const w = window;
338
+ if (w.__rimoriAudio) {
339
+ w.__rimoriAudio.mediaPlayCalls = 0;
340
+ w.__rimoriAudio.audioPlayCalls = 0;
341
+ }
342
+ });
343
+ },
344
+ };
177
345
  this.runtime = {
178
346
  mockFetchBackend: () => { },
179
347
  };
@@ -199,8 +367,84 @@ class RimoriTestEnvironment {
199
367
  };
200
368
  this.page = options.page;
201
369
  this.pluginId = options.pluginId;
202
- // TODO move to a function
203
- this.rimoriInfo = {
370
+ this.rimoriInfo = this.getRimoriInfo(options);
371
+ // Initialize settings state manager
372
+ this.settingsManager = new SettingsStateManager_1.SettingsStateManager(options.settings || null, options.pluginId, this.rimoriInfo.guild.id);
373
+ this.interceptRoutes(options.pluginUrl);
374
+ }
375
+ interceptRoutes(pluginUrl) {
376
+ // Intercept all /locales requests and fetch from the dev server
377
+ this.page.route(`${pluginUrl}/locales/**`, async (route) => {
378
+ const request = route.request();
379
+ const url = new URL(request.url());
380
+ const devServerUrl = `http://${url.host}/locales/en.json`;
381
+ // console.log('Fetching locales from: ' + devServerUrl);
382
+ // throw new Error('Test: ' + devServerUrl);
383
+ try {
384
+ // Fetch from the dev server
385
+ const response = await fetch(devServerUrl);
386
+ const body = await response.text();
387
+ await route.fulfill({
388
+ status: response.status,
389
+ headers: { 'Content-Type': 'application/json' },
390
+ body,
391
+ });
392
+ }
393
+ catch (error) {
394
+ console.error(`Error fetching translation from ${devServerUrl}:`, error);
395
+ await route.fulfill({
396
+ status: 500,
397
+ headers: { 'Content-Type': 'application/json' },
398
+ body: JSON.stringify({ error: 'Failed to load translations' }),
399
+ });
400
+ }
401
+ });
402
+ this.page.route(`${this.rimoriInfo.backendUrl}/**`, (route) => this.handleRoute(route, this.backendRoutes));
403
+ this.page.route(`${this.rimoriInfo.url}/**`, (route) => this.handleRoute(route, this.supabaseRoutes));
404
+ }
405
+ async setup() {
406
+ // console.log('Setting up RimoriTestEnvironment');
407
+ this.page.on('console', (msg) => {
408
+ const logLevel = msg.type();
409
+ const logMessage = msg.text();
410
+ if (logLevel === 'debug')
411
+ return;
412
+ if (logMessage.includes('Download the React DevTools'))
413
+ return;
414
+ if (logMessage.includes('languageChanged en'))
415
+ return;
416
+ if (logMessage.includes('i18next: initialized {debug: true'))
417
+ return;
418
+ console.log(`[browser:${logLevel}]`, logMessage);
419
+ });
420
+ // Set up default handlers for plugin_settings routes using SettingsStateManager
421
+ this.setupSettingsRoutes();
422
+ // Initialize MessageChannelSimulator to simulate parent-iframe communication
423
+ // This makes the plugin think it's running in an iframe (not standalone mode)
424
+ // Convert RimoriInfo from CommunicationHandler format to MessageChannelSimulator format
425
+ this.messageChannelSimulator = new MessageChannelSimulator_1.MessageChannelSimulator({
426
+ page: this.page,
427
+ pluginId: this.pluginId,
428
+ queryParams: {},
429
+ rimoriInfo: this.rimoriInfo,
430
+ });
431
+ // Initialize the simulator - this injects the necessary shims
432
+ // to intercept window.parent.postMessage calls and set up MessageChannel communication
433
+ await this.messageChannelSimulator.initialize();
434
+ // Set up a no-op handler for pl454583483.session.triggerUrlChange
435
+ // This prevents errors if the plugin emits this event
436
+ this.messageChannelSimulator.on(`${this.pluginId}.session.triggerUrlChange`, () => {
437
+ // No-op handler - does nothing
438
+ });
439
+ this.messageChannelSimulator.on('global.accomplishment.triggerMicro', () => {
440
+ // No-op handler - does nothing
441
+ });
442
+ this.messageChannelSimulator.on('global.accomplishment.triggerMacro', () => {
443
+ // No-op handler - does nothing
444
+ });
445
+ }
446
+ getRimoriInfo(options) {
447
+ return {
204
448
  key: 'rimori-testing-key',
205
449
  token: 'rimori-testing-token',
206
450
  url: 'http://localhost:3500',
@@ -231,80 +475,54 @@ class RimoriTestEnvironment {
231
475
  profile: default_user_info_1.DEFAULT_USER_INFO,
232
476
  mainPanelPlugin: undefined,
233
477
  sidePanelPlugin: undefined,
478
+ interfaceLanguage: default_user_info_1.DEFAULT_USER_INFO.mother_tongue.code, // Set interface language from user's mother tongue
234
479
  };
235
- this.interceptRoutes();
236
480
  }
237
- interceptRoutes() {
238
- this.page.route(`${this.rimoriInfo.backendUrl}/**`, (route) => this.handleRoute(route, this.backendRoutes));
239
- this.page.route(`${this.rimoriInfo.url}/**`, (route) => this.handleRoute(route, this.supabaseRoutes));
240
- }
241
- async setup() {
242
- console.log('Setting up RimoriTestEnvironment');
243
- this.page.on('console', (msg) => {
244
- console.log(`[browser:${msg.type()}]`, msg.text());
481
+ /**
482
+ * Sets up the plugin_settings routes to use the SettingsStateManager.
483
+ * GET returns current state, PATCH updates state, POST creates/updates state.
484
+ */
485
+ setupSettingsRoutes() {
486
+ // GET: Return current settings state
487
+ this.addSupabaseRoute('plugin_settings', () => this.settingsManager.getSettings(), {
488
+ method: 'GET',
245
489
  });
246
- // Add default handlers for common routes that plugins typically access
247
- // These can be overridden by explicit mock calls
248
- if (!this.supabaseRoutes[this.createRouteKey('GET', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
249
- // Default: no settings exist (null) - triggers INSERT flow
250
- // Can be overridden with mockGetSettings() to return existing settings
251
- this.plugin.mockGetSettings(null);
252
- }
253
- if (!this.supabaseRoutes[this.createRouteKey('PATCH', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
254
- // Default PATCH handler for plugin_settings - returns empty array (no rows updated)
255
- // This triggers INSERT (POST) flow
256
- // Can be overridden with mockSetSettings() to simulate successful update
257
- this.plugin.mockSetSettings([]);
258
- }
259
- if (!this.supabaseRoutes[this.createRouteKey('POST', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
260
- // Default POST handler for plugin_settings - simulates successful insert
261
- // Can be overridden with mockInsertSettings() to customize response
262
- this.plugin.mockInsertSettings();
263
- }
264
- // Initialize MessageChannelSimulator to simulate parent-iframe communication
265
- // This makes the plugin think it's running in an iframe (not standalone mode)
266
- // Convert RimoriInfo from CommunicationHandler format to MessageChannelSimulator format
267
- this.messageChannelSimulator = new MessageChannelSimulator_1.MessageChannelSimulator({
268
- page: this.page,
269
- pluginId: this.pluginId,
270
- queryParams: {},
271
- rimoriInfo: {
272
- ...this.rimoriInfo,
273
- guild: {
274
- id: this.rimoriInfo.guild.id,
275
- longTermGoalOverride: 'longTermGoalOverride' in this.rimoriInfo.guild ? this.rimoriInfo.guild.longTermGoalOverride : '',
276
- allowUserPluginSettings: this.rimoriInfo.guild.allowUserPluginSettings,
277
- },
278
- installedPlugins: this.rimoriInfo.installedPlugins.map((p) => ({
279
- id: p.id,
280
- title: p.info?.title || '',
281
- description: p.info?.description || '',
282
- logo: p.info?.logo || '',
283
- url: p.pages?.external_hosted_url || '',
284
- })),
285
- mainPanelPlugin: this.rimoriInfo.mainPanelPlugin
286
- ? {
287
- id: this.rimoriInfo.mainPanelPlugin.id,
288
- title: this.rimoriInfo.mainPanelPlugin.info?.title || '',
289
- description: this.rimoriInfo.mainPanelPlugin.info?.description || '',
290
- logo: this.rimoriInfo.mainPanelPlugin.info?.logo || '',
291
- url: this.rimoriInfo.mainPanelPlugin.pages?.external_hosted_url || '',
292
- }
293
- : undefined,
294
- sidePanelPlugin: this.rimoriInfo.sidePanelPlugin
295
- ? {
296
- id: this.rimoriInfo.sidePanelPlugin.id,
297
- title: this.rimoriInfo.sidePanelPlugin.info?.title || '',
298
- description: this.rimoriInfo.sidePanelPlugin.info?.description || '',
299
- logo: this.rimoriInfo.sidePanelPlugin.info?.logo || '',
300
- url: this.rimoriInfo.sidePanelPlugin.pages?.external_hosted_url || '',
301
- }
302
- : undefined,
303
- },
490
+ // PATCH: Update settings based on request body
491
+ this.addSupabaseRoute('plugin_settings', async (request) => {
492
+ try {
493
+ const postData = request.postData();
494
+ if (postData) {
495
+ const updates = JSON.parse(postData);
496
+ return this.settingsManager.updateSettings(updates);
497
+ }
498
+ // If no body, return empty array (no update)
499
+ return this.settingsManager.updateSettings({});
500
+ }
501
+ catch {
502
+ // If parsing fails, return empty array
503
+ return this.settingsManager.updateSettings({});
504
+ }
505
+ }, {
506
+ method: 'PATCH',
507
+ });
508
+ // POST: Insert/update settings based on request body
509
+ this.addSupabaseRoute('plugin_settings', async (request) => {
510
+ try {
511
+ const postData = request.postData();
512
+ if (postData) {
513
+ const newSettings = JSON.parse(postData);
514
+ return this.settingsManager.insertSettings(newSettings);
515
+ }
516
+ // If no body, insert with defaults
517
+ return this.settingsManager.insertSettings({});
518
+ }
519
+ catch {
520
+ // If parsing fails, insert with defaults
521
+ return this.settingsManager.insertSettings({});
522
+ }
523
+ }, {
524
+ method: 'POST',
304
525
  });
305
- // Initialize the simulator - this injects the necessary shims
306
- // to intercept window.parent.postMessage calls and set up MessageChannel communication
307
- await this.messageChannelSimulator.initialize();
308
526
  }
309
527
  /**
310
528
  * Formats text as SSE (Server-Sent Events) response.
@@ -359,15 +577,15 @@ class RimoriTestEnvironment {
359
577
  }
360
578
  }
361
579
  async handleRoute(route, routes) {
362
- console.warn('handleRoute is not tested');
363
580
  const request = route.request();
364
581
  const requestUrl = request.url();
365
582
  const method = request.method().toUpperCase();
366
583
  const routeKey = this.createRouteKey(method, requestUrl);
367
- console.log('Handling route', routeKey);
584
+ // console.log('Handling route', routeKey);
368
585
  const mocks = routes[routeKey];
369
586
  if (!mocks || mocks.length === 0) {
370
587
  console.error('No route handler found for route', routeKey);
588
+ throw new Error('No route handler found for route: ' + routeKey);
371
589
  route.abort('not_found');
372
590
  return;
373
591
  }
@@ -407,10 +625,15 @@ class RimoriTestEnvironment {
407
625
  if (options?.error) {
408
626
  return await route.abort(options.error);
409
627
  }
628
+ // Handle function-based mocks (for stateful responses like settings)
629
+ let responseValue = matchingMock.value;
630
+ if (typeof matchingMock.value === 'function') {
631
+ responseValue = await matchingMock.value(request);
632
+ }
410
633
  // Handle streaming responses (for mockGetSteamedText)
411
634
  // Since Playwright requires complete body, we format as SSE without delays
412
- if (matchingMock.isStreaming && typeof matchingMock.value === 'string') {
413
- const body = this.formatAsSSE(matchingMock.value);
635
+ if (matchingMock.isStreaming && typeof responseValue === 'string') {
636
+ const body = this.formatAsSSE(responseValue);
414
637
  return await route.fulfill({
415
638
  status: 200,
416
639
  headers: { 'Content-Type': 'text/event-stream' },
@@ -418,7 +641,7 @@ class RimoriTestEnvironment {
418
641
  });
419
642
  }
420
643
  // Regular JSON response
421
- const responseBody = JSON.stringify(matchingMock.value);
644
+ const responseBody = JSON.stringify(responseValue);
422
645
  route.fulfill({
423
646
  status: 200,
424
647
  body: responseBody,
@@ -431,10 +654,10 @@ class RimoriTestEnvironment {
431
654
  * @param options - The options for the route. Method defaults to 'GET' if not specified.
432
655
  */
433
656
  addSupabaseRoute(path, values, options) {
434
- console.warn('addSupabaseRoute is not tested');
435
657
  const method = options?.method ?? 'GET';
436
658
  const fullPath = `${this.rimoriInfo.url}/rest/v1/${path}`;
437
659
  const routeKey = this.createRouteKey(method, fullPath);
660
+ // console.log('Registering supabase route:', routeKey);
438
661
  if (!this.supabaseRoutes[routeKey]) {
439
662
  this.supabaseRoutes[routeKey] = [];
440
663
  }
@@ -452,7 +675,6 @@ class RimoriTestEnvironment {
452
675
  * @param isStreaming - Optional flag to mark this as a streaming response.
453
676
  */
454
677
  addBackendRoute(path, values, options) {
455
- console.warn('addBackendRoute is not tested');
456
678
  const method = options?.method ?? 'POST';
457
679
  const fullPath = `${this.rimoriInfo.backendUrl}${path.startsWith('/') ? path : '/' + path}`;
458
680
  const routeKey = this.createRouteKey(method, fullPath);
@@ -470,4 +692,3 @@ class RimoriTestEnvironment {
470
692
  }
471
693
  exports.RimoriTestEnvironment = RimoriTestEnvironment;
472
694
  // Todo: How to test if the event was received by the parent?
473
- // TODO: The matcher option of RimoriTestEnvironment v1 might be useful to use
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Manages plugin settings state for test environment.
3
+ * Provides a single source of truth for settings that can be modified by mocked API calls.
4
+ */
5
+ export interface PluginSettings {
6
+ id?: string;
7
+ plugin_id?: string;
8
+ guild_id?: string;
9
+ settings?: Record<string, unknown>;
10
+ is_guild_setting?: boolean;
11
+ user_id?: string | null;
12
+ }
13
+ export declare class SettingsStateManager {
14
+ private settings;
15
+ constructor(initialSettings: PluginSettings | null, pluginId: string, guildId: string);
16
+ /**
17
+ * Get current settings state (for GET requests)
18
+ * Returns null if no settings exist, otherwise returns the full settings object
19
+ */
20
+ getSettings(): PluginSettings | null;
21
+ /**
22
+ * Update settings (for PATCH requests)
23
+ * @param updates - Partial settings to update
24
+ * @returns Array with updated row if settings exist, empty array if no settings exist
25
+ */
26
+ updateSettings(updates: Partial<PluginSettings>): PluginSettings[];
27
+ /**
28
+ * Insert new settings (for POST requests)
29
+ * @param newSettings - Settings to insert
30
+ * @returns The inserted settings object
31
+ */
32
+ insertSettings(newSettings: Partial<PluginSettings>): PluginSettings;
33
+ /**
34
+ * Manually set settings (useful for test setup)
35
+ */
36
+ setSettings(settings: PluginSettings | null): void;
37
+ /**
38
+ * Check if settings exist
39
+ */
40
+ hasSettings(): boolean;
41
+ }