sunpeak 0.16.21 → 0.16.24

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.
Files changed (68) hide show
  1. package/README.md +4 -3
  2. package/bin/commands/dev.mjs +22 -3
  3. package/bin/commands/new.mjs +6 -2
  4. package/bin/commands/start.mjs +4 -0
  5. package/bin/lib/get-port.mjs +60 -0
  6. package/bin/lib/live/browser-auth.mjs +125 -0
  7. package/bin/lib/live/chatgpt-config.d.mts +5 -0
  8. package/bin/lib/live/chatgpt-config.mjs +12 -0
  9. package/bin/lib/live/chatgpt-fixtures.d.mts +12 -0
  10. package/bin/lib/live/chatgpt-fixtures.mjs +25 -0
  11. package/bin/lib/live/chatgpt-page.mjs +210 -0
  12. package/bin/lib/live/global-setup.mjs +150 -0
  13. package/bin/lib/live/host-fixtures.mjs +61 -0
  14. package/bin/lib/live/host-page.mjs +242 -0
  15. package/bin/lib/live/live-config.d.mts +38 -0
  16. package/bin/lib/live/live-config.mjs +98 -0
  17. package/bin/lib/live/live-fixtures.d.mts +11 -0
  18. package/bin/lib/live/live-fixtures.mjs +102 -0
  19. package/bin/lib/live/test-config.d.mts +10 -0
  20. package/bin/lib/live/test-config.mjs +35 -0
  21. package/bin/lib/live/types.d.mts +54 -0
  22. package/bin/lib/live/utils.mjs +70 -0
  23. package/bin/sunpeak.js +1 -1
  24. package/dist/chatgpt/index.cjs +1 -1
  25. package/dist/chatgpt/index.js +1 -1
  26. package/dist/claude/index.cjs +1 -1
  27. package/dist/claude/index.js +1 -1
  28. package/dist/{index-CX6Z4bED.js → index-B7Qw3Vhh.js} +2 -2
  29. package/dist/index-B7Qw3Vhh.js.map +1 -0
  30. package/dist/{index-B4aC3vjH.js → index-BEHP_bM8.js} +2 -2
  31. package/dist/index-BEHP_bM8.js.map +1 -0
  32. package/dist/{index-bKBBCBK6.cjs → index-SfudQ9Y_.cjs} +2 -2
  33. package/dist/index-SfudQ9Y_.cjs.map +1 -0
  34. package/dist/{index-CKabCJyV.cjs → index-XKHXfBiD.cjs} +2 -2
  35. package/dist/index-XKHXfBiD.cjs.map +1 -0
  36. package/dist/index.cjs +13 -5
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.js +13 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/mcp/index.cjs +38 -13
  41. package/dist/mcp/index.cjs.map +1 -1
  42. package/dist/mcp/index.js +38 -13
  43. package/dist/mcp/index.js.map +1 -1
  44. package/dist/mcp/types.d.ts +2 -0
  45. package/dist/simulator/index.cjs +1 -1
  46. package/dist/simulator/index.js +1 -1
  47. package/dist/simulator/simple-sidebar.d.ts +3 -1
  48. package/dist/{simulator-D8t-r7HH.js → simulator-BCq2iOT-.js} +67 -27
  49. package/dist/simulator-BCq2iOT-.js.map +1 -0
  50. package/dist/{simulator-FFNttkqL.cjs → simulator-DRUsm6IZ.cjs} +67 -27
  51. package/dist/simulator-DRUsm6IZ.cjs.map +1 -0
  52. package/package.json +25 -1
  53. package/template/README.md +24 -2
  54. package/template/_gitignore +1 -0
  55. package/template/package.json +3 -2
  56. package/template/playwright.config.ts +24 -1
  57. package/template/tests/live/albums.spec.ts +53 -0
  58. package/template/tests/live/carousel.spec.ts +52 -0
  59. package/template/tests/live/map.spec.ts +31 -0
  60. package/template/tests/live/playwright.config.ts +3 -0
  61. package/template/tests/live/review.spec.ts +54 -0
  62. package/template/vitest.config.ts +1 -1
  63. package/dist/index-B4aC3vjH.js.map +0 -1
  64. package/dist/index-CKabCJyV.cjs.map +0 -1
  65. package/dist/index-CX6Z4bED.js.map +0 -1
  66. package/dist/index-bKBBCBK6.cjs.map +0 -1
  67. package/dist/simulator-D8t-r7HH.js.map +0 -1
  68. package/dist/simulator-FFNttkqL.cjs.map +0 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Global setup for live tests.
