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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT host page object for live testing.
|
|
3
|
+
*
|
|
4
|
+
* All ChatGPT-specific DOM selectors and interaction logic lives here.
|
|
5
|
+
* When ChatGPT updates their UI, only this file needs updating.
|
|
6
|
+
*
|
|
7
|
+
* Extends HostPage which provides shared behavior (sendMessage, login, etc.).
|
|
8
|
+
*/
|
|
9
|
+
import { HostPage } from './host-page.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* All ChatGPT DOM selectors in one place for easy maintenance.
|
|
13
|
+
*
|
|
14
|
+
* Last verified: 2026-03-17 via live Playwright inspection.
|
|
15
|
+
*/
|
|
16
|
+
const SELECTORS = {
|
|
17
|
+
// Chat interface
|
|
18
|
+
chatInput: '#prompt-textarea',
|
|
19
|
+
sendButton: '[data-testid="send-button"]',
|
|
20
|
+
newChatLink: 'a:has-text("New chat")',
|
|
21
|
+
|
|
22
|
+
// Login detection — ChatGPT renders two profile buttons (sidebar compact + expanded); always use .first().
|
|
23
|
+
loggedInIndicator: '[data-testid="accounts-profile-button"]',
|
|
24
|
+
loginPage: 'button:has-text("Log in")',
|
|
25
|
+
|
|
26
|
+
// Settings navigation
|
|
27
|
+
appsTab: '[role="tab"]:has-text("Apps")',
|
|
28
|
+
refreshButton: 'button:has-text("Refresh")',
|
|
29
|
+
reconnectButton: 'button:has-text("Reconnect")',
|
|
30
|
+
|
|
31
|
+
// App iframe — ChatGPT uses a nested iframe structure:
|
|
32
|
+
// outer: iframe[sandbox] (connector sandbox, no direct content)
|
|
33
|
+
// inner: iframe name="root" (actual app React content)
|
|
34
|
+
mcpAppOuterIframe: 'iframe[sandbox*="allow-scripts"]',
|
|
35
|
+
mcpAppInnerFrameName: 'root',
|
|
36
|
+
|
|
37
|
+
// Streaming indicator
|
|
38
|
+
stopButton: 'button[aria-label="Stop streaming"], button:has-text("Stop")',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const URLS = {
|
|
42
|
+
base: 'https://chatgpt.com',
|
|
43
|
+
settings: 'https://chatgpt.com/#settings/Connectors',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export { SELECTORS as CHATGPT_SELECTORS, URLS as CHATGPT_URLS };
|
|
47
|
+
|
|
48
|
+
export class ChatGPTPage extends HostPage {
|
|
49
|
+
get hostId() { return 'chatgpt'; }
|
|
50
|
+
get hostName() { return 'ChatGPT'; }
|
|
51
|
+
get selectors() { return SELECTORS; }
|
|
52
|
+
get urls() { return URLS; }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Refresh the MCP server connection in ChatGPT settings.
|
|
56
|
+
* Navigates to Settings > Apps, clicks the app entry, clicks Refresh,
|
|
57
|
+
* and waits for the success/error toast.
|
|
58
|
+
*/
|
|
59
|
+
async refreshMcpServer({ tunnelUrl, appName } = {}) {
|
|
60
|
+
await this.page.goto(URLS.settings, { waitUntil: 'domcontentloaded' });
|
|
61
|
+
await this.page.waitForTimeout(3_000);
|
|
62
|
+
|
|
63
|
+
const found = await this._findAndClickRefresh(appName);
|
|
64
|
+
|
|
65
|
+
if (!found) {
|
|
66
|
+
const appsTab = this.page.locator(SELECTORS.appsTab);
|
|
67
|
+
const hasAppsTab = await appsTab.isVisible().catch(() => false);
|
|
68
|
+
if (hasAppsTab) {
|
|
69
|
+
await appsTab.click();
|
|
70
|
+
await this.page.waitForTimeout(2_000);
|
|
71
|
+
const retryFound = await this._findAndClickRefresh(appName);
|
|
72
|
+
if (!retryFound) {
|
|
73
|
+
await this._screenshotAndThrow('refresh-mcp-settings', tunnelUrl);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
await this._screenshotAndThrow('no-apps-tab', tunnelUrl);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Wait for the refresh toast
|
|
81
|
+
const { hasError, errorText } = await this._waitForToast();
|
|
82
|
+
if (hasError) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`MCP server refresh failed in ChatGPT:\n${errorText.trim()}\n\n` +
|
|
85
|
+
`Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Wait for resource preloading to complete
|
|
90
|
+
await this.page.waitForTimeout(3_000);
|
|
91
|
+
|
|
92
|
+
// Navigate back to chat
|
|
93
|
+
await this.page.goto(URLS.base, { waitUntil: 'domcontentloaded' });
|
|
94
|
+
await this.page.locator(SELECTORS.chatInput).waitFor({ timeout: 10_000 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wait for a MCP app iframe to appear in the conversation.
|
|
99
|
+
* ChatGPT renders apps in a nested iframe (outer sandbox > inner #root).
|
|
100
|
+
*/
|
|
101
|
+
async waitForAppIframe({ timeout = 90_000 } = {}) {
|
|
102
|
+
// Wait for streaming to finish
|
|
103
|
+
try {
|
|
104
|
+
await this.page.locator(SELECTORS.stopButton).waitFor({ state: 'hidden', timeout });
|
|
105
|
+
} catch {
|
|
106
|
+
// Stop button may never appear if response was instant
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Wait for the outer sandbox iframe
|
|
110
|
+
await this.page.locator(SELECTORS.mcpAppOuterIframe).first().waitFor({ state: 'attached', timeout: 30_000 });
|
|
111
|
+
|
|
112
|
+
// Wait for the inner frame to appear inside the sandboxed outer iframe.
|
|
113
|
+
// waitForFunction can't cross the sandbox boundary, so use Playwright's frameLocator instead.
|
|
114
|
+
const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
|
|
115
|
+
await outerFrame
|
|
116
|
+
.locator(`iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`)
|
|
117
|
+
.waitFor({ state: 'attached', timeout: 15_000 });
|
|
118
|
+
|
|
119
|
+
const appFrame = this.getAppIframe();
|
|
120
|
+
|
|
121
|
+
// Wait for content to render
|
|
122
|
+
try {
|
|
123
|
+
await appFrame.locator('body *').first().waitFor({ state: 'visible', timeout: 15_000 });
|
|
124
|
+
} catch {
|
|
125
|
+
// Caller's assertions will catch missing content
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return appFrame;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getAppIframe() {
|
|
132
|
+
const name = SELECTORS.mcpAppInnerFrameName;
|
|
133
|
+
const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
|
|
134
|
+
return outerFrame.frameLocator(`iframe[name="${name}"], iframe#${name}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Send a message, pausing around the space after the /{appName} prefix.
|
|
139
|
+
* ChatGPT needs a moment to associate the app before the rest of the prompt is typed.
|
|
140
|
+
*/
|
|
141
|
+
async sendMessage(text) {
|
|
142
|
+
const input = this.page.locator(SELECTORS.chatInput);
|
|
143
|
+
await input.waitFor({ timeout: 10_000 });
|
|
144
|
+
await input.click();
|
|
145
|
+
|
|
146
|
+
if (text.startsWith('/')) {
|
|
147
|
+
const spaceIdx = text.indexOf(' ');
|
|
148
|
+
if (spaceIdx !== -1) {
|
|
149
|
+
await input.pressSequentially(text.slice(0, spaceIdx), { delay: 10 });
|
|
150
|
+
await this.page.waitForTimeout(500);
|
|
151
|
+
await input.pressSequentially(' ', { delay: 10 });
|
|
152
|
+
await this.page.waitForTimeout(500);
|
|
153
|
+
await input.pressSequentially(text.slice(spaceIdx + 1), { delay: 10 });
|
|
154
|
+
} else {
|
|
155
|
+
await input.pressSequentially(text, { delay: 10 });
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
await input.pressSequentially(text, { delay: 10 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sendBtn = this.page.locator(SELECTORS.sendButton);
|
|
162
|
+
await sendBtn.waitFor({ state: 'visible', timeout: 10_000 });
|
|
163
|
+
await sendBtn.click();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- ChatGPT-specific private methods ---
|
|
167
|
+
|
|
168
|
+
/** @private */
|
|
169
|
+
async _findAndClickRefresh(appName) {
|
|
170
|
+
const refreshBtn = this.page.locator(SELECTORS.refreshButton).first();
|
|
171
|
+
const reconnectBtn = this.page.locator(SELECTORS.reconnectButton).first();
|
|
172
|
+
|
|
173
|
+
const tryClickRefresh = async () => {
|
|
174
|
+
if (await refreshBtn.isVisible().catch(() => false)) {
|
|
175
|
+
await refreshBtn.click();
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (await reconnectBtn.isVisible().catch(() => false)) {
|
|
179
|
+
await reconnectBtn.click();
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (await tryClickRefresh()) return true;
|
|
186
|
+
|
|
187
|
+
if (appName) {
|
|
188
|
+
const strategies = [
|
|
189
|
+
() => this.page.getByText(appName, { exact: true }).first(),
|
|
190
|
+
() => this.page.locator(`text=${appName}`).first(),
|
|
191
|
+
() => this.page.locator(`a:has-text("${appName}"), [role="button"]:has-text("${appName}")`).first(),
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
for (const getLocator of strategies) {
|
|
195
|
+
try {
|
|
196
|
+
const el = getLocator();
|
|
197
|
+
if (await el.isVisible().catch(() => false)) {
|
|
198
|
+
await el.click();
|
|
199
|
+
await this.page.waitForTimeout(2_000);
|
|
200
|
+
if (await tryClickRefresh()) return true;
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Strategy didn't work, try next
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global setup for live tests.
|
|
3
|
+
*
|
|
4
|
+
* Runs exactly once before all workers. Two responsibilities:
|
|
5
|
+
* 1. Authenticate — launch a browser, verify login, wait for user if needed.
|
|
6
|
+
* 2. Refresh MCP server — navigate to host settings and click Refresh so
|
|
7
|
+
* all workers start with pre-loaded resources.
|
|
8
|
+
*
|
|
9
|
+
* Auth approach:
|
|
10
|
+
* - Opens a browser with storageState if a fresh auth file exists (<24h).
|
|
11
|
+
* - Checks that we're truly logged in (profile button visible AND no "Log in" buttons).
|
|
12
|
+
* - If not logged in, prints a clear message and waits for the user to log in
|
|
13
|
+
* in the open browser window (up to 5 minutes).
|
|
14
|
+
* - Saves storageState after successful login for future runs.
|
|
15
|
+
* - The same browser session is reused for MCP refresh so Cloudflare's
|
|
16
|
+
* HttpOnly cookies (which storageState can't capture) remain valid.
|
|
17
|
+
*
|
|
18
|
+
* This file is referenced by the Playwright config created by defineLiveConfig().
|
|
19
|
+
* The auth file path is passed via SUNPEAK_AUTH_FILE env var.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
|
|
22
|
+
import { dirname } from 'path';
|
|
23
|
+
import { ANTI_BOT_ARGS, CHROME_USER_AGENT, resolvePlaywright, getAppName } from './utils.mjs';
|
|
24
|
+
import { ChatGPTPage, CHATGPT_SELECTORS, CHATGPT_URLS } from './chatgpt-page.mjs';
|
|
25
|
+
|
|
26
|
+
/** Auth state expires after 24 hours — ChatGPT session cookies are short-lived. */
|
|
27
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
const CHATGPT_URL = CHATGPT_URLS.base;
|
|
30
|
+
|
|
31
|
+
function isAuthFresh(authFile) {
|
|
32
|
+
if (!existsSync(authFile)) return false;
|
|
33
|
+
const age = Date.now() - statSync(authFile).mtimeMs;
|
|
34
|
+
return age < MAX_AGE_MS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if we're truly logged into ChatGPT.
|
|
39
|
+
* Must have the profile button AND must NOT have any "Log in" buttons.
|
|
40
|
+
* The logged-out ChatGPT page can show some UI elements that look like
|
|
41
|
+
* a logged-in state, so checking just the profile button isn't enough.
|
|
42
|
+
*/
|
|
43
|
+
async function isFullyLoggedIn(page) {
|
|
44
|
+
const hasProfile = await page
|
|
45
|
+
.locator(CHATGPT_SELECTORS.loggedInIndicator)
|
|
46
|
+
.first()
|
|
47
|
+
.isVisible()
|
|
48
|
+
.catch(() => false);
|
|
49
|
+
|
|
50
|
+
if (!hasProfile) return false;
|
|
51
|
+
|
|
52
|
+
// Must NOT have "Log in" buttons — these appear on the logged-out page
|
|
53
|
+
const hasLoginButton = await page
|
|
54
|
+
.locator(CHATGPT_SELECTORS.loginPage)
|
|
55
|
+
.first()
|
|
56
|
+
.isVisible()
|
|
57
|
+
.catch(() => false);
|
|
58
|
+
|
|
59
|
+
return !hasLoginButton;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default async function globalSetup() {
|
|
63
|
+
const authFile = process.env.SUNPEAK_AUTH_FILE;
|
|
64
|
+
|
|
65
|
+
if (process.env.SUNPEAK_STORAGE_STATE) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!authFile) {
|
|
70
|
+
console.warn('SUNPEAK_AUTH_FILE not set — skipping auth setup.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
|
|
75
|
+
const appName = getAppName(projectRoot);
|
|
76
|
+
const { chromium } = resolvePlaywright(projectRoot);
|
|
77
|
+
|
|
78
|
+
// Launch a browser. Use saved storageState if fresh, otherwise start clean.
|
|
79
|
+
const hasFreshAuth = isAuthFresh(authFile);
|
|
80
|
+
const browser = await chromium.launch({
|
|
81
|
+
headless: false,
|
|
82
|
+
args: ANTI_BOT_ARGS,
|
|
83
|
+
});
|
|
84
|
+
const context = await browser.newContext({
|
|
85
|
+
userAgent: CHROME_USER_AGENT,
|
|
86
|
+
...(hasFreshAuth ? { storageState: authFile } : {}),
|
|
87
|
+
});
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await page.goto(CHATGPT_URL, { waitUntil: 'domcontentloaded' });
|
|
92
|
+
|
|
93
|
+
// Wait for page to settle — Cloudflare challenge or ChatGPT UI loading
|
|
94
|
+
await page.waitForTimeout(5_000);
|
|
95
|
+
|
|
96
|
+
// Check if truly logged in (profile button visible, no "Log in" buttons)
|
|
97
|
+
let loggedIn = await isFullyLoggedIn(page);
|
|
98
|
+
|
|
99
|
+
if (loggedIn) {
|
|
100
|
+
console.log('Authenticated (from saved session).');
|
|
101
|
+
} else {
|
|
102
|
+
// If we loaded a stale auth file that didn't work, delete it
|
|
103
|
+
if (hasFreshAuth) {
|
|
104
|
+
try { unlinkSync(authFile); } catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(
|
|
108
|
+
`\n` +
|
|
109
|
+
`╔══════════════════════════════════════════════════════════════╗\n` +
|
|
110
|
+
`║ Please log in to ChatGPT ║\n` +
|
|
111
|
+
`║ ║\n` +
|
|
112
|
+
`║ A browser window has opened at chatgpt.com. ║\n` +
|
|
113
|
+
`║ Log in and wait for the chat to load. ║\n` +
|
|
114
|
+
`║ ║\n` +
|
|
115
|
+
`║ Waiting up to 5 minutes... ║\n` +
|
|
116
|
+
`╚══════════════════════════════════════════════════════════════╝\n`
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Poll until truly logged in
|
|
120
|
+
const maxWait = 300_000; // 5 minutes
|
|
121
|
+
const pollInterval = 3_000;
|
|
122
|
+
const start = Date.now();
|
|
123
|
+
|
|
124
|
+
while (Date.now() - start < maxWait) {
|
|
125
|
+
loggedIn = await isFullyLoggedIn(page);
|
|
126
|
+
if (loggedIn) break;
|
|
127
|
+
await page.waitForTimeout(pollInterval);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!loggedIn) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'Login timed out after 5 minutes.\n' +
|
|
133
|
+
'Please log in to chatgpt.com in the browser window that opened.\n' +
|
|
134
|
+
'If the session expired, delete the .auth/ directory and try again.'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
console.log('Logged in!');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Save session for future runs (best effort — HttpOnly cookies won't be captured).
|
|
141
|
+
mkdirSync(dirname(authFile), { recursive: true });
|
|
142
|
+
await context.storageState({ path: authFile });
|
|
143
|
+
console.log('Session saved.\n');
|
|
144
|
+
|
|
145
|
+
// Refresh MCP server in the SAME browser session.
|
|
146
|
+
// This is critical — Cloudflare's cf_clearance cookie is HttpOnly and
|
|
147
|
+
// won't be in the saved storageState. By refreshing here, the cookie
|
|
148
|
+
// is still valid for navigating to settings.
|
|
149
|
+
//
|
|
150
|
+
// This MUST succeed — if the MCP server isn't reachable or the refresh
|
|
151
|
+
// fails, tests will fail with confusing iframe/timeout errors.
|
|
152
|
+
const hostPage = new ChatGPTPage(page);
|
|
153
|
+
await hostPage.refreshMcpServer({ appName });
|
|
154
|
+
console.log('MCP server refreshed.');
|
|
155
|
+
} finally {
|
|
156
|
+
await browser.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -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
|
+
}
|