@rimori/playwright-testing 0.2.1 → 0.2.3-next.1

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,72 +11,146 @@ 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
- // POST request returns the inserted row or success response
47
- // Default to an object representing successful insert
48
- const defaultResponse = response ?? {
49
- id: 'mock-settings-id',
50
- plugin_id: this.pluginId,
51
- guild_id: this.rimoriInfo.guild.id,
52
- };
53
- this.addSupabaseRoute('plugin_settings', defaultResponse, { ...options, method: 'POST' });
47
+ this.addSupabaseRoute('plugin_settings', response, { ...options, method: 'POST' });
54
48
  },
55
49
  mockGetUserInfo: (userInfo, options) => {
56
- console.log('Mocking get user info for mockGetUserInfo', userInfo, options);
57
- console.warn('mockGetUserInfo is not tested');
58
50
  this.addSupabaseRoute('/user-info', { ...this.rimoriInfo.profile, ...userInfo }, { ...options, delay: 0 });
59
51
  },
60
52
  mockGetPluginInfo: (pluginInfo, options) => {
61
- console.log('Mocking get plugin info for mockGetPluginInfo', pluginInfo, options);
62
- console.warn('mockGetPluginInfo is not tested');
63
53
  this.addSupabaseRoute('/plugin-info', pluginInfo, options);
64
54
  },
65
55
  };
66
56
  this.db = {
67
- mockFrom: () => { },
68
- mockTable: () => { },
57
+ /**
58
+ * Mocks a Supabase table endpoint (from(tableName)).
59
+ * The table name will be prefixed with the plugin ID in the actual URL.
60
+ *
61
+ * Supabase operations map to HTTP methods as follows:
62
+ * - .select() → GET
63
+ * - .insert() → POST
64
+ * - .update() → PATCH
65
+ * - .delete() → DELETE (can return data with .delete().select())
66
+ * - .upsert() → POST
67
+ *
68
+ * @param tableName - The table name (e.g., 'decks')
69
+ * @param value - The response value to return for the request
70
+ * @param options - Mock options including HTTP method (defaults to 'GET' if not specified)
71
+ */
72
+ mockFrom: (tableName, value, options) => {
73
+ // console.log('Mocking db.from for table:', tableName, 'method:', options?.method ?? 'GET', value, options);
74
+ const fullTableName = `${this.pluginId}_${tableName}`;
75
+ this.addSupabaseRoute(fullTableName, value, options);
76
+ },
69
77
  };
70
78
  this.event = {
71
- mockEmit: () => { },
72
- mockRequest: () => { },
73
- 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
+ },
74
134
  mockOnce: () => { },
75
135
  mockRespond: () => { },
76
136
  mockEmitAccomplishment: () => { },
77
137
  mockOnAccomplishment: () => { },
