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.
Files changed (90) hide show
  1. package/README.md +4 -3
  2. package/bin/commands/dev.mjs +120 -8
  3. package/bin/commands/new.mjs +6 -2
  4. package/bin/commands/start.mjs +7 -2
  5. package/bin/lib/get-port.mjs +60 -0
  6. package/bin/lib/live/browser-auth.mjs +161 -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 +158 -0
  13. package/bin/lib/live/host-fixtures.mjs +61 -0
  14. package/bin/lib/live/host-page.mjs +294 -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/lib/sandbox-server.mjs +304 -0
  24. package/bin/sunpeak.js +1 -1
  25. package/dist/chatgpt/chatgpt-conversation.d.ts +3 -7
  26. package/dist/chatgpt/globals.css +18 -0
  27. package/dist/chatgpt/index.cjs +1 -1
  28. package/dist/chatgpt/index.js +1 -1
  29. package/dist/claude/claude-conversation.d.ts +3 -2
  30. package/dist/claude/index.cjs +1 -1
  31. package/dist/claude/index.js +1 -1
  32. package/dist/{index-bKBBCBK6.cjs → index-BEWVLFfB.cjs} +2 -2
  33. package/dist/index-BEWVLFfB.cjs.map +1 -0
  34. package/dist/{index-CX6Z4bED.js → index-C6XYFOmh.js} +2 -2
  35. package/dist/index-C6XYFOmh.js.map +1 -0
  36. package/dist/{index-CKabCJyV.cjs → index-D0FsXP3Y.cjs} +2 -2
  37. package/dist/index-D0FsXP3Y.cjs.map +1 -0
  38. package/dist/{index-B4aC3vjH.js → index-Rg7SWjvl.js} +2 -2
  39. package/dist/index-Rg7SWjvl.js.map +1 -0
  40. package/dist/index.cjs +13 -5
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.js +13 -5
  43. package/dist/index.js.map +1 -1
  44. package/dist/mcp/favicon.d.ts +3 -1
  45. package/dist/mcp/index.cjs +90 -49
  46. package/dist/mcp/index.cjs.map +1 -1
  47. package/dist/mcp/index.d.ts +2 -2
  48. package/dist/mcp/index.js +90 -49
  49. package/dist/mcp/index.js.map +1 -1
  50. package/dist/mcp/production-server.d.ts +7 -1
  51. package/dist/mcp/types.d.ts +32 -1
  52. package/dist/simulator/hosts.d.ts +11 -2
  53. package/dist/simulator/iframe-resource.d.ts +8 -1
  54. package/dist/simulator/index.cjs +1 -1
  55. package/dist/simulator/index.js +1 -1
  56. package/dist/simulator/mcp-app-host.d.ts +17 -0
  57. package/dist/simulator/sandbox-proxy.d.ts +38 -0
  58. package/dist/simulator/simple-sidebar.d.ts +3 -1
  59. package/dist/simulator/simulator.d.ts +7 -1
  60. package/dist/simulator/use-simulator-state.d.ts +2 -4
  61. package/dist/{simulator-D8t-r7HH.js → simulator-B-CrMHVs.js} +504 -192
  62. package/dist/simulator-B-CrMHVs.js.map +1 -0
  63. package/dist/{simulator-FFNttkqL.cjs → simulator-Gc6n_fT4.cjs} +503 -191
  64. package/dist/simulator-Gc6n_fT4.cjs.map +1 -0
  65. package/dist/style.css +18 -0
  66. package/package.json +25 -1
  67. package/template/.sunpeak/dev.tsx +9 -3
  68. package/template/README.md +24 -2
  69. package/template/_gitignore +1 -0
  70. package/template/package.json +3 -2
  71. package/template/playwright.config.ts +34 -6
  72. package/template/src/server.ts +16 -2
  73. package/template/src/tools/show-albums.ts +17 -0
  74. package/template/tests/e2e/albums.spec.ts +37 -5
  75. package/template/tests/e2e/carousel.spec.ts +6 -6
  76. package/template/tests/e2e/global-setup.ts +6 -21
  77. package/template/tests/e2e/map.spec.ts +11 -11
  78. package/template/tests/e2e/review.spec.ts +24 -24
  79. package/template/tests/live/albums.spec.ts +53 -0
  80. package/template/tests/live/carousel.spec.ts +52 -0
  81. package/template/tests/live/map.spec.ts +31 -0
  82. package/template/tests/live/playwright.config.ts +3 -0
  83. package/template/tests/live/review.spec.ts +54 -0
  84. package/template/vitest.config.ts +1 -1
  85. package/dist/index-B4aC3vjH.js.map +0 -1
  86. package/dist/index-CKabCJyV.cjs.map +0 -1
  87. package/dist/index-CX6Z4bED.js.map +0 -1
  88. package/dist/index-bKBBCBK6.cjs.map +0 -1
  89. package/dist/simulator-D8t-r7HH.js.map +0 -1
  90. 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
+ }