@rimori/playwright-testing 0.3.5 → 0.3.6-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/dist/core/RimoriE2ETestEnvironment.d.ts +48 -0
- package/dist/core/RimoriE2ETestEnvironment.js +185 -0
- package/dist/core/RimoriTestEnvironment.d.ts +39 -2
- package/dist/core/RimoriTestEnvironment.js +102 -12
- package/dist/helpers/e2e/onboarding.d.ts +3 -0
- package/dist/helpers/e2e/onboarding.js +57 -0
- package/dist/helpers/e2e/study-plan-setup.d.ts +11 -0
- package/dist/helpers/e2e/study-plan-setup.js +54 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +6 -3
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Browser, Page } from '@playwright/test';
|
|
2
|
+
interface RimoriE2ETestEnvironmentOptions {
|
|
3
|
+
browser: Browser;
|
|
4
|
+
pluginId: string;
|
|
5
|
+
}
|
|
6
|
+
interface Exercise {
|
|
7
|
+
title: string;
|
|
8
|
+
description: string;
|
|
9
|
+
pluginId: string;
|
|
10
|
+
actionKey: string;
|
|
11
|
+
parameters?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface Onboarding {
|
|
14
|
+
motivation_type?: string;
|
|
15
|
+
preferred_genre?: string;
|
|
16
|
+
target_country?: string;
|
|
17
|
+
target_city?: string;
|
|
18
|
+
}
|
|
19
|
+
interface SetupOptions {
|
|
20
|
+
onboarding?: Onboarding;
|
|
21
|
+
exercises?: Array<Exercise>;
|
|
22
|
+
studyPlan?: {
|
|
23
|
+
complete: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export declare class RimoriE2ETestEnvironment {
|
|
27
|
+
private browser;
|
|
28
|
+
private pluginId;
|
|
29
|
+
private persistentUserContext;
|
|
30
|
+
private tempUserContext;
|
|
31
|
+
private testUserEmail;
|
|
32
|
+
private existingUserEmail;
|
|
33
|
+
private authToken;
|
|
34
|
+
constructor(options: RimoriE2ETestEnvironmentOptions);
|
|
35
|
+
setup({ onboarding, exercises, studyPlan }?: SetupOptions): Promise<void>;
|
|
36
|
+
getTempUserPage(): Promise<Page>;
|
|
37
|
+
getPersistUserPage(): Promise<Page>;
|
|
38
|
+
getTempUserEmail(): string;
|
|
39
|
+
getPersistUserEmail(): string;
|
|
40
|
+
private createTestUserViaApi;
|
|
41
|
+
private deleteTestUserViaApi;
|
|
42
|
+
private setupConsoleLogging;
|
|
43
|
+
private setSessionFromMagicLink;
|
|
44
|
+
private completeOnboarding;
|
|
45
|
+
private completeExerciseSetup;
|
|
46
|
+
private completeStudyPlanCreation;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RimoriE2ETestEnvironment = void 0;
|
|
4
|
+
const dotenv_1 = require("dotenv");
|
|
5
|
+
const study_plan_setup_1 = require("../helpers/e2e/study-plan-setup");
|
|
6
|
+
const onboarding_1 = require("../helpers/e2e/onboarding");
|
|
7
|
+
(0, dotenv_1.config)();
|
|
8
|
+
const RIMORI_URL = 'https://dev-app.rimori.se';
|
|
9
|
+
const BACKEND_URL = 'http://localhost:2800';
|
|
10
|
+
class RimoriE2ETestEnvironment {
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.persistentUserContext = null;
|
|
13
|
+
this.tempUserContext = null;
|
|
14
|
+
this.testUserEmail = null;
|
|
15
|
+
this.existingUserEmail = null;
|
|
16
|
+
this.authToken = null;
|
|
17
|
+
this.browser = options.browser;
|
|
18
|
+
this.pluginId = options.pluginId;
|
|
19
|
+
this.authToken = process.env.RIMORI_TOKEN ?? '';
|
|
20
|
+
if (!this.authToken) {
|
|
21
|
+
throw new Error('RIMORI_TOKEN is not set as an environment variable.');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async setup({ onboarding, exercises, studyPlan } = {}) {
|
|
25
|
+
const onboardingData = {
|
|
26
|
+
motivation_type: onboarding?.motivation_type ?? 'accomplishment',
|
|
27
|
+
preferred_genre: onboarding?.preferred_genre ?? 'comedy',
|
|
28
|
+
target_country: onboarding?.target_country ?? 'SE',
|
|
29
|
+
target_city: onboarding?.target_city ?? 'Malmö',
|
|
30
|
+
};
|
|
31
|
+
// Step 1: Create both test users (temp + persist) via API
|
|
32
|
+
const { temp, persist } = await this.createTestUserViaApi();
|
|
33
|
+
this.testUserEmail = temp.email;
|
|
34
|
+
this.existingUserEmail = persist.email;
|
|
35
|
+
console.log(`[E2E] Test user (temp): ${temp.email}`);
|
|
36
|
+
console.log(`[E2E] Existing user (persist): ${persist.email}`);
|
|
37
|
+
this.tempUserContext = await this.browser.newContext({ baseURL: RIMORI_URL });
|
|
38
|
+
this.persistentUserContext = await this.browser.newContext({ baseURL: RIMORI_URL });
|
|
39
|
+
await this.setupConsoleLogging(this.tempUserContext, 'temp');
|
|
40
|
+
await this.setupConsoleLogging(this.persistentUserContext, 'persist');
|
|
41
|
+
console.log(`[E2E] Preparing existing user context`);
|
|
42
|
+
// Step 2: Set up existing user browser context with session via magic link
|
|
43
|
+
await this.setSessionFromMagicLink(this.persistentUserContext, persist.magicLink);
|
|
44
|
+
// Step 3: Run onboarding for existing user
|
|
45
|
+
await this.completeOnboarding(this.persistentUserContext, onboardingData);
|
|
46
|
+
console.log(`[E2E] Setting up test user context`);
|
|
47
|
+
// Step 4: Set up test user browser context with session via magic link
|
|
48
|
+
await this.setSessionFromMagicLink(this.tempUserContext, temp.magicLink);
|
|
49
|
+
// Delete test user when test user context is closed
|
|
50
|
+
this.tempUserContext.on('close', async () => {
|
|
51
|
+
await this.deleteTestUserViaApi(temp.email);
|
|
52
|
+
console.log(`[E2E] Deleted test user: ${temp.email}`);
|
|
53
|
+
});
|
|
54
|
+
// Step 5: Run onboarding for test user with e2e plugin flag
|
|
55
|
+
await this.completeOnboarding(this.tempUserContext, onboardingData, this.pluginId);
|
|
56
|
+
// Step 6: Add exercises if specified
|
|
57
|
+
if (exercises && exercises?.length > 0) {
|
|
58
|
+
console.log(`[E2E] Setting up exercises`);
|
|
59
|
+
await this.completeExerciseSetup(this.tempUserContext, exercises);
|
|
60
|
+
}
|
|
61
|
+
// Step 7: Complete study plan creation if specified
|
|
62
|
+
if (studyPlan?.complete) {
|
|
63
|
+
console.log(`[E2E] Setting up study plan`);
|
|
64
|
+
await this.completeStudyPlanCreation(this.tempUserContext);
|
|
65
|
+
}
|
|
66
|
+
console.log(`[E2E] Setup completed`);
|
|
67
|
+
}
|
|
68
|
+
async getTempUserPage() {
|
|
69
|
+
if (!this.tempUserContext) {
|
|
70
|
+
throw new Error('Test user context not initialized. Call setup() first.');
|
|
71
|
+
}
|
|
72
|
+
return this.tempUserContext.newPage();
|
|
73
|
+
}
|
|
74
|
+
async getPersistUserPage() {
|
|
75
|
+
if (!this.persistentUserContext) {
|
|
76
|
+
throw new Error('Existing user context not initialized. Call setup() first.');
|
|
77
|
+
}
|
|
78
|
+
return this.persistentUserContext.newPage();
|
|
79
|
+
}
|
|
80
|
+
getTempUserEmail() {
|
|
81
|
+
if (!this.testUserEmail) {
|
|
82
|
+
throw new Error('Test user not created. Call setup() first.');
|
|
83
|
+
}
|
|
84
|
+
return this.testUserEmail;
|
|
85
|
+
}
|
|
86
|
+
getPersistUserEmail() {
|
|
87
|
+
if (!this.existingUserEmail) {
|
|
88
|
+
throw new Error('Existing user not created. Call setup() first.');
|
|
89
|
+
}
|
|
90
|
+
return this.existingUserEmail;
|
|
91
|
+
}
|
|
92
|
+
async createTestUserViaApi() {
|
|
93
|
+
const response = await fetch(`${BACKEND_URL}/testing/test-user`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
plugin_id: this.pluginId,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const errorText = await response.text();
|
|
105
|
+
throw new Error(`Failed to create test user: ${response.status} ${errorText}`);
|
|
106
|
+
}
|
|
107
|
+
return response.json();
|
|
108
|
+
}
|
|
109
|
+
async deleteTestUserViaApi(email) {
|
|
110
|
+
const response = await fetch(`${BACKEND_URL}/testing/test-user`, {
|
|
111
|
+
method: 'DELETE',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
plugin_id: this.pluginId,
|
|
118
|
+
email,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const errorText = await response.text();
|
|
123
|
+
throw new Error(`Failed to delete test user: ${response.status} ${errorText}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async setupConsoleLogging(context, user) {
|
|
127
|
+
console.log(`[E2E] Setting up console logging for ${user}`);
|
|
128
|
+
context.on('console', (msg) => {
|
|
129
|
+
const logLevel = msg.type();
|
|
130
|
+
const logMessage = msg.text();
|
|
131
|
+
if (logLevel === 'debug')
|
|
132
|
+
return;
|
|
133
|
+
if (logMessage.includes('Download the React DevTools'))
|
|
134
|
+
return;
|
|
135
|
+
if (logMessage.includes('languageChanged en'))
|
|
136
|
+
return;
|
|
137
|
+
if (logMessage.includes('i18next: initialized {debug: true'))
|
|
138
|
+
return;
|
|
139
|
+
console.log(`[browser:${logLevel}] [${user}]`, logMessage);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async setSessionFromMagicLink(context, magicLink) {
|
|
143
|
+
const page = await context.newPage();
|
|
144
|
+
await page.goto(magicLink, { waitUntil: 'networkidle' });
|
|
145
|
+
await page.waitForTimeout(5000);
|
|
146
|
+
const url = page.url();
|
|
147
|
+
if (!url.includes('/dashboard') && !url.includes('/onboarding')) {
|
|
148
|
+
throw new Error(`Failed to set session from magic link: ${url}`);
|
|
149
|
+
}
|
|
150
|
+
await page.close();
|
|
151
|
+
console.log(`[E2E] Authentication completed`);
|
|
152
|
+
}
|
|
153
|
+
async completeOnboarding(context, onboarding, e2ePluginId) {
|
|
154
|
+
console.log(`[E2E] Starting onboarding`);
|
|
155
|
+
const page = await context.newPage();
|
|
156
|
+
await page.goto('/onboarding');
|
|
157
|
+
await page.waitForTimeout(5000);
|
|
158
|
+
const isOnboaded = page.url().includes('/dashboard');
|
|
159
|
+
if (!isOnboaded) {
|
|
160
|
+
console.log(`[E2E] Onboarding user`);
|
|
161
|
+
await (0, onboarding_1.completeOnboarding)(page, onboarding, e2ePluginId);
|
|
162
|
+
console.log(`[E2E] Onboarding completed`);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
console.log(`[E2E] User already onboarded`);
|
|
166
|
+
}
|
|
167
|
+
await page.close();
|
|
168
|
+
}
|
|
169
|
+
async completeExerciseSetup(context, exercises) {
|
|
170
|
+
const page = await context.newPage();
|
|
171
|
+
for (const exercise of exercises) {
|
|
172
|
+
const encoded = encodeURIComponent(JSON.stringify(exercise));
|
|
173
|
+
await page.goto(`${RIMORI_URL}/dashboard?flag-e2e-exercise=${encoded}`);
|
|
174
|
+
// Wait for the exercise to be created and the flag to be cleared from URL
|
|
175
|
+
await page.waitForURL((url) => !url.searchParams.has('flag-e2e-exercise'), { timeout: 15000 });
|
|
176
|
+
}
|
|
177
|
+
await page.close();
|
|
178
|
+
}
|
|
179
|
+
async completeStudyPlanCreation(context) {
|
|
180
|
+
const page = await context.newPage();
|
|
181
|
+
await (0, study_plan_setup_1.completeStudyPlanGettingStarted)(page);
|
|
182
|
+
await page.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
exports.RimoriE2ETestEnvironment = RimoriE2ETestEnvironment;
|
|
@@ -79,6 +79,19 @@ export declare class RimoriTestEnvironment {
|
|
|
79
79
|
* Removes a one-time mock from the mocks array after it's been used.
|
|
80
80
|
*/
|
|
81
81
|
private removeOneTimeMock;
|
|
82
|
+
/**
|
|
83
|
+
* Creates a wrapper around the Playwright Request object that provides backwards compatibility
|
|
84
|
+
* for matchers. The new rimori-client sends `messages` array instead of `instructions`,
|
|
85
|
+
* so this wrapper extracts the prompts from messages and provides them as `instructions`.
|
|
86
|
+
*
|
|
87
|
+
* The old API had a single `instructions` field which typically contained the user's specific
|
|
88
|
+
* instruction (what the AI should do). The new API splits this into:
|
|
89
|
+
* - systemPrompt (messages[0] with role='system'): High-level behavior instructions
|
|
90
|
+
* - userPrompt (messages[1] with role='user'): Specific task instruction
|
|
91
|
+
*
|
|
92
|
+
* For backwards compatibility, we concatenate all message contents into `instructions`.
|
|
93
|
+
*/
|
|
94
|
+
private createBackwardsCompatibleRequest;
|
|
82
95
|
private handleRoute;
|
|
83
96
|
/**
|
|
84
97
|
* Adds a supabase route to the supabase routes object.
|
|
@@ -216,21 +229,45 @@ export declare class RimoriTestEnvironment {
|
|
|
216
229
|
triggerOnMainPanelAction: (payload: MainPanelAction) => Promise<void>;
|
|
217
230
|
};
|
|
218
231
|
readonly ai: {
|
|
232
|
+
/**
|
|
233
|
+
* Mocks a text response from the LLM endpoint.
|
|
234
|
+
* Since getText now uses streamObject internally with a { result: string } schema,
|
|
235
|
+
* the mock value should be the full response object.
|
|
236
|
+
*
|
|
237
|
+
* @param values - The response object to return. Should include { result: string } for getText calls.
|
|
238
|
+
* @param options - Optional mock options.
|
|
239
|
+
*/
|
|
219
240
|
mockGetText: (values: unknown, options?: MockOptions) => void;
|
|
220
241
|
/**
|
|
221
242
|
* Mocks a streaming text response from the LLM endpoint.
|
|
222
|
-
* The
|
|
243
|
+
* The new rimori-client's getSteamedText uses streamObject internally with { result: string } schema,
|
|
244
|
+
* so the text is wrapped in a result object.
|
|
223
245
|
*
|
|
224
246
|
* **Note**: Due to Playwright's route.fulfill() requiring a complete response body,
|
|
225
247
|
* all SSE chunks are sent at once (no delays). The client will still parse it as SSE correctly.
|
|
226
248
|
*
|
|
227
|
-
* @param text - The text to stream. Will be formatted as SSE
|
|
249
|
+
* @param text - The text to stream. Will be wrapped as { result: text } and formatted as SSE.
|
|
228
250
|
* @param options - Optional mock options.
|
|
229
251
|
*/
|
|
230
252
|
mockGetSteamedText: (text: string, options?: MockOptions) => void;
|
|
231
253
|
mockGetVoice: (values: Buffer, options?: MockOptions) => void;
|
|
232
254
|
mockGetTextFromVoice: (text: string, options?: MockOptions) => void;
|
|
255
|
+
/**
|
|
256
|
+
* Mocks an object response from the LLM endpoint.
|
|
257
|
+
* Since getObject now uses streamObject internally, this is a streaming response.
|
|
258
|
+
*
|
|
259
|
+
* @param value - The object to return from the LLM.
|
|
260
|
+
* @param options - Optional mock options.
|
|
261
|
+
*/
|
|
233
262
|
mockGetObject: (value: Record<string, unknown>, options?: MockOptions) => void;
|
|
263
|
+
/**
|
|
264
|
+
* Mocks a streaming object response from the LLM endpoint.
|
|
265
|
+
* Returns the object via SSE format with data: prefix.
|
|
266
|
+
*
|
|
267
|
+
* @param value - The object to stream from the LLM.
|
|
268
|
+
* @param options - Optional mock options.
|
|
269
|
+
*/
|
|
270
|
+
mockGetStreamedObject: (value: Record<string, unknown>, options?: MockOptions) => void;
|
|
234
271
|
};
|
|
235
272
|
/**
|
|
236
273
|
* Helpers for tracking browser audio playback in tests.
|
|
@@ -47,7 +47,13 @@ class RimoriTestEnvironment {
|
|
|
47
47
|
this.addSupabaseRoute('plugin_settings', response, { ...options, method: 'POST' });
|
|
48
48
|
},
|
|
49
49
|
mockGetUserInfo: (userInfo, options) => {
|
|
50
|
-
|
|
50
|
+
// Update the rimoriInfo.profile so that MessageChannelSimulator returns the correct user info
|
|
51
|
+
this.rimoriInfo.profile = { ...this.rimoriInfo.profile, ...userInfo };
|
|
52
|
+
// Also update the MessageChannelSimulator if it exists (setup() has been called)
|
|
53
|
+
if (this.messageChannelSimulator) {
|
|
54
|
+
this.messageChannelSimulator.setUserInfo(this.rimoriInfo.profile);
|
|
55
|
+
}
|
|
56
|
+
this.addSupabaseRoute('/user-info', this.rimoriInfo.profile, { ...options, delay: 0 });
|
|
51
57
|
},
|
|
52
58
|
mockGetPluginInfo: (pluginInfo, options) => {
|
|
53
59
|
this.addSupabaseRoute('/plugin-info', pluginInfo, options);
|
|
@@ -191,23 +197,31 @@ class RimoriTestEnvironment {
|
|
|
191
197
|
},
|
|
192
198
|
};
|
|
193
199
|
this.ai = {
|
|
200
|
+
/**
|
|
201
|
+
* Mocks a text response from the LLM endpoint.
|
|
202
|
+
* Since getText now uses streamObject internally with a { result: string } schema,
|
|
203
|
+
* the mock value should be the full response object.
|
|
204
|
+
*
|
|
205
|
+
* @param values - The response object to return. Should include { result: string } for getText calls.
|
|
206
|
+
* @param options - Optional mock options.
|
|
207
|
+
*/
|
|
194
208
|
mockGetText: (values, options) => {
|
|
195
|
-
|
|
196
|
-
console.warn('mockGetText is not tested');
|
|
197
|
-
this.addBackendRoute('/llm-text', values, options);
|
|
209
|
+
this.addBackendRoute('/ai/llm', values, { ...options, isStreaming: true });
|
|
198
210
|
},
|
|
199
211
|
/**
|
|
200
212
|
* Mocks a streaming text response from the LLM endpoint.
|
|
201
|
-
* The
|
|
213
|
+
* The new rimori-client's getSteamedText uses streamObject internally with { result: string } schema,
|
|
214
|
+
* so the text is wrapped in a result object.
|
|
202
215
|
*
|
|
203
216
|
* **Note**: Due to Playwright's route.fulfill() requiring a complete response body,
|
|
204
217
|
* all SSE chunks are sent at once (no delays). The client will still parse it as SSE correctly.
|
|
205
218
|
*
|
|
206
|
-
* @param text - The text to stream. Will be formatted as SSE
|
|
219
|
+
* @param text - The text to stream. Will be wrapped as { result: text } and formatted as SSE.
|
|
207
220
|
* @param options - Optional mock options.
|
|
208
221
|
*/
|
|
209
222
|
mockGetSteamedText: (text, options) => {
|
|
210
|
-
|
|
223
|
+
// Wrap text in result object as the new client expects { result: string }
|
|
224
|
+
this.addBackendRoute('/ai/llm', { result: text }, { ...options, isStreaming: true });
|
|
211
225
|
},
|
|
212
226
|
mockGetVoice: (values, options) => {
|
|
213
227
|
this.addBackendRoute('/voice/tts', values, options);
|
|
@@ -215,8 +229,25 @@ class RimoriTestEnvironment {
|
|
|
215
229
|
mockGetTextFromVoice: (text, options) => {
|
|
216
230
|
this.addBackendRoute('/voice/stt', text, options);
|
|
217
231
|
},
|
|
232
|
+
/**
|
|
233
|
+
* Mocks an object response from the LLM endpoint.
|
|
234
|
+
* Since getObject now uses streamObject internally, this is a streaming response.
|
|
235
|
+
*
|
|
236
|
+
* @param value - The object to return from the LLM.
|
|
237
|
+
* @param options - Optional mock options.
|
|
238
|
+
*/
|
|
218
239
|
mockGetObject: (value, options) => {
|
|
219
|
-
this.addBackendRoute('/ai/llm
|
|
240
|
+
this.addBackendRoute('/ai/llm', value, { ...options, isStreaming: true });
|
|
241
|
+
},
|
|
242
|
+
/**
|
|
243
|
+
* Mocks a streaming object response from the LLM endpoint.
|
|
244
|
+
* Returns the object via SSE format with data: prefix.
|
|
245
|
+
*
|
|
246
|
+
* @param value - The object to stream from the LLM.
|
|
247
|
+
* @param options - Optional mock options.
|
|
248
|
+
*/
|
|
249
|
+
mockGetStreamedObject: (value, options) => {
|
|
250
|
+
this.addBackendRoute('/ai/llm', value, { ...options, isStreaming: true });
|
|
220
251
|
},
|
|
221
252
|
};
|
|
222
253
|
/**
|
|
@@ -767,6 +798,53 @@ class RimoriTestEnvironment {
|
|
|
767
798
|
mocks.splice(index, 1);
|
|
768
799
|
}
|
|
769
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Creates a wrapper around the Playwright Request object that provides backwards compatibility
|
|
803
|
+
* for matchers. The new rimori-client sends `messages` array instead of `instructions`,
|
|
804
|
+
* so this wrapper extracts the prompts from messages and provides them as `instructions`.
|
|
805
|
+
*
|
|
806
|
+
* The old API had a single `instructions` field which typically contained the user's specific
|
|
807
|
+
* instruction (what the AI should do). The new API splits this into:
|
|
808
|
+
* - systemPrompt (messages[0] with role='system'): High-level behavior instructions
|
|
809
|
+
* - userPrompt (messages[1] with role='user'): Specific task instruction
|
|
810
|
+
*
|
|
811
|
+
* For backwards compatibility, we concatenate all message contents into `instructions`.
|
|
812
|
+
*/
|
|
813
|
+
createBackwardsCompatibleRequest(originalRequest) {
|
|
814
|
+
// Create a proxy that intercepts postDataJSON calls
|
|
815
|
+
return new Proxy(originalRequest, {
|
|
816
|
+
get(target, prop) {
|
|
817
|
+
if (prop === 'postDataJSON') {
|
|
818
|
+
return () => {
|
|
819
|
+
try {
|
|
820
|
+
const body = target.postDataJSON();
|
|
821
|
+
if (body && body.messages && Array.isArray(body.messages) && !body.instructions) {
|
|
822
|
+
// Concatenate all message contents for backwards compatibility
|
|
823
|
+
// This allows matchers to check for text that might be in either system or user prompts
|
|
824
|
+
const allContent = body.messages
|
|
825
|
+
.map((m) => m.content || '')
|
|
826
|
+
.filter((content) => content.length > 0)
|
|
827
|
+
.join('\n');
|
|
828
|
+
if (allContent) {
|
|
829
|
+
return { ...body, instructions: allContent };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return body;
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
// For all other properties, return the original value bound to the target
|
|
840
|
+
const value = target[prop];
|
|
841
|
+
if (typeof value === 'function') {
|
|
842
|
+
return value.bind(target);
|
|
843
|
+
}
|
|
844
|
+
return value;
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
}
|
|
770
848
|
async handleRoute(route, routes) {
|
|
771
849
|
const request = route.request();
|
|
772
850
|
const requestUrl = request.url();
|
|
@@ -780,6 +858,8 @@ class RimoriTestEnvironment {
|
|
|
780
858
|
route.abort('not_found');
|
|
781
859
|
return;
|
|
782
860
|
}
|
|
861
|
+
// Create backwards-compatible request wrapper for matchers
|
|
862
|
+
const compatRequest = this.createBackwardsCompatibleRequest(request);
|
|
783
863
|
// Find the first matching mock based on matcher function
|
|
784
864
|
// Priority: mocks with matchers that match > mocks without matchers (as fallback)
|
|
785
865
|
let matchingMock;
|
|
@@ -787,7 +867,8 @@ class RimoriTestEnvironment {
|
|
|
787
867
|
for (const mock of mocks) {
|
|
788
868
|
if (mock.options?.matcher) {
|
|
789
869
|
try {
|
|
790
|
-
|
|
870
|
+
// Use the backwards-compatible request wrapper for matchers
|
|
871
|
+
if (mock.options.matcher(compatRequest)) {
|
|
791
872
|
matchingMock = mock;
|
|
792
873
|
break;
|
|
793
874
|
}
|
|
@@ -821,10 +902,19 @@ class RimoriTestEnvironment {
|
|
|
821
902
|
if (typeof matchingMock.value === 'function') {
|
|
822
903
|
responseValue = await matchingMock.value(request);
|
|
823
904
|
}
|
|
824
|
-
// Handle streaming responses (for mockGetSteamedText)
|
|
905
|
+
// Handle streaming responses (for mockGetSteamedText and mockGetStreamedObject)
|
|
825
906
|
// Since Playwright requires complete body, we format as SSE without delays
|
|
826
|
-
if (matchingMock.isStreaming
|
|
827
|
-
|
|
907
|
+
if (matchingMock.isStreaming) {
|
|
908
|
+
let body;
|
|
909
|
+
if (typeof responseValue === 'string') {
|
|
910
|
+
// Text streaming (mockGetSteamedText)
|
|
911
|
+
body = this.formatAsSSE(responseValue);
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
// Object streaming (mockGetStreamedObject)
|
|
915
|
+
// Format as SSE with JSON payload, followed by [DONE] marker
|
|
916
|
+
body = `data: ${JSON.stringify(responseValue)}\n\ndata: [DONE]\n\n`;
|
|
917
|
+
}
|
|
828
918
|
return await route.fulfill({
|
|
829
919
|
status: 200,
|
|
830
920
|
headers: { 'Content-Type': 'text/event-stream' },
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.completeOnboarding = completeOnboarding;
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
async function completeOnboarding(page, onboarding, e2ePluginId) {
|
|
6
|
+
console.log(`[E2E] Onboarding user`);
|
|
7
|
+
console.log(`[E2E] E2E plugin ID: ${e2ePluginId}`);
|
|
8
|
+
console.log(`[E2E] Onboarding: ${JSON.stringify(onboarding)}`);
|
|
9
|
+
const url = e2ePluginId ? `/onboarding?flag-e2e-plugin-id=${e2ePluginId}` : `/onboarding`;
|
|
10
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
11
|
+
page.setDefaultTimeout(60000);
|
|
12
|
+
page.setDefaultNavigationTimeout(60000);
|
|
13
|
+
// Ensure we're on onboarding page
|
|
14
|
+
await (0, test_1.expect)(page).toHaveURL(/\/onboarding/);
|
|
15
|
+
// Step 1: Purpose/Long-term goal
|
|
16
|
+
const goalInput = page.locator('textarea, input[type="text"]');
|
|
17
|
+
await goalInput.waitFor({ state: 'visible' });
|
|
18
|
+
await goalInput.click();
|
|
19
|
+
await goalInput.fill("test goal");
|
|
20
|
+
const continueButton = page.getByRole('button', { name: /continue/i });
|
|
21
|
+
await (0, test_1.expect)(continueButton).toBeEnabled({ timeout: 10000 });
|
|
22
|
+
await continueButton.click();
|
|
23
|
+
// Step 2: Motivation type (auto-advances after selection)
|
|
24
|
+
// Wait for the motivation step heading to appear
|
|
25
|
+
const motivationHeading = page.getByText('What motivates you most?');
|
|
26
|
+
await (0, test_1.expect)(motivationHeading).toBeVisible({ timeout: 10000 });
|
|
27
|
+
const motivationOption = page.locator('label').filter({ hasText: '🏆Progress & Accomplishment' });
|
|
28
|
+
await (0, test_1.expect)(motivationOption).toBeVisible({ timeout: 10000 });
|
|
29
|
+
await motivationOption.click();
|
|
30
|
+
// Step 3: Genre preference (auto-advances after selection)
|
|
31
|
+
// Wait for the genre step heading to appear
|
|
32
|
+
const genreHeading = page.getByText('What kind of stories do you like most?');
|
|
33
|
+
await (0, test_1.expect)(genreHeading).toBeVisible({ timeout: 10000 });
|
|
34
|
+
const genreOption = page.locator('label').filter({ hasText: 'Comedy' });
|
|
35
|
+
await (0, test_1.expect)(genreOption).toBeVisible({ timeout: 10000 });
|
|
36
|
+
await genreOption.click();
|
|
37
|
+
// Step 4: Location
|
|
38
|
+
// Wait for the location step to appear
|
|
39
|
+
const countrySelect = page.getByLabel('Country');
|
|
40
|
+
await (0, test_1.expect)(countrySelect).toBeVisible({ timeout: 10000 });
|
|
41
|
+
await countrySelect.selectOption('SE');
|
|
42
|
+
await page.getByLabel('City (optional)').selectOption('Malmö');
|
|
43
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
44
|
+
// Step 5: Wait for setup completion
|
|
45
|
+
await (0, test_1.expect)(page.getByRole('heading', { name: 'Almost there!' })).toBeVisible({ timeout: 10000 });
|
|
46
|
+
await page.waitForURL('**/dashboard', { timeout: 120000 });
|
|
47
|
+
// await page.screenshot({ path: path.join(process.cwd(), 'playwright/dashboard.png') });
|
|
48
|
+
await (0, test_1.expect)(page.getByRole('heading', { name: "Today's Mission" })).toBeVisible({ timeout: 30000 });
|
|
49
|
+
await (0, test_1.expect)(page.getByRole('button', { name: 'Grammar', exact: true })).toBeVisible({ timeout: 60000 });
|
|
50
|
+
await (0, test_1.expect)(page.getByRole('heading', { name: 'Getting Started: Create your first study plan' })).toBeVisible({
|
|
51
|
+
timeout: 60000,
|
|
52
|
+
});
|
|
53
|
+
await (0, test_1.expect)(page.getByText('Train your first flashcard deck', { exact: true })).toBeVisible({ timeout: 200000 });
|
|
54
|
+
await (0, test_1.expect)(page.locator('iframe').contentFrame().getByRole('button', { name: 'Back to Plugins' })).toBeVisible({
|
|
55
|
+
timeout: 250000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Navigates through the study plan getting-started flow on the dashboard.
|
|
4
|
+
* This clicks through the real UI: milestone planning (Submit Topics) and
|
|
5
|
+
* exercise creation (Save Exercises).
|
|
6
|
+
*
|
|
7
|
+
* Expects the page to already be on the dashboard with a "Getting Started" exercise visible.
|
|
8
|
+
*
|
|
9
|
+
* @param page - Playwright page instance, should be on the dashboard
|
|
10
|
+
*/
|
|
11
|
+
export declare function completeStudyPlanGettingStarted(page: Page): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.completeStudyPlanGettingStarted = completeStudyPlanGettingStarted;
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
/**
|
|
6
|
+
* Navigates through the study plan getting-started flow on the dashboard.
|
|
7
|
+
* This clicks through the real UI: milestone planning (Submit Topics) and
|
|
8
|
+
* exercise creation (Save Exercises).
|
|
9
|
+
*
|
|
10
|
+
* Expects the page to already be on the dashboard with a "Getting Started" exercise visible.
|
|
11
|
+
*
|
|
12
|
+
* @param page - Playwright page instance, should be on the dashboard
|
|
13
|
+
*/
|
|
14
|
+
async function completeStudyPlanGettingStarted(page) {
|
|
15
|
+
page.goto('/dashboard');
|
|
16
|
+
await page.waitForTimeout(2000);
|
|
17
|
+
// Step 1: Find and click the Getting Started exercise card
|
|
18
|
+
const card = page.getByText('Getting Started: Create your first study plan', { exact: false });
|
|
19
|
+
await card.waitFor({ timeout: 10000, state: 'visible' }).catch(() => {
|
|
20
|
+
/* not visible within 10s, continue to early return below */
|
|
21
|
+
});
|
|
22
|
+
if (!(await card.isVisible())) {
|
|
23
|
+
page.close();
|
|
24
|
+
console.warn(`[E2E] Getting Started card not found, skipping study plan setup`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const gettingStartedCard = page.getByText('Start Exercise', { exact: false }).first();
|
|
28
|
+
await (0, test_1.expect)(gettingStartedCard).toBeVisible({ timeout: 30000 });
|
|
29
|
+
await gettingStartedCard.click();
|
|
30
|
+
// Wait for the study plan plugin iframe to load
|
|
31
|
+
const iframe = page.locator('iframe').first();
|
|
32
|
+
await (0, test_1.expect)(iframe).toBeVisible({ timeout: 30000 });
|
|
33
|
+
const frame = iframe.contentFrame();
|
|
34
|
+
// Step 2: Milestone Planning Stage
|
|
35
|
+
// Wait for the 3 milestone cards to appear (AI generates them)
|
|
36
|
+
await (0, test_1.expect)(frame.getByText('Week 1', { exact: false })).toBeVisible({ timeout: 180000 });
|
|
37
|
+
await (0, test_1.expect)(frame.getByText('Week 2', { exact: false })).toBeVisible({ timeout: 10000 });
|
|
38
|
+
await (0, test_1.expect)(frame.getByText('Week 3', { exact: false })).toBeVisible({ timeout: 10000 });
|
|
39
|
+
// Wait for "Submit Topics" button to be enabled and click it
|
|
40
|
+
const submitTopicsButton = frame.getByRole('button', { name: /submit topics/i });
|
|
41
|
+
await (0, test_1.expect)(submitTopicsButton).toBeEnabled({ timeout: 180000 });
|
|
42
|
+
await submitTopicsButton.click();
|
|
43
|
+
// Step 3: Exercise Creation Stage
|
|
44
|
+
// Wait for "Save Exercises" button to appear (AI generates all exercises)
|
|
45
|
+
const saveExercisesButton = frame.getByRole('button', { name: /save exercises/i });
|
|
46
|
+
await (0, test_1.expect)(saveExercisesButton).toBeVisible({ timeout: 300000 });
|
|
47
|
+
await (0, test_1.expect)(saveExercisesButton).toBeEnabled({ timeout: 30000 });
|
|
48
|
+
await saveExercisesButton.click();
|
|
49
|
+
// Wait for save to complete (button should disappear or page navigates)
|
|
50
|
+
await (0, test_1.expect)(saveExercisesButton).toBeHidden({ timeout: 30000 });
|
|
51
|
+
// Step 4: Verify completion - should be back on dashboard
|
|
52
|
+
// The "Getting Started" card should be gone and exercises should appear
|
|
53
|
+
await (0, test_1.expect)(page.getByText("Today's Mission", { exact: false })).toBeVisible({ timeout: 30000 });
|
|
54
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -15,3 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./core/RimoriTestEnvironment"), exports);
|
|
18
|
+
__exportStar(require("./core/RimoriE2ETestEnvironment"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimori/playwright-testing",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6-next.1",
|
|
4
4
|
"description": "Playwright testing utilities for Rimori plugins and workers",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -21,13 +21,16 @@
|
|
|
21
21
|
"test:ui": "playwright test --ui",
|
|
22
22
|
"test:headed:debug": "playwright test --headed --debug"
|
|
23
23
|
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"dotenv": "^16.4.5"
|
|
26
|
+
},
|
|
24
27
|
"peerDependencies": {
|
|
25
28
|
"@playwright/test": "^1.40.0",
|
|
26
|
-
"@rimori/client": "
|
|
29
|
+
"@rimori/client": "2.5.12-next.4"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"@playwright/test": "^1.40.0",
|
|
30
|
-
"@rimori/client": "
|
|
33
|
+
"@rimori/client": "2.5.12-next.4",
|
|
31
34
|
"@types/node": "^20.12.7",
|
|
32
35
|
"rimraf": "^5.0.7",
|
|
33
36
|
"typescript": "^5.7.2"
|