sunpeak 0.16.21 → 0.16.27
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 +4 -3
- package/bin/commands/dev.mjs +120 -8
- package/bin/commands/new.mjs +6 -2
- package/bin/commands/start.mjs +7 -2
- package/bin/lib/get-port.mjs +60 -0
- package/bin/lib/live/browser-auth.mjs +161 -0
- package/bin/lib/live/chatgpt-config.d.mts +5 -0
- package/bin/lib/live/chatgpt-config.mjs +12 -0
- package/bin/lib/live/chatgpt-fixtures.d.mts +12 -0
- package/bin/lib/live/chatgpt-fixtures.mjs +25 -0
- package/bin/lib/live/chatgpt-page.mjs +210 -0
- package/bin/lib/live/global-setup.mjs +158 -0
- package/bin/lib/live/host-fixtures.mjs +61 -0
- package/bin/lib/live/host-page.mjs +294 -0
- package/bin/lib/live/live-config.d.mts +38 -0
- package/bin/lib/live/live-config.mjs +98 -0
- package/bin/lib/live/live-fixtures.d.mts +11 -0
- package/bin/lib/live/live-fixtures.mjs +102 -0
- package/bin/lib/live/test-config.d.mts +10 -0
- package/bin/lib/live/test-config.mjs +35 -0
- package/bin/lib/live/types.d.mts +54 -0
- package/bin/lib/live/utils.mjs +70 -0
- package/bin/lib/sandbox-server.mjs +304 -0
- package/bin/sunpeak.js +1 -1
- package/dist/chatgpt/chatgpt-conversation.d.ts +3 -7
- package/dist/chatgpt/globals.css +18 -0
- package/dist/chatgpt/index.cjs +1 -1
- package/dist/chatgpt/index.js +1 -1
- package/dist/claude/claude-conversation.d.ts +3 -2
- package/dist/claude/index.cjs +1 -1
- package/dist/claude/index.js +1 -1
- package/dist/{index-bKBBCBK6.cjs → index-BEWVLFfB.cjs} +2 -2
- package/dist/index-BEWVLFfB.cjs.map +1 -0
- package/dist/{index-CX6Z4bED.js → index-C6XYFOmh.js} +2 -2
- package/dist/index-C6XYFOmh.js.map +1 -0
- package/dist/{index-CKabCJyV.cjs → index-D0FsXP3Y.cjs} +2 -2
- package/dist/index-D0FsXP3Y.cjs.map +1 -0
- package/dist/{index-B4aC3vjH.js → index-Rg7SWjvl.js} +2 -2
- package/dist/index-Rg7SWjvl.js.map +1 -0
- package/dist/index.cjs +13 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/favicon.d.ts +3 -1
- package/dist/mcp/index.cjs +90 -49
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.d.ts +2 -2
- package/dist/mcp/index.js +90 -49
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/production-server.d.ts +7 -1
- package/dist/mcp/types.d.ts +32 -1
- package/dist/simulator/hosts.d.ts +11 -2
- package/dist/simulator/iframe-resource.d.ts +8 -1
- package/dist/simulator/index.cjs +1 -1
- package/dist/simulator/index.js +1 -1
- package/dist/simulator/mcp-app-host.d.ts +17 -0
- package/dist/simulator/sandbox-proxy.d.ts +38 -0
- package/dist/simulator/simple-sidebar.d.ts +3 -1
- package/dist/simulator/simulator.d.ts +7 -1
- package/dist/simulator/use-simulator-state.d.ts +2 -4
- package/dist/{simulator-D8t-r7HH.js → simulator-B-CrMHVs.js} +504 -192
- package/dist/simulator-B-CrMHVs.js.map +1 -0
- package/dist/{simulator-FFNttkqL.cjs → simulator-Gc6n_fT4.cjs} +503 -191
- package/dist/simulator-Gc6n_fT4.cjs.map +1 -0
- package/dist/style.css +18 -0
- package/package.json +25 -1
- package/template/.sunpeak/dev.tsx +9 -3
- package/template/README.md +24 -2
- package/template/_gitignore +1 -0
- package/template/package.json +3 -2
- package/template/playwright.config.ts +34 -6
- package/template/src/server.ts +16 -2
- package/template/src/tools/show-albums.ts +17 -0
- package/template/tests/e2e/albums.spec.ts +37 -5
- package/template/tests/e2e/carousel.spec.ts +6 -6
- package/template/tests/e2e/global-setup.ts +6 -21
- package/template/tests/e2e/map.spec.ts +11 -11
- package/template/tests/e2e/review.spec.ts +24 -24
- package/template/tests/live/albums.spec.ts +53 -0
- package/template/tests/live/carousel.spec.ts +52 -0
- package/template/tests/live/map.spec.ts +31 -0
- package/template/tests/live/playwright.config.ts +3 -0
- package/template/tests/live/review.spec.ts +54 -0
- package/template/vitest.config.ts +1 -1
- package/dist/index-B4aC3vjH.js.map +0 -1
- package/dist/index-CKabCJyV.cjs.map +0 -1
- package/dist/index-CX6Z4bED.js.map +0 -1
- package/dist/index-bKBBCBK6.cjs.map +0 -1
- package/dist/simulator-D8t-r7HH.js.map +0 -1
- package/dist/simulator-FFNttkqL.cjs.map +0 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for host page objects (ChatGPT, Claude, etc.).
|
|
3
|
+
*
|
|
4
|
+
* Each host subclass provides:
|
|
5
|
+
* - selectors: DOM selectors for the host's UI
|
|
6
|
+
* - urls: host-specific URLs
|
|
7
|
+
* - Host-specific overrides for login detection, MCP refresh, iframe access
|
|
8
|
+
*
|
|
9
|
+
* Shared behavior (sendMessage flow, screenshot debugging, selector health checks)
|
|
10
|
+
* lives here and is inherited by all hosts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} HostSelectors
|
|
15
|
+
* @property {string} chatInput - Chat input field
|
|
16
|
+
* @property {string} sendButton - Send message button
|
|
17
|
+
* @property {string} newChatLink - "New chat" link/button
|
|
18
|
+
* @property {string} loggedInIndicator - Element visible when logged in
|
|
19
|
+
* @property {string} loginPage - Element visible on login page
|
|
20
|
+
* @property {string} stopButton - Streaming stop button
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} HostUrls
|
|
25
|
+
* @property {string} base - Host base URL (e.g., 'https://chatgpt.com')
|
|
26
|
+
* @property {string} settings - MCP settings URL
|
|
27
|
+
* @property {string} loginTestId - Test ID for login detection (if using getByTestId)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export class HostPage {
|
|
31
|
+
/**
|
|
32
|
+
* @param {import('playwright').Page} page
|
|
33
|
+
*/
|
|
34
|
+
constructor(page) {
|
|
35
|
+
this.page = page;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @returns {string} Host identifier ('chatgpt' | 'claude') */
|
|
39
|
+
get hostId() {
|
|
40
|
+
throw new Error('Subclass must implement hostId');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns {string} Host display name */
|
|
44
|
+
get hostName() {
|
|
45
|
+
throw new Error('Subclass must implement hostName');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @returns {HostSelectors} */
|
|
49
|
+
get selectors() {
|
|
50
|
+
throw new Error('Subclass must implement selectors');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {HostUrls} */
|
|
54
|
+
get urls() {
|
|
55
|
+
throw new Error('Subclass must implement urls');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check that key selectors still resolve on the current page.
|
|
60
|
+
* Logs warnings instead of failing so tests can still attempt to run.
|
|
61
|
+
*/
|
|
62
|
+
async checkSelectorsHealth() {
|
|
63
|
+
const criticalSelectors = [
|
|
64
|
+
['chatInput', this.selectors.chatInput],
|
|
65
|
+
['loggedInIndicator', this.selectors.loggedInIndicator],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const warnings = [];
|
|
69
|
+
for (const [name, selector] of criticalSelectors) {
|
|
70
|
+
try {
|
|
71
|
+
const count = await this.page.locator(selector).first().count();
|
|
72
|
+
if (count === 0) {
|
|
73
|
+
warnings.push(` "${name}" selector not found: ${selector}`);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
warnings.push(` "${name}" selector error: ${selector}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (warnings.length > 0) {
|
|
81
|
+
console.warn(
|
|
82
|
+
`\n⚠️ ${this.hostName} DOM may have changed — update selectors in ${this.hostId}-page.mjs:\n` +
|
|
83
|
+
warnings.join('\n') + '\n'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return warnings.length === 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if truly logged in: profile button visible AND no "Log in" buttons.
|
|
92
|
+
* The logged-out page can show UI elements that look like a logged-in state
|
|
93
|
+
* (e.g., sidebar with profile-like elements), so checking just the profile
|
|
94
|
+
* button isn't enough.
|
|
95
|
+
*/
|
|
96
|
+
async _isFullyLoggedIn() {
|
|
97
|
+
const hasProfile = await this.page
|
|
98
|
+
.locator(this.selectors.loggedInIndicator)
|
|
99
|
+
.first()
|
|
100
|
+
.isVisible()
|
|
101
|
+
.catch(() => false);
|
|
102
|
+
|
|
103
|
+
if (!hasProfile) return false;
|
|
104
|
+
|
|
105
|
+
const hasLoginButton = await this.page
|
|
106
|
+
.locator(this.selectors.loginPage)
|
|
107
|
+
.first()
|
|
108
|
+
.isVisible()
|
|
109
|
+
.catch(() => false);
|
|
110
|
+
|
|
111
|
+
return !hasLoginButton;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Verify the user is logged into the host.
|
|
116
|
+
* Navigates to the host if not already there.
|
|
117
|
+
*
|
|
118
|
+
* If not logged in, waits up to 3 minutes for the user to complete login
|
|
119
|
+
* in the open browser window, polling every 5 seconds. This handles the
|
|
120
|
+
* case where storageState doesn't capture Cloudflare's HttpOnly cookies
|
|
121
|
+
* and the browser needs a fresh login.
|
|
122
|
+
*/
|
|
123
|
+
async verifyLoggedIn() {
|
|
124
|
+
const url = this.page.url();
|
|
125
|
+
if (!url.includes(new URL(this.urls.base).hostname)) {
|
|
126
|
+
await this.page.goto(this.urls.base, { waitUntil: 'domcontentloaded' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Wait for the page to settle (Cloudflare challenge or UI loading)
|
|
130
|
+
await this.page.waitForTimeout(5_000);
|
|
131
|
+
|
|
132
|
+
// Quick check: truly logged in? (profile button AND no "Log in" buttons)
|
|
133
|
+
if (await this._isFullyLoggedIn()) return;
|
|
134
|
+
|
|
135
|
+
// Not logged in. Wait for the user to authenticate in this browser window.
|
|
136
|
+
console.log(
|
|
137
|
+
`\n` +
|
|
138
|
+
`╔══════════════════════════════════════════════════════════════╗\n` +
|
|
139
|
+
`║ Not logged into ${this.hostName.padEnd(42)}║\n` +
|
|
140
|
+
`║ ║\n` +
|
|
141
|
+
`║ Please log in at: ${this.urls.base.padEnd(39)}║\n` +
|
|
142
|
+
`║ in the browser window that just opened. ║\n` +
|
|
143
|
+
`║ ║\n` +
|
|
144
|
+
`║ Waiting up to 3 minutes... ║\n` +
|
|
145
|
+
`╚══════════════════════════════════════════════════════════════╝\n`
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Poll for login — the user may need to pass Cloudflare + enter credentials
|
|
149
|
+
const maxWait = 180_000; // 3 minutes
|
|
150
|
+
const pollInterval = 5_000;
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
|
|
153
|
+
while (Date.now() - start < maxWait) {
|
|
154
|
+
if (await this._isFullyLoggedIn()) {
|
|
155
|
+
console.log(`Logged into ${this.hostName}!\n`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await this.page.waitForTimeout(pollInterval);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Login to ${this.hostName} timed out after 3 minutes.\n` +
|
|
163
|
+
`Please log in at ${this.urls.base} in the browser window and try again.\n` +
|
|
164
|
+
'If the session expired, delete the .auth/ directory and try again.'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Refresh the MCP server connection in host settings.
|
|
170
|
+
* Subclasses must implement this — each host has different settings UI.
|
|
171
|
+
*
|
|
172
|
+
* @param {Object} [options]
|
|
173
|
+
* @param {string} [options.tunnelUrl] - Tunnel URL for error messages
|
|
174
|
+
* @param {string} [options.appName] - App name as configured in the host
|
|
175
|
+
*/
|
|
176
|
+
async refreshMcpServer(_options) {
|
|
177
|
+
throw new Error(`${this.hostName} refreshMcpServer not implemented`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Start a new chat conversation.
|
|
182
|
+
*/
|
|
183
|
+
async startNewChat() {
|
|
184
|
+
const newChatLink = this.page.locator(this.selectors.newChatLink).first();
|
|
185
|
+
const isVisible = await newChatLink.isVisible().catch(() => false);
|
|
186
|
+
if (isVisible) {
|
|
187
|
+
await newChatLink.click();
|
|
188
|
+
} else {
|
|
189
|
+
await this.page.goto(this.urls.base, { waitUntil: 'domcontentloaded' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await this.page.locator(this.selectors.chatInput).waitFor({ timeout: 10_000 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send a message in the current chat.
|
|
197
|
+
* @param {string} text - The message to send
|
|
198
|
+
*/
|
|
199
|
+
async sendMessage(text) {
|
|
200
|
+
const input = this.page.locator(this.selectors.chatInput);
|
|
201
|
+
await input.waitFor({ timeout: 10_000 });
|
|
202
|
+
await input.click();
|
|
203
|
+
|
|
204
|
+
// Use keyboard typing — host React textareas often don't respond to fill()
|
|
205
|
+
await input.pressSequentially(text, { delay: 10 });
|
|
206
|
+
|
|
207
|
+
const sendBtn = this.page.locator(this.selectors.sendButton);
|
|
208
|
+
await sendBtn.waitFor({ state: 'visible', timeout: 10_000 });
|
|
209
|
+
await sendBtn.click();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Wait for a MCP app iframe to appear in the conversation.
|
|
214
|
+
* Subclasses must implement this — each host renders iframes differently.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} [options]
|
|
217
|
+
* @param {number} [options.timeout=90000] - Max time to wait (ms)
|
|
218
|
+
* @returns {Promise<import('playwright').FrameLocator>}
|
|
219
|
+
*/
|
|
220
|
+
async waitForAppIframe(_options) {
|
|
221
|
+
throw new Error(`${this.hostName} waitForAppIframe not implemented`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get the app iframe FrameLocator.
|
|
226
|
+
* @returns {import('playwright').FrameLocator}
|
|
227
|
+
*/
|
|
228
|
+
getAppIframe() {
|
|
229
|
+
throw new Error(`${this.hostName} getAppIframe not implemented`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Capture a debug screenshot and throw with helpful message.
|
|
234
|
+
* @param {string} context - Context label for the screenshot filename
|
|
235
|
+
* @param {string} [tunnelUrl] - Tunnel URL for the error message
|
|
236
|
+
* @protected
|
|
237
|
+
*/
|
|
238
|
+
async _screenshotAndThrow(context, tunnelUrl) {
|
|
239
|
+
const screenshotPath = `/tmp/sunpeak-live-debug-${this.hostId}-${context}.png`;
|
|
240
|
+
try {
|
|
241
|
+
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
242
|
+
console.error(`\nDebug screenshot saved to: ${screenshotPath}`);
|
|
243
|
+
} catch {
|
|
244
|
+
// Screenshot failed — continue with the error
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const buttons = await this.page.locator('button').allTextContents();
|
|
249
|
+
console.error('Visible buttons on page:', buttons.filter(t => t.trim()).join(', '));
|
|
250
|
+
} catch {
|
|
251
|
+
// Best effort
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Could not find Refresh/Reconnect button in ${this.hostName} settings.\n` +
|
|
256
|
+
`Make sure your MCP server is added in ${this.hostName} settings` +
|
|
257
|
+
(tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
|
|
258
|
+
`\n\nDebug screenshot: ${screenshotPath}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Wait for a toast/alert banner and check for errors.
|
|
264
|
+
* Many hosts show success/error toasts after settings actions.
|
|
265
|
+
* @param {Object} [options]
|
|
266
|
+
* @param {string} [options.alertSelector='[role="alert"]'] - Selector for toast elements
|
|
267
|
+
* @param {number} [options.timeout=30000] - Max time to wait
|
|
268
|
+
* @param {number} [options.minTextLength=5] - Minimum text length to consider as a real toast
|
|
269
|
+
* @protected
|
|
270
|
+
*/
|
|
271
|
+
async _waitForToast({ alertSelector = '[role="alert"]', timeout = 30_000, minTextLength = 5 } = {}) {
|
|
272
|
+
try {
|
|
273
|
+
await this.page.waitForFunction(
|
|
274
|
+
({ selector, minLen }) => {
|
|
275
|
+
const alerts = document.querySelectorAll(selector);
|
|
276
|
+
for (const alert of alerts) {
|
|
277
|
+
const text = alert.textContent?.trim();
|
|
278
|
+
if (text && text.length > minLen) return true;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
},
|
|
282
|
+
{ selector: alertSelector, minLen: minTextLength },
|
|
283
|
+
{ timeout },
|
|
284
|
+
);
|
|
285
|
+
} catch {
|
|
286
|
+
console.warn('No toast detected — assuming success.');
|
|
287
|
+
return { texts: [], hasError: false };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const texts = await this.page.locator(alertSelector).allTextContents();
|
|
291
|
+
const errorText = texts.find((t) => /error/i.test(t));
|
|
292
|
+
return { texts, hasError: !!errorText, errorText };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PlaywrightTestConfig } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export interface LiveConfigOptions {
|
|
4
|
+
/** Test directory relative to playwright.config.ts. Default: '.' */
|
|
5
|
+
testDir?: string;
|
|
6
|
+
/** Directory for auth state. Default: '{testDir}/.auth' */
|
|
7
|
+
authDir?: string;
|
|
8
|
+
/** Port for the Vite dev server. MCP server always uses 8000. Default: 3456 */
|
|
9
|
+
vitePort?: number;
|
|
10
|
+
|
|
11
|
+
// --- Browser environment ---
|
|
12
|
+
|
|
13
|
+
/** Emulate light or dark mode (sets prefers-color-scheme). The host follows this for theming. */
|
|
14
|
+
colorScheme?: 'light' | 'dark';
|
|
15
|
+
/** Browser viewport size. Default: Playwright's default (1280x720). */
|
|
16
|
+
viewport?: { width: number; height: number };
|
|
17
|
+
/** Browser locale (e.g., 'en-US', 'fr-FR'). */
|
|
18
|
+
locale?: string;
|
|
19
|
+
/** IANA timezone ID (e.g., 'America/New_York', 'Europe/London'). */
|
|
20
|
+
timezoneId?: string;
|
|
21
|
+
/** Emulate geolocation coordinates. */
|
|
22
|
+
geolocation?: { latitude: number; longitude: number };
|
|
23
|
+
/** Browser permissions to grant (e.g., ['geolocation']). */
|
|
24
|
+
permissions?: string[];
|
|
25
|
+
|
|
26
|
+
/** Additional Playwright `use` options, merged with defaults. */
|
|
27
|
+
use?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HostConfigOptions {
|
|
31
|
+
hostId: string;
|
|
32
|
+
authFileName?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export declare function createLiveConfig(
|
|
36
|
+
hostOptions: HostConfigOptions,
|
|
37
|
+
options?: LiveConfigOptions,
|
|
38
|
+
): PlaywrightTestConfig;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Playwright config factory for live tests across all hosts.
|
|
3
|
+
*
|
|
4
|
+
* Each host (ChatGPT, Claude) calls createLiveConfig() with its host-specific
|
|
5
|
+
* settings (project name, auth file name). Browser environment options
|
|
6
|
+
* (colorScheme, viewport, locale, etc.) are shared across all hosts.
|
|
7
|
+
*
|
|
8
|
+
* Host-specific configs re-export as defineLiveConfig() for user convenience.
|
|
9
|
+
*/
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { ANTI_BOT_ARGS, CHROME_USER_AGENT } from './utils.mjs';
|
|
13
|
+
import { getPortSync } from '../get-port.mjs';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const GLOBAL_SETUP_PATH = join(__dirname, 'global-setup.mjs');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a Playwright config for live testing against a specific host.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} hostOptions - Host-specific settings
|
|
22
|
+
* @param {string} hostOptions.hostId - Host identifier (e.g., 'chatgpt', 'claude')
|
|
23
|
+
* @param {string} [hostOptions.authFileName] - Auth state filename (default: '{hostId}.json')
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} [options] - User-facing options
|
|
26
|
+
* @param {string} [options.testDir='.'] - Test directory (relative to playwright.config.ts)
|
|
27
|
+
* @param {string} [options.authDir] - Directory for auth state (defaults to {testDir}/.auth)
|
|
28
|
+
* @param {number} [options.vitePort] - Port for the Vite dev server (defaults to a free port near 3456)
|
|
29
|
+
* @param {'light'|'dark'} [options.colorScheme] - Emulate light or dark mode (prefers-color-scheme)
|
|
30
|
+
* @param {{ width: number, height: number }} [options.viewport] - Browser viewport size
|
|
31
|
+
* @param {string} [options.locale] - Browser locale (e.g., 'en-US', 'fr-FR')
|
|
32
|
+
* @param {string} [options.timezoneId] - Timezone (e.g., 'America/New_York')
|
|
33
|
+
* @param {{ latitude: number, longitude: number }} [options.geolocation] - Geolocation coordinates
|
|
34
|
+
* @param {string[]} [options.permissions] - Browser permissions to grant (e.g., ['geolocation'])
|
|
35
|
+
* @param {Object} [options.use] - Additional Playwright `use` options (merged with defaults)
|
|
36
|
+
*/
|
|
37
|
+
export function createLiveConfig(hostOptions, options = {}) {
|
|
38
|
+
const { hostId, authFileName } = hostOptions;
|
|
39
|
+
const {
|
|
40
|
+
testDir = '.',
|
|
41
|
+
authDir,
|
|
42
|
+
vitePort = getPortSync(3456),
|
|
43
|
+
colorScheme,
|
|
44
|
+
viewport,
|
|
45
|
+
locale,
|
|
46
|
+
timezoneId,
|
|
47
|
+
geolocation,
|
|
48
|
+
permissions,
|
|
49
|
+
use: userUse,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
const resolvedAuthDir = authDir || join(testDir, '.auth');
|
|
53
|
+
const authFile = join(process.cwd(), resolvedAuthDir, authFileName || `${hostId}.json`);
|
|
54
|
+
|
|
55
|
+
// Pass auth file path to global setup via env var (Playwright runs globalSetup
|
|
56
|
+
// in a separate worker, so we can't pass it as a function argument).
|
|
57
|
+
process.env.SUNPEAK_AUTH_FILE = authFile;
|
|
58
|
+
|
|
59
|
+
// Only include browser env keys that were actually passed so Playwright uses its defaults.
|
|
60
|
+
const browserEnv = {
|
|
61
|
+
...(colorScheme && { colorScheme }),
|
|
62
|
+
...(viewport && { viewport }),
|
|
63
|
+
...(locale && { locale }),
|
|
64
|
+
...(timezoneId && { timezoneId }),
|
|
65
|
+
...(geolocation && { geolocation }),
|
|
66
|
+
...(permissions && { permissions }),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
testDir,
|
|
71
|
+
globalSetup: GLOBAL_SETUP_PATH,
|
|
72
|
+
timeout: 120_000, // 2 minutes per test — LLM responses can be slow
|
|
73
|
+
retries: 1, // One retry for LLM non-determinism
|
|
74
|
+
fullyParallel: true, // Each test gets its own chat — safe to parallelize
|
|
75
|
+
reporter: 'list',
|
|
76
|
+
use: {
|
|
77
|
+
headless: false,
|
|
78
|
+
storageState: process.env.SUNPEAK_STORAGE_STATE || authFile,
|
|
79
|
+
userAgent: CHROME_USER_AGENT,
|
|
80
|
+
launchOptions: {
|
|
81
|
+
args: ANTI_BOT_ARGS,
|
|
82
|
+
},
|
|
83
|
+
...browserEnv,
|
|
84
|
+
...userUse,
|
|
85
|
+
},
|
|
86
|
+
projects: [
|
|
87
|
+
{
|
|
88
|
+
name: hostId,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
webServer: {
|
|
92
|
+
command: `SUNPEAK_LIVE_TEST=1 SUNPEAK_SANDBOX_PORT=${getPortSync(24680)} pnpm dev -- --prod-resources --port ${vitePort}`,
|
|
93
|
+
url: `http://localhost:${vitePort}/health`,
|
|
94
|
+
reuseExistingServer: !process.env.CI,
|
|
95
|
+
timeout: 60_000,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LiveFixture } from './types.d.mts';
|
|
2
|
+
|
|
3
|
+
export type { LiveFixture } from './types.d.mts';
|
|
4
|
+
|
|
5
|
+
export declare const test: {
|
|
6
|
+
(title: string, fn: (args: { live: LiveFixture } & Record<string, any>) => Promise<void>): void;
|
|
7
|
+
describe: (title: string, fn: () => void) => void;
|
|
8
|
+
skip: (title: string, fn: (args: { live: LiveFixture } & Record<string, any>) => Promise<void>) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export declare const expect: (...args: any[]) => any;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic Playwright fixtures for live testing.
|
|
3
|
+
*
|
|
4
|
+
* Users import from 'sunpeak/test' and get a `live` fixture that
|
|
5
|
+
* automatically resolves the correct host page object based on the
|
|
6
|
+
* Playwright project name. Adding a new host never changes user imports.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { test, expect } from 'sunpeak/test';
|
|
10
|
+
*
|
|
11
|
+
* test('my resource renders', async ({ live }) => {
|
|
12
|
+
* const app = await live.invoke('show me something');
|
|
13
|
+
* await expect(app.locator('img').first()).toBeVisible();
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
import { resolvePlaywrightESM, getAppName } from './utils.mjs';
|
|
17
|
+
|
|
18
|
+
const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
|
|
19
|
+
const { test: base, expect } = await resolvePlaywrightESM(projectRoot);
|
|
20
|
+
const appName = getAppName(projectRoot);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registry of host page classes and their message formatters.
|
|
24
|
+
* Classes are lazy-loaded and cached to avoid importing all hosts when only one is used.
|
|
25
|
+
*/
|
|
26
|
+
const HOST_REGISTRY = {
|
|
27
|
+
chatgpt: {
|
|
28
|
+
_cached: null,
|
|
29
|
+
load() {
|
|
30
|
+
this._cached ??= import('./chatgpt-page.mjs').then((m) => m.ChatGPTPage);
|
|
31
|
+
return this._cached;
|
|
32
|
+
},
|
|
33
|
+
formatMessage: (name, text) => `/${name} ${text}`,
|
|
34
|
+
},
|
|
35
|
+
// Future: claude: { _cached: null, load() { ... }, formatMessage: ... }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the host ID from the current Playwright project name.
|
|
40
|
+
* Project names match the host ID directly (e.g., 'chatgpt').
|
|
41
|
+
*/
|
|
42
|
+
function resolveHostId(projectName) {
|
|
43
|
+
if (!projectName) return 'chatgpt';
|
|
44
|
+
for (const hostId of Object.keys(HOST_REGISTRY)) {
|
|
45
|
+
if (projectName.startsWith(hostId)) return hostId;
|
|
46
|
+
}
|
|
47
|
+
return 'chatgpt';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const test = base.extend({
|
|
51
|
+
live: async ({ page }, use, testInfo) => {
|
|
52
|
+
const hostId = resolveHostId(testInfo.project.name);
|
|
53
|
+
const hostEntry = HOST_REGISTRY[hostId];
|
|
54
|
+
if (!hostEntry) {
|
|
55
|
+
throw new Error(`Unknown live test host: "${hostId}". Supported: ${Object.keys(HOST_REGISTRY).join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HostPageClass = await hostEntry.load();
|
|
59
|
+
const hostPage = new HostPageClass(page);
|
|
60
|
+
await hostPage.verifyLoggedIn();
|
|
61
|
+
|
|
62
|
+
const { formatMessage } = hostEntry;
|
|
63
|
+
const fixture = Object.create(hostPage);
|
|
64
|
+
fixture.page = page;
|
|
65
|
+
|
|
66
|
+
if (formatMessage) {
|
|
67
|
+
fixture.sendMessage = async (text) => hostPage.sendMessage(formatMessage(appName, text));
|
|
68
|
+
}
|
|
69
|
+
fixture.sendRawMessage = hostPage.sendMessage.bind(hostPage);
|
|
70
|
+
fixture.invoke = async (prompt, options) => {
|
|
71
|
+
await hostPage.startNewChat();
|
|
72
|
+
const message = formatMessage ? formatMessage(appName, prompt) : prompt;
|
|
73
|
+
await hostPage.sendMessage(message);
|
|
74
|
+
return hostPage.waitForAppIframe(options);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Switch the browser's color scheme and wait for the app to apply the new theme.
|
|
79
|
+
* Use this to test both light and dark mode within a single test after invoke(),
|
|
80
|
+
* avoiding a second tool invocation and resource refresh.
|
|
81
|
+
*
|
|
82
|
+
* @param {'light'|'dark'} scheme
|
|
83
|
+
* @param {object} [appFrame] - FrameLocator returned by invoke(). When provided,
|
|
84
|
+
* waits for data-theme on the app's <html> to confirm the theme propagated.
|
|
85
|
+
*/
|
|
86
|
+
fixture.setColorScheme = async (scheme, appFrame) => {
|
|
87
|
+
await page.emulateMedia({ colorScheme: scheme });
|
|
88
|
+
if (appFrame) {
|
|
89
|
+
try {
|
|
90
|
+
await appFrame.locator(`html[data-theme="${scheme}"]`).waitFor({ timeout: 10_000 });
|
|
91
|
+
} catch {
|
|
92
|
+
// App may not set data-theme; fall back to a short settle wait
|
|
93
|
+
await page.waitForTimeout(1_500);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
await use(fixture);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export { test, expect };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PlaywrightTestConfig } from '@playwright/test';
|
|
2
|
+
import type { LiveConfigOptions } from './live-config.d.mts';
|
|
3
|
+
|
|
4
|
+
export interface TestConfigOptions extends LiveConfigOptions {
|
|
5
|
+
/** Hosts to test against. Default: ['chatgpt'] */
|
|
6
|
+
hosts?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Create a complete Playwright config with one project per host. */
|
|
10
|
+
export declare function defineLiveConfig(options?: TestConfigOptions): PlaywrightTestConfig;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic Playwright config factory for live tests.
|
|
3
|
+
*
|
|
4
|
+
* Generates one Playwright project per host. Tests switch color scheme,
|
|
5
|
+
* viewport, and other host state internally via live.setColorScheme() and
|
|
6
|
+
* live.page so each resource is only invoked once per host.
|
|
7
|
+
*
|
|
8
|
+
* Usage in playwright.config.ts:
|
|
9
|
+
* import { defineLiveConfig } from 'sunpeak/test/config';
|
|
10
|
+
* export default defineLiveConfig(); // ChatGPT
|
|
11
|
+
* export default defineLiveConfig({ hosts: ['chatgpt', 'claude'] }); // Both hosts
|
|
12
|
+
*/
|
|
13
|
+
import { createLiveConfig } from './live-config.mjs';
|
|
14
|
+
|
|
15
|
+
/** Default hosts to test against. */
|
|
16
|
+
const DEFAULT_HOSTS = ['chatgpt'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a complete Playwright config with one project per host.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {string[]} [options.hosts=['chatgpt']] - Hosts to test against
|
|
23
|
+
* @param {import('./live-config.d.mts').LiveConfigOptions} [options] - All other options passed to createLiveConfig
|
|
24
|
+
*/
|
|
25
|
+
export function defineLiveConfig(options = {}) {
|
|
26
|
+
const { hosts = DEFAULT_HOSTS, ...configOptions } = options;
|
|
27
|
+
|
|
28
|
+
// Use the first host for the base config (shared settings like webServer, globalSetup)
|
|
29
|
+
const baseConfig = createLiveConfig({ hostId: hosts[0] }, configOptions);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...baseConfig,
|
|
33
|
+
projects: hosts.map((host) => ({ name: host })),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Playwright Locator subset used in live test assertions. */
|
|
2
|
+
export interface Locator {
|
|
3
|
+
first(): Locator;
|
|
4
|
+
nth(index: number): Locator;
|
|
5
|
+
last(): Locator;
|
|
6
|
+
count(): Promise<number>;
|
|
7
|
+
click(options?: Record<string, any>): Promise<void>;
|
|
8
|
+
isVisible(options?: Record<string, any>): Promise<boolean>;
|
|
9
|
+
waitFor(options?: Record<string, any>): Promise<void>;
|
|
10
|
+
evaluate<R>(fn: (el: HTMLElement) => R): Promise<R>;
|
|
11
|
+
textContent(): Promise<string | null>;
|
|
12
|
+
innerText(): Promise<string>;
|
|
13
|
+
getAttribute(name: string): Promise<string | null>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Playwright FrameLocator subset used in live test assertions. */
|
|
17
|
+
export interface FrameLocator {
|
|
18
|
+
locator(selector: string): Locator;
|
|
19
|
+
getByText(text: string | RegExp, options?: Record<string, any>): Locator;
|
|
20
|
+
getByRole(role: string, options?: Record<string, any>): Locator;
|
|
21
|
+
getByTestId(testId: string): Locator;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Common fixture interface shared by all host-specific and generic live fixtures. */
|
|
25
|
+
export interface LiveFixture {
|
|
26
|
+
/**
|
|
27
|
+
* The underlying Playwright Page. Exposed for advanced host-state changes
|
|
28
|
+
* (e.g., page.setViewportSize(), page.emulateMedia()) that don't have
|
|
29
|
+
* dedicated fixture helpers yet.
|
|
30
|
+
*/
|
|
31
|
+
page: import('@playwright/test').Page;
|
|
32
|
+
/** Start a new chat, send the prompt, and return the app FrameLocator. */
|
|
33
|
+
invoke(prompt: string, options?: { timeout?: number }): Promise<FrameLocator>;
|
|
34
|
+
/** Start a new conversation. */
|
|
35
|
+
startNewChat(): Promise<void>;
|
|
36
|
+
/** Send a message (with host-appropriate formatting). */
|
|
37
|
+
sendMessage(text: string): Promise<void>;
|
|
38
|
+
/** Send a message without any prefix. */
|
|
39
|
+
sendRawMessage(text: string): Promise<void>;
|
|
40
|
+
/** Wait for the MCP app iframe to render and return a FrameLocator. */
|
|
41
|
+
waitForAppIframe(options?: { timeout?: number }): Promise<FrameLocator>;
|
|
42
|
+
/** Get the app iframe FrameLocator. */
|
|
43
|
+
getAppIframe(): FrameLocator;
|
|
44
|
+
/**
|
|
45
|
+
* Switch the browser's color scheme and wait for the app to apply the new theme.
|
|
46
|
+
* Use this to test both light and dark mode within a single test after invoke(),
|
|
47
|
+
* avoiding a second tool invocation and resource refresh.
|
|
48
|
+
*
|
|
49
|
+
* @param scheme - 'light' or 'dark'
|
|
50
|
+
* @param appFrame - FrameLocator returned by invoke(). When provided, waits for
|
|
51
|
+
* data-theme on the app's <html> element to confirm the theme propagated.
|
|
52
|
+
*/
|
|
53
|
+
setColorScheme(scheme: 'light' | 'dark', appFrame?: FrameLocator): Promise<void>;
|
|
54
|
+
}
|