78
- 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
+ },
79
154
  /**
80
155
  * Triggers a side panel action event as the parent application would.
81
156
  * This simulates how rimori-main's SidebarPluginHandler responds to plugin's 'action.requestSidebar' events.
@@ -86,15 +161,11 @@ class RimoriTestEnvironment {
86
161
  throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
87
162
  }
88
163
  const topic = `${this.pluginId}.action.requestSidebar`;
89
- console.log('[RimoriTestEnvironment] Setting up listener for topic:', topic, 'with payload:', payload);
90
164
  const actionPayload = payload;
91
165
  const off = this.messageChannelSimulator.on(topic, async (event) => {
92
- console.log('[RimoriTestEnvironment] Received action.requestSidebar event:', event);
93
- console.log('[RimoriTestEnvironment] Responding to action.requestSidebar with payload:', actionPayload);
94
166
  await this.messageChannelSimulator.emit(topic, actionPayload, 'sidebar');
95
167
  off();
96
168
  });
97
- console.log('[RimoriTestEnvironment] Listener set up for topic:', topic);
98
169
  },
99
170
  /**
100
171
  * Triggers a main panel action event as the parent application would.
@@ -111,20 +182,16 @@ class RimoriTestEnvironment {
111
182
  // Listen for when the plugin emits 'action.requestMain' (which becomes '{pluginId}.action.requestMain')
112
183
  // and respond with the MainPanelAction payload, matching rimori-main's EventBus.respond behavior
113
184
  const topic = `${this.pluginId}.action.requestMain`;
114
- console.log('[RimoriTestEnvironment] Setting up listener for topic:', topic, 'with payload:', payload);
115
185
  // Store the payload in a closure so we can respond with it
116
186
  const actionPayload = payload;
117
187
  // Set up a one-time listener that responds when the plugin emits 'action.requestMain'
118
188
  // The handler receives the event object from the plugin
119
189
  const off = this.messageChannelSimulator.on(topic, async (event) => {
120
- console.log('[RimoriTestEnvironment] Received action.requestMain event:', event);
121
- console.log('[RimoriTestEnvironment] Responding to action.requestMain with payload:', actionPayload);
122
190
  // When plugin emits 'action.requestMain', respond with the MainPanelAction data
123
191
  // The sender is 'mainPanel' to match rimori-main's MainPluginHandler behavior
124
192
  await this.messageChannelSimulator.emit(topic, actionPayload, 'mainPanel');
125
193
  off(); // Remove listener after responding once (one-time response like EventBus.respond)
126
194
  });
127
- console.log('[RimoriTestEnvironment] Listener set up for topic:', topic);
128
195
  },
129
196
  };
130
197
  this.ai = {
@@ -144,26 +211,137 @@ class RimoriTestEnvironment {
144
211
  * @param options - Optional mock options.
145
212
  */
146
213
  mockGetSteamedText: (text, options) => {
147
- console.log('Mocking get steamed text for mockGetSteamedText', text, options);
148
- console.warn('mockGetSteamedText is not tested');
149
214
  this.addBackendRoute('/ai/llm', text, { ...options, isStreaming: true });
150
215
  },
151
216
  mockGetVoice: (values, options) => {
152
- console.log('Mocking get voice for mockGetVoice', values, options);
153
- console.warn('mockGetVoice is not tested');
154
217
  this.addBackendRoute('/voice/tts', values, options);
155
218
  },
156
219
  mockGetTextFromVoice: (text, options) => {
157
- console.log('Mocking get text from voice for mockGetTextFromVoice', text, options);
158
- console.warn('mockGetTextFromVoice is not tested');
159
220
  this.addBackendRoute('/voice/stt', text, options);
160
221
  },
161
222
  mockGetObject: (value, options) => {
162
- console.log('Mocking get object for mockGetObject', value, options);
163
- console.warn('mockGetObject is not tested');
164
223
  this.addBackendRoute('/ai/llm-object', value, { ...options, method: 'POST' });
165
224
  },
166
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
+ };
167
345
  this.runtime = {
168
346
  mockFetchBackend: () => { },
169
347
  };
@@ -189,8 +367,84 @@ class RimoriTestEnvironment {
189
367
  };
190
368
  this.page = options.page;
191
369
  this.pluginId = options.pluginId;
192
- // TODO move to a function
193
- 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 {
194
448
  key: 'rimori-testing-key',
195
449
  token: 'rimori-testing-token',
196
450
  url: 'http://localhost:3500',
@@ -221,77 +475,54 @@ class RimoriTestEnvironment {
221
475
  profile: default_user_info_1.DEFAULT_USER_INFO,
222
476
  mainPanelPlugin: undefined,
223
477
  sidePanelPlugin: undefined,
478
+ interfaceLanguage: default_user_info_1.DEFAULT_USER_INFO.mother_tongue.code, // Set interface language from user's mother tongue
224
479
  };
225
- this.interceptRoutes();
226
- }
227
- interceptRoutes() {
228
- this.page.route(`${this.rimoriInfo.backendUrl}/**`, (route) => this.handleRoute(route, this.backendRoutes));
229
- this.page.route(`${this.rimoriInfo.url}/**`, (route) => this.handleRoute(route, this.supabaseRoutes));
230
480
  }
