UNPKG

@withkeystone/cli

Version:

Keystone CLI - Test automation for modern web apps

583 lines (582 loc) 26.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.startServer = startServer; const ws_1 = __importDefault(require("ws")); const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const http_1 = require("http"); const runner_core_1 = require("@withkeystone/runner-core"); const browser_js_1 = require("./browser.js"); const chalk_1 = __importDefault(require("chalk")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const os = __importStar(require("os")); const getAuthenticatedClient_js_1 = require("./auth/getAuthenticatedClient.js"); async function startServer(options) { const app = (0, express_1.default)(); const server = (0, http_1.createServer)(app); // Enable CORS for Studio app.use((0, cors_1.default)({ origin: [ 'http://localhost:3000', // Local frontend dev 'http://localhost:3001', // Local Studio dev 'https://app.withkeystone.com', // Production Studio 'https://*.withkeystone.com' // Preview deployments ] })); // If proxy mode is enabled, connect to backend proxy endpoint if (options.proxy) { // Check if user is authenticated if (!await (0, getAuthenticatedClient_js_1.isAuthenticated)()) { console.error(chalk_1.default.red('❌ Not authenticated')); console.log(chalk_1.default.gray('Please run "keystone init" to login.')); process.exit(1); } await connectToProxy(options); return; } // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', version: '0.1.0', capabilities: { recording: true, screenshot: true, video: true, networkCapture: true } }); }); // Browser state let browser = null; let currentSession = null; let controlWs = null; // Backend URL (default to local) const backendUrl = options.backendUrl || process.env.KEYSTONE_API_URL || 'https://api.withkeystone.com'; const backendWsUrl = backendUrl.replace('http', 'ws'); // Function to connect to backend control channel async function connectToBackend(sessionId) { return new Promise((resolve, reject) => { const wsUrl = `${backendWsUrl}/api/v1/ws/control/${sessionId}`; console.log(`[Local Runner] Connecting to backend control channel: ${wsUrl}`); const ws = new ws_1.default(wsUrl); const connectionTimeout = setTimeout(() => { ws.close(); reject(new Error('Connection timeout after 10 seconds')); }, 10000); ws.on('open', () => { clearTimeout(connectionTimeout); console.log('[Local Runner] ✅ Connected to backend control channel'); // Send initial connection message ws.send(JSON.stringify({ event: 'agent_connected', sessionId: sessionId, timestamp: Date.now() })); console.log('[Local Runner] ✅ Agent connected message sent'); resolve(ws); }); ws.on('error', (error) => { clearTimeout(connectionTimeout); console.error('[Local Runner] ❌ Control channel error:', error); reject(error); }); ws.on('close', (code, reason) => { console.log(`[Local Runner] ❌ Control channel closed: ${code} - ${reason}`); controlWs = null; }); ws.on('message', (data) => { console.log('[Local Runner] 📨 Control channel message:', data.toString()); }); }); } // Function to start a browser session async function startBrowserSession(sessionId) { try { // Launch browser if needed if (!browser) { const chromePath = await (0, browser_js_1.findChrome)(); browser = await (0, browser_js_1.launchBrowser)({ executablePath: chromePath, headless: options.headless !== undefined ? options.headless : true }); console.log('[Local Runner] Chrome launched'); } // Clean up previous session if (currentSession) { await currentSession.cleanup?.(); } // Connect to backend control channel console.log(`[Local Runner] 🔄 Connecting to backend for session ${sessionId}...`); controlWs = await connectToBackend(sessionId); // Add a small delay to ensure the backend registers the agent connection await new Promise(resolve => setTimeout(resolve, 500)); // Create WebSocket shim for type compatibility const wsShim = { send: (data) => { console.log('[Local Runner] 📤 Sending to control channel:', data.substring(0, 100) + '...'); controlWs.send(data); }, on: (event, handler) => { if (event === 'message') { controlWs.on('message', (data) => { console.log('[Local Runner] 📥 Control channel message:', data.toString().substring(0, 100) + '...'); handler(data.toString()); }); } }, addEventListener: (event, handler) => { if (event === 'message') { controlWs.on('message', (data) => handler(data.toString())); } }, removeEventListener: () => { } }; // Start new session with the control WebSocket console.log(`[Local Runner] 🚀 Starting runner session...`); currentSession = await (0, runner_core_1.runSession)({ browserWSEndpoint: browser.wsEndpoint(), ws: wsShim, config: { mode: 'local', uploadArtifacts: false, backendUrl: backendUrl, onFrameCapture: (frame, metadata) => { // Send frame through control channel const frameMessage = { event: 'frame', data: frame.toString('base64'), metadata: metadata ? { width: metadata.deviceWidth, height: metadata.deviceHeight, scale: metadata.pageScaleFactor } : { width: 1440, height: 900, scale: 1 }, timestamp: Date.now() }; controlWs.send(JSON.stringify(frameMessage)); } } }); console.log(`[Local Runner] ✅ Session ${sessionId} started successfully`); } catch (error) { console.error('[Local Runner] Session error:', error); throw error; } } // Endpoint to start a session (called by Studio) app.post('/session/:sessionId', async (req, res) => { const { sessionId } = req.params; try { await startBrowserSession(sessionId); res.json({ success: true, sessionId }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Cleanup function for test files const cleanupTestFiles = () => { const testFilesDir = process.env.TEST_FILES_DIR; if (testFilesDir && fs.existsSync(testFilesDir)) { console.log(`[Local Runner] Cleaning up test files from ${testFilesDir}`); fs.rmSync(testFilesDir, { recursive: true, force: true }); delete process.env.TEST_FILES_DIR; } }; // Graceful shutdown process.on('SIGINT', async () => { console.log('\n[Local Runner] Shutting down...'); cleanupTestFiles(); if (controlWs) controlWs.close(); if (browser) await browser.close(); server.close(); process.exit(0); }); server.listen(options.port, () => { console.log(` ${chalk_1.default.green('✓')} Local runner server started ${chalk_1.default.gray('├')} API: ${chalk_1.default.cyan(`http://localhost:${options.port}`)} ${chalk_1.default.gray('├')} Health: ${chalk_1.default.cyan(`http://localhost:${options.port}/health`)} ${chalk_1.default.gray('├')} Backend: ${chalk_1.default.cyan(backendUrl)} ${chalk_1.default.gray('└')} Status: ${chalk_1.default.yellow('Ready to accept sessions')} ${chalk_1.default.gray('To connect from Studio:')} ${chalk_1.default.gray('1. Open')} ${chalk_1.default.cyan('http://localhost:3000/studio')} ${chalk_1.default.gray('2. Enter your localhost URL')} ${chalk_1.default.gray('3. Studio will auto-detect this local runner')} `); }); } // Connect to backend proxy for cloud Studio access async function connectToProxy(options) { const backendUrl = options.backendUrl || process.env.KEYSTONE_API_URL || 'https://api.withkeystone.com'; const backendWsUrl = backendUrl.replace('http', 'ws'); // Get JWT token const accessToken = await (0, getAuthenticatedClient_js_1.getAccessToken)(); if (!accessToken) { console.error(chalk_1.default.red('❌ No access token found')); console.log(chalk_1.default.gray('Please run "keystone init" to login.')); process.exit(1); } const proxyUrl = `${backendWsUrl}/api/v1/proxy?token=${accessToken}`; console.log(` ${chalk_1.default.bold('🌐 Keystone CLI - Proxy Mode')} ${chalk_1.default.gray('━'.repeat(50))} `); console.log(`${chalk_1.default.gray('Connecting to proxy...')}`); const ws = new ws_1.default(proxyUrl); let browser = null; let currentSession = null; let reconnectInterval = null; let wsShim = null; let messageHandlers = []; const cleanup = () => { if (reconnectInterval) { clearInterval(reconnectInterval); reconnectInterval = null; } // Clean up test files const testFilesDir = process.env.TEST_FILES_DIR; if (testFilesDir && fs.existsSync(testFilesDir)) { console.log(`[Proxy] Cleaning up test files from ${testFilesDir}`); fs.rmSync(testFilesDir, { recursive: true, force: true }); delete process.env.TEST_FILES_DIR; } if (browser) { browser.close().catch(() => { }); browser = null; } if (currentSession) { currentSession = null; } }; ws.on('open', () => { console.log(`${chalk_1.default.green('✓')} Connected to Keystone proxy`); console.log(`${chalk_1.default.gray('└')} Ready to accept requests from cloud Studio\n`); // Report CLI capabilities ws.send(JSON.stringify({ type: 'capabilities', version: '0.1.0', features: { files: true, // File provisioning supported env: true, // Environment variables supported artifacts: false, // TODO: Artifact upload support multiSession: false, // TODO: Multi-session support screencast: true, // Frame capture supported network: true, // Network capture supported console: true // Console log capture supported } })); // Send periodic pings to keep connection alive const pingInterval = setInterval(() => { if (ws.readyState === ws_1.default.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } else { clearInterval(pingInterval); } }, 30000); }); ws.on('message', async (data) => { try { const message = JSON.parse(data.toString()); console.log(`[Proxy] Received message:`, message.type || message.event); // Handle different message types if (message.type === 'pong') { // Ignore pong responses return; } if (message.type === 'connected') { console.log(`${chalk_1.default.green('✓')} Proxy connection established`); console.log(`${chalk_1.default.gray(' Proxy ID:')} ${message.proxyId}`); return; } // Handle commands from Studio if (message.type === 'start_session' || message.type === 'goto' || message.type === 'navigate' || message.type === 'NAVIGATE') { const sessionId = message.sessionId || `proxy-session-${Date.now()}`; const startUrl = message.url || message.startUrl || message.href || 'http://localhost:3000'; console.log(`\n${chalk_1.default.blue('▶')} Handling ${message.type} command`); console.log(`${chalk_1.default.gray(' Session:')} ${sessionId}`); console.log(`${chalk_1.default.gray(' URL:')} ${startUrl}`); // If we already have a session for this ID, forward the command if (currentSession && currentSession.sessionId === sessionId) { console.log(`${chalk_1.default.gray(' Using existing session')}`); // Forward navigate command to existing session if (message.type === 'goto' || message.type === 'navigate' || message.type === 'NAVIGATE') { const navMessage = { ...message, type: 'NAVIGATE', sessionId, id: message.id || `nav-${Date.now()}` }; console.log(`[Proxy] Forwarding navigate to existing session: ${JSON.stringify(navMessage)}`); if (messageHandlers.length > 0) { const messageStr = JSON.stringify(navMessage); for (const handler of messageHandlers) { try { handler(messageStr); } catch (err) { console.error(`[Proxy] Error in navigate handler:`, err); } } } return; } } console.log(`${chalk_1.default.gray(' Creating new session')}`); // Launch browser if not already running if (!browser) { const chromePath = await (0, browser_js_1.findChrome)(); if (!chromePath) { console.error(chalk_1.default.red('❌ Chrome/Chromium not found')); ws.send(JSON.stringify({ event: 'error', sessionId, error: 'Chrome not found' })); return; } browser = await (0, browser_js_1.launchBrowser)({ executablePath: chromePath, headless: options.headless !== undefined ? options.headless : true }); console.log(`${chalk_1.default.green('✓')} Browser launched (${options.headless ? 'headless' : 'headed'})`); } // Get browser WebSocket endpoint const browserWSEndpoint = browser.wsEndpoint(); // Create WebSocket shim for runner-core wsShim = { send: (data) => { try { const parsed = JSON.parse(data); // Add sessionId to all messages ws.send(JSON.stringify({ ...parsed, sessionId })); } catch (e) { // If not JSON, send as-is ws.send(data); } }, on: (event, handler) => { if (event === 'message') { messageHandlers.push(handler); } }, addEventListener: (event, handler) => { if (event === 'message') { messageHandlers.push(handler); } }, removeEventListener: (event, handler) => { if (event === 'message') { messageHandlers = messageHandlers.filter(h => h !== handler); } } }; // Start runner session const context = await (0, runner_core_1.runSession)({ browserWSEndpoint, ws: wsShim, config: { mode: 'local', onFrameCapture: (frame, metadata) => { console.log(`[Proxy] onFrameCapture called, sending frame for session ${sessionId}`); // Send frame through proxy ws.send(JSON.stringify({ event: 'frame', sessionId, data: frame.toString('base64'), metadata: metadata ? { width: metadata.deviceWidth, height: metadata.deviceHeight, scale: metadata.pageScaleFactor } : undefined, timestamp: Date.now() })); } } }); // Store session data currentSession = { ...context, sessionId, browserWSEndpoint }; // Register session with proxy ws.send(JSON.stringify({ type: 'register_session', sessionId })); console.log(`${chalk_1.default.green('✓')} Session started: ${sessionId}`); // Navigate to the URL if (startUrl && context.page) { console.log(`[Proxy] Navigating to initial URL: ${startUrl}`); try { await context.page.goto(startUrl, { waitUntil: 'domcontentloaded' }); console.log(`[Proxy] ✓ Navigation complete`); // Send URL update ws.send(JSON.stringify({ event: 'url', sessionId, href: startUrl })); } catch (err) { console.error(`[Proxy] Navigation error:`, err); } } } // Handle file provisioning if (message.type === 'provision_files') { console.log(`[Proxy] Received provision_files command with ${message.files?.length || 0} files`); if (message.files && message.files.length > 0) { // Create temp directory for test files const tempDir = path.join(os.tmpdir(), 'keystone-test-files', message.sessionId || 'default'); await fs.promises.mkdir(tempDir, { recursive: true }); // Write files to disk for (const file of message.files) { const filePath = path.join(tempDir, file.filename); console.log(`[Proxy] Writing file: ${filePath}`); await fs.promises.writeFile(filePath, Buffer.from(file.data, 'base64')); } // Set environment variable for runner process.env.TEST_FILES_DIR = tempDir; console.log(`[Proxy] ✓ Provisioned ${message.files.length} files to ${tempDir}`); // Send success response ws.send(JSON.stringify({ event: 'provision_complete', sessionId: message.sessionId, filesCount: message.files.length, directory: tempDir })); } return; } // Handle environment variables if (message.type === 'set_env') { console.log(`[Proxy] Received set_env command`); if (message.env) { Object.entries(message.env).forEach(([key, value]) => { console.log(`[Proxy] Setting env: ${key}=${value}`); process.env[key] = String(value); }); // Send success response ws.send(JSON.stringify({ event: 'env_set', sessionId: message.sessionId, count: Object.keys(message.env).length })); } return; } // Handle start_screencast command specially to set up frame capture if (message.type === 'start_screencast' || message.type === 'stop_screencast') { console.log(`[Proxy] Received ${message.type} command`); // Forward to current session if available if (currentSession && messageHandlers.length > 0) { const messageStr = JSON.stringify(message); console.log(`[Proxy] Forwarding ${message.type} to session`); for (const handler of messageHandlers) { try { handler(messageStr); } catch (err) { console.error(`[Proxy] Error in message handler:`, err); } } } else { console.log(`[Proxy] Warning: No session available to handle ${message.type}`); } return; // Don't process further } // Forward all commands to current session via WebSocket if (currentSession && message.sessionId && messageHandlers.length > 0) { // Forward the message to all registered handlers const messageStr = JSON.stringify(message); console.log(`[Proxy] Forwarding command to session: ${message.type}`); for (const handler of messageHandlers) { try { handler(messageStr); } catch (err) { console.error(`[Proxy] Error in message handler:`, err); } } } } catch (error) { console.error(`${chalk_1.default.red('❌')} Error handling message:`, error); } }); ws.on('error', (error) => { console.error(`${chalk_1.default.red('❌')} Proxy connection error:`, error.message); }); ws.on('close', (code, reason) => { console.log(`\n${chalk_1.default.yellow('⚠')} Proxy connection closed: ${code} - ${reason}`); cleanup(); // Auto-reconnect after 5 seconds console.log(`${chalk_1.default.gray('Reconnecting in 5 seconds...')}`); setTimeout(() => { connectToProxy(options); }, 5000); }); // Handle graceful shutdown process.on('SIGINT', () => { console.log(`\n${chalk_1.default.yellow('⚠')} Shutting down...`); cleanup(); ws.close(); process.exit(0); }); } //# sourceMappingURL=server.js.map