3
+ *
4
+ * Runs exactly once before all workers. Two responsibilities:
5
+ * 1. Authenticate — import cookies from the user's real browser or open a
6
+ * manual login window. Session state is saved to disk for 24 hours.
7
+ * 2. Refresh MCP server — navigate to host settings and click Refresh so
8
+ * all workers start with pre-loaded resources.
9
+ *
10
+ * This file is referenced by the Playwright config created by defineLiveConfig().
11
+ * The auth file path is passed via SUNPEAK_AUTH_FILE env var.
12
+ */
13
+ import { existsSync, mkdirSync, statSync } from 'fs';
14
+ import { dirname } from 'path';
15
+ import { ANTI_BOT_ARGS, CHROME_USER_AGENT, resolvePlaywright, getAppName } from './utils.mjs';
16
+ import { launchAuthenticatedBrowser, detectBrowser } from './browser-auth.mjs';
17
+ import { ChatGPTPage, CHATGPT_SELECTORS, CHATGPT_URLS } from './chatgpt-page.mjs';
18
+
19
+ /** Auth state expires after 24 hours — ChatGPT session cookies are short-lived. */
20
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
21
+
22
+ /** Reuse selectors and URLs from the canonical ChatGPT page object. */
23
+ const CHATGPT_URL = CHATGPT_URLS.base;
24
+ const LOGIN_SELECTOR = CHATGPT_SELECTORS.loggedInIndicator;
25
+
26
+ function isAuthFresh(authFile) {
27
+ if (!existsSync(authFile)) return false;
28
+ const age = Date.now() - statSync(authFile).mtimeMs;
29
+ return age < MAX_AGE_MS;
30
+ }
31
+
32
+ /**
33
+ * Refresh the MCP server connection in ChatGPT settings.
34
+ * Opens a browser with the saved auth, navigates to settings, clicks Refresh,
35
+ * then closes. Runs once so parallel test workers don't each refresh.
36
+ */
37
+ async function refreshMcpServer(authFile) {
38
+ const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
39
+ const appName = getAppName(projectRoot);
40
+ const { chromium } = resolvePlaywright(projectRoot);
41
+
42
+ const browser = await chromium.launch({
43
+ headless: false,
44
+ args: ANTI_BOT_ARGS,
45
+ });
46
+ const context = await browser.newContext({
47
+ userAgent: CHROME_USER_AGENT,
48
+ storageState: authFile,
49
+ });
50
+ const page = await context.newPage();
51
+
52
+ try {
53
+ const hostPage = new ChatGPTPage(page);
54
+ await hostPage.refreshMcpServer({ appName });
55
+ console.log('MCP server refreshed.');
56
+ } finally {
57
+ await browser.close();
58
+ }
59
+ }
60
+
61
+ export default async function globalSetup() {
62
+ // If storage state was provided externally, skip auth but still refresh.
63
+ const authFile = process.env.SUNPEAK_AUTH_FILE;
64
+
65
+ if (!process.env.SUNPEAK_STORAGE_STATE) {
66
+ if (!authFile) {
67
+ console.warn('SUNPEAK_AUTH_FILE not set — skipping auth setup.');
68
+ return;
69
+ }
70
+
71
+ if (!isAuthFresh(authFile)) {
72
+ await authenticate(authFile);
73
+ }
74
+ }
75
+
76
+ // Refresh MCP server connection so all workers start with pre-loaded resources.
77
+ const resolvedAuth = process.env.SUNPEAK_STORAGE_STATE || authFile;
78
+ if (resolvedAuth && existsSync(resolvedAuth)) {
79
+ await refreshMcpServer(resolvedAuth);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Authenticate by importing cookies from the user's browser or manual login.
85
+ */
86
+ async function authenticate(authFile) {
87
+ // Try importing cookies from the user's real browser profile.
88
+ const browserName = process.env.SUNPEAK_LIVE_BROWSER || detectBrowser();
89
+ if (browserName) {
90
+ let auth;
91
+ try {
92
+ auth = await launchAuthenticatedBrowser({ browser: browserName, headless: false });
93
+ const page = auth.page;
94
+
95
+ await page.goto(CHATGPT_URL, { waitUntil: 'domcontentloaded' });
96
+
97
+ const loggedIn = await page
98
+ .locator(LOGIN_SELECTOR)
99
+ .first()
100
+ .waitFor({ timeout: 15_000 })
101
+ .then(() => true)
102
+ .catch(() => false);
103
+
104
+ if (loggedIn) {
105
+ mkdirSync(dirname(authFile), { recursive: true });
106
+ await auth.context.storageState({ path: authFile });
107
+ console.log(`Session imported from ${browserName} browser.`);
108
+ }
109
+
110
+ await auth.context.close();
111
+ auth.cleanup();
112
+
113
+ if (loggedIn) return;
114
+ // Not logged in — fall through to manual login.
115
+ } catch {
116
+ // Profile copy failed — clean up and fall through to manual login.
117
+ if (auth) {
118
+ try { await auth.context.close(); } catch {}
119
+ auth.cleanup();
120
+ }
121
+ }
122
+ }
123
+
124
+ // Fallback: open a bare browser for the user to log in manually.
125
+ console.log('\nNo saved ChatGPT session found (or session expired).');
126
+ console.log('Opening browser — please log in to chatgpt.com.\n');
127
+
128
+ const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
129
+ const { chromium } = resolvePlaywright(projectRoot);
130
+
131
+ const browser = await chromium.launch({
132
+ headless: false,
133
+ args: ANTI_BOT_ARGS,
134
+ });
135
+
136
+ const context = await browser.newContext({
137
+ userAgent: CHROME_USER_AGENT,
138
+ });
139
+
140
+ const page = await context.newPage();
141
+ await page.goto(CHATGPT_URL, { waitUntil: 'domcontentloaded' });
142
+
143
+ console.log('Waiting for login... (this will timeout after 5 minutes)\n');
144
+ await page.locator(LOGIN_SELECTOR).first().waitFor({ timeout: 300_000 });
145
+ console.log('Logged in! Saving session...\n');
146
+
147
+ mkdirSync(dirname(authFile), { recursive: true });
148
+ await context.storageState({ path: authFile });
149
+ await browser.close();
150
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Factory for creating host-specific Playwright fixtures.
3
+ *
4
+ * Each host (ChatGPT, Claude) creates its own fixtures by calling
5
+ * createHostFixtures() with its HostPage subclass and message formatting.
6
+ *
7
+ * This keeps the fixture logic DRY — login, refresh, invoke, and
8
+ * app name handling are shared across all hosts.
9
+ */
10
+ import { resolvePlaywrightESM, getAppName } from './utils.mjs';
11
+
12
+ // Resolve @playwright/test from the user's project (pnpm strict resolution).
13
+ const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
14
+ const { test: base, expect } = await resolvePlaywrightESM(projectRoot);
15
+
16
+ /** App name from the project's package.json — read once at module load. */
17
+ const appName = getAppName(projectRoot);
18
+
19
+ /**
20
+ * Create Playwright test fixtures for a specific host.
21
+ *
22
+ * @param {Object} options
23
+ * @param {string} options.fixtureName - Fixture name in test signatures (e.g., 'chatgpt', 'claude')
24
+ * @param {typeof import('./host-page.mjs').HostPage} options.HostPageClass - Host page class
25
+ * @param {(appName: string, text: string) => string} [options.formatMessage] - Format user messages (e.g., add /{appName} prefix)
26
+ */
27
+ export function createHostFixtures({ fixtureName, HostPageClass, formatMessage }) {
28
+ const test = base.extend({
29
+ [fixtureName]: async ({ page }, use) => {
30
+ const hostPage = new HostPageClass(page);
31
+ await hostPage.verifyLoggedIn();
32
+
33
+ // MCP server refresh is handled once in globalSetup — no per-worker refresh needed.
34
+
35
+ // Enhanced interface with app name handling and invoke() shortcut
36
+ const fixture = Object.create(hostPage);
37
+
38
+ if (formatMessage) {
39
+ fixture.sendMessage = async (text) => {
40
+ return hostPage.sendMessage(formatMessage(appName, text));
41
+ };
42
+ }
43
+ fixture.sendRawMessage = hostPage.sendMessage.bind(hostPage);
44
+
45
+ /**
46
+ * One-liner: start a new chat, send a prompt, and wait for the app iframe.
47
+ * Returns the FrameLocator for the rendered app — ready for assertions.
48
+ */
49
+ fixture.invoke = async (prompt, options) => {
50
+ await hostPage.startNewChat();
51
+ const message = formatMessage ? formatMessage(appName, prompt) : prompt;
52
+ await hostPage.sendMessage(message);
53
+ return hostPage.waitForAppIframe(options);
54
+ };
55
+
56
+ await use(fixture);
57
+ },
58
+ });
59
+
60
+ return { test, expect };
61
+ }
@@ -0,0 +1,242 @@
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
+ * Verify the user is logged into the host.
92
+ * Navigates to the host if not already there.
93
+ */
94
+ async verifyLoggedIn() {
95
+ const url = this.page.url();
96
+ if (!url.includes(new URL(this.urls.base).hostname)) {
97
+ await this.page.goto(this.urls.base, { waitUntil: 'domcontentloaded' });
98
+ }
99
+
100
+ const loggedIn = this.page.locator(this.selectors.loggedInIndicator).first();
101
+ const loginPage = this.page.locator(this.selectors.loginPage);
102
+
103
+ const result = await Promise.race([
104
+ loggedIn.waitFor({ timeout: 15_000 }).then(() => 'logged-in'),
105
+ loginPage.waitFor({ timeout: 15_000 }).then(() => 'login-page'),
106
+ ]).catch(() => 'timeout');
107
+
108
+ if (result !== 'logged-in') {
109
+ throw new Error(
110
+ `Not logged into ${this.hostName}. Run \`pnpm test:live\` to open a browser and log in.\n` +
111
+ 'Your session is saved for 24 hours after the first login.'
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Refresh the MCP server connection in host settings.
118
+ * Subclasses must implement this — each host has different settings UI.
119
+ *
120
+ * @param {Object} [options]
121
+ * @param {string} [options.tunnelUrl] - Tunnel URL for error messages
122
+ * @param {string} [options.appName] - App name as configured in the host
123
+ */
124
+ async refreshMcpServer(_options) {
125
+ throw new Error(`${this.hostName} refreshMcpServer not implemented`);
126
+ }
127
+
128
+ /**
129
+ * Start a new chat conversation.
130
+ */
131
+ async startNewChat() {
132
+ const newChatLink = this.page.locator(this.selectors.newChatLink).first();
133
+ const isVisible = await newChatLink.isVisible().catch(() => false);
134
+ if (isVisible) {
135
+ await newChatLink.click();
136
+ } else {
137
+ await this.page.goto(this.urls.base, { waitUntil: 'domcontentloaded' });
138
+ }
139
+
140
+ await this.page.locator(this.selectors.chatInput).waitFor({ timeout: 10_000 });
141
+ }
142
+
143
+ /**
144
+ * Send a message in the current chat.
145
+ * @param {string} text - The message to send
146
+ */
147
+ async sendMessage(text) {
148
+ const input = this.page.locator(this.selectors.chatInput);
149
+ await input.waitFor({ timeout: 10_000 });
150
+ await input.click();
151
+
152
+ // Use keyboard typing — host React textareas often don't respond to fill()
153
+ await input.pressSequentially(text, { delay: 10 });
154
+
155
+ const sendBtn = this.page.locator(this.selectors.sendButton);
156
+ await sendBtn.waitFor({ state: 'visible', timeout: 10_000 });
157
+ await sendBtn.click();
158
+ }
159
+
160
+ /**
161
+ * Wait for a MCP app iframe to appear in the conversation.
162
+ * Subclasses must implement this — each host renders iframes differently.
163
+ *
164
+ * @param {Object} [options]
165
+ * @param {number} [options.timeout=90000] - Max time to wait (ms)
166
+ * @returns {Promise<import('playwright').FrameLocator>}
167
+ */
168
+ async waitForAppIframe(_options) {
169
+ throw new Error(`${this.hostName} waitForAppIframe not implemented`);
170
+ }
171
+
172
+ /**
173
+ * Get the app iframe FrameLocator.
174
+ * @returns {import('playwright').FrameLocator}
175
+ */
176
+ getAppIframe() {
177
+ throw new Error(`${this.hostName} getAppIframe not implemented`);
178
+ }
179
+
180
+ /**
181
+ * Capture a debug screenshot and throw with helpful message.
182
+ * @param {string} context - Context label for the screenshot filename
183
+ * @param {string} [tunnelUrl] - Tunnel URL for the error message
184
+ * @protected
185
+ */
186
+ async _screenshotAndThrow(context, tunnelUrl) {
187
+ const screenshotPath = `/tmp/sunpeak-live-debug-${this.hostId}-${context}.png`;
188
+ try {
189
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
190
+ console.error(`\nDebug screenshot saved to: ${screenshotPath}`);
191
+ } catch {
192
+ // Screenshot failed — continue with the error
193
+ }
194
+
195
+ try {
196
+ const buttons = await this.page.locator('button').allTextContents();
197
+ console.error('Visible buttons on page:', buttons.filter(t => t.trim()).join(', '));
198
+ } catch {
199
+ // Best effort
200
+ }
201
+
202
+ throw new Error(
203
+ `Could not find Refresh/Reconnect button in ${this.hostName} settings.\n` +
204
+ `Make sure your MCP server is added in ${this.hostName} settings` +
205
+ (tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
206
+ `\n\nDebug screenshot: ${screenshotPath}`
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Wait for a toast/alert banner and check for errors.
212
+ * Many hosts show success/error toasts after settings actions.
213
+ * @param {Object} [options]
214
+ * @param {string} [options.alertSelector='[role="alert"]'] - Selector for toast elements
215
+ * @param {number} [options.timeout=30000] - Max time to wait
216
+ * @param {number} [options.minTextLength=5] - Minimum text length to consider as a real toast
217
+ * @protected
218
+ */
219
+ async _waitForToast({ alertSelector = '[role="alert"]', timeout = 30_000, minTextLength = 5 } = {}) {
220
+ try {
221
+ await this.page.waitForFunction(
222
+ ({ selector, minLen }) => {
223
+ const alerts = document.querySelectorAll(selector);
224
+ for (const alert of alerts) {
225
+ const text = alert.textContent?.trim();
226
+ if (text && text.length > minLen) return true;
227
+ }
228
+ return false;
229
+ },
230
+ { selector: alertSelector, minLen: minTextLength },
231
+ { timeout },
232
+ );
233
+ } catch {
234
+ console.warn('No toast detected — assuming success.');
235
+ return { texts: [], hasError: false };
236
+ }
237
+
238
+ const texts = await this.page.locator(alertSelector).allTextContents();
239
+ const errorText = texts.find((t) => /error/i.test(t));
240
+ return { texts, hasError: !!errorText, errorText };
241
+ }
242
+ }
@@ -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 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;