@rimori/playwright-testing 0.3.35-next.0 → 0.3.35
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 +40 -0
- package/dist/core/MessageChannelSimulator.d.ts +12 -0
- package/dist/core/MessageChannelSimulator.js +32 -0
- package/dist/core/RimoriTestEnvironment.d.ts +50 -1
- package/dist/core/RimoriTestEnvironment.js +76 -0
- package/dist/core/WorkerSimulator.d.ts +80 -0
- package/dist/core/WorkerSimulator.js +267 -0
- package/dist/harness/__mfe_internal__rimori_mf_2_scenario_mf_2_host__loadShare___mf_0_rimori_mf_1_client__loadShare__.mjs.js +1 -1
- package/dist/harness/__mfe_internal__rimori_mf_2_scenario_mf_2_host__loadShare___mf_0_rimori_mf_1_react_mf_2_client__loadShare__.mjs.js +1 -1
- package/dist/harness/dist.js +1 -1
- package/dist/harness/dist2.js +27 -27
- package/dist/harness/dist3.js +3 -3
- package/dist/harness/hostInit.js +1 -1
- package/dist/harness/localSharedImportMap.js +1 -1
- package/dist/harness/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__rimori-scenario-host__remoteEntry-_hash_.js +1 -1
- package/dist/helpers/e2e/onboarding.js +14 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -367,6 +367,46 @@ await env.event.triggerOnMainPanelAction({
|
|
|
367
367
|
});
|
|
368
368
|
```
|
|
369
369
|
|
|
370
|
+
### Worker Testing (`env.worker`)
|
|
371
|
+
|
|
372
|
+
Drives the plugin's web worker (`public/web-worker.js`) through the **real** worker handshake — the
|
|
373
|
+
same `rimori:hello` → `rimori:init` → `rimori:acknowledged` flow `rimori-main` uses — inside the
|
|
374
|
+
Playwright page. The worker's `client.event.respond(...)` / `.on(...)` listeners run for real, and its
|
|
375
|
+
AI/Supabase calls are served by the **same route mocks** as the UI (`page.route` intercepts
|
|
376
|
+
dedicated-worker requests), so worker logic is tested deterministically without a real stack.
|
|
377
|
+
|
|
378
|
+
Requires the worker bundle to be built — add `pnpm build:worker` to your scenario `webServer.command`
|
|
379
|
+
(e.g. `pnpm build:scenario && pnpm build:worker && pnpm dev:standalone`).
|
|
380
|
+
|
|
381
|
+
| Method | Description |
|
|
382
|
+
| --- | --- |
|
|
383
|
+
| `start()` | Spawn the worker and complete the handshake. Idempotent. Mock any routes the worker hits **during init** first. |
|
|
384
|
+
| `request<T>(topic, data?, sender?)` | Send a request, resolve with the worker's response data. |
|
|
385
|
+
| `emit(topic, data?, sender?)` | Fire-and-forget event into the worker. |
|
|
386
|
+
| `on(topic, handler)` | Observe events the worker emits. Returns an `off()` function. |
|
|
387
|
+
| `mockRequest(topic, responder)` | Auto-respond to a request the worker itself makes over the event bus. |
|
|
388
|
+
| `stop()` | Terminate the worker (call from `afterEach`). |
|
|
389
|
+
|
|
390
|
+
**Topics are fully qualified** on the wire (`<pluginId>.<area>.<action>`), exactly as another plugin
|
|
391
|
+
or `rimori-main` would send them — a worker `respond('deck.requestOpenToday', …)` is reached via
|
|
392
|
+
`request('<pluginId>.deck.requestOpenToday', …)`.
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
await env.setup();
|
|
396
|
+
|
|
397
|
+
// Mock everything the worker touches during init AND when answering the request.
|
|
398
|
+
env.db.mockFrom('decks', decks, { method: 'GET' });
|
|
399
|
+
|
|
400
|
+
await env.worker.start();
|
|
401
|
+
|
|
402
|
+
const result = await env.worker.request(`${pluginId}.deck.requestOpenToday`, {});
|
|
403
|
+
expect(result).toHaveLength(2);
|
|
404
|
+
|
|
405
|
+
// afterEach: await env.worker.stop();
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
See `plugins/flashcards/test/scenario/worker-integration.test.ts` for a complete example.
|
|
409
|
+
|
|
370
410
|
### Mock Options
|
|
371
411
|
|
|
372
412
|
All mock methods accept an optional `MockOptions` parameter:
|
|
@@ -60,6 +60,18 @@ export declare class MessageChannelSimulator {
|
|
|
60
60
|
* @returns A function to manually remove the responder before it's used
|
|
61
61
|
*/
|
|
62
62
|
respondOnce(topic: string, responder: AutoResponder | unknown): () => void;
|
|
63
|
+
/**
|
|
64
|
+
* Registers a responder for a streaming request (`client.event.requestStream`). When the
|
|
65
|
+
* plugin emits the request (carrying a `__streamId`), the provided items are streamed back
|
|
66
|
+
* as `item` chunks on the derived chunk topic followed by a `done` chunk.
|
|
67
|
+
*
|
|
68
|
+
* @param topic - The streaming request topic (e.g. `pl123.lookup.requestBulkStream`).
|
|
69
|
+
* @param items - The items to stream, or a function receiving the request event that returns them.
|
|
70
|
+
* @returns A function to remove the responder.
|
|
71
|
+
*/
|
|
72
|
+
respondStream(topic: string, items: unknown[] | ((event: EventBusMessage) => unknown[] | Promise<unknown[]>)): () => void;
|
|
73
|
+
/** Mirrors EventModule.deriveStreamTopics — chunk flows on the requester's namespace. */
|
|
74
|
+
private static deriveChunkTopic;
|
|
63
75
|
/**
|
|
64
76
|
* Overrides the user info.
|
|
65
77
|
*/
|
|
@@ -195,6 +195,38 @@ class MessageChannelSimulator {
|
|
|
195
195
|
}
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Registers a responder for a streaming request (`client.event.requestStream`). When the
|
|
200
|
+
* plugin emits the request (carrying a `__streamId`), the provided items are streamed back
|
|
201
|
+
* as `item` chunks on the derived chunk topic followed by a `done` chunk.
|
|
202
|
+
*
|
|
203
|
+
* @param topic - The streaming request topic (e.g. `pl123.lookup.requestBulkStream`).
|
|
204
|
+
* @param items - The items to stream, or a function receiving the request event that returns them.
|
|
205
|
+
* @returns A function to remove the responder.
|
|
206
|
+
*/
|
|
207
|
+
respondStream(topic, items) {
|
|
208
|
+
return this.on(topic, async (event) => {
|
|
209
|
+
const streamId = event.data?.__streamId;
|
|
210
|
+
if (streamId === undefined)
|
|
211
|
+
return;
|
|
212
|
+
// Chunks flow on the REQUESTER's namespace (the plugin under test, i.e. event.sender)
|
|
213
|
+
// so the plugin's requestStream receives them — mirrors EventModule routing.
|
|
214
|
+
const chunkTopic = MessageChannelSimulator.deriveChunkTopic(topic, event.sender);
|
|
215
|
+
const list = typeof items === 'function' ? await items(event) : items;
|
|
216
|
+
for (const payload of list) {
|
|
217
|
+
await this.emit(chunkTopic, { __streamId: streamId, type: 'item', payload }, 'rimori-main');
|
|
218
|
+
}
|
|
219
|
+
await this.emit(chunkTopic, { __streamId: streamId, type: 'done' }, 'rimori-main');
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Mirrors EventModule.deriveStreamTopics — chunk flows on the requester's namespace. */
|
|
223
|
+
static deriveChunkTopic(requestTopic, requesterId) {
|
|
224
|
+
const [, area, action] = requestTopic.split('.');
|
|
225
|
+
const base = action.startsWith('request')
|
|
226
|
+
? action.slice('request'.length)
|
|
227
|
+
: action.charAt(0).toUpperCase() + action.slice(1);
|
|
228
|
+
return `${requesterId}.${area}.trigger${base}Chunk`;
|
|
229
|
+
}
|
|
198
230
|
/**
|
|
199
231
|
* Overrides the user info.
|
|
200
232
|
*/
|
|
@@ -2,7 +2,7 @@ import { Page, Request } from '@playwright/test';
|
|
|
2
2
|
import { UserInfo } from '@rimori/client';
|
|
3
3
|
import { MainPanelAction, Plugin, SidebarAction } from '@rimori/client';
|
|
4
4
|
import { PluginSettings } from './SettingsStateManager';
|
|
5
|
-
import { EventPayload } from '@rimori/client';
|
|
5
|
+
import { EventPayload, EventBusMessage } from '@rimori/client';
|
|
6
6
|
import { LanguageLevel } from '@rimori/client';
|
|
7
7
|
interface RimoriTestEnvironmentOptions {
|
|
8
8
|
page: Page;
|
|
@@ -46,6 +46,7 @@ export declare class RimoriTestEnvironment {
|
|
|
46
46
|
private backendRoutes;
|
|
47
47
|
private supabaseRoutes;
|
|
48
48
|
private messageChannelSimulator;
|
|
49
|
+
private workerSimulator;
|
|
49
50
|
private settingsManager;
|
|
50
51
|
constructor(options: RimoriTestEnvironmentOptions);
|
|
51
52
|
/**
|
|
@@ -200,6 +201,25 @@ export declare class RimoriTestEnvironment {
|
|
|
200
201
|
* @returns A function to manually remove the responder
|
|
201
202
|
*/
|
|
202
203
|
mockRequest: (topic: string, response: unknown | ((event: unknown) => unknown)) => () => void;
|
|
204
|
+
/**
|
|
205
|
+
* Registers a responder for a streaming request (`client.event.requestStream`), e.g. the
|
|
206
|
+
* translator's `lookup.requestBulkStream` used by flashcard deck generation. The items are
|
|
207
|
+
* streamed back as `item` chunks then a `done`. Pass a function to derive items from the
|
|
208
|
+
* request event (e.g. one item per `event.data.words` entry).
|
|
209
|
+
*
|
|
210
|
+
* ```ts
|
|
211
|
+
* env.event.mockRequestStream('pl7720512027.lookup.requestBulkStream', (event) =>
|
|
212
|
+
* (event.data.words as { id: string; word: string }[]).map((w) => ({
|
|
213
|
+
* id: w.id, word: w.word, translation: {...}, formatted: { front: '...', back: w.word },
|
|
214
|
+
* })),
|
|
215
|
+
* );
|
|
216
|
+
* ```
|
|
217
|
+
*
|
|
218
|
+
* @param topic - The streaming request topic.
|
|
219
|
+
* @param items - The items to stream, or a function receiving the request event that returns them.
|
|
220
|
+
* @returns A function to remove the responder.
|
|
221
|
+
*/
|
|
222
|
+
mockRequestStream: (topic: string, items: unknown[] | ((event: EventBusMessage) => unknown[] | Promise<unknown[]>)) => () => void;
|
|
203
223
|
/**
|
|
204
224
|
* Listen for events emitted by the plugin.
|
|
205
225
|
* @param topic - The event topic to listen for (e.g., 'global.accomplishment.triggerMicro')
|
|
@@ -243,6 +263,35 @@ export declare class RimoriTestEnvironment {
|
|
|
243
263
|
*/
|
|
244
264
|
triggerOnMainPanelAction: (payload: MainPanelAction) => Promise<void>;
|
|
245
265
|
};
|
|
266
|
+
private getWorkerSimulator;
|
|
267
|
+
/**
|
|
268
|
+
* Drives the plugin's web worker (`public/web-worker.js`) through the real worker handshake,
|
|
269
|
+
* with AI/DB calls served by the same route mocks as the UI. Mock any routes the worker hits
|
|
270
|
+
* during init (e.g. flashcards' study-deck rotation) before calling `start()`.
|
|
271
|
+
*
|
|
272
|
+
* ```ts
|
|
273
|
+
* await env.setup();
|
|
274
|
+
* env.db.mockFrom('decks', [{ id: 'd1', name: 'Deck', state: 'active', created_at: '...' }]);
|
|
275
|
+
* await env.worker.start();
|
|
276
|
+
* const decks = await env.worker.request('deck.requestOpenToday', {});
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
readonly worker: {
|
|
280
|
+
/** Spawn the worker and complete the hello/init/ack handshake. Idempotent. */
|
|
281
|
+
start: () => Promise<void>;
|
|
282
|
+
/** Fire-and-forget event into the worker. */
|
|
283
|
+
emit: (topic: string, data?: EventPayload, sender?: string) => Promise<void>;
|
|
284
|
+
/** Send a request and resolve with the worker's response data. */
|
|
285
|
+
request: <T = unknown>(topic: string, data?: EventPayload, sender?: string) => Promise<T>;
|
|
286
|
+
/** Drive a streaming request (`respondStream`); resolves with all pushed items on done. */
|
|
287
|
+
requestStream: <TItem = unknown>(topic: string, data?: EventPayload, onItem?: (item: TItem) => void, sender?: string) => Promise<TItem[]>;
|
|
288
|
+
/** Observe events the worker emits. Returns an off() function. */
|
|
289
|
+
on: (topic: string, handler: (data: unknown, event: EventBusMessage) => void | Promise<void>) => (() => void);
|
|
290
|
+
/** Auto-respond to a request the worker itself makes over the event bus. */
|
|
291
|
+
mockRequest: (topic: string, responder: unknown) => (() => void);
|
|
292
|
+
/** Terminate the worker. Call from afterEach to avoid cross-test leaks. */
|
|
293
|
+
stop: () => Promise<void>;
|
|
294
|
+
};
|
|
246
295
|
readonly ai: {
|
|
247
296
|
/**
|
|
248
297
|
* Mocks a text response from the LLM endpoint.
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.RimoriTestEnvironment = void 0;
|
|
37
37
|
const default_user_info_1 = require("../fixtures/default-user-info");
|
|
38
38
|
const MessageChannelSimulator_1 = require("./MessageChannelSimulator");
|
|
39
|
+
const WorkerSimulator_1 = require("./WorkerSimulator");
|
|
39
40
|
const SettingsStateManager_1 = require("./SettingsStateManager");
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
41
42
|
const path = __importStar(require("path"));
|
|
@@ -50,6 +51,7 @@ class RimoriTestEnvironment {
|
|
|
50
51
|
this.backendRoutes = {};
|
|
51
52
|
this.supabaseRoutes = {};
|
|
52
53
|
this.messageChannelSimulator = null;
|
|
54
|
+
this.workerSimulator = null;
|
|
53
55
|
this.plugin = {
|
|
54
56
|
/**
|
|
55
57
|
* Manually set the settings state (useful for test setup).
|
|
@@ -164,6 +166,30 @@ class RimoriTestEnvironment {
|
|
|
164
166
|
}
|
|
165
167
|
return this.messageChannelSimulator.respond(topic, response);
|
|
166
168
|
},
|
|
169
|
+
/**
|
|
170
|
+
* Registers a responder for a streaming request (`client.event.requestStream`), e.g. the
|
|
171
|
+
* translator's `lookup.requestBulkStream` used by flashcard deck generation. The items are
|
|
172
|
+
* streamed back as `item` chunks then a `done`. Pass a function to derive items from the
|
|
173
|
+
* request event (e.g. one item per `event.data.words` entry).
|
|
174
|
+
*
|
|
175
|
+
* ```ts
|
|
176
|
+
* env.event.mockRequestStream('pl7720512027.lookup.requestBulkStream', (event) =>
|
|
177
|
+
* (event.data.words as { id: string; word: string }[]).map((w) => ({
|
|
178
|
+
* id: w.id, word: w.word, translation: {...}, formatted: { front: '...', back: w.word },
|
|
179
|
+
* })),
|
|
180
|
+
* );
|
|
181
|
+
* ```
|
|
182
|
+
*
|
|
183
|
+
* @param topic - The streaming request topic.
|
|
184
|
+
* @param items - The items to stream, or a function receiving the request event that returns them.
|
|
185
|
+
* @returns A function to remove the responder.
|
|
186
|
+
*/
|
|
187
|
+
mockRequestStream: (topic, items) => {
|
|
188
|
+
if (!this.messageChannelSimulator) {
|
|
189
|
+
throw new Error('MessageChannelSimulator not initialized. Call setup() first.');
|
|
190
|
+
}
|
|
191
|
+
return this.messageChannelSimulator.respondStream(topic, items);
|
|
192
|
+
},
|
|
167
193
|
/**
|
|
168
194
|
* Listen for events emitted by the plugin.
|
|
169
195
|
* @param topic - The event topic to listen for (e.g., 'global.accomplishment.triggerMicro')
|
|
@@ -239,6 +265,34 @@ class RimoriTestEnvironment {
|
|
|
239
265
|
});
|
|
240
266
|
},
|
|
241
267
|
};
|
|
268
|
+
/**
|
|
269
|
+
* Drives the plugin's web worker (`public/web-worker.js`) through the real worker handshake,
|
|
270
|
+
* with AI/DB calls served by the same route mocks as the UI. Mock any routes the worker hits
|
|
271
|
+
* during init (e.g. flashcards' study-deck rotation) before calling `start()`.
|
|
272
|
+
*
|
|
273
|
+
* ```ts
|
|
274
|
+
* await env.setup();
|
|
275
|
+
* env.db.mockFrom('decks', [{ id: 'd1', name: 'Deck', state: 'active', created_at: '...' }]);
|
|
276
|
+
* await env.worker.start();
|
|
277
|
+
* const decks = await env.worker.request('deck.requestOpenToday', {});
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
this.worker = {
|
|
281
|
+
/** Spawn the worker and complete the hello/init/ack handshake. Idempotent. */
|
|
282
|
+
start: () => this.getWorkerSimulator().start(),
|
|
283
|
+
/** Fire-and-forget event into the worker. */
|
|
284
|
+
emit: (topic, data = {}, sender = 'rimori-main') => this.getWorkerSimulator().emit(topic, data, sender),
|
|
285
|
+
/** Send a request and resolve with the worker's response data. */
|
|
286
|
+
request: (topic, data = {}, sender = 'rimori-main') => this.getWorkerSimulator().request(topic, data, sender),
|
|
287
|
+
/** Drive a streaming request (`respondStream`); resolves with all pushed items on done. */
|
|
288
|
+
requestStream: (topic, data = {}, onItem, sender = 'rimori-main') => this.getWorkerSimulator().requestStream(topic, data, onItem, sender),
|
|
289
|
+
/** Observe events the worker emits. Returns an off() function. */
|
|
290
|
+
on: (topic, handler) => this.getWorkerSimulator().on(topic, handler),
|
|
291
|
+
/** Auto-respond to a request the worker itself makes over the event bus. */
|
|
292
|
+
mockRequest: (topic, responder) => this.getWorkerSimulator().mockRequest(topic, responder),
|
|
293
|
+
/** Terminate the worker. Call from afterEach to avoid cross-test leaks. */
|
|
294
|
+
stop: () => (this.workerSimulator ? this.workerSimulator.stop() : Promise.resolve()),
|
|
295
|
+
};
|
|
242
296
|
this.ai = {
|
|
243
297
|
/**
|
|
244
298
|
* Mocks a text response from the LLM endpoint.
|
|
@@ -712,6 +766,15 @@ class RimoriTestEnvironment {
|
|
|
712
766
|
await route.fulfill({ status: 500, headers: { 'Content-Type': 'text/plain' }, body: String(error?.message ?? error) });
|
|
713
767
|
}
|
|
714
768
|
});
|
|
769
|
+
// Minimal same-origin page used by WorkerSimulator to host the worker. Being on the
|
|
770
|
+
// plugin dev-server origin makes the in-page `fetch('/web-worker.js')` same-origin.
|
|
771
|
+
this.page.route(`${pluginUrl}/__rimori_worker_harness__.html`, async (route) => {
|
|
772
|
+
await route.fulfill({
|
|
773
|
+
status: 200,
|
|
774
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
775
|
+
body: '<!doctype html><html><head><meta charset="utf-8"><title>Rimori Worker Harness</title></head><body></body></html>',
|
|
776
|
+
});
|
|
777
|
+
});
|
|
715
778
|
// Intercept all /locales requests and fetch from the dev server
|
|
716
779
|
this.page.route(`${pluginUrl}/locales/**`, async (route) => {
|
|
717
780
|
const request = route.request();
|
|
@@ -1132,5 +1195,18 @@ class RimoriTestEnvironment {
|
|
|
1132
1195
|
isStreaming: isStreaming ?? false,
|
|
1133
1196
|
});
|
|
1134
1197
|
}
|
|
1198
|
+
getWorkerSimulator() {
|
|
1199
|
+
if (!this.workerSimulator) {
|
|
1200
|
+
this.workerSimulator = new WorkerSimulator_1.WorkerSimulator({
|
|
1201
|
+
page: this.page,
|
|
1202
|
+
pluginId: this.pluginId,
|
|
1203
|
+
workerUrl: `${this.pluginUrl}/web-worker.js`,
|
|
1204
|
+
// Same RimoriInfo object the UI bridge uses, so mockGetUserInfo() updates are reflected
|
|
1205
|
+
// as long as they run before worker.start() (the worker snapshots it at init).
|
|
1206
|
+
rimoriInfo: this.rimoriInfo,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
return this.workerSimulator;
|
|
1210
|
+
}
|
|
1135
1211
|
}
|
|
1136
1212
|
exports.RimoriTestEnvironment = RimoriTestEnvironment;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import { RimoriInfo, EventBusMessage, EventPayload } from '@rimori/client';
|
|
3
|
+
type EventListener = (data: unknown, event: EventBusMessage) => void | Promise<void>;
|
|
4
|
+
type AutoResponder = (event: EventBusMessage) => unknown | Promise<unknown>;
|
|
5
|
+
interface WorkerSimulatorArgs {
|
|
6
|
+
page: Page;
|
|
7
|
+
pluginId: string;
|
|
8
|
+
workerUrl: string;
|
|
9
|
+
rimoriInfo: RimoriInfo;
|
|
10
|
+
/** Default timeout (ms) for `request()` before rejecting. */
|
|
11
|
+
requestTimeout?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Drives a plugin's web worker (`public/web-worker.js`) inside the Playwright page,
|
|
15
|
+
* playing the host (rimori-main) role of the worker handshake exactly like
|
|
16
|
+
* `apps/rimori-main/src/hooks/useWorkerHandler.ts`:
|
|
17
|
+
*
|
|
18
|
+
* worker → host: `rimori:hello`
|
|
19
|
+
* host → worker: `rimori:init` (+ transferred MessagePort + rimoriInfo)
|
|
20
|
+
* worker → host: `rimori:acknowledged`
|
|
21
|
+
*
|
|
22
|
+
* After the handshake all EventBus traffic is bridged across the port, so the same
|
|
23
|
+
* Playwright route mocks (AI `/ai/llm`, Supabase `/rest/v1/*`) that the UI uses also
|
|
24
|
+
* cover the worker's fetches — `page.route` intercepts dedicated-worker requests.
|
|
25
|
+
*/
|
|
26
|
+
export declare class WorkerSimulator {
|
|
27
|
+
private readonly page;
|
|
28
|
+
private readonly pluginId;
|
|
29
|
+
private readonly workerUrl;
|
|
30
|
+
private readonly rimoriInfo;
|
|
31
|
+
private readonly requestTimeout;
|
|
32
|
+
private readonly listeners;
|
|
33
|
+
private readonly autoResponders;
|
|
34
|
+
private readonly pending;
|
|
35
|
+
private bindingReady;
|
|
36
|
+
private started;
|
|
37
|
+
constructor({ page, pluginId, workerUrl, rimoriInfo, requestTimeout }: WorkerSimulatorArgs);
|
|
38
|
+
/**
|
|
39
|
+
* Spawns the worker and completes the handshake. Idempotent — a second call is a no-op.
|
|
40
|
+
* Mock any backend/Supabase routes the worker touches *during init* (e.g. flashcards'
|
|
41
|
+
* study-deck rotation queries the `decks` table) before calling this.
|
|
42
|
+
*/
|
|
43
|
+
start(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Emits a fire-and-forget event into the worker (no response awaited).
|
|
46
|
+
* @param sender - Defaults to 'rimori-main'; must NOT be the plugin id or the worker's
|
|
47
|
+
* respond() listeners ignore it (they blacklist their own sender for non-self topics).
|
|
48
|
+
*/
|
|
49
|
+
emit(topic: string, data?: EventPayload, sender?: string): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Emits a request into the worker and resolves with the worker's response data.
|
|
52
|
+
* The worker's `client.event.respond(topic, …)` handler answers with the same eventId.
|
|
53
|
+
*/
|
|
54
|
+
request<T = unknown>(topic: string, data?: EventPayload, sender?: string): Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* Drives a streaming request (`client.event.respondStream`) into the worker: emits the
|
|
57
|
+
* request carrying a generated `__streamId`, collects the chunk events the worker pushes on
|
|
58
|
+
* the derived chunk topic, and resolves with all items on `done` (rejects on `error`/timeout).
|
|
59
|
+
*/
|
|
60
|
+
requestStream<TItem = unknown>(topic: string, data?: EventPayload, onItem?: (item: TItem) => void, sender?: string): Promise<TItem[]>;
|
|
61
|
+
/** Mirrors EventModule.deriveStreamTopics — chunk flows on the requester's namespace. */
|
|
62
|
+
private static deriveChunkTopic;
|
|
63
|
+
/** Observe events the worker emits (e.g. accomplishments, triggerUpdate). Returns an off() fn. */
|
|
64
|
+
on(topic: string, handler: EventListener): () => void;
|
|
65
|
+
/**
|
|
66
|
+
* Auto-respond to a request the *worker* makes over the event bus (e.g. an inter-plugin
|
|
67
|
+
* `client.event.request(...)`). The value (or function result) is returned to the worker.
|
|
68
|
+
*/
|
|
69
|
+
mockRequest(topic: string, responder: AutoResponder | unknown): () => void;
|
|
70
|
+
/** Terminate the worker and clear pending state. Call from afterEach to avoid leaks. */
|
|
71
|
+
stop(): Promise<void>;
|
|
72
|
+
private ensureBinding;
|
|
73
|
+
private buildEvent;
|
|
74
|
+
private handlePortMessage;
|
|
75
|
+
private buildEventWithId;
|
|
76
|
+
private resolvePending;
|
|
77
|
+
private rejectPending;
|
|
78
|
+
private send;
|
|
79
|
+
}
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorkerSimulator = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Drives a plugin's web worker (`public/web-worker.js`) inside the Playwright page,
|
|
6
|
+
* playing the host (rimori-main) role of the worker handshake exactly like
|
|
7
|
+
* `apps/rimori-main/src/hooks/useWorkerHandler.ts`:
|
|
8
|
+
*
|
|
9
|
+
* worker → host: `rimori:hello`
|
|
10
|
+
* host → worker: `rimori:init` (+ transferred MessagePort + rimoriInfo)
|
|
11
|
+
* worker → host: `rimori:acknowledged`
|
|
12
|
+
*
|
|
13
|
+
* After the handshake all EventBus traffic is bridged across the port, so the same
|
|
14
|
+
* Playwright route mocks (AI `/ai/llm`, Supabase `/rest/v1/*`) that the UI uses also
|
|
15
|
+
* cover the worker's fetches — `page.route` intercepts dedicated-worker requests.
|
|
16
|
+
*/
|
|
17
|
+
class WorkerSimulator {
|
|
18
|
+
constructor({ page, pluginId, workerUrl, rimoriInfo, requestTimeout }) {
|
|
19
|
+
this.listeners = new Map();
|
|
20
|
+
this.autoResponders = new Map();
|
|
21
|
+
this.pending = new Map();
|
|
22
|
+
this.bindingReady = false;
|
|
23
|
+
this.started = false;
|
|
24
|
+
this.page = page;
|
|
25
|
+
this.pluginId = pluginId;
|
|
26
|
+
this.workerUrl = workerUrl;
|
|
27
|
+
this.rimoriInfo = rimoriInfo;
|
|
28
|
+
this.requestTimeout = requestTimeout ?? 10000;
|
|
29
|
+
// Host-side responders the worker may need during init / runtime. The worker already
|
|
30
|
+
// receives rimoriInfo via the init handshake, so these are fallbacks for any plugin
|
|
31
|
+
// that explicitly re-requests access or user info over the event bus.
|
|
32
|
+
this.autoResponders.set('global.supabase.requestAccess', () => this.rimoriInfo);
|
|
33
|
+
this.autoResponders.set('global.profile.requestUserInfo', () => this.rimoriInfo.profile);
|
|
34
|
+
this.autoResponders.set('global.profile.getUserInfo', () => this.rimoriInfo.profile);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Spawns the worker and completes the handshake. Idempotent — a second call is a no-op.
|
|
38
|
+
* Mock any backend/Supabase routes the worker touches *during init* (e.g. flashcards'
|
|
39
|
+
* study-deck rotation queries the `decks` table) before calling this.
|
|
40
|
+
*/
|
|
41
|
+
async start() {
|
|
42
|
+
if (this.started)
|
|
43
|
+
return;
|
|
44
|
+
await this.ensureBinding();
|
|
45
|
+
// Navigate to a minimal, same-origin page on the plugin dev server so the in-page
|
|
46
|
+
// `fetch(workerUrl)` is same-origin (web-worker.js is served from the plugin's public/).
|
|
47
|
+
await this.page.goto(`${this.workerUrl.replace(/\/web-worker\.js$/, '')}/__rimori_worker_harness__.html`);
|
|
48
|
+
await this.page.evaluate(async ({ pluginId, workerUrl, rimoriInfo }) => {
|
|
49
|
+
const res = await fetch(workerUrl);
|
|
50
|
+
const code = await res.text();
|
|
51
|
+
const blob = new Blob([code], { type: 'application/javascript' });
|
|
52
|
+
const worker = new Worker(URL.createObjectURL(blob), { type: 'module' });
|
|
53
|
+
const channel = new MessageChannel();
|
|
54
|
+
// Bridge worker → Node. The worker posts EventBus traffic on the transferred port.
|
|
55
|
+
channel.port1.onmessage = ({ data }) => {
|
|
56
|
+
// @ts-expect-error binding injected via exposeBinding
|
|
57
|
+
window.__rimoriWorker_onMessage(data);
|
|
58
|
+
};
|
|
59
|
+
window.__rimoriWorker_send = (payload) => channel.port1.postMessage(payload);
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
const timeout = setTimeout(() => reject(new Error('[WorkerSimulator] handshake timed out after 10s')), 10000);
|
|
62
|
+
worker.onerror = (e) => {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
reject(new Error('[WorkerSimulator] worker error: ' + (e.message || 'unknown')));
|
|
65
|
+
};
|
|
66
|
+
// hello / acknowledged arrive on the main worker channel (self.postMessage), not the port.
|
|
67
|
+
worker.onmessage = ({ data }) => {
|
|
68
|
+
if (data?.type === 'rimori:hello') {
|
|
69
|
+
worker.postMessage({ type: 'rimori:init', pluginId, queryParams: {}, rimoriInfo }, [channel.port2]);
|
|
70
|
+
}
|
|
71
|
+
else if (data?.type === 'rimori:acknowledged') {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
resolve();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}, { pluginId: this.pluginId, workerUrl: this.workerUrl, rimoriInfo: this.rimoriInfo });
|
|
78
|
+
this.started = true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Emits a fire-and-forget event into the worker (no response awaited).
|
|
82
|
+
* @param sender - Defaults to 'rimori-main'; must NOT be the plugin id or the worker's
|
|
83
|
+
* respond() listeners ignore it (they blacklist their own sender for non-self topics).
|
|
84
|
+
*/
|
|
85
|
+
async emit(topic, data = {}, sender = 'rimori-main') {
|
|
86
|
+
await this.send({ event: this.buildEvent(topic, data, sender) });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Emits a request into the worker and resolves with the worker's response data.
|
|
90
|
+
* The worker's `client.event.respond(topic, …)` handler answers with the same eventId.
|
|
91
|
+
*/
|
|
92
|
+
async request(topic, data = {}, sender = 'rimori-main') {
|
|
93
|
+
const event = this.buildEvent(topic, data, sender);
|
|
94
|
+
const eventId = event.eventId;
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
this.pending.delete(eventId);
|
|
98
|
+
reject(new Error(`[WorkerSimulator] request '${topic}' timed out after ${this.requestTimeout}ms`));
|
|
99
|
+
}, this.requestTimeout);
|
|
100
|
+
this.pending.set(eventId, { resolve: resolve, reject, timer });
|
|
101
|
+
void this.send({ event });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Drives a streaming request (`client.event.respondStream`) into the worker: emits the
|
|
106
|
+
* request carrying a generated `__streamId`, collects the chunk events the worker pushes on
|
|
107
|
+
* the derived chunk topic, and resolves with all items on `done` (rejects on `error`/timeout).
|
|
108
|
+
*/
|
|
109
|
+
async requestStream(topic, data = {}, onItem, sender = 'rimori-main') {
|
|
110
|
+
const streamId = Math.floor(Math.random() * 1000000000);
|
|
111
|
+
// The worker responder emits chunks on the REQUESTER's namespace (our `sender`).
|
|
112
|
+
const chunkTopic = WorkerSimulator.deriveChunkTopic(topic, sender);
|
|
113
|
+
const items = [];
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
off();
|
|
117
|
+
reject(new Error(`[WorkerSimulator] requestStream '${topic}' timed out after ${this.requestTimeout}ms`));
|
|
118
|
+
}, this.requestTimeout);
|
|
119
|
+
const off = this.on(chunkTopic, (chunkData) => {
|
|
120
|
+
const d = chunkData;
|
|
121
|
+
if (d.__streamId !== streamId)
|
|
122
|
+
return;
|
|
123
|
+
if (d.type === 'item') {
|
|
124
|
+
items.push(d.payload);
|
|
125
|
+
onItem?.(d.payload);
|
|
126
|
+
}
|
|
127
|
+
else if (d.type === 'done') {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
off();
|
|
130
|
+
resolve(items);
|
|
131
|
+
}
|
|
132
|
+
else if (d.type === 'error') {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
off();
|
|
135
|
+
reject(new Error(d.error ?? 'stream error'));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
void this.send({ event: this.buildEvent(topic, { __streamId: streamId, ...data }, sender) });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Mirrors EventModule.deriveStreamTopics — chunk flows on the requester's namespace. */
|
|
142
|
+
static deriveChunkTopic(requestTopic, requesterId) {
|
|
143
|
+
const [, area, action] = requestTopic.split('.');
|
|
144
|
+
const base = action.startsWith('request')
|
|
145
|
+
? action.slice('request'.length)
|
|
146
|
+
: action.charAt(0).toUpperCase() + action.slice(1);
|
|
147
|
+
return `${requesterId}.${area}.trigger${base}Chunk`;
|
|
148
|
+
}
|
|
149
|
+
/** Observe events the worker emits (e.g. accomplishments, triggerUpdate). Returns an off() fn. */
|
|
150
|
+
on(topic, handler) {
|
|
151
|
+
const handlers = this.listeners.get(topic) ?? new Set();
|
|
152
|
+
handlers.add(handler);
|
|
153
|
+
this.listeners.set(topic, handlers);
|
|
154
|
+
return () => {
|
|
155
|
+
this.listeners.get(topic)?.delete(handler);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Auto-respond to a request the *worker* makes over the event bus (e.g. an inter-plugin
|
|
160
|
+
* `client.event.request(...)`). The value (or function result) is returned to the worker.
|
|
161
|
+
*/
|
|
162
|
+
mockRequest(topic, responder) {
|
|
163
|
+
const wrapped = (event) => (typeof responder === 'function' ? responder(event) : responder);
|
|
164
|
+
this.autoResponders.set(topic, wrapped);
|
|
165
|
+
return () => {
|
|
166
|
+
this.autoResponders.delete(topic);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Terminate the worker and clear pending state. Call from afterEach to avoid leaks. */
|
|
170
|
+
async stop() {
|
|
171
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
reject(new Error('[WorkerSimulator] stopped before response'));
|
|
174
|
+
}
|
|
175
|
+
this.pending.clear();
|
|
176
|
+
if (!this.started)
|
|
177
|
+
return;
|
|
178
|
+
this.started = false;
|
|
179
|
+
await this.page
|
|
180
|
+
.evaluate(() => {
|
|
181
|
+
const w = window.__rimoriWorker;
|
|
182
|
+
w?.worker.terminate();
|
|
183
|
+
})
|
|
184
|
+
.catch(() => {
|
|
185
|
+
/* page may already be closed */
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async ensureBinding() {
|
|
189
|
+
if (this.bindingReady)
|
|
190
|
+
return;
|
|
191
|
+
await this.page.exposeBinding('__rimoriWorker_onMessage', (_source, payload) => this.handlePortMessage(payload));
|
|
192
|
+
this.bindingReady = true;
|
|
193
|
+
}
|
|
194
|
+
buildEvent(topic, data, sender) {
|
|
195
|
+
return {
|
|
196
|
+
timestamp: new Date().toISOString(),
|
|
197
|
+
eventId: Math.floor(Math.random() * 1000000000),
|
|
198
|
+
sender,
|
|
199
|
+
topic,
|
|
200
|
+
data,
|
|
201
|
+
debug: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async handlePortMessage(payload) {
|
|
205
|
+
if (!payload)
|
|
206
|
+
return;
|
|
207
|
+
// Explicit response/error envelopes (not currently emitted by the worker, but handle them).
|
|
208
|
+
if (payload.type === 'response') {
|
|
209
|
+
this.resolvePending(payload.eventId, payload.response?.data);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (payload.type === 'error') {
|
|
213
|
+
this.rejectPending(payload.eventId, payload.error);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const event = payload.event;
|
|
217
|
+
if (!event)
|
|
218
|
+
return;
|
|
219
|
+
// A response to one of our requests: same eventId, sender is the plugin id.
|
|
220
|
+
if (event.eventId && this.pending.has(event.eventId)) {
|
|
221
|
+
this.resolvePending(event.eventId, event.data);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// A request originated by the worker that we know how to answer.
|
|
225
|
+
if (event.eventId && this.autoResponders.has(event.topic)) {
|
|
226
|
+
const responder = this.autoResponders.get(event.topic);
|
|
227
|
+
const data = (await responder(event));
|
|
228
|
+
// Reply with a non-plugin sender so the worker's request resolver picks it up.
|
|
229
|
+
await this.send({ event: this.buildEventWithId(event.topic, data, 'rimori-main', event.eventId) });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Otherwise it's a plain emitted event — notify observers.
|
|
233
|
+
const handlers = this.listeners.get(event.topic);
|
|
234
|
+
if (handlers?.size) {
|
|
235
|
+
for (const handler of handlers)
|
|
236
|
+
await handler(event.data, event);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
buildEventWithId(topic, data, sender, eventId) {
|
|
240
|
+
return { timestamp: new Date().toISOString(), eventId, sender, topic, data, debug: false };
|
|
241
|
+
}
|
|
242
|
+
resolvePending(eventId, data) {
|
|
243
|
+
const pending = this.pending.get(eventId);
|
|
244
|
+
if (!pending)
|
|
245
|
+
return;
|
|
246
|
+
clearTimeout(pending.timer);
|
|
247
|
+
this.pending.delete(eventId);
|
|
248
|
+
pending.resolve(data);
|
|
249
|
+
}
|
|
250
|
+
rejectPending(eventId, error) {
|
|
251
|
+
const pending = this.pending.get(eventId);
|
|
252
|
+
if (!pending)
|
|
253
|
+
return;
|
|
254
|
+
clearTimeout(pending.timer);
|
|
255
|
+
this.pending.delete(eventId);
|
|
256
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
257
|
+
}
|
|
258
|
+
async send(payload) {
|
|
259
|
+
await this.page.evaluate((value) => {
|
|
260
|
+
const bridge = window.__rimoriWorker_send;
|
|
261
|
+
if (!bridge)
|
|
262
|
+
throw new Error('[WorkerSimulator] worker bridge unavailable — call start() first');
|
|
263
|
+
bridge(value);
|
|
264
|
+
}, payload);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
exports.WorkerSimulator = WorkerSimulator;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var e=`modulepreload`,t=function(e){return`/__rimori_harness__/`+e},n={},r=function(r,i,a){let o=Promise.resolve();if(i&&i.length>0){let r=document.getElementsByTagName(`link`),s=document.querySelector(`meta[property=csp-nonce]`),c=s?.nonce||s?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}o=l(i.map(i=>{if(i=t(i,a),i in n)return;n[i]=!0;let o=i.endsWith(`.css`),s=o?`[rel="stylesheet"]`:``;if(a)for(let e=r.length-1;e>=0;e--){let t=r[e];if(t.href===i&&(!o||t.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${i}"]${s}`))return;let l=document.createElement(`link`);if(l.rel=o?`stylesheet`:e,o||(l.as=`script`),l.crossOrigin=``,l.href=i,c&&l.setAttribute(`nonce`,c),document.head.appendChild(l),o)return new Promise((e,t)=>{l.addEventListener(`load`,e),l.addEventListener(`error`,()=>t(Error(`Unable to preload CSS for ${i}`)))})}))}function s(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return o.then(e=>{for(let t of e||[])t.status===`rejected`&&s(t.reason);return r().catch(s)})},i=`__mf_module_cache__`;globalThis[i]||={share:{},remote:{}},globalThis[i].share||={},globalThis[i].remote||={};var a=globalThis[i],o=a.share[`@rimori/client`];o===void 0&&(o=await r(()=>import(`./dist3.js`),[]),a.share[`@rimori/client`]=o),o.__esModule,o.default;var{setupWorker:s,AudioController:c,
|
|
1
|
+
var e=`modulepreload`,t=function(e){return`/__rimori_harness__/`+e},n={},r=function(r,i,a){let o=Promise.resolve();if(i&&i.length>0){let r=document.getElementsByTagName(`link`),s=document.querySelector(`meta[property=csp-nonce]`),c=s?.nonce||s?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}o=l(i.map(i=>{if(i=t(i,a),i in n)return;n[i]=!0;let o=i.endsWith(`.css`),s=o?`[rel="stylesheet"]`:``;if(a)for(let e=r.length-1;e>=0;e--){let t=r[e];if(t.href===i&&(!o||t.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${i}"]${s}`))return;let l=document.createElement(`link`);if(l.rel=o?`stylesheet`:e,o||(l.as=`script`),l.crossOrigin=``,l.href=i,c&&l.setAttribute(`nonce`,c),document.head.appendChild(l),o)return new Promise((e,t)=>{l.addEventListener(`load`,e),l.addEventListener(`error`,()=>t(Error(`Unable to preload CSS for ${i}`)))})}))}function s(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return o.then(e=>{for(let t of e||[])t.status===`rejected`&&s(t.reason);return r().catch(s)})},i=`__mf_module_cache__`;globalThis[i]||={share:{},remote:{}},globalThis[i].share||={},globalThis[i].remote||={};var a=globalThis[i],o=a.share[`@rimori/client`];o===void 0&&(o=await r(()=>import(`./dist3.js`),[]),a.share[`@rimori/client`]=o),o.__esModule,o.default;var{setupWorker:s,AudioController:c,voiceDebug:l,Translator:u,TIER_ORDER:d,ROLE_ORDER:f,StorageModule:p,AssetsModule:m,EventBusHandler:h,EventBus:g,RimoriClient:_,StandaloneClient:v,MessageSender:y,getDifficultyLevel:b,getDifficultyLabel:x,getNeighborDifficultyLevel:S,compareLanguageLevels:C,RimoriCommunicationHandler:w}=o;export{l as a,g as c,y as i,r as l,_ as n,d as o,v as r,h as s,c as t};
|