231
- async setup() {
232
- console.log('Setting up RimoriTestEnvironment');
233
- // Add default handlers for common routes that plugins typically access
234
- // These can be overridden by explicit mock calls
235
- if (!this.supabaseRoutes[this.createRouteKey('GET', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
236
- // Default: no settings exist (null) - triggers INSERT flow
237
- // Can be overridden with mockGetSettings() to return existing settings
238
- this.plugin.mockGetSettings(null);
239
- }
240
- if (!this.supabaseRoutes[this.createRouteKey('PATCH', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
241
- // Default PATCH handler for plugin_settings - returns empty array (no rows updated)
242
- // This triggers INSERT (POST) flow
243
- // Can be overridden with mockSetSettings() to simulate successful update
244
- this.plugin.mockSetSettings([]);
245
- }
246
- if (!this.supabaseRoutes[this.createRouteKey('POST', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
247
- // Default POST handler for plugin_settings - simulates successful insert
248
- // Can be overridden with mockInsertSettings() to customize response
249
- this.plugin.mockInsertSettings();
250
- }
251
- // Initialize MessageChannelSimulator to simulate parent-iframe communication
252
- // This makes the plugin think it's running in an iframe (not standalone mode)
253
- // Convert RimoriInfo from CommunicationHandler format to MessageChannelSimulator format
254
- this.messageChannelSimulator = new MessageChannelSimulator_1.MessageChannelSimulator({
255
- page: this.page,
256
- pluginId: this.pluginId,
257
- queryParams: {},
258
- rimoriInfo: {
259
- ...this.rimoriInfo,
260
- guild: {
261
- id: this.rimoriInfo.guild.id,
262
- longTermGoalOverride: 'longTermGoalOverride' in this.rimoriInfo.guild ? this.rimoriInfo.guild.longTermGoalOverride : '',
263
- allowUserPluginSettings: this.rimoriInfo.guild.allowUserPluginSettings,
264
- },
265
- installedPlugins: this.rimoriInfo.installedPlugins.map((p) => ({
266
- id: p.id,
267
- title: p.info?.title || '',
268
- description: p.info?.description || '',
269
- logo: p.info?.logo || '',
270
- url: p.pages?.external_hosted_url || '',
271
- })),
272
- mainPanelPlugin: this.rimoriInfo.mainPanelPlugin
273
- ? {
274
- id: this.rimoriInfo.mainPanelPlugin.id,
275
- title: this.rimoriInfo.mainPanelPlugin.info?.title || '',
276
- description: this.rimoriInfo.mainPanelPlugin.info?.description || '',
277
- logo: this.rimoriInfo.mainPanelPlugin.info?.logo || '',
278
- url: this.rimoriInfo.mainPanelPlugin.pages?.external_hosted_url || '',
279
- }
280
- : undefined,
281
- sidePanelPlugin: this.rimoriInfo.sidePanelPlugin
282
- ? {
283
- id: this.rimoriInfo.sidePanelPlugin.id,
284
- title: this.rimoriInfo.sidePanelPlugin.info?.title || '',
285
- description: this.rimoriInfo.sidePanelPlugin.info?.description || '',
286
- logo: this.rimoriInfo.sidePanelPlugin.info?.logo || '',
287
- url: this.rimoriInfo.sidePanelPlugin.pages?.external_hosted_url || '',
288
- }
289
- : undefined,
290
- },
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',
489
+ });
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',
291
525
  });
292
- // Initialize the simulator - this injects the necessary shims
293
- // to intercept window.parent.postMessage calls and set up MessageChannel communication
294
- await this.messageChannelSimulator.initialize();
295
526
  }
296
527
  /**
297
528
  * Formats text as SSE (Server-Sent Events) response.
@@ -334,16 +565,27 @@ class RimoriTestEnvironment {
334
565
  const normalizedUrl = this.normalizeUrl(url);
335
566
  return `${method} ${normalizedUrl}`;
336
567
  }
568
+ /**
569
+ * Removes a one-time mock from the mocks array after it's been used.
570
+ */
571
+ removeOneTimeMock(mock, mocks) {
572
+ if (!mock.options?.once)
573
+ return;
574
+ const index = mocks.indexOf(mock);
575
+ if (index > -1) {
576
+ mocks.splice(index, 1);
577
+ }
578
+ }
337
579
  async handleRoute(route, routes) {
338
- console.warn('handleRoute is not tested');
339
580
  const request = route.request();
340
581
  const requestUrl = request.url();
341
582
  const method = request.method().toUpperCase();
342
583
  const routeKey = this.createRouteKey(method, requestUrl);
343
- console.log('Handling route', routeKey);
584
+ // console.log('Handling route', routeKey);
344
585
  const mocks = routes[routeKey];
345
586
  if (!mocks || mocks.length === 0) {
346
587
  console.error('No route handler found for route', routeKey);
588
+ throw new Error('No route handler found for route: ' + routeKey);
347
589
  route.abort('not_found');
348
590
  return;
349
591
  }
@@ -378,13 +620,20 @@ class RimoriTestEnvironment {
378
620
  // Handle the matched mock
379
621
  const options = matchingMock.options;
380
622
  await new Promise((resolve) => setTimeout(resolve, options?.delay ?? 0));
623
+ // Remove one-time mock after handling (before responding)
624
+ this.removeOneTimeMock(matchingMock, mocks);
381
625
  if (options?.error) {
382
626
  return await route.abort(options.error);
383
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
+ }
384
633
  // Handle streaming responses (for mockGetSteamedText)
385
634
  // Since Playwright requires complete body, we format as SSE without delays
386
- if (matchingMock.isStreaming && typeof matchingMock.value === 'string') {
387
- const body = this.formatAsSSE(matchingMock.value);
635
+ if (matchingMock.isStreaming && typeof responseValue === 'string') {
636
+ const body = this.formatAsSSE(responseValue);
388
637
  return await route.fulfill({
389
638
  status: 200,
390
639
  headers: { 'Content-Type': 'text/event-stream' },
@@ -392,9 +641,10 @@ class RimoriTestEnvironment {
392
641
  });
393
642
  }
394
643
  // Regular JSON response
644
+ const responseBody = JSON.stringify(responseValue);
395
645
  route.fulfill({
396
646
  status: 200,
397
- body: JSON.stringify(matchingMock.value),
647
+ body: responseBody,
398
648
  });
399
649
  }
400
650
  /**
@@ -404,7 +654,6 @@ class RimoriTestEnvironment {
404
654
  * @param options - The options for the route. Method defaults to 'GET' if not specified.
405
655
  */
406
656
  addSupabaseRoute(path, values, options) {
407
- console.warn('addSupabaseRoute is not tested');
408
657
  const method = options?.method ?? 'GET';
409
658
  const fullPath = `${this.rimoriInfo.url}/rest/v1/${path}`;
410
659
  const routeKey = this.createRouteKey(method, fullPath);
@@ -425,7 +674,6 @@ class RimoriTestEnvironment {
425
674
  * @param isStreaming - Optional flag to mark this as a streaming response.
426
675
  */
427
676
  addBackendRoute(path, values, options) {
428
- console.warn('addBackendRoute is not tested');
429
677
  const method = options?.method ?? 'POST';
430
678
  const fullPath = `${this.rimoriInfo.backendUrl}${path.startsWith('/') ? path : '/' + path}`;
431
679
  const routeKey = this.createRouteKey(method, fullPath);
@@ -443,4 +691,3 @@ class RimoriTestEnvironment {
443
691
  }
444
692
  exports.RimoriTestEnvironment = RimoriTestEnvironment;
445
693
  // Todo: How to test if the event was received by the parent?
446
- // TODO: The matcher option of RimoriTestEnvironment v1 might be useful to use