@rimori/playwright-testing 0.2.1 → 0.2.3
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
CHANGED
|
@@ -17,6 +17,157 @@ The `@rimori/playwright-testing` package enables end-to-end testing of Rimori pl
|
|
|
17
17
|
npm install --save-dev @rimori/playwright-testing @playwright/test
|
|
18
18
|
# or
|
|
19
19
|
pnpm add -D @rimori/playwright-testing @playwright/test
|
|
20
|
+
# or
|
|
21
|
+
yarn add -D @rimori/playwright-testing @playwright/test
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Setup Steps
|
|
25
|
+
|
|
26
|
+
To initialize Playwright testing in your Rimori plugin, follow these steps:
|
|
27
|
+
|
|
28
|
+
### 1. Install Dependencies
|
|
29
|
+
|
|
30
|
+
Add the required dependencies to your `package.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@playwright/test": "^1.40.0",
|
|
36
|
+
"@rimori/playwright-testing": "^0.2.1"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then run:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install
|
|
45
|
+
# or
|
|
46
|
+
yarn install
|
|
47
|
+
# or
|
|
48
|
+
pnpm install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Create Playwright Configuration
|
|
52
|
+
|
|
53
|
+
Create a `playwright.config.ts` file in your plugin root:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
57
|
+
|
|
58
|
+
export default defineConfig({
|
|
59
|
+
testDir: './test',
|
|
60
|
+
fullyParallel: true,
|
|
61
|
+
forbidOnly: !!process.env.CI,
|
|
62
|
+
retries: process.env.CI ? 2 : 0,
|
|
63
|
+
workers: process.env.CI ? 1 : undefined,
|
|
64
|
+
reporter: 'html',
|
|
65
|
+
use: {
|
|
66
|
+
trace: 'on-first-retry',
|
|
67
|
+
headless: false,
|
|
68
|
+
screenshot: 'only-on-failure',
|
|
69
|
+
},
|
|
70
|
+
timeout: 30000,
|
|
71
|
+
expect: {
|
|
72
|
+
timeout: 5000,
|
|
73
|
+
},
|
|
74
|
+
projects: [
|
|
75
|
+
{
|
|
76
|
+
name: 'chromium',
|
|
77
|
+
use: { ...devices['Desktop Chrome'] },
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 3. Add Test Scripts
|
|
84
|
+
|
|
85
|
+
Add test scripts to your `package.json`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"scripts": {
|
|
90
|
+
"test": "playwright test",
|
|
91
|
+
"test:headed": "playwright test --headed",
|
|
92
|
+
"test:debug": "playwright test --debug",
|
|
93
|
+
"test:ui": "playwright test --ui",
|
|
94
|
+
"test:headed:debug": "playwright test --headed --debug"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 4. Create Test Directory and Files
|
|
100
|
+
|
|
101
|
+
Create a `test` directory in your plugin root and add your test files:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// test/my-plugin.test.ts
|
|
105
|
+
import { test, expect } from '@playwright/test';
|
|
106
|
+
import { RimoriTestEnvironment } from '@rimori/playwright-testing';
|
|
107
|
+
|
|
108
|
+
const pluginId = 'pl1234567890'; // Your plugin ID from rimori.config.ts
|
|
109
|
+
const pluginUrl = 'http://localhost:3002'; // Your dev server URL
|
|
110
|
+
|
|
111
|
+
test.describe('My Plugin', () => {
|
|
112
|
+
let env: RimoriTestEnvironment;
|
|
113
|
+
|
|
114
|
+
test.beforeEach(async ({ page }) => {
|
|
115
|
+
env = new RimoriTestEnvironment({ page, pluginId });
|
|
116
|
+
|
|
117
|
+
// Set up your mocks here
|
|
118
|
+
// env.ai.mockGetObject(...);
|
|
119
|
+
// env.plugin.mockGetSettings(...);
|
|
120
|
+
|
|
121
|
+
await env.setup();
|
|
122
|
+
await page.goto(`${pluginUrl}/#/your-page`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('should work correctly', async ({ page }) => {
|
|
126
|
+
await expect(page.getByText('Hello')).toBeVisible();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 5. Get Your Plugin ID
|
|
132
|
+
|
|
133
|
+
Find your plugin ID in `rimori/rimori.config.ts`:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
const config: RimoriPluginConfig = {
|
|
137
|
+
id: 'pl1234567890', // <-- This is your plugin ID
|
|
138
|
+
// ...
|
|
139
|
+
};
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 6. Run Tests
|
|
143
|
+
|
|
144
|
+
1. **Start your dev server** in one terminal:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm run dev
|
|
148
|
+
# or
|
|
149
|
+
yarn dev
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
2. **Run tests** in another terminal:
|
|
153
|
+
```bash
|
|
154
|
+
npm test
|
|
155
|
+
# or
|
|
156
|
+
yarn test
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Complete Example Setup
|
|
160
|
+
|
|
161
|
+
Here's a complete example of what your plugin structure should look like:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
your-plugin/
|
|
165
|
+
├── package.json # With @playwright/test and test scripts
|
|
166
|
+
├── playwright.config.ts # Playwright configuration
|
|
167
|
+
├── rimori/
|
|
168
|
+
│ └── rimori.config.ts # Contains your plugin ID
|
|
169
|
+
└── test/
|
|
170
|
+
└── my-plugin.test.ts # Your test files
|
|
20
171
|
```
|
|
21
172
|
|
|
22
173
|
## Quick Start
|
|
@@ -76,6 +76,7 @@ type MessageChannelSimulatorArgs = {
|
|
|
76
76
|
rimoriInfo?: RimoriInfo;
|
|
77
77
|
};
|
|
78
78
|
type EventListener = (event: EventBusMessage) => void | Promise<void>;
|
|
79
|
+
type AutoResponder = (event: EventBusMessage) => unknown | Promise<unknown>;
|
|
79
80
|
export declare class MessageChannelSimulator {
|
|
80
81
|
private readonly page;
|
|
81
82
|
private readonly pluginId;
|
|
@@ -111,6 +112,16 @@ export declare class MessageChannelSimulator {
|
|
|
111
112
|
* Registers a handler for events emitted from the plugin.
|
|
112
113
|
*/
|
|
113
114
|
on(topic: string, handler: EventListener): () => void;
|
|
115
|
+
/**
|
|
116
|
+
* Registers a one-time auto-responder for a request/response topic.
|
|
117
|
+
* When a request with an eventId comes in for this topic, the responder will
|
|
118
|
+
* be called once and then automatically removed.
|
|
119
|
+
*
|
|
120
|
+
* @param topic - The event topic to respond to
|
|
121
|
+
* @param responder - A function that returns the response data, or a value to return directly
|
|
122
|
+
* @returns A function to manually remove the responder before it's used
|
|
123
|
+
*/
|
|
124
|
+
respondOnce(topic: string, responder: AutoResponder | unknown): () => void;
|
|
114
125
|
/**
|
|
115
126
|
* Overrides the default profile returned by the auto responders.
|
|
116
127
|
*/
|
|
@@ -142,6 +142,39 @@ class MessageChannelSimulator {
|
|
|
142
142
|
}
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Registers a one-time auto-responder for a request/response topic.
|
|
147
|
+
* When a request with an eventId comes in for this topic, the responder will
|
|
148
|
+
* be called once and then automatically removed.
|
|
149
|
+
*
|
|
150
|
+
* @param topic - The event topic to respond to
|
|
151
|
+
* @param responder - A function that returns the response data, or a value to return directly
|
|
152
|
+
* @returns A function to manually remove the responder before it's used
|
|
153
|
+
*/
|
|
154
|
+
respondOnce(topic, responder) {
|
|
155
|
+
let used = false;
|
|
156
|
+
const wrappedResponder = (event) => {
|
|
157
|
+
if (used) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
used = true;
|
|
161
|
+
// Remove from autoResponders after first use
|
|
162
|
+
this.autoResponders.delete(topic);
|
|
163
|
+
// If responder is a function, call it with the event, otherwise return the value directly
|
|
164
|
+
if (typeof responder === 'function') {
|
|
165
|
+
return responder(event);
|
|
166
|
+
}
|
|
167
|
+
return responder;
|
|
168
|
+
};
|
|
169
|
+
this.autoResponders.set(topic, wrappedResponder);
|
|
170
|
+
// Return a function to manually remove the responder
|
|
171
|
+
return () => {
|
|
172
|
+
if (!used) {
|
|
173
|
+
this.autoResponders.delete(topic);
|
|
174
|
+
used = true;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
145
178
|
/**
|
|
146
179
|
* Overrides the default profile returned by the auto responders.
|
|
147
180
|
*/
|
|
@@ -26,6 +26,12 @@ interface MockOptions {
|
|
|
26
26
|
* The HTTP method for the route. If not provided, defaults will be used based on the route type.
|
|
27
27
|
*/
|
|
28
28
|
method?: HttpMethod;
|
|
29
|
+
/**
|
|
30
|
+
* If true, the mock is removed after first use. Default: false (persistent).
|
|
31
|
+
* This allows for sequential mock responses where each mock is consumed once.
|
|
32
|
+
* Useful for testing flows where the same route is called multiple times with different responses.
|
|
33
|
+
*/
|
|
34
|
+
once?: boolean;
|
|
29
35
|
}
|
|
30
36
|
export declare class RimoriTestEnvironment {
|
|
31
37
|
private readonly page;
|
|
@@ -50,6 +56,10 @@ export declare class RimoriTestEnvironment {
|
|
|
50
56
|
* Creates a route key combining HTTP method and normalized URL.
|
|
51
57
|
*/
|
|
52
58
|
private createRouteKey;
|
|
59
|
+
/**
|
|
60
|
+
* Removes a one-time mock from the mocks array after it's been used.
|
|
61
|
+
*/
|
|
62
|
+
private removeOneTimeMock;
|
|
53
63
|
private handleRoute;
|
|
54
64
|
/**
|
|
55
65
|
* Adds a supabase route to the supabase routes object.
|
|
@@ -96,18 +106,79 @@ export declare class RimoriTestEnvironment {
|
|
|
96
106
|
mockGetPluginInfo: (pluginInfo: Plugin, options?: MockOptions) => void;
|
|
97
107
|
};
|
|
98
108
|
readonly db: {
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Mocks a Supabase table endpoint (from(tableName)).
|
|
111
|
+
* The table name will be prefixed with the plugin ID in the actual URL.
|
|
112
|
+
*
|
|
113
|
+
* Supabase operations map to HTTP methods as follows:
|
|
114
|
+
* - .select() → GET
|
|
115
|
+
* - .insert() → POST
|
|
116
|
+
* - .update() → PATCH
|
|
117
|
+
* - .delete() → DELETE (can return data with .delete().select())
|
|
118
|
+
* - .upsert() → POST
|
|
119
|
+
*
|
|
120
|
+
* @param tableName - The table name (e.g., 'decks')
|
|
121
|
+
* @param value - The response value to return for the request
|
|
122
|
+
* @param options - Mock options including HTTP method (defaults to 'GET' if not specified)
|
|
123
|
+
*/
|
|
124
|
+
mockFrom: (tableName: string, value: unknown, options?: MockOptions) => void;
|
|
101
125
|
};
|
|
102
126
|
readonly event: {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Emit an event into the plugin as if it came from Rimori main or another plugin.
|
|
129
|
+
*
|
|
130
|
+
* Note: This does NOT currently reach worker listeners such as those in
|
|
131
|
+
* `worker/listeners/decks.ts` or `worker/listeners/flascards.ts` – those run in a
|
|
132
|
+
* separate process. This helper is intended for UI‑side events only.
|
|
133
|
+
*/
|
|
134
|
+
mockEmit: (topic: string, data: unknown, sender?: string) => Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Registers a one-time auto-responder for request/response style events.
|
|
137
|
+
*
|
|
138
|
+
* When the plugin calls `plugin.event.request(topic, data)`, this registered responder
|
|
139
|
+
* will automatically return the provided response value. The responder is automatically
|
|
140
|
+
* removed after the first request, ensuring it only responds once.
|
|
141
|
+
*
|
|
142
|
+
* Example:
|
|
143
|
+
* ```ts
|
|
144
|
+
* // Register a responder that will return deck summaries when requested
|
|
145
|
+
* env.event.mockRequest('deck.requestOpenToday', [
|
|
146
|
+
* { id: 'deck-1', name: 'My Deck', total_new: 5, total_learning: 2, total_review: 10 }
|
|
147
|
+
* ]);
|
|
148
|
+
*
|
|
149
|
+
* // Now when the plugin calls: plugin.event.request('deck.requestOpenToday', {})
|
|
150
|
+
* // It will receive the deck summaries array above
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* @param topic - The event topic to respond to (e.g., 'deck.requestOpenToday')
|
|
154
|
+
* @param response - The response value to return, or a function that receives the event and returns the response
|
|
155
|
+
* @returns A function to manually remove the responder before it's used
|
|
156
|
+
*/
|
|
157
|
+
mockRequest: (topic: string, response: unknown | ((event: unknown) => unknown)) => () => void;
|
|
158
|
+
/**
|
|
159
|
+
* Listen for events emitted by the plugin.
|
|
160
|
+
* @param topic - The event topic to listen for (e.g., 'global.accomplishment.triggerMicro')
|
|
161
|
+
* @param handler - The handler function that receives the event data
|
|
162
|
+
* @returns A function to unsubscribe from the event
|
|
163
|
+
*/
|
|
164
|
+
on: (topic: string, handler: (data: unknown) => void) => (() => void);
|
|
106
165
|
mockOnce: () => void;
|
|
107
166
|
mockRespond: () => void;
|
|
108
167
|
mockEmitAccomplishment: () => void;
|
|
109
168
|
mockOnAccomplishment: () => void;
|
|
110
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Emits a sidebar action event into the plugin as if Rimori main had triggered it.
|
|
171
|
+
* This is useful for testing sidebar-driven flows like flashcard creation from selected text.
|
|
172
|
+
*
|
|
173
|
+
* It sends a message on the 'global.sidebar.triggerAction' topic, which plugins can listen to via:
|
|
174
|
+
* plugin.event.on<{ action: string; text: string }>('global.sidebar.triggerAction', ...)
|
|
175
|
+
*
|
|
176
|
+
* @param payload - The payload forwarded to the plugin, typically including an `action` key and optional `text`.
|
|
177
|
+
*/
|
|
178
|
+
triggerSidebarAction: (payload: {
|
|
179
|
+
action: string;
|
|
180
|
+
text?: string;
|
|
181
|
+
}) => Promise<void>;
|
|
111
182
|
/**
|
|
112
183
|
* Triggers a side panel action event as the parent application would.
|
|
113
184
|
* This simulates how rimori-main's SidebarPluginHandler responds to plugin's 'action.requestSidebar' events.
|
|
@@ -141,6 +212,61 @@ export declare class RimoriTestEnvironment {
|
|
|
141
212
|
mockGetTextFromVoice: (text: string, options?: MockOptions) => void;
|
|
142
213
|
mockGetObject: (value: unknown, options?: MockOptions) => void;
|
|
143
214
|
};
|
|
215
|
+
/**
|
|
216
|
+
* Helpers for tracking browser audio playback in tests.
|
|
217
|
+
*
|
|
218
|
+
* This is useful for components like the AudioPlayer in @rimori/react-client which:
|
|
219
|
+
* 1) Fetch audio data from the backend (mocked via `env.ai.mockGetVoice`)
|
|
220
|
+
* 2) Create `new Audio(url)` and call `.play()`
|
|
221
|
+
*
|
|
222
|
+
* With tracking enabled you can assert how many times audio playback was attempted:
|
|
223
|
+
*
|
|
224
|
+
* ```ts
|
|
225
|
+
* await env.audio.enableTracking();
|
|
226
|
+
* await env.ai.mockGetVoice(Buffer.from('dummy'), { method: 'POST' });
|
|
227
|
+
* await env.setup();
|
|
228
|
+
* // ...navigate and trigger audio...
|
|
229
|
+
* const counts = await env.audio.getPlayCounts();
|
|
230
|
+
* expect(counts.mediaPlayCalls).toBeGreaterThan(0);
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* **Counter Types:**
|
|
234
|
+
* - `mediaPlayCalls`: Tracks calls to `.play()` on any `HTMLMediaElement` instance
|
|
235
|
+
* (including `<audio>`, `<video>` elements, or any element that inherits from `HTMLMediaElement`).
|
|
236
|
+
* This counter increments whenever `HTMLMediaElement.prototype.play()` is invoked.
|
|
237
|
+
* - `audioPlayCalls`: Tracks calls to `.play()` specifically on instances created via the `Audio` constructor
|
|
238
|
+
* (e.g., `new Audio(url).play()`). This is a subset of `mediaPlayCalls` but provides more specific
|
|
239
|
+
* tracking for programmatically created audio elements.
|
|
240
|
+
*
|
|
241
|
+
* **Note**: Since `Audio` instances are also `HTMLMediaElement` instances, calling `.play()` on an
|
|
242
|
+
* `Audio` object will increment **both** counters. For most use cases, checking `mediaPlayCalls`
|
|
243
|
+
* is sufficient as it captures all audio playback attempts.
|
|
244
|
+
*/
|
|
245
|
+
readonly audio: {
|
|
246
|
+
/**
|
|
247
|
+
* Injects tracking hooks for HTMLMediaElement.play and the Audio constructor.
|
|
248
|
+
* Must be called before the plugin code runs (ideally before env.setup()).
|
|
249
|
+
*/
|
|
250
|
+
enableTracking: () => Promise<void>;
|
|
251
|
+
/**
|
|
252
|
+
* Returns current audio play counters from the browser context.
|
|
253
|
+
*
|
|
254
|
+
* @returns An object with two counters:
|
|
255
|
+
* - `mediaPlayCalls`: Total number of `.play()` calls on any `HTMLMediaElement` (includes all audio/video elements)
|
|
256
|
+
* - `audioPlayCalls`: Number of `.play()` calls on instances created via `new Audio()` (subset of `mediaPlayCalls`)
|
|
257
|
+
*
|
|
258
|
+
* **Note**: Since `Audio` extends `HTMLMediaElement`, calling `.play()` on an `Audio` instance increments both counters.
|
|
259
|
+
* For general audio playback tracking, use `mediaPlayCalls` as it captures all playback attempts.
|
|
260
|
+
*/
|
|
261
|
+
getPlayCounts: () => Promise<{
|
|
262
|
+
mediaPlayCalls: number;
|
|
263
|
+
audioPlayCalls: number;
|
|
264
|
+
}>;
|
|
265
|
+
/**
|
|
266
|
+
* Resets the audio play counters to zero.
|
|
267
|
+
*/
|
|
268
|
+
resetPlayCounts: () => Promise<void>;
|
|
269
|
+
};
|
|
144
270
|
readonly runtime: {
|
|
145
271
|
mockFetchBackend: () => void;
|
|
146
272
|
};
|
|
@@ -43,6 +43,7 @@ class RimoriTestEnvironment {
|
|
|
43
43
|
mockInsertSettings: (response, options) => {
|
|
44
44
|
console.log('Mocking insert settings for mockInsertSettings', response, options);
|
|
45
45
|
console.warn('mockInsertSettings is not tested');
|
|
46
|
+
// TODO this function should not exist and possibly be combined with the mockSetSettings function
|
|
46
47
|
// POST request returns the inserted row or success response
|
|
47
48
|
// Default to an object representing successful insert
|
|
48
49
|
const defaultResponse = response ?? {
|
|
@@ -64,18 +65,103 @@ class RimoriTestEnvironment {
|
|
|
64
65
|
},
|
|
65
66
|
};
|
|
66
67
|
this.db = {
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Mocks a Supabase table endpoint (from(tableName)).
|
|
70
|
+
* The table name will be prefixed with the plugin ID in the actual URL.
|
|
71
|
+
*
|
|
72
|
+
* Supabase operations map to HTTP methods as follows:
|
|
73
|
+
* - .select() → GET
|
|
74
|
+
* - .insert() → POST
|
|
75
|
+
* - .update() → PATCH
|
|
76
|
+
* - .delete() → DELETE (can return data with .delete().select())
|
|
77
|
+
* - .upsert() → POST
|
|
78
|
+
*
|
|
79
|
+
* @param tableName - The table name (e.g., 'decks')
|
|
80
|
+
* @param value - The response value to return for the request
|
|
81
|
+
* @param options - Mock options including HTTP method (defaults to 'GET' if not specified)
|
|
82
|
+
*/
|
|
83
|
+
mockFrom: (tableName, value, options) => {
|
|
84
|
+
console.log('Mocking db.from for table:', tableName, 'method:', options?.method ?? 'GET', value, options);
|
|
85
|
+
const fullTableName = `${this.pluginId}_${tableName}`;
|
|
86
|
+
this.addSupabaseRoute(fullTableName, value, options);
|
|
87
|
+
},
|
|
69
88
|
};
|
|
70
89
|
this.event = {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Emit an event into the plugin as if it came from Rimori main or another plugin.
|
|
92
|
+
*
|
|
93
|
+
* Note: This does NOT currently reach worker listeners such as those in
|
|
94
|
+
* `worker/listeners/decks.ts` or `worker/listeners/flascards.ts` – those run in a
|
|
95
|
+
* separate process. This helper is intended for UI‑side events only.
|
|
96
|
+
*/
|
|
97
|
+
mockEmit: async (topic, data, sender = 'test') => {
|
|
98
|
+
if (!this.messageChannelSimulator) {
|
|
99
|
+
throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
|
|
100
|
+
}
|
|
101
|
+
await this.messageChannelSimulator.emit(topic, data, sender);
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* Registers a one-time auto-responder for request/response style events.
|
|
105
|
+
*
|
|
106
|
+
* When the plugin calls `plugin.event.request(topic, data)`, this registered responder
|
|
107
|
+
* will automatically return the provided response value. The responder is automatically
|
|
108
|
+
* removed after the first request, ensuring it only responds once.
|
|
109
|
+
*
|
|
110
|
+
* Example:
|
|
111
|
+
* ```ts
|
|
112
|
+
* // Register a responder that will return deck summaries when requested
|
|
113
|
+
* env.event.mockRequest('deck.requestOpenToday', [
|
|
114
|
+
* { id: 'deck-1', name: 'My Deck', total_new: 5, total_learning: 2, total_review: 10 }
|
|
115
|
+
* ]);
|
|
116
|
+
*
|
|
117
|
+
* // Now when the plugin calls: plugin.event.request('deck.requestOpenToday', {})
|
|
118
|
+
* // It will receive the deck summaries array above
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @param topic - The event topic to respond to (e.g., 'deck.requestOpenToday')
|
|
122
|
+
* @param response - The response value to return, or a function that receives the event and returns the response
|
|
123
|
+
* @returns A function to manually remove the responder before it's used
|
|
124
|
+
*/
|
|
125
|
+
mockRequest: (topic, response) => {
|
|
126
|
+
if (!this.messageChannelSimulator) {
|
|
127
|
+
throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
|
|
128
|
+
}
|
|
129
|
+
return this.messageChannelSimulator.respondOnce(topic, response);
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* Listen for events emitted by the plugin.
|
|
133
|
+
* @param topic - The event topic to listen for (e.g., 'global.accomplishment.triggerMicro')
|
|
134
|
+
* @param handler - The handler function that receives the event data
|
|
135
|
+
* @returns A function to unsubscribe from the event
|
|
136
|
+
*/
|
|
137
|
+
on: (topic, handler) => {
|
|
138
|
+
if (!this.messageChannelSimulator) {
|
|
139
|
+
throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
|
|
140
|
+
}
|
|
141
|
+
return this.messageChannelSimulator.on(topic, (event) => {
|
|
142
|
+
handler(event.data);
|
|
143
|
+
});
|
|
144
|
+
},
|
|
74
145
|
mockOnce: () => { },
|
|
75
146
|
mockRespond: () => { },
|
|
76
147
|
mockEmitAccomplishment: () => { },
|
|
77
148
|
mockOnAccomplishment: () => { },
|
|
78
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Emits a sidebar action event into the plugin as if Rimori main had triggered it.
|
|
151
|
+
* This is useful for testing sidebar-driven flows like flashcard creation from selected text.
|
|
152
|
+
*
|
|
153
|
+
* It sends a message on the 'global.sidebar.triggerAction' topic, which plugins can listen to via:
|
|
154
|
+
* plugin.event.on<{ action: string; text: string }>('global.sidebar.triggerAction', ...)
|
|
155
|
+
*
|
|
156
|
+
* @param payload - The payload forwarded to the plugin, typically including an `action` key and optional `text`.
|
|
157
|
+
*/
|
|
158
|
+
triggerSidebarAction: async (payload) => {
|
|
159
|
+
if (!this.messageChannelSimulator) {
|
|
160
|
+
throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
|
|
161
|
+
}
|
|
162
|
+
// Simulate Rimori main emitting the sidebar trigger event towards the plugin
|
|
163
|
+
await this.messageChannelSimulator.emit('global.sidebar.triggerAction', payload, 'sidebar');
|
|
164
|
+
},
|
|
79
165
|
/**
|
|
80
166
|
* Triggers a side panel action event as the parent application would.
|
|
81
167
|
* This simulates how rimori-main's SidebarPluginHandler responds to plugin's 'action.requestSidebar' events.
|
|
@@ -86,15 +172,11 @@ class RimoriTestEnvironment {
|
|
|
86
172
|
throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
|
|
87
173
|
}
|
|
88
174
|
const topic = `${this.pluginId}.action.requestSidebar`;
|
|
89
|
-
console.log('[RimoriTestEnvironment] Setting up listener for topic:', topic, 'with payload:', payload);
|
|
90
175
|
const actionPayload = payload;
|
|
91
176
|
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
177
|
await this.messageChannelSimulator.emit(topic, actionPayload, 'sidebar');
|
|
95
178
|
off();
|
|
96
179
|
});
|
|
97
|
-
console.log('[RimoriTestEnvironment] Listener set up for topic:', topic);
|
|
98
180
|
},
|
|
99
181
|
/**
|
|
100
182
|
* Triggers a main panel action event as the parent application would.
|
|
@@ -111,20 +193,16 @@ class RimoriTestEnvironment {
|
|
|
111
193
|
// Listen for when the plugin emits 'action.requestMain' (which becomes '{pluginId}.action.requestMain')
|
|
112
194
|
// and respond with the MainPanelAction payload, matching rimori-main's EventBus.respond behavior
|
|
113
195
|
const topic = `${this.pluginId}.action.requestMain`;
|
|
114
|
-
console.log('[RimoriTestEnvironment] Setting up listener for topic:', topic, 'with payload:', payload);
|
|
115
196
|
// Store the payload in a closure so we can respond with it
|
|
116
197
|
const actionPayload = payload;
|
|
117
198
|
// Set up a one-time listener that responds when the plugin emits 'action.requestMain'
|
|
118
199
|
// The handler receives the event object from the plugin
|
|
119
200
|
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
201
|
// When plugin emits 'action.requestMain', respond with the MainPanelAction data
|
|
123
202
|
// The sender is 'mainPanel' to match rimori-main's MainPluginHandler behavior
|
|
124
203
|
await this.messageChannelSimulator.emit(topic, actionPayload, 'mainPanel');
|
|
125
204
|
off(); // Remove listener after responding once (one-time response like EventBus.respond)
|
|
126
205
|
});
|
|
127
|
-
console.log('[RimoriTestEnvironment] Listener set up for topic:', topic);
|
|
128
206
|
},
|
|
129
207
|
};
|
|
130
208
|
this.ai = {
|
|
@@ -145,7 +223,6 @@ class RimoriTestEnvironment {
|
|
|
145
223
|
*/
|
|
146
224
|
mockGetSteamedText: (text, options) => {
|
|
147
225
|
console.log('Mocking get steamed text for mockGetSteamedText', text, options);
|
|
148
|
-
console.warn('mockGetSteamedText is not tested');
|
|
149
226
|
this.addBackendRoute('/ai/llm', text, { ...options, isStreaming: true });
|
|
150
227
|
},
|
|
151
228
|
mockGetVoice: (values, options) => {
|
|
@@ -160,10 +237,128 @@ class RimoriTestEnvironment {
|
|
|
160
237
|
},
|
|
161
238
|
mockGetObject: (value, options) => {
|
|
162
239
|
console.log('Mocking get object for mockGetObject', value, options);
|
|
163
|
-
console.warn('mockGetObject is not tested');
|
|
164
240
|
this.addBackendRoute('/ai/llm-object', value, { ...options, method: 'POST' });
|
|
165
241
|
},
|
|
166
242
|
};
|
|
243
|
+
/**
|
|
244
|
+
* Helpers for tracking browser audio playback in tests.
|
|
245
|
+
*
|
|
246
|
+
* This is useful for components like the AudioPlayer in @rimori/react-client which:
|
|
247
|
+
* 1) Fetch audio data from the backend (mocked via `env.ai.mockGetVoice`)
|
|
248
|
+
* 2) Create `new Audio(url)` and call `.play()`
|
|
249
|
+
*
|
|
250
|
+
* With tracking enabled you can assert how many times audio playback was attempted:
|
|
251
|
+
*
|
|
252
|
+
* ```ts
|
|
253
|
+
* await env.audio.enableTracking();
|
|
254
|
+
* await env.ai.mockGetVoice(Buffer.from('dummy'), { method: 'POST' });
|
|
255
|
+
* await env.setup();
|
|
256
|
+
* // ...navigate and trigger audio...
|
|
257
|
+
* const counts = await env.audio.getPlayCounts();
|
|
258
|
+
* expect(counts.mediaPlayCalls).toBeGreaterThan(0);
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* **Counter Types:**
|
|
262
|
+
* - `mediaPlayCalls`: Tracks calls to `.play()` on any `HTMLMediaElement` instance
|
|
263
|
+
* (including `<audio>`, `<video>` elements, or any element that inherits from `HTMLMediaElement`).
|
|
264
|
+
* This counter increments whenever `HTMLMediaElement.prototype.play()` is invoked.
|
|
265
|
+
* - `audioPlayCalls`: Tracks calls to `.play()` specifically on instances created via the `Audio` constructor
|
|
266
|
+
* (e.g., `new Audio(url).play()`). This is a subset of `mediaPlayCalls` but provides more specific
|
|
267
|
+
* tracking for programmatically created audio elements.
|
|
268
|
+
*
|
|
269
|
+
* **Note**: Since `Audio` instances are also `HTMLMediaElement` instances, calling `.play()` on an
|
|
270
|
+
* `Audio` object will increment **both** counters. For most use cases, checking `mediaPlayCalls`
|
|
271
|
+
* is sufficient as it captures all audio playback attempts.
|
|
272
|
+
*/
|
|
273
|
+
this.audio = {
|
|
274
|
+
/**
|
|
275
|
+
* Injects tracking hooks for HTMLMediaElement.play and the Audio constructor.
|
|
276
|
+
* Must be called before the plugin code runs (ideally before env.setup()).
|
|
277
|
+
*/
|
|
278
|
+
enableTracking: async () => {
|
|
279
|
+
await this.page.addInitScript(() => {
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
281
|
+
const w = window;
|
|
282
|
+
if (!w.__rimoriAudio) {
|
|
283
|
+
w.__rimoriAudio = {
|
|
284
|
+
mediaPlayCalls: 0,
|
|
285
|
+
audioPlayCalls: 0,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const proto = (w.HTMLMediaElement && w.HTMLMediaElement.prototype) || undefined;
|
|
289
|
+
if (proto && !proto.__rimoriPatched) {
|
|
290
|
+
const originalPlay = proto.play;
|
|
291
|
+
proto.play = function (...args) {
|
|
292
|
+
w.__rimoriAudio.mediaPlayCalls += 1;
|
|
293
|
+
return originalPlay.apply(this, args);
|
|
294
|
+
};
|
|
295
|
+
Object.defineProperty(proto, '__rimoriPatched', {
|
|
296
|
+
value: true,
|
|
297
|
+
configurable: false,
|
|
298
|
+
enumerable: false,
|
|
299
|
+
writable: false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const OriginalAudio = w.Audio;
|
|
303
|
+
if (OriginalAudio && !OriginalAudio.__rimoriPatched) {
|
|
304
|
+
const PatchedAudio = function (...args) {
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
306
|
+
const audio = new OriginalAudio(...args);
|
|
307
|
+
const originalPlay = audio.play.bind(audio);
|
|
308
|
+
audio.play = () => {
|
|
309
|
+
w.__rimoriAudio.audioPlayCalls += 1;
|
|
310
|
+
return originalPlay();
|
|
311
|
+
};
|
|
312
|
+
return audio;
|
|
313
|
+
};
|
|
314
|
+
PatchedAudio.prototype = OriginalAudio.prototype;
|
|
315
|
+
Object.defineProperty(PatchedAudio, '__rimoriPatched', {
|
|
316
|
+
value: true,
|
|
317
|
+
configurable: false,
|
|
318
|
+
enumerable: false,
|
|
319
|
+
writable: false,
|
|
320
|
+
});
|
|
321
|
+
w.Audio = PatchedAudio;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
/**
|
|
326
|
+
* Returns current audio play counters from the browser context.
|
|
327
|
+
*
|
|
328
|
+
* @returns An object with two counters:
|
|
329
|
+
* - `mediaPlayCalls`: Total number of `.play()` calls on any `HTMLMediaElement` (includes all audio/video elements)
|
|
330
|
+
* - `audioPlayCalls`: Number of `.play()` calls on instances created via `new Audio()` (subset of `mediaPlayCalls`)
|
|
331
|
+
*
|
|
332
|
+
* **Note**: Since `Audio` extends `HTMLMediaElement`, calling `.play()` on an `Audio` instance increments both counters.
|
|
333
|
+
* For general audio playback tracking, use `mediaPlayCalls` as it captures all playback attempts.
|
|
334
|
+
*/
|
|
335
|
+
getPlayCounts: async () => {
|
|
336
|
+
return this.page.evaluate(() => {
|
|
337
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
338
|
+
const w = window;
|
|
339
|
+
if (!w.__rimoriAudio) {
|
|
340
|
+
return { mediaPlayCalls: 0, audioPlayCalls: 0 };
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
mediaPlayCalls: Number(w.__rimoriAudio.mediaPlayCalls || 0),
|
|
344
|
+
audioPlayCalls: Number(w.__rimoriAudio.audioPlayCalls || 0),
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
/**
|
|
349
|
+
* Resets the audio play counters to zero.
|
|
350
|
+
*/
|
|
351
|
+
resetPlayCounts: async () => {
|
|
352
|
+
await this.page.evaluate(() => {
|
|
353
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
354
|
+
const w = window;
|
|
355
|
+
if (w.__rimoriAudio) {
|
|
356
|
+
w.__rimoriAudio.mediaPlayCalls = 0;
|
|
357
|
+
w.__rimoriAudio.audioPlayCalls = 0;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
};
|
|
167
362
|
this.runtime = {
|
|
168
363
|
mockFetchBackend: () => { },
|
|
169
364
|
};
|
|
@@ -230,6 +425,9 @@ class RimoriTestEnvironment {
|
|
|
230
425
|
}
|
|
231
426
|
async setup() {
|
|
232
427
|
console.log('Setting up RimoriTestEnvironment');
|
|
428
|
+
this.page.on('console', (msg) => {
|
|
429
|
+
console.log(`[browser:${msg.type()}]`, msg.text());
|
|
430
|
+
});
|
|
233
431
|
// Add default handlers for common routes that plugins typically access
|
|
234
432
|
// These can be overridden by explicit mock calls
|
|
235
433
|
if (!this.supabaseRoutes[this.createRouteKey('GET', `${this.rimoriInfo.url}/rest/v1/plugin_settings`)]) {
|
|
@@ -334,8 +532,18 @@ class RimoriTestEnvironment {
|
|
|
334
532
|
const normalizedUrl = this.normalizeUrl(url);
|
|
335
533
|
return `${method} ${normalizedUrl}`;
|
|
336
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Removes a one-time mock from the mocks array after it's been used.
|
|
537
|
+
*/
|
|
538
|
+
removeOneTimeMock(mock, mocks) {
|
|
539
|
+
if (!mock.options?.once)
|
|
540
|
+
return;
|
|
541
|
+
const index = mocks.indexOf(mock);
|
|
542
|
+
if (index > -1) {
|
|
543
|
+
mocks.splice(index, 1);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
337
546
|
async handleRoute(route, routes) {
|
|
338
|
-
console.warn('handleRoute is not tested');
|
|
339
547
|
const request = route.request();
|
|
340
548
|
const requestUrl = request.url();
|
|
341
549
|
const method = request.method().toUpperCase();
|
|
@@ -378,6 +586,8 @@ class RimoriTestEnvironment {
|
|
|
378
586
|
// Handle the matched mock
|
|
379
587
|
const options = matchingMock.options;
|
|
380
588
|
await new Promise((resolve) => setTimeout(resolve, options?.delay ?? 0));
|
|
589
|
+
// Remove one-time mock after handling (before responding)
|
|
590
|
+
this.removeOneTimeMock(matchingMock, mocks);
|
|
381
591
|
if (options?.error) {
|
|
382
592
|
return await route.abort(options.error);
|
|
383
593
|
}
|
|
@@ -392,9 +602,10 @@ class RimoriTestEnvironment {
|
|
|
392
602
|
});
|
|
393
603
|
}
|
|
394
604
|
// Regular JSON response
|
|
605
|
+
const responseBody = JSON.stringify(matchingMock.value);
|
|
395
606
|
route.fulfill({
|
|
396
607
|
status: 200,
|
|
397
|
-
body:
|
|
608
|
+
body: responseBody,
|
|
398
609
|
});
|
|
399
610
|
}
|
|
400
611
|
/**
|
|
@@ -404,7 +615,6 @@ class RimoriTestEnvironment {
|
|
|
404
615
|
* @param options - The options for the route. Method defaults to 'GET' if not specified.
|
|
405
616
|
*/
|
|
406
617
|
addSupabaseRoute(path, values, options) {
|
|
407
|
-
console.warn('addSupabaseRoute is not tested');
|
|
408
618
|
const method = options?.method ?? 'GET';
|
|
409
619
|
const fullPath = `${this.rimoriInfo.url}/rest/v1/${path}`;
|
|
410
620
|
const routeKey = this.createRouteKey(method, fullPath);
|