Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0aaed03
Initial plan
Copilot Mar 6, 2026
2ccb981
Fix critical security vulnerabilities and bugs in CodeceptUI
Copilot Mar 6, 2026
dcd1554
Add tests for security fixes and bug fixes
Copilot Mar 6, 2026
a0e9914
Address code review feedback: fix fgrep escaping, path consistency, t…
Copilot Mar 6, 2026
f4897d3
Adapt CodeceptUI for CodeceptJS 4.x ESM compatibility
Copilot Mar 6, 2026
5d342ee
Address code review: add error handling for async module loading, fix…
Copilot Mar 6, 2026
6e03a9b
Rename root config files from .js to .cjs for ESM compatibility
Copilot Mar 6, 2026
aac5f7e
Convert lib/utils/ files from CommonJS to ESM
Copilot Mar 6, 2026
921b695
Convert lib/config/ files from CommonJS to ESM
Copilot Mar 6, 2026
564d3ad
Convert lib/model/ from CommonJS to ESM
Copilot Mar 6, 2026
7135c45
Convert lib/codeceptjs/ from CommonJS to ESM
Copilot Mar 6, 2026
99a75a2
Convert lib/api/ files from CommonJS to ESM
Copilot Mar 6, 2026
79c088e
Convert bin/codecept-ui.js, lib/commands/init.js, lib/commands/electr…
Copilot Mar 6, 2026
61a5e7f
Convert all test files from CommonJS to ESM
Copilot Mar 6, 2026
3e0247d
Fix remaining ESM issues: cheerio import, settings default export, ou…
Copilot Mar 6, 2026
241dea6
Fix misleading comment about testrunRepo import timing
Copilot Mar 6, 2026
9c9ec27
Add e2e tests for CodeceptJS 4.x ESM integration and fix @codeceptjs/…
Copilot Mar 7, 2026
1e7fa54
Add interaction-based e2e tests for CodeceptUI actions with screensho…
Copilot Mar 7, 2026
69ae975
Fix codecept_helper global for backward compatibility with CJS helpers
Copilot Mar 7, 2026
333c309
Fix test running hanging forever: set global.container and align even…
Copilot Mar 8, 2026
c5cf3e8
Fix CI workflow: remove NODE_OPTIONS from backend/tests, use --legacy…
Copilot Mar 9, 2026
8ca2ea4
Log full error object in list-steps.js catch handler for better debug…
Copilot Mar 9, 2026
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
12 changes: 9 additions & 3 deletions .github/workflows/e2-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ jobs:
- run: git config --global user.name "GitHub CD bot"
- run: git config --global user.email "github-cd-bot@example.com"
- name: Install deps
run: export NODE_OPTIONS=--openssl-legacy-provider && npm i -g wait-for-localhost-cli && PUPPETEER_SKIP_DOWNLOAD=true npm i -f
run: npm i -g wait-for-localhost-cli && PUPPETEER_SKIP_DOWNLOAD=true npm i --legacy-peer-deps
- name: Run unit tests
run: npm test
- name: Build frontend
run: export NODE_OPTIONS=--openssl-legacy-provider && npm run build
run: NODE_OPTIONS=--openssl-legacy-provider npm run build
- name: Start app and run tests
run: export NODE_OPTIONS=--openssl-legacy-provider && npm run backend & wait-for-localhost 3333; cd test/e2e; npm i && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npx playwright install-deps chromium && npm run test
run: |
node bin/codecept-ui.js -c node_modules/@codeceptjs/examples/codecept.conf.js &
wait-for-localhost 3333
cd test/e2e
npm i
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npx playwright install-deps chromium
npm run test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions .github/workflows/publish-node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ jobs:
- run: git config --global user.name "GitHub CD bot"
- run: git config --global user.email "github-cd-bot@example.com"
- name: Install deps
run: npm i
run: npm i --legacy-peer-deps
- name: Run unit tests
run: npm test
- name: Build the app
run: export NODE_OPTIONS=--openssl-legacy-provider && npm run build
run: NODE_OPTIONS=--openssl-legacy-provider npm run build
- run: npx semantic-release
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ package-lock.json
test/e2e/node_modules
test/e2e/yarn.lock
test/e2e/output
test/e2e-esm/output
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
Loading