Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
65 changes: 22 additions & 43 deletions bin/codecept-ui.js
Original file line number Diff line number Diff line change
@@ -1,73 +1,61 @@
#!/usr/bin/env node
const debug = require('debug')('codeceptjs:ui');

// initialize CodeceptJS and return startup options
const path = require('path');
const { existsSync } = require('fs');
const express = require('express');
const options = require('../lib/commands/init')();
const codeceptjsFactory = require('../lib/model/codeceptjs-factory');
const { getPort } = require('../lib/config/env');

// Configure Socket.IO with CORS support for cross-origin requests
const io = require('socket.io')({
import Debug from 'debug';
const debug = Debug('codeceptjs:ui');

import path from 'path';
import { existsSync } from 'fs';
import express from 'express';
import init from '../lib/commands/init.js';
import codeceptjsFactory from '../lib/model/codeceptjs-factory.js';
import { getPort } from '../lib/config/env.js';
import { Server } from 'socket.io';
import { events } from '../lib/model/ws-events.js';

const options = init();

const io = new Server({
cors: {
origin: process.env.CORS_ORIGIN || `http://localhost:${getPort('application')}`,
credentials: true,
methods: ["GET", "POST"],
transports: ['websocket', 'polling']
},
allowEIO3: true, // Support for older Socket.IO clients
// Add additional configuration for better reliability
allowEIO3: true,
pingTimeout: 60000,
pingInterval: 25000,
connectTimeout: 45000,
serveClient: true,
// Allow connections from localhost variations
allowRequest: (req, callback) => {
const origin = req.headers.origin;
const host = req.headers.host;

// Allow localhost connections and same-host connections
if (!origin ||
origin.includes('localhost') ||
origin.includes('127.0.0.1') ||
(host && origin.includes(host.split(':')[0]))) {
callback(null, true);
} else {
callback(null, true); // Allow all for now, can be more restrictive if needed
callback(null, true);
}
}
});

const { events } = require('../lib/model/ws-events');

// Serve frontend from dist
const AppDir = path.join(__dirname, '..', 'dist');
const AppDir = path.join(import.meta.dirname, '..', 'dist');
if (!existsSync(AppDir)) {
// eslint-disable-next-line no-console
console.error('\n ⚠️You have to build Vue application by `npm run build`\n');
process.exit(1);
}


codeceptjsFactory.create({}, options).then(() => {
codeceptjsFactory.create({}, options).then(async () => {
debug('CodeceptJS initialized, starting application');

const api = require('../lib/api');
const apiModule = await import('../lib/api/index.js');
const api = apiModule.default;
const app = express();



/**
* HTTP Routes
*/
app.use(express.static(AppDir));
app.use('/api', api);

/**
* Websocket Events
*/
io.on('connection', socket => {
const emit = (evtName, data) => {
debug(evtName);
Expand All @@ -85,26 +73,19 @@ codeceptjsFactory.create({}, options).then(() => {
const applicationPort = options.port;
const webSocketsPort = options.wsPort;

// Start servers with proper error handling and readiness checks
let httpServer;
let wsServer;

try {
// Start WebSocket server first
wsServer = io.listen(webSocketsPort);
debug(`WebSocket server started on port ${webSocketsPort}`);

// Start HTTP server
httpServer = app.listen(applicationPort, () => {
// eslint-disable-next-line no-console
console.log('🌟 CodeceptUI started!');
// eslint-disable-next-line no-console
console.log(`👉 Open http://localhost:${applicationPort} to see CodeceptUI in a browser\n\n`);
// eslint-disable-next-line no-console
debug(`Listening for websocket connections on port ${webSocketsPort}`);
});

// Handle server errors
httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`❌ Port ${applicationPort} is already in use. Please try a different port or stop the service using this port.`);
Expand All @@ -128,7 +109,6 @@ codeceptjsFactory.create({}, options).then(() => {
process.exit(1);
}

// Graceful shutdown handling
const gracefulShutdown = () => {
console.log('\n🛑 Shutting down CodeceptUI...');
if (httpServer) {
Expand All @@ -152,9 +132,8 @@ codeceptjsFactory.create({}, options).then(() => {
});

if (options.app) {
// open electron app
global.isElectron = true;
require('../lib/commands/electron');
await import('../lib/commands/electron.js');
}

}).catch((e) => {
Expand Down
File renamed without changes.
122 changes: 70 additions & 52 deletions lib/api/editor.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const editorRepository = require('../model/editor-repository');
const path = require('path');
const codeceptjsFactory = require('../model/codeceptjs-factory');
import editorRepository from '../model/editor-repository.js';
import fs from 'fs';
import path from 'path';
import codeceptjsFactory from '../model/codeceptjs-factory.js';

// Helper function to get CodeceptJS config
const getCodeceptjsConfig = () => {
Expand All @@ -17,11 +18,52 @@ const getCodeceptjsConfig = () => {
}
};

/**
* Securely resolve a file path and ensure it's within the tests directory.
* Uses fs.realpathSync to prevent symlink-based directory traversal.
* @param {string} file - Relative file path from request
* @returns {{ filePath: string } | { error: string, status: number }}
*/
const resolveSecurePath = (file) => {
const config = getCodeceptjsConfig();
const testsPath = config.tests || './';
const filePath = path.resolve(testsPath, file);

// Resolve real paths to prevent symlink attacks
let realTestsDir;
try {
realTestsDir = fs.realpathSync(path.resolve(testsPath));
} catch (err) {
return { error: 'Tests directory not found.', status: 500 };
}

// Check resolved path before realpathSync (file may not exist yet)
if (!filePath.startsWith(realTestsDir)) {
return { error: 'Access denied. File must be within tests directory.', status: 403 };
}

// For existing files, also check the real path to prevent symlink attacks
if (fs.existsSync(filePath)) {
try {
const realFilePath = fs.realpathSync(filePath);
if (!realFilePath.startsWith(realTestsDir)) {
return { error: 'Access denied. File must be within tests directory.', status: 403 };
}
return { filePath: realFilePath };
} catch (err) {
return { error: 'Failed to resolve file path.', status: 500 };
}
}

// File doesn't exist yet — normalize the path for consistency
return { filePath: path.normalize(filePath) };
};

/**
* Get scenario source code for editing
* GET /api/editor/scenario/:file/:line
*/
module.exports.getScenarioSource = async (req, res) => {
export const getScenarioSource = async (req, res) => {
try {
const { file, line } = req.params;
const lineNumber = parseInt(line, 10);
Expand All @@ -33,18 +75,12 @@ module.exports.getScenarioSource = async (req, res) => {
});
}

// Get the absolute file path
const config = getCodeceptjsConfig();
const testsPath = config.tests || './';
const filePath = path.resolve(testsPath, file);

// Security check - ensure file is within tests directory
const testsDir = path.resolve(testsPath);
if (!filePath.startsWith(testsDir)) {
return res.status(403).json({
error: 'Access denied. File must be within tests directory.'
});
// Securely resolve file path
const resolved = resolveSecurePath(file);
if (resolved.error) {
return res.status(resolved.status).json({ error: resolved.error });
}
const filePath = resolved.filePath;

const result = editorRepository.getScenarioSource(filePath, lineNumber);

Expand All @@ -71,7 +107,7 @@ module.exports.getScenarioSource = async (req, res) => {
* Update scenario source code
* PUT /api/editor/scenario/:file/:line
*/
module.exports.updateScenario = async (req, res) => {
export const updateScenario = async (req, res) => {
try {
const { file, line } = req.params;
const { source } = req.body;
Expand All @@ -84,18 +120,12 @@ module.exports.updateScenario = async (req, res) => {
});
}

// Get the absolute file path
const config = getCodeceptjsConfig();
const testsPath = config.tests || './';
const filePath = path.resolve(testsPath, file);

// Security check
const testsDir = path.resolve(testsPath);
if (!filePath.startsWith(testsDir)) {
return res.status(403).json({
error: 'Access denied. File must be within tests directory.'
});
// Securely resolve file path
const resolved = resolveSecurePath(file);
if (resolved.error) {
return res.status(resolved.status).json({ error: resolved.error });
}
const filePath = resolved.filePath;

const success = editorRepository.updateScenario(filePath, lineNumber, source);

Expand Down Expand Up @@ -124,7 +154,7 @@ module.exports.updateScenario = async (req, res) => {
* Get full file content for editing
* GET /api/editor/file/:file
*/
module.exports.getFileContent = async (req, res) => {
export const getFileContent = async (req, res) => {
try {
const { file } = req.params;

Expand All @@ -134,18 +164,12 @@ module.exports.getFileContent = async (req, res) => {
});
}

// Get the absolute file path
const config = getCodeceptjsConfig();
const testsPath = config.tests || './';
const filePath = path.resolve(testsPath, file);

// Security check
const testsDir = path.resolve(testsPath);
if (!filePath.startsWith(testsDir)) {
return res.status(403).json({
error: 'Access denied. File must be within tests directory.'
});
// Securely resolve file path
const resolved = resolveSecurePath(file);
if (resolved.error) {
return res.status(resolved.status).json({ error: resolved.error });
}
const filePath = resolved.filePath;

const content = editorRepository.getFileContent(filePath);

Expand All @@ -170,7 +194,7 @@ module.exports.getFileContent = async (req, res) => {
* Update full file content
* PUT /api/editor/file/:file
*/
module.exports.updateFileContent = async (req, res) => {
export const updateFileContent = async (req, res) => {
try {
const { file } = req.params;
const { content } = req.body;
Expand All @@ -181,18 +205,12 @@ module.exports.updateFileContent = async (req, res) => {
});
}

// Get the absolute file path
const config = getCodeceptjsConfig();
const testsPath = config.tests || './';
const filePath = path.resolve(testsPath, file);

// Security check
const testsDir = path.resolve(testsPath);
if (!filePath.startsWith(testsDir)) {
return res.status(403).json({
error: 'Access denied. File must be within tests directory.'
});
// Securely resolve file path
const resolved = resolveSecurePath(file);
if (resolved.error) {
return res.status(resolved.status).json({ error: resolved.error });
}
const filePath = resolved.filePath;

const success = editorRepository.updateFileContent(filePath, content);

Expand Down Expand Up @@ -221,7 +239,7 @@ module.exports.updateFileContent = async (req, res) => {
* Get CodeceptJS autocomplete suggestions
* GET /api/editor/autocomplete
*/
module.exports.getAutocompleteSuggestions = async (req, res) => {
export const getAutocompleteSuggestions = async (req, res) => {
try {
const suggestions = editorRepository.getAutocompleteSuggestions();

Expand Down
4 changes: 2 additions & 2 deletions lib/api/get-config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const codeceptjsFactory = require('../model/codeceptjs-factory');
import codeceptjsFactory from '../model/codeceptjs-factory.js';

module.exports = (req, res) => {
export default (req, res) => {
const internalHelpers = Object.keys(codeceptjsFactory.codeceptjsHelpersConfig.helpers);
const { config, container } = codeceptjsFactory.getInstance();
const helpers = Object.keys(container.helpers()).filter(helper => internalHelpers.indexOf(helper) < 0);
Expand Down
Loading