@withkeystone/cli
Version:
Keystone CLI - Test automation for modern web apps
583 lines (582 loc) • 26.4 kB
JavaScript
;
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