@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.
- package/README.md +151 -0
- package/dist/core/MessageChannelSimulator.d.ts +18 -82
- package/dist/core/MessageChannelSimulator.js +63 -104
- package/dist/core/RimoriTestEnvironment.d.ts +163 -25
- package/dist/core/RimoriTestEnvironment.js +383 -136
- package/dist/core/SettingsStateManager.d.ts +41 -0
- package/dist/core/SettingsStateManager.js +74 -0
- package/dist/fixtures/default-user-info.js +1 -0
- package/dist/test/translator.test.js +1 -1
- package/package.json +7 -2
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
387
|
-
const body = this.formatAsSSE(
|
|
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:
|
|
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
|