├── .npmrc ├── codeceptui.gif ├── codecept-ui2.gif ├── lib ├── utils │ ├── port-validator.js │ ├── port-type-validator.js │ ├── absolutize-paths.js │ ├── mkdir.js │ ├── port-validator.spec.js │ ├── port-type-validator.spec.js │ ├── mkdir.spec.js │ ├── safe-serialize.js │ └── absolutize-paths.spec.js ├── api │ ├── get-settings.js │ ├── list-profiles.js │ ├── get-scenario-status.js │ ├── open-test-in-editor.js │ ├── get-scenario.js │ ├── save-testrun.js │ ├── get-steps.js │ ├── get-testrun.js │ ├── list-scenarios.js │ ├── get-config.js │ ├── script.js │ ├── get-snapshot-html.js │ ├── get-page-objects.js │ ├── list-steps.js │ ├── store-settings.js │ ├── run-scenario-parallel.js │ ├── get-snapshot-image.js │ ├── run-scenario.js │ ├── stop.js │ ├── list-actions.js │ ├── new-test.js │ ├── list-profiles.spec.js │ ├── get-file.js │ ├── index.js │ └── stop.spec.js ├── config │ ├── url.js │ ├── env.js │ └── url.spec.js ├── model │ ├── throttling.js │ ├── settings-repository.js │ ├── open-in-editor.js │ ├── profile-repository.js │ ├── testrun-repository.js │ ├── scenario-status-repository.js │ ├── snapshot-store │ │ ├── index.js │ │ ├── fix-htmls-snapshot.spec.js │ │ └── fix-html-snapshot.js │ ├── profile-repository.spec.js │ ├── throttling.spec.js │ └── ws-events.js ├── codeceptjs │ ├── configure │ │ └── setBrowser.js │ ├── network-recorder.helper.js │ ├── console-recorder.helper.js │ ├── single-session.helper.js │ └── brk.js ├── commands │ ├── electron.js │ └── init.js └── app.js ├── public ├── icon.png ├── favicon.ico ├── favicon.png └── index.html ├── src ├── assets │ ├── logo.png │ └── tailwind.css ├── store │ ├── modules │ │ ├── profiles.js │ │ ├── testrun-page.js │ │ ├── cli.js │ │ ├── settings.js │ │ └── scenarios.js │ └── index.js ├── services │ ├── selector.js │ └── selector-finder.js ├── components │ ├── steps │ │ ├── ActionStep.vue │ │ ├── ConsoleLogStep.vue │ │ ├── WaiterStep.vue │ │ ├── GrabberStep.vue │ │ ├── AssertionStep.vue │ │ ├── MetaStep.vue │ │ └── Argument.vue │ ├── CapabilityFolder.vue │ ├── SnapshotREST.vue │ ├── pages │ │ ├── SettingsPage.vue │ │ └── PageObjectsPage.vue │ ├── TestResult.vue │ ├── RuntimeModeIndicator.vue │ ├── ProfileSelection.vue │ ├── EditorNotFound.vue │ ├── Header.vue │ ├── RunButton.vue │ ├── Logo.vue │ ├── Feature.vue │ ├── Console.vue │ ├── Scenario.vue │ └── EnhancedLoading.vue ├── routes.js ├── main.js ├── App.vue └── background.js ├── babel.config.js ├── codecept-ui-test-editor.png ├── codecept-ui-window-mode.png ├── codecept-ui-headless-mode.png ├── codecept-ui-page-objects.png ├── codecept-ui-settings-demo.png ├── codecept-ui-demo-interface.png ├── codecept-ui-ide-split-view.png ├── codecept-ui-main-interface.png ├── codecept-ui-monaco-editor-demo.png ├── codecept-ui-test-results-demo.png ├── codecept-ui-mobile-responsive-demo.png ├── codecept-ui-monaco-autocomplete-demo.png ├── postcss.config.js ├── tailwind.js ├── .editorconfig ├── vue.config.js ├── test ├── e2e │ ├── steps_file.ts │ ├── steps.d.ts │ ├── tsconfig.json │ ├── package.json │ └── codecept.conf.ts ├── cors-config.spec.js ├── reverse-proxy.spec.js ├── memory-management.spec.js ├── ws-events-circular-fix.spec.js ├── safe-serialize.spec.js ├── memory-fixes.spec.js ├── server-startup.spec.js ├── browser-cleanup.spec.js ├── file-api-fixes.spec.js ├── ui-enhancements.spec.js └── language-detection.spec.js ├── codecept.conf.js ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE.md ├── workflows │ ├── e2-tests.yml │ └── publish-node.js.yml └── CONTRIBUTING.md ├── .gitignore └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /codeceptui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codeceptui.gif -------------------------------------------------------------------------------- /codecept-ui2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui2.gif -------------------------------------------------------------------------------- /lib/utils/port-validator.js: -------------------------------------------------------------------------------- 1 | module.exports = (port) => port && parseInt(port); 2 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | }; 6 | -------------------------------------------------------------------------------- /src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /codecept-ui-test-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-test-editor.png -------------------------------------------------------------------------------- /codecept-ui-window-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-window-mode.png -------------------------------------------------------------------------------- /codecept-ui-headless-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-headless-mode.png -------------------------------------------------------------------------------- /codecept-ui-page-objects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-page-objects.png -------------------------------------------------------------------------------- /codecept-ui-settings-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-settings-demo.png -------------------------------------------------------------------------------- /codecept-ui-demo-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-demo-interface.png -------------------------------------------------------------------------------- /codecept-ui-ide-split-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-ide-split-view.png -------------------------------------------------------------------------------- /codecept-ui-main-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-main-interface.png -------------------------------------------------------------------------------- /codecept-ui-monaco-editor-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-monaco-editor-demo.png -------------------------------------------------------------------------------- /codecept-ui-test-results-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-test-results-demo.png -------------------------------------------------------------------------------- /codecept-ui-mobile-responsive-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-mobile-responsive-demo.png -------------------------------------------------------------------------------- /codecept-ui-monaco-autocomplete-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeceptjs/ui/HEAD/codecept-ui-monaco-autocomplete-demo.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'plugins': [ 3 | require('tailwindcss')('tailwind.js'), 4 | require('autoprefixer')(), 5 | ] 6 | }; -------------------------------------------------------------------------------- /tailwind.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | extend: {} 4 | }, 5 | purge: [ 6 | './src/**/*.vue', 7 | ], 8 | variants: {}, 9 | plugins: [] 10 | }; 11 | -------------------------------------------------------------------------------- /lib/utils/port-type-validator.js: -------------------------------------------------------------------------------- 1 | const TYPES = ['application', 'ws']; 2 | 3 | module.exports = (type) => { 4 | if (!TYPES.includes(type)) { 5 | throw Error('Type must be "application" or "ws"'); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /lib/api/get-settings.js: -------------------------------------------------------------------------------- 1 | const settingsRepository = require('../model/settings-repository'); 2 | 3 | module.exports = (req, res) => { 4 | const settings = settingsRepository.getSettings(); 5 | 6 | res.json(settings); 7 | }; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const { getPort } = require('./lib/config/env'); 2 | 3 | module.exports = { 4 | devServer: { 5 | proxy: { 6 | '^/api': { 7 | target: `http://localhost:${getPort('application')}`, 8 | } 9 | } 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /lib/config/url.js: -------------------------------------------------------------------------------- 1 | const { getPort } = require('./env'); 2 | const portTypeValidator = require('../utils/port-type-validator'); 3 | 4 | module.exports = { 5 | getUrl(type) { 6 | portTypeValidator(type); 7 | return `http://localhost:${getPort(type)}`; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /test/e2e/steps_file.ts: -------------------------------------------------------------------------------- 1 | // in this file you can append custom step methods to 'I' object 2 | 3 | export = function() { 4 | return actor({ 5 | 6 | // Define custom steps here, use 'this' to access default methods of I. 7 | // It is recommended to place a general 'login' function here. 8 | 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /lib/api/list-profiles.js: -------------------------------------------------------------------------------- 1 | const profileRepository = require('../model/profile-repository'); 2 | 3 | module.exports = (req, res) => { 4 | const profiles = profileRepository.getProfiles(); 5 | if (!profiles) { 6 | return res.status(404).json({ message: 'No profiles configured' }); 7 | } 8 | res.json(profiles); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/model/throttling.js: -------------------------------------------------------------------------------- 1 | const throttled = (delay, fn) => { 2 | let lastCall = 0; 3 | return function (...args) { 4 | const now = (new Date).getTime(); 5 | if (now - lastCall < delay) { 6 | return; 7 | } 8 | lastCall = now; 9 | return fn(...args); 10 | }; 11 | }; 12 | 13 | module.exports = throttled; 14 | -------------------------------------------------------------------------------- /codecept.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tests: './test/e2e/tests/*.js', 3 | output: './output', 4 | helpers: { 5 | Playwright: { 6 | url: 'http://localhost:3000', 7 | browser: 'chromium', 8 | show: false, 9 | } 10 | }, 11 | include: {}, 12 | bootstrap: null, 13 | mocha: {}, 14 | name: 'ui-test' 15 | }; -------------------------------------------------------------------------------- /lib/utils/absolutize-paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = (obj) => { 4 | Object.keys(obj).forEach(key => { 5 | if (typeof obj[key] === 'string' && obj[key] && !path.isAbsolute(obj[key])) { 6 | obj[key] = path.resolve(global.codecept_dir || '', obj[key]); 7 | } 8 | }); 9 | 10 | return obj; 11 | }; 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # github-actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | target-branch: "master" 9 | # npm 10 | - package-ecosystem: "npm" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | target-branch: "master" 15 | -------------------------------------------------------------------------------- /lib/api/get-scenario-status.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:get-scenario-status'); 2 | const scenarioStatusRepository = require('../model/scenario-status-repository'); 3 | 4 | module.exports = (req, res) => { 5 | const status = scenarioStatusRepository.getStatus(); 6 | debug(`Initial status is ${JSON.stringify(status)}`); 7 | 8 | res.json(status); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/utils/mkdir.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const mkdir = function(dir) { 3 | // making directory without exception if exists 4 | try { 5 | if (!fs.existsSync(dir)) { 6 | fs.mkdirSync(dir, { recursive: true }); 7 | } 8 | } catch(e) { 9 | if(e.code !== 'EEXIST') { 10 | throw e; 11 | } 12 | } 13 | }; 14 | 15 | module.exports = mkdir; 16 | -------------------------------------------------------------------------------- /test/e2e/steps.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type steps_file = typeof import('./steps_file'); 3 | 4 | declare namespace CodeceptJS { 5 | interface SupportObject { I: I, current: any } 6 | interface Methods extends Playwright {} 7 | interface I extends ReturnType {} 8 | namespace Translation { 9 | interface Actions {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/api/open-test-in-editor.js: -------------------------------------------------------------------------------- 1 | const openInEditor = require('../model/open-in-editor'); 2 | 3 | 4 | module.exports = async (req, res) => { 5 | const { file } = req.params; 6 | try { 7 | await openInEditor(file); 8 | res.json('OK'); 9 | } catch (e) { 10 | res.status(422).send({ message: 'Editor can\'t be started', description: e.stderr.toString().trim() }); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/api/get-scenario.js: -------------------------------------------------------------------------------- 1 | const scenarioRepository = require('../model/scenario-repository'); 2 | 3 | module.exports = (req, res) => { 4 | const { id } = req.params; 5 | 6 | const scenario = scenarioRepository.getScenario(id); 7 | if (!scenario) { 8 | return res.status(404).json({ 9 | message: `Could not find scenario ${id}`, 10 | }); 11 | } 12 | res.json(scenario); 13 | }; 14 | -------------------------------------------------------------------------------- /test/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "files": true 4 | }, 5 | "compilerOptions": { 6 | "target": "es2018", 7 | "lib": ["es2018", "DOM"], 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "strictNullChecks": false, 11 | "types": ["codeceptjs", "node"], 12 | "declaration": true, 13 | "skipLibCheck": true 14 | }, 15 | "exclude": ["node_modules"] 16 | } -------------------------------------------------------------------------------- /lib/api/save-testrun.js: -------------------------------------------------------------------------------- 1 | const testRunRepository = require('../model/testrun-repository'); 2 | const snapshotStore = require('../model/snapshot-store'); 3 | 4 | module.exports = (req, res) => { 5 | const { id } = req.params; 6 | 7 | const saveId = encodeURIComponent(id); 8 | 9 | testRunRepository.saveTestRun(saveId, req.body); 10 | snapshotStore.saveWithTestRun(saveId); 11 | 12 | res.json({ 13 | message: 'OK' 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/model/settings-repository.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:settings-repository'); 2 | 3 | const settings = {}; 4 | 5 | const getSettings = () => { 6 | debug('get settings', settings); 7 | return settings; 8 | }; 9 | 10 | const storeSettings = newSettings => { 11 | debug('set settings', settings); 12 | Object.assign(settings, newSettings); 13 | }; 14 | 15 | module.exports = { 16 | getSettings, 17 | storeSettings 18 | }; 19 | -------------------------------------------------------------------------------- /test/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npx codeceptjs run --verbose" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "codeceptjs": "^3.7.4", 13 | "playwright": "^1.41.2" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "20.11.20", 17 | "ts-node": "10.9.2", 18 | "typescript": "5.3.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | #Electron-builder output 24 | /dist_electron 25 | 26 | package-lock.json 27 | 28 | 29 | test/e2e/node_modules 30 | test/e2e/yarn.lock 31 | test/e2e/output 32 | -------------------------------------------------------------------------------- /lib/api/get-steps.js: -------------------------------------------------------------------------------- 1 | const bddHelper = require('codeceptjs/lib/mocha/bdd'); 2 | const stepsData = bddHelper.getSteps(); 3 | const steps = {}; 4 | 5 | Object.keys(stepsData).map(name => { 6 | const line = stepsData[name].line; 7 | const matches = line.match(/^\((?.+):(?\d+):(?\d+)\)$/); 8 | if (matches && matches.groups) { 9 | steps[name] = { 10 | ...matches.groups, 11 | }; 12 | } 13 | }); 14 | 15 | module.exports = (req, res) => { 16 | res.json(steps); 17 | }; 18 | -------------------------------------------------------------------------------- /src/store/modules/profiles.js: -------------------------------------------------------------------------------- 1 | const profiles = { 2 | namespaced: true, 3 | state: { 4 | selectedProfileName: undefined, 5 | }, 6 | getters: { 7 | selectedProfileName: (state) => { 8 | if (!state.selectedProfileName) { 9 | return undefined; 10 | } 11 | return state.selectedProfileName; 12 | } 13 | }, 14 | mutations: { 15 | selectProfileName: (state, profileName) => { 16 | state.selectedProfileName = profileName; 17 | } 18 | } 19 | }; 20 | 21 | export default profiles; -------------------------------------------------------------------------------- /lib/api/get-testrun.js: -------------------------------------------------------------------------------- 1 | const testRunRepository = require('../model/testrun-repository'); 2 | const snapshotStore = require('../model/snapshot-store'); 3 | 4 | module.exports = (req, res) => { 5 | const { id } = req.params; 6 | 7 | const saveId = encodeURIComponent(id); 8 | 9 | const testRun = testRunRepository.getTestRun(saveId); 10 | if (!testRun) return res.status(404).json({ 11 | message: `No testrun with id ${saveId}`, 12 | }); 13 | 14 | snapshotStore.restoreFromTestRun(saveId); 15 | 16 | res.json(testRun); 17 | }; -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vuex from 'vuex'; 2 | 3 | import testRuns from './modules/testruns'; 4 | import testRunPage from './modules/testrun-page'; 5 | import cli from './modules/cli'; 6 | import scenarios from './modules/scenarios'; 7 | import profiles from './modules/profiles'; 8 | import settings from './modules/settings'; 9 | 10 | const store = new Vuex.Store({ 11 | modules: { 12 | testRuns, 13 | testRunPage, 14 | cli, 15 | scenarios, 16 | profiles, 17 | settings 18 | } 19 | }); 20 | 21 | export default store; -------------------------------------------------------------------------------- /src/services/selector.js: -------------------------------------------------------------------------------- 1 | export function getSelectorString(stepArg) { 2 | const first = stepArg; 3 | 4 | let value = first; 5 | let label = value; 6 | 7 | if (!first) return { label, value: '' }; 8 | 9 | if (typeof first === 'object') { 10 | if (first.output) { 11 | label = `<${first.output}>`; 12 | value = first.value || first; 13 | } else if (first.xpath || first.css) { 14 | value = first.xpath || first.css; 15 | label = value; 16 | } else { 17 | value = first.value || first; 18 | label = value; 19 | } 20 | } 21 | return { label, value }; 22 | } -------------------------------------------------------------------------------- /lib/api/list-scenarios.js: -------------------------------------------------------------------------------- 1 | const scenarioRepository = require('../model/scenario-repository'); 2 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 3 | 4 | module.exports = (req, res) => { 5 | const { config } = codeceptjsFactory.getInstance(); 6 | const searchQuery = req.query.q; 7 | const matchType = req.query.m || 'all'; 8 | 9 | scenarioRepository.reloadSuites(); 10 | const features = scenarioRepository.getFeatures(searchQuery, { matchType }); 11 | 12 | res.send({ 13 | name: config.get('name'), 14 | features: scenarioRepository.groupFeaturesByCapability(features), 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/steps/ActionStep.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /lib/api/get-config.js: -------------------------------------------------------------------------------- 1 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 2 | 3 | module.exports = (req, res) => { 4 | const internalHelpers = Object.keys(codeceptjsFactory.codeceptjsHelpersConfig.helpers); 5 | const { config, container } = codeceptjsFactory.getInstance(); 6 | const helpers = Object.keys(container.helpers()).filter(helper => internalHelpers.indexOf(helper) < 0); 7 | 8 | const currentConfig = { 9 | helpers, 10 | plugins: Object.keys(container.plugins()), 11 | file: codeceptjsFactory.getConfigFile(), 12 | config: config.get(), 13 | }; 14 | 15 | res.json(currentConfig); 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import NewTestPage from './components/pages/NewTestPage.vue'; 2 | import ScenariosPage from './components/pages/ScenariosPage.vue'; 3 | import SettingsPage from './components/pages/SettingsPage.vue'; 4 | import TestRunPage from './components/pages/TestRunPage.vue'; 5 | import PageObjectsPage from '@/components/pages/PageObjectsPage.vue'; 6 | 7 | export default [ 8 | { path: '/', component: ScenariosPage }, 9 | { path: '/page-objects', component: PageObjectsPage }, 10 | { path: '/testrun/:scenarioId', component: TestRunPage }, 11 | { path: '/new-test', component: NewTestPage }, 12 | { path: '/settings', component: SettingsPage }, 13 | ]; 14 | -------------------------------------------------------------------------------- /lib/codeceptjs/configure/setBrowser.js: -------------------------------------------------------------------------------- 1 | const { config } = require('codeceptjs'); 2 | 3 | module.exports = function(browser) { 4 | 5 | config.addHook((cfg) => { 6 | if (cfg.helpers.Puppeteer) { 7 | cfg.helpers.Puppeteer.browser = browser; 8 | } 9 | if (cfg.helpers.Protractor) { 10 | cfg.helpers.Protractor.browser = browser; 11 | } 12 | if (cfg.helpers.WebDriver) { 13 | cfg.helpers.WebDriver.browser = browser; 14 | } 15 | if (cfg.helpers.TestCafe) { 16 | cfg.helpers.TestCafe.browser = browser; 17 | } 18 | if (cfg.helpers.Appium) { 19 | cfg.helpers.Appium.browser = browser; 20 | } 21 | }); 22 | }; -------------------------------------------------------------------------------- /src/components/steps/ConsoleLogStep.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/api/script.js: -------------------------------------------------------------------------------- 1 | const { event } = require('codeceptjs'); 2 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 3 | 4 | const { codecept, container } = codeceptjsFactory.getInstance({}, { 5 | grep: 'Create a new todo item' 6 | }); 7 | 8 | const mocha = container.mocha(); 9 | mocha.grep('Create a new todo item'); 10 | 11 | // run bootstrap function from config 12 | codecept.runBootstrap(); 13 | 14 | // process.chdir(TestProjectDir); 15 | 16 | codecept.run(); 17 | 18 | event.dispatcher.once(event.all.result, () => { 19 | mocha.unloadFiles(); 20 | mocha.suite.cleanReferences(); 21 | mocha.suite.suites = []; 22 | codecept.run(); 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /lib/api/get-snapshot-html.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:get-snapshot-html'); 2 | const snapshotStore = require('../model/snapshot-store'); 3 | 4 | module.exports = (req, res) => { 5 | const { id } = req.params; 6 | 7 | if (!snapshotStore.exists(id)) { 8 | debug(`step ${id} does not exist`); 9 | res.status(404).send(`A snapshot for this step (${id}) does not exist`); 10 | return; 11 | } 12 | if (!snapshotStore.hasSnapshot(id)) { 13 | debug(`step ${id} does not have a snapshot`); 14 | res.status(404).send(`No snapshot for step id ${id}`); 15 | return; 16 | } 17 | 18 | const source = snapshotStore.get(id).source; 19 | 20 | res.send(source); 21 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What are you trying to achieve? 2 | 3 | - Provide a descriptive text that what you are trying to achieve with CodeceptUI. 4 | - Attach a screenshot should be more than welcome. 5 | - Provide a console output for frontend (open DevTools and copy or make a screenshot of last messages) 6 | - Provide output for backend (copy last mesage from terminal) 7 | 8 | #### What do you get instead? 9 | 10 | > Provide console output if related. 11 | 12 | ```bash 13 | # paste output here 14 | ``` 15 | 16 | > Provide test source code if related 17 | 18 | ```js 19 | // paste test source code here 20 | ``` 21 | 22 | ### Environment info 23 | 24 | - Copy and paste your environment info by using `npx codeceptjs info` 25 | 26 | ```js 27 | // paste env info here 28 | ``` 29 | -------------------------------------------------------------------------------- /lib/model/open-in-editor.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:open-in-editor'); 2 | const { execSync } = require('child_process'); 3 | const { getSettings } = require('./settings-repository'); 4 | 5 | const openVSCode = fileName => { 6 | const { editor } = getSettings(); 7 | return execSync(`${editor} "${fileName}"`, (err) => { 8 | if (!err) return 'Ok'; 9 | console.error('Failed to open editor: ', err.toString()); // eslint-disable-line 10 | console.error(`Please, update your settings. Current editor command: ${editor}`); // eslint-disable-line 11 | debug(`Failed to open Editor - ${err}`); 12 | return `${err}`; 13 | }); 14 | }; 15 | 16 | module.exports = fileName => { 17 | // TODO Make this configurable 18 | openVSCode(fileName); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/steps/WaiterStep.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | 34 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/CapabilityFolder.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | 38 | -------------------------------------------------------------------------------- /lib/api/get-page-objects.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const absolutizePaths = require('../utils/absolutize-paths'); 3 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 4 | const { container, config } = codeceptjsFactory.getInstance(); 5 | 6 | const files = absolutizePaths(config.get('include', {})); 7 | 8 | const supportObjects = container.support(); 9 | const pageObjects = {}; 10 | 11 | Object.getOwnPropertyNames(supportObjects).map(async pageObject => { 12 | pageObjects[pageObject] = { 13 | name: pageObjects[pageObject], 14 | path: files[pageObject], 15 | source: await fs.readFile(files[pageObject], 'utf-8'), 16 | methods: Object.keys(supportObjects[pageObject]), 17 | }; 18 | }); 19 | 20 | module.exports = (req, res) => { 21 | res.json(pageObjects); 22 | }; 23 | -------------------------------------------------------------------------------- /lib/config/env.js: -------------------------------------------------------------------------------- 1 | const portTypeValidator = require('../utils/port-type-validator'); 2 | 3 | const DEFAULTS = { 4 | application: process.env.PORT || 3333, 5 | ws: process.env.WS_PORT || 2999, 6 | }; 7 | 8 | module.exports = { 9 | getPort(type) { 10 | portTypeValidator(type); 11 | // Support both new and legacy environment variable naming conventions 12 | // This ensures compatibility when users set port=X or wsPort=Y 13 | const legacyEnvVar = type === 'application' ? process.env.port : process.env.wsPort; 14 | const modernEnvVar = process.env[`${type}Port`]; 15 | return modernEnvVar || legacyEnvVar || DEFAULTS[type]; 16 | }, 17 | setPort(type, port) { 18 | portTypeValidator(type); 19 | return process.env[`${type}Port`] = port && Number(port) || DEFAULTS[type]; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/model/profile-repository.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:profile-repository'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const CodeceptJSDir = path.join(process.cwd(), '.codeceptjs'); 6 | const ProfileConfigFile = path.join(CodeceptJSDir, 'profile.conf.js'); 7 | 8 | const getProfiles = () => { 9 | if (!fs.existsSync(ProfileConfigFile)) return; 10 | 11 | const profileConfig = require(ProfileConfigFile); 12 | 13 | // TODO Do a schema check 14 | debug('Read profile config', profileConfig); 15 | 16 | return profileConfig; 17 | }; 18 | 19 | const getProfile = profileName => { 20 | const profiles = getProfiles(); 21 | if (!profiles) return; 22 | return profiles[profileName] || profiles[profiles.default]; 23 | }; 24 | 25 | module.exports = { 26 | getProfiles, 27 | getProfile 28 | }; -------------------------------------------------------------------------------- /src/components/steps/GrabberStep.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 36 | 37 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /lib/api/list-steps.js: -------------------------------------------------------------------------------- 1 | const { getUrl } = require('../config/url'); 2 | const { event } = require('codeceptjs'); 3 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 4 | 5 | const WS_URL = getUrl('ws'); 6 | const TestProjectDir = process.cwd(); 7 | 8 | 9 | module.exports = async (req, res) => { 10 | const socket = require('socket.io-client')(WS_URL); 11 | 12 | const { scenario } = req.params; 13 | const { codecept, container } = codeceptjsFactory.getInstance(); 14 | 15 | const mocha = container.mocha(); 16 | mocha.grep(scenario); 17 | 18 | process.chdir(TestProjectDir); 19 | 20 | event.dispatcher.once(event.all.result, () => { 21 | mocha.unloadFiles(); 22 | mocha.suite.cleanReferences(); 23 | mocha.suite.suites = []; 24 | socket.emit('codeceptjs.exit', process.exitCode); 25 | }); 26 | 27 | codecept.run(); 28 | 29 | res.status(200).send('OK'); 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /lib/api/store-settings.js: -------------------------------------------------------------------------------- 1 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 2 | const { storeSettings } = require('../model/settings-repository'); 3 | const setBrowser = require('../codeceptjs/configure/setBrowser'); 4 | const { setHeadlessWhen, setWindowSize, setHeadedWhen } = require('@codeceptjs/configure'); 5 | 6 | module.exports = (req, res) => { 7 | const settings = req.body || {}; 8 | const { isHeadless, windowSize, browser, editor, isSingleSession } = settings; 9 | 10 | codeceptjsFactory.reloadConfig(() => { 11 | setHeadlessWhen(isHeadless === true); 12 | setHeadedWhen(isHeadless === false); 13 | if (browser) setBrowser(browser); 14 | if (windowSize && windowSize.width && windowSize.height) { 15 | setWindowSize(windowSize.width, windowSize.height); 16 | } 17 | }); 18 | 19 | storeSettings({ editor, isSingleSession }); 20 | 21 | res.json({ 22 | message: 'Settings stored' 23 | }); 24 | }; 25 | 26 | -------------------------------------------------------------------------------- /lib/model/testrun-repository.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:testRunRepository'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const mkdir = require('../utils/mkdir'); 5 | 6 | mkdir(path.join(global.output_dir, '.ui')); 7 | const TestRunBaseDir = path.join(global.output_dir, '.ui', 'testruns'); 8 | mkdir(TestRunBaseDir); 9 | 10 | const fileNameFromId = id => `${encodeURIComponent(id)}.json`; 11 | 12 | module.exports = { 13 | saveTestRun(id, testRun) { 14 | debug(`Saving testrun ${id}`); 15 | fs.writeFileSync(path.join(TestRunBaseDir, fileNameFromId(id)), JSON.stringify(testRun), 'utf8'); 16 | }, 17 | 18 | getTestRun(id) { 19 | const testRunFile = path.join(TestRunBaseDir, fileNameFromId(id)); 20 | if (!fs.existsSync(testRunFile)) return; 21 | 22 | debug(`Retrieving testrun ${id}`); 23 | const testRunAsString = fs.readFileSync(testRunFile); 24 | return JSON.parse(testRunAsString); 25 | } 26 | }; -------------------------------------------------------------------------------- /lib/api/run-scenario-parallel.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const debug = require('debug')('codeceptjs:run-scenario-multiple'); 3 | const wsEvents = require('../model/ws-events'); 4 | const { event } = require('codeceptjs'); 5 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 6 | const runWithWorkers = require('../model/codeceptjs-run-workers'); 7 | 8 | module.exports = async (req, res) => { 9 | const { grep } = req.params; 10 | const numberOfWorkers = os.cpus().length; 11 | 12 | const { config, codecept, container } = codeceptjsFactory.getInstance(); 13 | 14 | event.dispatcher.once(event.all.after, () => { 15 | debug('testrun finished'); 16 | try { 17 | codeceptjsFactory.resetSuites(); 18 | } catch (err) { 19 | debug('ERROR resetting suites', err); 20 | } 21 | wsEvents.codeceptjs.exit(0); 22 | }); 23 | 24 | debug('Running with workers...'); 25 | runWithWorkers(numberOfWorkers, config.get(), { grep }, codecept, container); 26 | 27 | return res.status(200).send('OK'); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/steps/AssertionStep.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 46 | 47 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | CodeceptUI - Interactive Runner for CodeceptJS 15 | 16 | 17 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/api/get-snapshot-image.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:get-snapshot-image'); 2 | const snapshotStore = require('../model/snapshot-store'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | module.exports = (req, res) => { 7 | const { id } = req.params; 8 | 9 | if (!snapshotStore.exists(id)) { 10 | res.status(404).send(`A snapshot for this step (${id}) does not exist`); 11 | return; 12 | } 13 | if (!snapshotStore.hasSnapshot(id)) { 14 | res.status(404).send(`No snapshot for step id ${id}`); 15 | return; 16 | } 17 | 18 | if (!snapshotStore.get(id).screenshot) { 19 | res.status(404).send(`Screenshot file does not exists for ${id}`); 20 | } 21 | 22 | const screenshotFile = path.join(global.output_dir, snapshotStore.get(id).screenshot); 23 | 24 | if (!fs.existsSync(screenshotFile)) { 25 | res.status(404).send(`Screenshot file does not exists for ${id}`); 26 | return; 27 | } 28 | 29 | try { 30 | res.sendFile(screenshotFile); 31 | } catch (err) { 32 | debug(`Screenshot was not loaded by id ${id}`, err); 33 | } 34 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /test/e2e/codecept.conf.ts: -------------------------------------------------------------------------------- 1 | export const config: CodeceptJS.MainConfig = { 2 | tests: './*_test.ts', 3 | output: './output', 4 | helpers: { 5 | Playwright: { 6 | browser: 'chromium', 7 | url: 'http://localhost:3333', 8 | show: false, 9 | timeout: 60000, 10 | waitForNavigation: 'load', 11 | waitForTimeout: 60000, 12 | chromium: { 13 | executablePath: '/usr/bin/chromium', 14 | args: [ 15 | '--no-sandbox', 16 | '--disable-setuid-sandbox', 17 | '--disable-dev-shm-usage', 18 | '--disable-gpu', 19 | '--disable-extensions', 20 | '--disable-plugins', 21 | '--disable-background-timer-throttling', 22 | '--disable-renderer-backgrounding', 23 | '--disable-backgrounding-occluded-windows', 24 | '--no-first-run', 25 | '--no-default-browser-check', 26 | '--disable-web-security', 27 | '--disable-features=VizDisplayCompositor', 28 | '--force-color-profile=srgb', 29 | '--memory-pressure-off', 30 | '--max_old_space_size=4096' 31 | ] 32 | } 33 | } 34 | }, 35 | include: { 36 | I: './steps_file' 37 | }, 38 | name: 'e2e' 39 | } 40 | -------------------------------------------------------------------------------- /src/components/SnapshotREST.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 53 | 58 | -------------------------------------------------------------------------------- /src/components/pages/SettingsPage.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 50 | -------------------------------------------------------------------------------- /.github/workflows/e2-tests.yml: -------------------------------------------------------------------------------- 1 | # This to verify lib version bump doesn't break anything 2 | name: E2E Tests 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | branches: 11 | - '**' 12 | 13 | jobs: 14 | publish-npm: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | registry-url: https://registry.npmjs.org/ 22 | - run: git config --global user.name "GitHub CD bot" 23 | - run: git config --global user.email "github-cd-bot@example.com" 24 | - name: Install deps 25 | run: export NODE_OPTIONS=--openssl-legacy-provider && npm i -g wait-for-localhost-cli && PUPPETEER_SKIP_DOWNLOAD=true npm i -f 26 | - name: Run unit tests 27 | run: npm test 28 | - name: Build frontend 29 | run: export NODE_OPTIONS=--openssl-legacy-provider && npm run build 30 | - name: Start app and run tests 31 | 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 32 | env: 33 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /src/components/steps/MetaStep.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 48 | 49 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/steps/Argument.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 65 | -------------------------------------------------------------------------------- /.github/workflows/publish-node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | name: Publish npm Package 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | - run: git config --global user.name "GitHub CD bot" 20 | - run: git config --global user.email "github-cd-bot@example.com" 21 | - name: Install deps 22 | run: npm i 23 | - name: Run unit tests 24 | run: npm test 25 | - name: Build the app 26 | run: export NODE_OPTIONS=--openssl-legacy-provider && npm run build 27 | - run: npx semantic-release 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | # push the version changes to GitHub 32 | - run: git add package.json package-lock.json && git commit -m'update version' && git push 33 | env: 34 | # The secret is passed automatically. Nothing to configure. 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /lib/codeceptjs/network-recorder.helper.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:network-helper'); 2 | const wsEvents = require('../model/ws-events'); 3 | 4 | // eslint-disable-next-line no-undef 5 | let Helper = codecept_helper; 6 | 7 | class NetworkRecorderHelper extends Helper { 8 | constructor(options) { 9 | super(options); 10 | this.isInitialized = false; 11 | } 12 | 13 | async _before() { 14 | if (this.isInitialized) { 15 | return; 16 | } 17 | const helper = this.helpers['Puppeteer']; 18 | if (!helper) { 19 | debug('Puppeteer helper not found -> network error reporting is disabled'); 20 | } 21 | 22 | const page = helper.page; 23 | 24 | debug('Setting request interception to true'); 25 | await page.setRequestInterception(true); 26 | 27 | page.on('request', interceptedRequest => { 28 | interceptedRequest.continue(); 29 | }); 30 | 31 | page.on('requestfailed', request => { 32 | const failedRequest = Object.assign({}, { 33 | status: 999, 34 | method: request.method(), 35 | url: request.url(), 36 | resourceType: request.resourceType(), 37 | errorMessage: request.failure().errorText, 38 | duration: undefined, 39 | ok: false, 40 | }); 41 | 42 | debug('Got a failed request', request); 43 | wsEvents.network.failedRequest(failedRequest); 44 | }); 45 | 46 | this.isInitialized = true; 47 | } 48 | } 49 | 50 | module.exports = NetworkRecorderHelper; 51 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Installing 2 | 3 | 1. clone repository 4 | 2. Run `npm install` 5 | 3. Run `npm run build` (this is required for a fresh start) 6 | 7 | We need frontend and backend server to be started for this application. 8 | 9 | Frontend server compiles assets, while backend server communicates with CodeceptJS and processes HTTP and WebSocket requests. HTTP is used to send commands from client to CodeceptJS, and websockets are used to send notifications from CodeceptJS to application. 10 | 11 | Note: if you error when building the app, just set this `NODE_OPTIONS=--openssl-legacy-provider` in your terminal 12 | 13 | ``` 14 | > @codeceptjs/ui@0.7.3 build 15 | > vue-cli-service build 16 | 17 | 18 | ⠹ Building for production...Error: error:0308010C:digital envelope routines::unsupported 19 | at new Hash (node:internal/crypto/hash:69:19) 20 | at Object.createHash (node:crypto:133:10) 21 | ``` 22 | 23 | 24 | Both servers must be executed for development: 25 | 26 | ### Launch application in Electron mode: 27 | 28 | ``` 29 | npm run electron:serve 30 | npm run app 31 | ``` 32 | 33 | ### Launch application in WebServer mode: 34 | 35 | ``` 36 | npm run frontend 37 | npm run backend 38 | ``` 39 | 40 | Open application at **http://127.0.0.1:8080**. 41 | 42 | > Pay attention that the port is **8080** and not 3333 in this case. 43 | 44 | ## Making Pull Requests 45 | 46 | 1. Create your own branch (fork project) 47 | 2. Create a branch for the feature you work on 48 | 3. Create a pull request 49 | 4. Wait for it to be approved... 50 | -------------------------------------------------------------------------------- /lib/commands/electron.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const { spawn } = require('child_process'); 3 | const { setHeadedWhen } = require('@codeceptjs/configure'); 4 | 5 | setHeadedWhen(true); 6 | 7 | const electron = 8 | process.env.ELECTRON_PATH || 9 | resolve('electron') || 10 | resolve('electron-prebuilt') || 11 | resolve('electron', require('which').sync); 12 | 13 | if (!electron) { 14 | /* eslint-disable no-console */ 15 | console.error(''); 16 | console.error(' Can not find `electron` in $PATH and $ELECTRON_PATH is not set.'); 17 | console.error(' Please either set $ELECTRON_PATH or `npm install electron`.'); 18 | console.error(''); 19 | process.exit(1); 20 | } 21 | 22 | run(electron); 23 | 24 | function resolve (module, resolver) { 25 | try { 26 | return (resolver || require)(module); 27 | } catch (_) { 28 | // ignore 29 | } 30 | } 31 | 32 | function run (electron) { 33 | let args = [ 34 | join(__dirname, '../app.js'), 35 | ...process.argv.slice(2) 36 | ]; 37 | 38 | let child = spawn(electron, args); 39 | 40 | // stdio 'inherit' not work reliably in Renderer! 41 | child.stdout.pipe(process.stdout); 42 | child.stderr.pipe(process.stderr); 43 | process.stdin.pipe(child.stdin); 44 | 45 | child.on('exit', (code, signal) => { 46 | if (signal) { 47 | process.kill(process.pid, signal); 48 | } else { 49 | process.exit(code); 50 | } 51 | }); 52 | 53 | process.on('SIGINT', () => { 54 | child.kill('SIGINT'); 55 | child.kill('SIGTERM'); 56 | }); 57 | } -------------------------------------------------------------------------------- /lib/utils/port-validator.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const portValidator = require('./port-validator'); 3 | 4 | test('should return parsed integer for valid port string', (t) => { 5 | const result = portValidator('8080'); 6 | t.is(result, 8080); 7 | }); 8 | 9 | test('should return parsed integer for valid port number', (t) => { 10 | const result = portValidator(3000); 11 | t.is(result, 3000); 12 | }); 13 | 14 | test('should return parsed integer for port with leading zeros', (t) => { 15 | const result = portValidator('0080'); 16 | t.is(result, 80); 17 | }); 18 | 19 | test('should return 0 for port "0"', (t) => { 20 | const result = portValidator('0'); 21 | t.is(result, 0); 22 | }); 23 | 24 | test('should return NaN for invalid port string', (t) => { 25 | const result = portValidator('invalid'); 26 | t.true(Number.isNaN(result)); 27 | }); 28 | 29 | test('should return empty string for empty string input', (t) => { 30 | const result = portValidator(''); 31 | t.is(result, ''); 32 | }); 33 | 34 | test('should return null for null input', (t) => { 35 | const result = portValidator(null); 36 | t.is(result, null); 37 | }); 38 | 39 | test('should return undefined for undefined input', (t) => { 40 | const result = portValidator(undefined); 41 | t.is(result, undefined); 42 | }); 43 | 44 | test('should handle floating point numbers by truncating', (t) => { 45 | const result = portValidator('8080.5'); 46 | t.is(result, 8080); 47 | }); 48 | 49 | test('should handle negative numbers', (t) => { 50 | const result = portValidator('-1234'); 51 | t.is(result, -1234); 52 | }); -------------------------------------------------------------------------------- /lib/model/scenario-status-repository.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const debug = require('debug')('codeceptjs:scenario-status-repository'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const mkdir = require('../utils/mkdir'); 6 | 7 | mkdir(path.join(global.output_dir, '.ui')); 8 | const ScenarioStatusFile = path.join(global.output_dir, '.ui', 'scenario-status.json'); 9 | 10 | const ScenarioStatus = ['passed', 'failed']; 11 | 12 | let scenarioStatus = {}; 13 | 14 | const saveStatus = () => { 15 | fs.writeFileSync(ScenarioStatusFile, JSON.stringify(scenarioStatus), 'utf8'); 16 | }; 17 | 18 | const restoreStatus = () => { 19 | if (!fs.existsSync(ScenarioStatusFile)) return; 20 | 21 | const scenarioStatusAsString = fs.readFileSync(ScenarioStatusFile); 22 | scenarioStatus = JSON.parse(scenarioStatusAsString); 23 | return scenarioStatus; 24 | }; 25 | 26 | restoreStatus(); 27 | 28 | module.exports = { 29 | setStatus(scenarioId, status) { 30 | assert(scenarioId, 'scenarioId is required'); 31 | 32 | if (!ScenarioStatus.includes(status.status)) { 33 | throw new Error(`status must be one of ${ScenarioStatus}`); 34 | } 35 | debug(`Setting scenario status of ${scenarioId} to ${status}`); 36 | scenarioStatus[scenarioId] = status; 37 | return scenarioStatus; 38 | }, 39 | getStatus() { 40 | return scenarioStatus; 41 | }, 42 | saveStatus, 43 | restoreStatus, 44 | }; 45 | 46 | process.on('SIGINT', () => { 47 | // eslint-disable-next-line no-console 48 | console.log('Saving status...'); 49 | saveStatus(); 50 | process.exit(); 51 | }); 52 | -------------------------------------------------------------------------------- /src/store/modules/testrun-page.js: -------------------------------------------------------------------------------- 1 | const testRunPage = { 2 | namespaced: true, 3 | 4 | state: { 5 | show: 'source', 6 | selectedStep: undefined, 7 | hoveredStep: undefined, 8 | showSubsteps: true, 9 | }, 10 | getters: { 11 | hoveredStep: state => { 12 | return state.hoveredStep; 13 | }, 14 | selectedStep: state => { 15 | return state.selectedStep; 16 | }, 17 | showImage: state => { 18 | return state.show === 'image'; 19 | }, 20 | showSource: state => { 21 | return state.show === 'source'; 22 | }, 23 | showSubsteps: state => state.showSubsteps 24 | }, 25 | mutations: { 26 | clearTests: (state) => { 27 | state.selectedStep = undefined; 28 | }, 29 | 30 | toggleSubsteps: (state) => state.showSubsteps = !state.showSubsteps, 31 | 32 | setSelectedStep: (state, selectedStep) => { 33 | if (!selectedStep) return; 34 | state.selectedStep = selectedStep; 35 | }, 36 | 37 | setHoveredStep: (state, hoveredStep) => { 38 | if (!hoveredStep) return; 39 | if (hoveredStep.type === 'meta') return; 40 | state.hoveredStep = hoveredStep; 41 | }, 42 | 43 | unsetHoveredStep: (state) => { 44 | state.hoveredStep = undefined; 45 | }, 46 | 47 | setShowImage: (state) => { 48 | state.show = 'image'; 49 | }, 50 | 51 | setShowSource: (state) => { 52 | state.show = 'source'; 53 | } 54 | }, 55 | actions: { 56 | 'SOCKET_step.after': function (context, step) { 57 | context.commit('setSelectedStep', step); 58 | }, 59 | } 60 | }; 61 | 62 | export default testRunPage; 63 | -------------------------------------------------------------------------------- /src/components/TestResult.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 47 | 48 | 76 | -------------------------------------------------------------------------------- /lib/api/run-scenario.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:run-scenario'); 2 | const wsEvents = require('../model/ws-events'); 3 | const { event } = require('codeceptjs'); 4 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 5 | 6 | module.exports = async (req, res) => { 7 | let { id, grep } = req.params; 8 | const { codecept, container } = codeceptjsFactory.getInstance(); 9 | 10 | const mocha = container.mocha(); 11 | mocha.grep(); // disable current grep 12 | 13 | if (id) { 14 | mocha.suite.eachTest(test => { 15 | if (test.uid === decodeURIComponent(id)) { 16 | test.parent.appendOnlyTest(test); 17 | } 18 | }); 19 | 20 | event.dispatcher.once(event.suite.before, suite => { 21 | suite._onlyTests = []; 22 | }); 23 | } 24 | 25 | if (grep) { 26 | mocha.fgrep(grep); 27 | } 28 | 29 | event.dispatcher.once(event.all.result, () => { 30 | mocha.grep(); // disable grep 31 | debug('testrun finished'); 32 | try { 33 | codeceptjsFactory.reloadSuites(); 34 | } catch (err) { 35 | debug('ERROR resetting suites', err); 36 | } 37 | wsEvents.codeceptjs.exit(0); 38 | }); 39 | 40 | debug('codecept.run()'); 41 | 42 | const done = () => { 43 | event.emit(event.all.result, codecept); 44 | event.emit(event.all.after, codecept); 45 | }; 46 | 47 | try { 48 | event.emit(event.all.before, codecept); 49 | wsEvents.codeceptjs.started({ timestamp: new Date().toISOString() }); 50 | global.runner = mocha.run(done); 51 | } catch (e) { 52 | throw new Error(e); 53 | } 54 | 55 | return res.status(200).send('OK'); 56 | }; 57 | -------------------------------------------------------------------------------- /lib/api/stop.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:stop-scenario'); 2 | const wsEvents = require('../model/ws-events'); 3 | const { event } = require('codeceptjs'); 4 | 5 | module.exports = async (req, res) => { 6 | debug('Stopping test execution'); 7 | 8 | try { 9 | if (global.runner) { 10 | debug('Aborting active runner'); 11 | global.runner.abort(); 12 | 13 | // Set a timeout to ensure we don't wait forever 14 | const timeout = setTimeout(() => { 15 | debug('Stop timeout reached, forcing exit event'); 16 | wsEvents.codeceptjs.exit(-1); 17 | global.runner = null; 18 | }, 5000); // 5 second timeout 19 | 20 | // Ensure we properly signal test completion and reset running state 21 | event.dispatcher.once(event.all.result, () => { 22 | clearTimeout(timeout); 23 | if (global.runner) { 24 | global.runner._abort = false; 25 | } 26 | global.runner = null; 27 | debug('Test runner stopped and reset'); 28 | // Emit exit event to reset frontend running state 29 | wsEvents.codeceptjs.exit(-1); // -1 indicates stopped by user 30 | }); 31 | } else { 32 | // If no runner is active, still emit exit event to reset frontend state 33 | debug('No active runner found, resetting state'); 34 | wsEvents.codeceptjs.exit(-1); 35 | } 36 | 37 | return res.status(200).send('OK'); 38 | } catch (error) { 39 | debug('Error stopping test execution:', error); 40 | // Always emit exit event to reset state even if there's an error 41 | wsEvents.codeceptjs.exit(-1); 42 | return res.status(500).send('Failed to stop execution'); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/RuntimeModeIndicator.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 56 | 57 | -------------------------------------------------------------------------------- /test/cors-config.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const { spawn } = require('child_process'); 3 | const path = require('path'); 4 | 5 | test('Socket.IO server includes CORS configuration', (t) => { 6 | const binPath = path.join(__dirname, '..', 'bin', 'codecept-ui.js'); 7 | const fs = require('fs'); 8 | const binContent = fs.readFileSync(binPath, 'utf8'); 9 | 10 | // Check that CORS configuration is present 11 | t.true(binContent.includes('cors:'), 'CORS configuration should be present'); 12 | t.true(binContent.includes('origin:'), 'CORS origin should be configurable'); 13 | t.true(binContent.includes('credentials: true'), 'CORS credentials should be enabled'); 14 | t.true(binContent.includes('allowEIO3: true'), 'EIO3 compatibility should be enabled'); 15 | }); 16 | 17 | test('CORS origin defaults to application port', (t) => { 18 | const binPath = path.join(__dirname, '..', 'bin', 'codecept-ui.js'); 19 | const fs = require('fs'); 20 | const binContent = fs.readFileSync(binPath, 'utf8'); 21 | 22 | // Check that default CORS origin uses application port 23 | t.true(binContent.includes('getPort(\'application\')'), 'Should use application port for default CORS origin'); 24 | }); 25 | 26 | test('CORS origin can be overridden via environment variable', (t) => { 27 | process.env.CORS_ORIGIN = 'http://example.com:3000'; 28 | 29 | const binPath = path.join(__dirname, '..', 'bin', 'codecept-ui.js'); 30 | const fs = require('fs'); 31 | const binContent = fs.readFileSync(binPath, 'utf8'); 32 | 33 | // Check that CORS_ORIGIN environment variable is used 34 | t.true(binContent.includes('process.env.CORS_ORIGIN'), 'Should check CORS_ORIGIN environment variable'); 35 | 36 | delete process.env.CORS_ORIGIN; 37 | }); -------------------------------------------------------------------------------- /lib/api/list-actions.js: -------------------------------------------------------------------------------- 1 | function _interopDefault(ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex.default : ex; } 2 | const acorn = require('acorn'); 3 | const parser = _interopDefault(require('parse-function'))({ parse: acorn.parse, ecmaVersion: 11 }); 4 | const debug = require('debug')('codeceptjs:codeceptjs-factory'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 8 | const { methodsOfObject } = require('codeceptjs/lib/utils'); 9 | 10 | module.exports = (req, res) => { 11 | const { container } = codeceptjsFactory.getInstance(); 12 | const docsWebApiFolderPath = path.join(path.dirname(require.resolve('codeceptjs')), '/../docs/webapi'); 13 | const docFileList = []; 14 | try { 15 | fs.readdirSync(docsWebApiFolderPath).map(fileName => { 16 | docFileList.push(path.basename(fileName,'.mustache')); 17 | }); 18 | } catch (e) { 19 | debug(`No documentation found due to ${e.message}`); 20 | } 21 | const helpers = container.helpers(); 22 | const actions = {}; 23 | for (const name in helpers) { 24 | const helper = helpers[name]; 25 | methodsOfObject(helper).forEach((action) => { 26 | 27 | if (docFileList.includes(action)) { 28 | let filePath = path.join(docsWebApiFolderPath, action + '.mustache'); 29 | let fn = helper[action].toString().replace(/\n/g, ' ').replace(/\{.*\}/gm, '{}'); 30 | try { 31 | let docData = fs.readFileSync(filePath, 'utf-8'); 32 | let params = parser.parse(fn); 33 | actions[action] = { params: params, actionDoc: docData }; 34 | } catch (err) { 35 | debug('Error in fetching doc for file content', fn, err); 36 | } 37 | } 38 | }); 39 | } 40 | res.send({ actions }); 41 | }; 42 | -------------------------------------------------------------------------------- /lib/model/snapshot-store/index.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:snapshot-store'); 2 | const assert = require('assert'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const fixHtmlSnapshot = require('./fix-html-snapshot'); 7 | 8 | const SnapshotBaseDir = path.join(global.output_dir || process.cwd(), '.ui', 'testruns'); 9 | 10 | const fixSnapshot = snapshot => { 11 | if (snapshot && snapshot.sourceContentType === 'html') { 12 | snapshot = fixHtmlSnapshot(snapshot); 13 | } 14 | return snapshot; 15 | }; 16 | 17 | const fileNameFromId = id => `${id}.snapshots.json`; 18 | 19 | module.exports = new class SnapshotStore { 20 | constructor() { 21 | this.steps = {}; 22 | } 23 | 24 | clear() { 25 | this.steps = {}; 26 | } 27 | 28 | add(id, snapshot) { 29 | assert(id, 'step id is required'); 30 | assert(snapshot, 'snapshot is required'); 31 | 32 | debug(`Adding step ${id}`); 33 | 34 | this.steps[id] = fixSnapshot(snapshot); 35 | } 36 | 37 | get(id) { 38 | return this.steps[id]; 39 | } 40 | 41 | exists(id) { 42 | return this.steps[id] !== undefined; 43 | } 44 | 45 | hasSnapshot(id) { 46 | return this.exists(id) && this.get(id); 47 | } 48 | 49 | saveWithTestRun(testRunId) { 50 | const snapshotFile = path.join(SnapshotBaseDir, fileNameFromId(testRunId)); 51 | 52 | debug(`Saving snapshots to ${snapshotFile}`); 53 | 54 | fs.writeFileSync(snapshotFile, JSON.stringify(this.steps), 'utf8'); 55 | } 56 | 57 | restoreFromTestRun(testRunId) { 58 | const snapshotFile = path.join(SnapshotBaseDir, fileNameFromId(testRunId)); 59 | if (!fs.existsSync(snapshotFile)) return; 60 | 61 | debug(`Retrieving snapshot for testrun ${testRunId}`); 62 | const snapshotData = fs.readFileSync(snapshotFile); 63 | this.steps = JSON.parse(snapshotData); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /test/reverse-proxy.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | test('Frontend handles reverse proxy configuration', (t) => { 6 | const mainJsPath = path.join(__dirname, '..', 'src', 'main.js'); 7 | const mainJsContent = fs.readFileSync(mainJsPath, 'utf8'); 8 | 9 | // Check that reverse proxy detection is present 10 | t.true(mainJsContent.includes('isReverseProxy'), 'Should detect reverse proxy setup'); 11 | t.true(mainJsContent.includes('window.location.pathname !== \'/\''), 'Should check for non-root path'); 12 | 13 | // Check that fallback handling is present 14 | t.true(mainJsContent.includes('catch (err)'), 'Should have fallback error handling'); 15 | t.true(mainJsContent.includes('baseUrl.replace(\'http\', \'ws\')'), 'Should use WebSocket protocol conversion'); 16 | }); 17 | 18 | test('Frontend uses relative paths for reverse proxy', (t) => { 19 | const mainJsPath = path.join(__dirname, '..', 'src', 'main.js'); 20 | const mainJsContent = fs.readFileSync(mainJsPath, 'utf8'); 21 | 22 | // Check that relative API calls are used 23 | t.true(mainJsContent.includes('\'/api/ports\''), 'Should use relative API path'); 24 | t.false(mainJsContent.includes('window.location.port'), 'Should not hardcode current port in API calls'); 25 | }); 26 | 27 | test('Port API endpoint returns correct format', (t) => { 28 | const apiIndexPath = path.join(__dirname, '..', 'lib', 'api', 'index.js'); 29 | const apiContent = fs.readFileSync(apiIndexPath, 'utf8'); 30 | 31 | // Check that ports endpoint exists and returns correct structure 32 | t.true(apiContent.includes('router.get(\'/ports\''), 'Ports endpoint should exist'); 33 | t.true(apiContent.includes('port: getPort(\'application\')'), 'Should return application port'); 34 | t.true(apiContent.includes('wsPort: getPort(\'ws\')'), 'Should return WebSocket port'); 35 | }); -------------------------------------------------------------------------------- /lib/model/snapshot-store/fix-htmls-snapshot.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const fixHtmlSnapshot = require('./fix-html-snapshot'); 3 | 4 | const makeHtml = snippet => { 5 | return `${snippet}`; 6 | }; 7 | 8 | test('Relative Url: same resource', (t) => { 9 | const pageUrl = 'http://foo/some/path'; 10 | const fixedSnapshot = fixHtmlSnapshot({ 11 | pageUrl, 12 | source: makeHtml('') 13 | }); 14 | t.is(fixedSnapshot.source, makeHtml('')); 15 | }); 16 | 17 | test('Relative Url: same protocol', (t) => { 18 | const pageUrl = 'http://www.check24-test.de'; 19 | const fixedSnapshot = fixHtmlSnapshot({ 20 | pageUrl, 21 | source: makeHtml('') 22 | }); 23 | t.is(fixedSnapshot.source, makeHtml('')); 24 | }); 25 | 26 | test('Relative Url: same domain', (t) => { 27 | const pageUrl = 'http://foo:1234'; 28 | const fixedSnapshot = fixHtmlSnapshot({ 29 | pageUrl, 30 | source: makeHtml('') 31 | }); 32 | t.is(fixedSnapshot.source, makeHtml('')); 33 | }); 34 | 35 | test('script tags are removed', (t) => { 36 | const pageUrl = 'http://foo:1234'; 37 | const fixedSnapshot = fixHtmlSnapshot({ 38 | pageUrl, 39 | source: makeHtml('') 40 | }); 41 | t.is(fixedSnapshot.source, makeHtml('')); 42 | }); -------------------------------------------------------------------------------- /lib/api/new-test.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:run-scenario'); 2 | const wsEvents = require('../model/ws-events'); 3 | const pause = require('../codeceptjs/brk'); 4 | const { event } = require('codeceptjs'); 5 | const { createTest } = require('codeceptjs/lib/mocha/test'); 6 | const { createSuite } = require('codeceptjs/lib/mocha/suite'); 7 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 8 | 9 | module.exports = async (req, res) => { 10 | const { codecept, container } = codeceptjsFactory.getInstance(); 11 | 12 | const mocha = container.mocha(); 13 | mocha.grep(); 14 | mocha.files = []; 15 | mocha.suite.suites = []; 16 | 17 | const code = eval(req.body.code); 18 | const test = createTest('new test', code); 19 | test.uid = 'new-test'; 20 | 21 | const suite = createSuite(mocha.suite, 'new test'); 22 | 23 | let pauseEnabled = true; 24 | 25 | // Note: In CodeceptJS 3.x, the scenario setup/teardown methods may have changed 26 | // For now, we'll keep the basic structure and let the framework handle setup 27 | 28 | suite.addTest(test); 29 | suite.appendOnlyTest(test); 30 | 31 | event.dispatcher.once(event.test.after, () => { 32 | if (pauseEnabled) pause(); 33 | }); 34 | 35 | event.dispatcher.once(event.all.result, () => { 36 | pauseEnabled = false; 37 | debug('testrun finished'); 38 | try { 39 | codeceptjsFactory.reloadSuites(); 40 | } catch (err) { 41 | debug('ERROR resetting suites', err); 42 | } 43 | wsEvents.codeceptjs.exit(0); 44 | mocha.suite.suites = []; 45 | }); 46 | 47 | debug('codecept.run()'); 48 | const done = () => { 49 | event.emit(event.all.result, codecept); 50 | event.emit(event.all.after, codecept); 51 | }; 52 | 53 | try { 54 | event.emit(event.all.before, codecept); 55 | global.runner = mocha.run(done); 56 | } catch (e) { 57 | throw new Error(e); 58 | } 59 | 60 | return res.status(200).send('OK'); 61 | }; 62 | -------------------------------------------------------------------------------- /lib/utils/port-type-validator.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const portTypeValidator = require('./port-type-validator'); 3 | 4 | test('should not throw error for valid "application" type', (t) => { 5 | t.notThrows(() => { 6 | portTypeValidator('application'); 7 | }); 8 | }); 9 | 10 | test('should not throw error for valid "ws" type', (t) => { 11 | t.notThrows(() => { 12 | portTypeValidator('ws'); 13 | }); 14 | }); 15 | 16 | test('should throw error for invalid type', (t) => { 17 | const error = t.throws(() => { 18 | portTypeValidator('invalid'); 19 | }); 20 | t.is(error.message, 'Type must be "application" or "ws"'); 21 | }); 22 | 23 | test('should throw error for empty string', (t) => { 24 | const error = t.throws(() => { 25 | portTypeValidator(''); 26 | }); 27 | t.is(error.message, 'Type must be "application" or "ws"'); 28 | }); 29 | 30 | test('should throw error for null', (t) => { 31 | const error = t.throws(() => { 32 | portTypeValidator(null); 33 | }); 34 | t.is(error.message, 'Type must be "application" or "ws"'); 35 | }); 36 | 37 | test('should throw error for undefined', (t) => { 38 | const error = t.throws(() => { 39 | portTypeValidator(undefined); 40 | }); 41 | t.is(error.message, 'Type must be "application" or "ws"'); 42 | }); 43 | 44 | test('should throw error for number input', (t) => { 45 | const error = t.throws(() => { 46 | portTypeValidator(123); 47 | }); 48 | t.is(error.message, 'Type must be "application" or "ws"'); 49 | }); 50 | 51 | test('should throw error for case-sensitive mismatch', (t) => { 52 | const error = t.throws(() => { 53 | portTypeValidator('Application'); 54 | }); 55 | t.is(error.message, 'Type must be "application" or "ws"'); 56 | }); 57 | 58 | test('should throw error for whitespace variations', (t) => { 59 | const error = t.throws(() => { 60 | portTypeValidator(' application '); 61 | }); 62 | t.is(error.message, 'Type must be "application" or "ws"'); 63 | }); -------------------------------------------------------------------------------- /src/store/modules/cli.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const cli = { 4 | namespaced: true, 5 | 6 | state: { 7 | cli: undefined, 8 | prompt: undefined, 9 | commands: undefined, 10 | message: undefined, 11 | actions: {}, 12 | steps: [], 13 | }, 14 | mutations: { 15 | startCli: (state, data) => { 16 | state.cli = true; 17 | state.prompt = data.prompt; 18 | if (data.commands) { 19 | state.commands = data.commands; 20 | } 21 | }, 22 | stopCli: (state) => { 23 | state.cli = undefined; 24 | state.prompt = undefined; 25 | state.commands = undefined; 26 | state.message = undefined; 27 | }, 28 | clearCliError: (state) => { 29 | state.message = undefined; 30 | }, 31 | setCliError: (state, data) => { 32 | if (!state) return; 33 | state.message = data.message; 34 | }, 35 | addStep: (state, step) => { 36 | if (!state.cli) return; 37 | if (step.status !== 'success') return; 38 | state.steps.push(step); 39 | } 40 | }, 41 | getters: { 42 | steps: state => state.steps, 43 | show: state => { 44 | return state.cli !== undefined; 45 | }, 46 | actions: state => state.actions, 47 | }, 48 | actions: { 49 | loadActions: async ({ state }) => { 50 | try { 51 | const resp = await axios.get('/api/actions'); 52 | state.actions = resp.data.actions; 53 | } catch (err) { 54 | state.actions = {}; 55 | } 56 | }, 57 | 'SOCKET_cli.start': function ({ commit }, data) { 58 | commit('startCli', data); 59 | }, 60 | 'SOCKET_cli.stop': function ({ commit }, data) { 61 | commit('stopCli', data); 62 | }, 63 | 'SOCKET_cli.output': function () { 64 | }, 65 | 'SOCKET_cli.error': function ({ commit }, data) { 66 | commit('setCliError', data); 67 | }, 68 | 'SOCKET_step.after': function ({ commit }, step) { 69 | commit('addStep', step); 70 | }, 71 | } 72 | }; 73 | 74 | export default cli; 75 | -------------------------------------------------------------------------------- /lib/commands/init.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const codeceptjsFactory = require('../model/codeceptjs-factory'); 6 | const { setPort } = require('../config/env'); 7 | const portValidator = require('../utils/port-validator'); 8 | 9 | module.exports = () => { 10 | const program = new commander.Command(); 11 | program.allowUnknownOption(); 12 | program.version(JSON.parse(fs.readFileSync(`${__dirname}/../../package.json`, 'utf8')).version); 13 | program 14 | .option('--app', 'launch Electron application') 15 | .option('--port ', 'Port for http:// connection', portValidator) 16 | .option('--wsPort ', 'Port for ws:// connection', portValidator) 17 | // codecept-only options 18 | .option('--steps', 'show step-by-step execution') 19 | .option('--debug', 'output additional information') 20 | .option('--verbose', 'output internal logging information') 21 | .option('-o, --override [value]', 'override current config options') 22 | .option('-c, --config [file]', 'configuration file to be used') 23 | .option('--features', 'run only *.feature files and skip tests') 24 | .option('--tests', 'run only JS test files and skip features') 25 | .option('-p, --plugins ', 'enable plugins, comma-separated'); 26 | 27 | program.parse(process.argv); 28 | 29 | const options = program.opts(); 30 | 31 | if (options.config) { 32 | const configFile = options.config; 33 | let configPath = configFile; 34 | if (!path.isAbsolute(configPath)) { 35 | configPath = path.join(process.cwd(), configFile); 36 | } 37 | if (!fs.lstatSync(configPath).isDirectory()) { 38 | codeceptjsFactory.setConfigFile(path.basename(configPath)); 39 | configPath = path.dirname(configPath); 40 | } 41 | process.chdir(configPath); 42 | codeceptjsFactory.setRootDir(configPath); 43 | } 44 | 45 | options.port = setPort('application', options.port); 46 | options.wsPort = setPort('ws', options.wsPort); 47 | 48 | return options; 49 | }; 50 | 51 | -------------------------------------------------------------------------------- /src/components/ProfileSelection.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 76 | -------------------------------------------------------------------------------- /lib/model/profile-repository.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Since the profile repository is hard to mock due to require() calls, 6 | // let's focus on testing the path construction and basic behavior 7 | 8 | test('should construct correct file path for profile config', (t) => { 9 | const originalCwd = process.cwd; 10 | process.cwd = () => '/test/project'; 11 | 12 | try { 13 | // Import after mocking process.cwd 14 | delete require.cache[require.resolve('./profile-repository')]; 15 | const profileRepository = require('./profile-repository'); 16 | 17 | const originalExistsSync = fs.existsSync; 18 | let checkedPath = ''; 19 | 20 | fs.existsSync = (filePath) => { 21 | checkedPath = filePath; 22 | return false; 23 | }; 24 | 25 | try { 26 | profileRepository.getProfiles(); 27 | const expectedPath = path.join('/test/project', '.codeceptjs', 'profile.conf.js'); 28 | t.is(checkedPath, expectedPath); 29 | } finally { 30 | fs.existsSync = originalExistsSync; 31 | } 32 | } finally { 33 | process.cwd = originalCwd; 34 | } 35 | }); 36 | 37 | test('getProfiles should return undefined when profile config file does not exist', (t) => { 38 | const originalExistsSync = fs.existsSync; 39 | fs.existsSync = () => false; 40 | 41 | try { 42 | // Clear cache and re-require to get fresh module 43 | delete require.cache[require.resolve('./profile-repository')]; 44 | const profileRepository = require('./profile-repository'); 45 | 46 | const result = profileRepository.getProfiles(); 47 | t.is(result, undefined); 48 | } finally { 49 | fs.existsSync = originalExistsSync; 50 | } 51 | }); 52 | 53 | test('getProfile should handle undefined profiles gracefully', (t) => { 54 | const originalExistsSync = fs.existsSync; 55 | fs.existsSync = () => false; 56 | 57 | try { 58 | delete require.cache[require.resolve('./profile-repository')]; 59 | const profileRepository = require('./profile-repository'); 60 | 61 | const result = profileRepository.getProfile('desktop'); 62 | t.is(result, undefined); 63 | } finally { 64 | fs.existsSync = originalExistsSync; 65 | } 66 | }); -------------------------------------------------------------------------------- /test/memory-management.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | // Test to verify worker memory management doesn't cause issues 4 | test('worker cleanup functions exist and don\'t throw', (t) => { 5 | // Mock the worker index module 6 | const workerIndex = require('../lib/model/codeceptjs-run-workers/index'); 7 | 8 | // Test that the constants are defined correctly 9 | t.true(typeof workerIndex === 'function'); 10 | 11 | // This test mainly verifies the module loads without syntax errors 12 | // and that our memory management constants are reasonable 13 | t.pass('Worker module loads without errors'); 14 | }); 15 | 16 | test('memory management constants are reasonable', (t) => { 17 | // Read the worker file to verify constants exist 18 | const fs = require('fs'); 19 | const workerContent = fs.readFileSync('lib/model/codeceptjs-run-workers/index.js', 'utf8'); 20 | 21 | // Verify memory management constants are present 22 | t.true(workerContent.includes('MAX_ERRORS')); 23 | t.true(workerContent.includes('MAX_FINISHED_TESTS')); 24 | t.true(workerContent.includes('cleanupWorkerMemory')); 25 | 26 | // Verify limits are reasonable (not too small, not too large) 27 | const maxErrorsMatch = workerContent.match(/MAX_ERRORS\s*=\s*(\d+)/); 28 | const maxTestsMatch = workerContent.match(/MAX_FINISHED_TESTS\s*=\s*(\d+)/); 29 | 30 | if (maxErrorsMatch) { 31 | const maxErrors = parseInt(maxErrorsMatch[1]); 32 | t.true(maxErrors >= 100 && maxErrors <= 10000, 'MAX_ERRORS should be reasonable'); 33 | } 34 | 35 | if (maxTestsMatch) { 36 | const maxTests = parseInt(maxTestsMatch[1]); 37 | t.true(maxTests >= 100 && maxTests <= 5000, 'MAX_FINISHED_TESTS should be reasonable'); 38 | } 39 | }); 40 | 41 | test('file API has memory protections', (t) => { 42 | const fs = require('fs'); 43 | const fileApiContent = fs.readFileSync('lib/api/get-file.js', 'utf8'); 44 | 45 | // Verify memory protections are present 46 | t.true(fileApiContent.includes('MAX_FILE_SIZE'), 'MAX_FILE_SIZE constant should exist'); 47 | t.true(fileApiContent.includes('createReadStream'), 'Should use streaming'); 48 | t.true(fileApiContent.includes('stats.size'), 'Should check file size'); 49 | t.true(fileApiContent.includes('pipe'), 'Should pipe the stream'); 50 | }); -------------------------------------------------------------------------------- /lib/model/snapshot-store/fix-html-snapshot.js: -------------------------------------------------------------------------------- 1 | const { URL } = require('url'); 2 | const cheerio = require('cheerio'); 3 | 4 | const extractBaseUrl = str => { 5 | const parsed = new URL(str); 6 | return `${parsed.protocol}//${parsed.host}`; 7 | }; 8 | 9 | const extractUrlWithPath = str => { 10 | const parsed = new URL(str); 11 | const pathname = parsed.pathname && !parsed.pathname.startsWith('/') ? `/${parsed.pathname}` : parsed.pathname; 12 | return `${parsed.protocol}//${parsed.hostname}${parsed.port ? ':' : ''}${parsed.port ? parsed.port : ''}${pathname}`; 13 | }; 14 | 15 | const isDataUrl = linkValue => { 16 | return linkValue.startsWith('data:'); 17 | }; 18 | 19 | const isSameResource = linkValue => { 20 | return (linkValue[0] !== '/' && !linkValue.startsWith('http')); 21 | }; 22 | 23 | const isSameDomain = linkValue => { 24 | return linkValue[0] === '/' && linkValue[1] !== '/'; 25 | }; 26 | 27 | const isSameProtocol = linkValue => { 28 | return linkValue[0] === '/' && linkValue[1] === '/'; 29 | }; 30 | 31 | const mapAttr = ($, snapshot, attrName) => { 32 | const parsed = new URL(snapshot.pageUrl); 33 | const protocolHostPort = extractBaseUrl(snapshot.pageUrl); 34 | const protocolHostPortPath = extractUrlWithPath(snapshot.pageUrl); 35 | 36 | return function () { 37 | const linkValue = $(this).attr(attrName); 38 | if (!linkValue) return; 39 | 40 | if (isDataUrl(linkValue)) { 41 | return; 42 | } 43 | if (isSameResource(linkValue)) { 44 | $(this).attr(attrName, `${protocolHostPortPath}/${linkValue}`); 45 | } 46 | if (isSameDomain(linkValue)) { 47 | $(this).attr(attrName, `${protocolHostPort}${linkValue}`); 48 | } 49 | if (isSameProtocol(linkValue)) { 50 | $(this).attr(attrName, `${parsed.protocol}${linkValue}`); 51 | } 52 | }; 53 | }; 54 | 55 | module.exports = snapshot => { 56 | // disable script tags 57 | snapshot.source = snapshot.source || ''; 58 | let $ = cheerio.load(snapshot.source.replace(/)<[^<]*)*<\/script>/gi, '')); 59 | 60 | // Convert links 61 | $('link').map(mapAttr($, snapshot, 'href')); 62 | $('img').map(mapAttr($, snapshot, 'src')); 63 | $('script').map(mapAttr($, snapshot, 'src')); 64 | 65 | snapshot.source = $.html(); 66 | 67 | return snapshot; 68 | }; 69 | -------------------------------------------------------------------------------- /lib/codeceptjs/console-recorder.helper.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('codeceptjs:console-recorder-helper'); 2 | const wsEvents = require('../model/ws-events'); 3 | const { v4: uuid } = require('uuid'); 4 | // eslint-disable-next-line no-undef 5 | let Helper = codecept_helper; 6 | 7 | class ConsoleRecorderHelper extends Helper { 8 | constructor(options) { 9 | super(options); 10 | } 11 | 12 | async _before() { 13 | const helper = this.helpers['Puppeteer']; 14 | if (!helper) { 15 | debug('Puppeteer helper not found -> console error reporting is disabled'); 16 | return; 17 | } 18 | const page = helper.page; 19 | 20 | if (!page) return; 21 | 22 | page.on('pageerror', async err => { 23 | debug('Got page error', err); 24 | wsEvents.console.jsError(err); 25 | 26 | this._addToLog({ 27 | type: 'error', 28 | message: err.toString() 29 | }); 30 | }); 31 | page.on('error', async err => { 32 | debug('Got error', err); 33 | wsEvents.console.error(err); 34 | 35 | this._addToLog({ 36 | type: 'error', 37 | message: err.toString() 38 | }); 39 | }); 40 | 41 | page.on('console', async msg => { 42 | // Parse all console.log args 43 | for (let i = 0; i < msg.args().length; ++i) { 44 | const arg = msg.args()[i]; 45 | let argVal = arg; 46 | if (arg.jsonValue) { 47 | try { 48 | argVal = JSON.stringify(await arg.jsonValue(), null, 2); 49 | } catch (err) { 50 | debug('ERROR getting json value', err); 51 | } 52 | } 53 | 54 | this._addToLog({ 55 | type: msg.type(), 56 | url: msg.location().url, 57 | lineNumber: msg.location().lineNumber, 58 | message: argVal 59 | }); 60 | debug('Got console message', msg.type()); 61 | } 62 | }); 63 | 64 | debug('Recording console logs is enabled'); 65 | } 66 | 67 | _addToLog(log) { 68 | 69 | if (!this.helpers.RealtimeReporterHelper) return; 70 | const step = this.helpers.RealtimeReporterHelper.step; 71 | 72 | if (!step.logs) step.logs = []; 73 | 74 | step.logs.push({ ...log, 75 | id: uuid, 76 | time: Date.now(), 77 | }); 78 | } 79 | } 80 | 81 | module.exports = ConsoleRecorderHelper; 82 | -------------------------------------------------------------------------------- /src/components/EditorNotFound.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 77 | 78 | -------------------------------------------------------------------------------- /src/store/modules/settings.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Vue from 'vue'; 3 | 4 | const defaults = { 5 | editor: 'code --goto ', 6 | }; 7 | 8 | if (!localStorage.codecept) { 9 | localStorage.codecept = JSON.stringify(defaults); 10 | } 11 | 12 | const settings = JSON.parse(localStorage.codecept); 13 | Object.keys(settings).forEach(k => { 14 | if (!settings[k]) delete settings[k]; 15 | }); 16 | 17 | // initialize current settings on backend 18 | axios.put('/api/settings', settings); 19 | 20 | export default { 21 | namespaced: true, 22 | state: settings, 23 | getters: { 24 | windowSize: state => { 25 | return state.windowSize || { width: null, height: null }; 26 | }, 27 | }, 28 | mutations: { 29 | setHeadless(state, isHeadless) { 30 | Vue.set(state, 'isHeadless', isHeadless); 31 | }, 32 | setSingleSession(state, isSingleSession) { 33 | Vue.set(state, 'isSingleSession', isSingleSession); 34 | }, 35 | setWindowSize(state, { width, height }) { 36 | Vue.set(state, 'windowSize', { width, height }); 37 | }, 38 | setEditor(state, editor) { 39 | if (editor) Vue.set(state, 'editor', editor); 40 | }, 41 | setBrowser(state, browser) { 42 | if (browser) Vue.set(state, 'browser', browser); 43 | }, 44 | 45 | }, 46 | actions: { 47 | setHeadless: async function({ commit, dispatch }, isHeadless) { 48 | commit('setHeadless', isHeadless); 49 | await dispatch('storeSettings'); 50 | }, 51 | 52 | setWindowSize: async function({ commit, dispatch }, size) { 53 | commit('setWindowSize', size); 54 | await dispatch('storeSettings'); 55 | }, 56 | setEditor: async function({ commit, dispatch }, editor) { 57 | commit('setEditor', editor); 58 | await dispatch('storeSettings'); 59 | }, 60 | setSingleSession: async function({ commit, dispatch }, isSingleSession) { 61 | commit('setSingleSession', isSingleSession); 62 | await dispatch('storeSettings'); 63 | }, 64 | setBrowser: async function({ commit, dispatch }, browser) { 65 | commit('setBrowser', browser); 66 | await dispatch('storeSettings'); 67 | }, 68 | storeSettings: async function ({ state }) { 69 | localStorage.codecept = JSON.stringify(state); 70 | await axios.put('/api/settings', state); 71 | return state; 72 | }, 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /lib/api/list-profiles.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const listProfiles = require('./list-profiles'); 3 | 4 | // Mock the profile repository 5 | const profileRepository = require('../model/profile-repository'); 6 | 7 | test.beforeEach((t) => { 8 | // Create mock request and response objects 9 | t.context.req = {}; 10 | t.context.res = { 11 | json: (data) => { t.context.responseData = data; }, 12 | status: (code) => { 13 | t.context.statusCode = code; 14 | return { json: (data) => { t.context.responseData = data; } }; 15 | } 16 | }; 17 | 18 | // Store original getProfiles function 19 | t.context.originalGetProfiles = profileRepository.getProfiles; 20 | }); 21 | 22 | test.afterEach((t) => { 23 | // Restore original function 24 | profileRepository.getProfiles = t.context.originalGetProfiles; 25 | }); 26 | 27 | test('should return profiles when profiles exist', (t) => { 28 | const mockProfiles = { 29 | default: 'desktop', 30 | desktop: { browsers: ['chrome'] }, 31 | mobile: { browsers: ['chrome:emulation:iPhone'] } 32 | }; 33 | 34 | // Mock getProfiles to return test data 35 | profileRepository.getProfiles = () => mockProfiles; 36 | 37 | listProfiles(t.context.req, t.context.res); 38 | 39 | t.deepEqual(t.context.responseData, mockProfiles); 40 | t.is(t.context.statusCode, undefined); // 200 is default 41 | }); 42 | 43 | test('should return 404 when no profiles exist', (t) => { 44 | // Mock getProfiles to return undefined 45 | profileRepository.getProfiles = () => undefined; 46 | 47 | listProfiles(t.context.req, t.context.res); 48 | 49 | t.is(t.context.statusCode, 404); 50 | t.deepEqual(t.context.responseData, { message: 'No profiles configured' }); 51 | }); 52 | 53 | test('should return 404 when getProfiles returns null', (t) => { 54 | // Mock getProfiles to return null 55 | profileRepository.getProfiles = () => null; 56 | 57 | listProfiles(t.context.req, t.context.res); 58 | 59 | t.is(t.context.statusCode, 404); 60 | t.deepEqual(t.context.responseData, { message: 'No profiles configured' }); 61 | }); 62 | 63 | test('should return empty object when profiles is empty', (t) => { 64 | const mockProfiles = {}; 65 | 66 | // Mock getProfiles to return empty object 67 | profileRepository.getProfiles = () => mockProfiles; 68 | 69 | listProfiles(t.context.req, t.context.res); 70 | 71 | t.deepEqual(t.context.responseData, mockProfiles); 72 | t.is(t.context.statusCode, undefined); 73 | }); -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const { BrowserWindow, app, shell } = require('electron'); 5 | const { getPort } = require('./config/env'); 6 | 7 | const NAME = 'CodeceptUI'; 8 | 9 | // Keep a global reference of the window object, if you don't, the window will 10 | // be closed automatically when the JavaScript object is garbage collected. 11 | let win; 12 | 13 | // Scheme must be registered before the app is ready 14 | // protocol.registerSchemesAsPrivileged([{scheme: 'app', privileges: { secure: true, standard: true } }]); 15 | 16 | app.name = NAME; 17 | app.setName(NAME); 18 | 19 | function createWindow() { 20 | const { screen } = require('electron'); 21 | const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().size; 22 | 23 | const width = Math.floor(screenWidth / 3.5) || 500; 24 | const x = screenWidth - Math.max(width, 500); 25 | // Create the browser window. 26 | win = new BrowserWindow({ 27 | width: screenWidth / 1.2, 28 | height: screenHeight, 29 | minWidth: 500, 30 | x, 31 | y: 0, 32 | title: NAME, 33 | autoHideMenuBar: true, 34 | icon: path.join(__dirname, '/build/icons/64x64.png'), 35 | webPreferences: { 36 | nodeIntegration: true, 37 | nodeIntegrationInWorker: true, 38 | }, 39 | }); 40 | 41 | win.loadURL(`http://localhost:${getPort('application')}`); 42 | 43 | // eslint-disable-next-line no-console 44 | console.log('Application window opened, switch to it to run tests...'); 45 | 46 | win.on('closed', () => { 47 | win = null; 48 | }); 49 | 50 | win.webContents.setWindowOpenHandler(({ url }) => { 51 | // open url in a browser and prevent default 52 | shell.openExternal(url); 53 | }); 54 | } 55 | 56 | // Quit when all windows are closed. 57 | app.on('window-all-closed', () => { 58 | // On macOS it is common for applications and their menu bar 59 | // to stay active until the user quits explicitly with Cmd + Q 60 | if (process.platform !== 'darwin') { 61 | app.quit(); 62 | } 63 | }); 64 | 65 | app.on('activate', () => { 66 | // On macOS it's common to re-create a window in the app when the 67 | // dock icon is clicked and there are no other windows open. 68 | if (win === null) { 69 | createWindow(); 70 | } 71 | }); 72 | 73 | // This method will be called when Electron has finished 74 | // initialization and is ready to create browser windows. 75 | // Some APIs can only be used after this event occurs. 76 | app.on('ready', () => { 77 | createWindow(); 78 | }); 79 | -------------------------------------------------------------------------------- /test/ws-events-circular-fix.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | // Mock the Socket.IO environment to test ws-events without actually connecting 4 | process.env.NODE_ENV = 'test'; 5 | 6 | const wsEvents = require('../lib/model/ws-events'); 7 | 8 | test('ws-events can emit error objects with circular references without crashing', (t) => { 9 | // Create an error object with a circular reference 10 | const error = new Error('Test error with circular reference'); 11 | error.code = 'CIRCULAR_ERROR'; 12 | error.cause = error; // Create circular reference 13 | 14 | // This should not throw "Maximum call stack size exceeded" 15 | t.notThrows(() => { 16 | wsEvents.console.jsError(error); 17 | }); 18 | 19 | t.notThrows(() => { 20 | wsEvents.console.error(error); 21 | }); 22 | 23 | t.notThrows(() => { 24 | wsEvents.codeceptjs.error(error); 25 | }); 26 | }); 27 | 28 | test('ws-events can emit objects with circular references without crashing', (t) => { 29 | // Create an object with circular reference 30 | const testData = { 31 | name: 'test', 32 | id: 123, 33 | steps: [] 34 | }; 35 | testData.parent = testData; // Create circular reference 36 | 37 | // This should not throw "Maximum call stack size exceeded" 38 | t.notThrows(() => { 39 | wsEvents.rtr.testBefore(testData); 40 | }); 41 | 42 | t.notThrows(() => { 43 | wsEvents.rtr.testAfter(testData); 44 | }); 45 | 46 | t.notThrows(() => { 47 | wsEvents.rtr.stepBefore(testData); 48 | }); 49 | 50 | t.notThrows(() => { 51 | wsEvents.rtr.stepAfter(testData); 52 | }); 53 | }); 54 | 55 | test('ws-events can handle complex nested objects', (t) => { 56 | // Create a deeply nested object that could potentially cause issues 57 | const complexData = { 58 | test: { 59 | suite: { 60 | title: 'Complex Test', 61 | tests: [ 62 | { title: 'Test 1', steps: [] }, 63 | { title: 'Test 2', steps: [] } 64 | ] 65 | } 66 | }, 67 | error: new Error('Complex error'), 68 | args: [1, 2, 3, { nested: { deep: { object: true } } }] 69 | }; 70 | 71 | // Add circular reference 72 | complexData.test.suite.parent = complexData; 73 | complexData.error.context = complexData; 74 | 75 | t.notThrows(() => { 76 | wsEvents.network.failedRequest(complexData); 77 | }); 78 | 79 | t.notThrows(() => { 80 | wsEvents.codeceptjs.started(complexData); 81 | }); 82 | 83 | t.notThrows(() => { 84 | wsEvents.codeceptjs.exit(complexData); 85 | }); 86 | }); -------------------------------------------------------------------------------- /test/safe-serialize.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const safeSerialize = require('../lib/utils/safe-serialize'); 3 | 4 | test('safeSerialize handles circular references', (t) => { 5 | const obj = { name: 'test', id: 123 }; 6 | obj.self = obj; 7 | 8 | const result = safeSerialize(obj); 9 | 10 | t.is(result.name, 'test'); 11 | t.is(result.id, 123); 12 | t.is(result.self, '[Circular Reference]'); 13 | }); 14 | 15 | test('safeSerialize handles Error objects with circular references', (t) => { 16 | const error = new Error('Test error'); 17 | error.code = 'TEST_CODE'; 18 | error.cause = error; // Create circular reference 19 | 20 | const result = safeSerialize(error); 21 | 22 | t.is(result.name, 'Error'); 23 | t.is(result.message, 'Test error'); 24 | t.is(result.code, 'TEST_CODE'); 25 | t.is(result.type, 'Error'); 26 | t.truthy(result.stack); 27 | }); 28 | 29 | test('safeSerialize limits recursion depth', (t) => { 30 | const deep = { level: 1 }; 31 | let current = deep; 32 | for (let i = 2; i <= 60; i++) { 33 | current.next = { level: i }; 34 | current = current.next; 35 | } 36 | 37 | const result = safeSerialize(deep); 38 | const serialized = JSON.stringify(result); 39 | 40 | t.true(serialized.includes('[Object: max depth reached]')); 41 | }); 42 | 43 | test('safeSerialize preserves normal objects', (t) => { 44 | const obj = { 45 | name: 'test', 46 | count: 42, 47 | tags: ['a', 'b'], 48 | nested: { 49 | value: 'nested' 50 | } 51 | }; 52 | 53 | const result = safeSerialize(obj); 54 | 55 | t.deepEqual(result, obj); 56 | }); 57 | 58 | test('safeSerialize handles arrays', (t) => { 59 | const arr = [1, 2, { name: 'test' }]; 60 | const result = safeSerialize(arr); 61 | 62 | t.deepEqual(result, arr); 63 | }); 64 | 65 | test('safeSerialize handles Date objects', (t) => { 66 | const date = new Date('2023-01-01T00:00:00.000Z'); 67 | const result = safeSerialize(date); 68 | 69 | t.is(result, '2023-01-01T00:00:00.000Z'); 70 | }); 71 | 72 | test('safeSerialize handles RegExp objects', (t) => { 73 | const regex = /test/gi; 74 | const result = safeSerialize(regex); 75 | 76 | t.is(result, '/test/gi'); 77 | }); 78 | 79 | test('safeSerialize handles null and undefined', (t) => { 80 | t.is(safeSerialize(null), null); 81 | t.is(safeSerialize(undefined), undefined); 82 | }); 83 | 84 | test('safeSerialize handles primitive types', (t) => { 85 | t.is(safeSerialize('string'), 'string'); 86 | t.is(safeSerialize(123), 123); 87 | t.is(safeSerialize(true), true); 88 | t.is(safeSerialize(false), false); 89 | }); -------------------------------------------------------------------------------- /lib/api/get-file.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // Maximum file size to prevent memory issues (10MB) 5 | const MAX_FILE_SIZE = 10 * 1024 * 1024; 6 | 7 | module.exports = async (req, res) => { 8 | // Support both old format (req.file) and new format (req.body.path) 9 | const filePath = (req.body && req.body.path) || req.file; 10 | 11 | if (!filePath) { 12 | return res.status(400).json({ error: 'File parameter is required' }); 13 | } 14 | 15 | // Resolve the absolute path 16 | const absolutePath = path.resolve(global.codecept_dir, filePath); 17 | 18 | if (!absolutePath.startsWith(global.codecept_dir)) { 19 | return res.status(403).json({ error: 'Access denied. File must be within codecept directory' }); 20 | } 21 | 22 | try { 23 | // Check if file exists and get stats 24 | const stats = await fs.promises.stat(absolutePath); 25 | 26 | // Check file size to prevent memory issues 27 | if (stats.size > MAX_FILE_SIZE) { 28 | return res.status(413).json({ 29 | error: 'File too large', 30 | message: `File size (${stats.size} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)` 31 | }); 32 | } 33 | 34 | // For API requests that expect JSON response with source content 35 | if (req.body && req.body.path) { 36 | // Read file content and return as JSON (for frontend) 37 | const content = await fs.promises.readFile(absolutePath, 'utf8'); 38 | 39 | res.json({ 40 | success: true, 41 | source: content, 42 | file: filePath 43 | }); 44 | } else { 45 | // For legacy requests, stream the file directly (for backward compatibility) 46 | const readStream = fs.createReadStream(absolutePath); 47 | 48 | // Set appropriate headers 49 | res.setHeader('Content-Type', 'text/plain; charset=utf-8'); 50 | res.setHeader('Content-Length', stats.size); 51 | 52 | // Handle stream errors 53 | readStream.on('error', (error) => { 54 | console.error('Error reading file:', error); 55 | if (!res.headersSent) { 56 | res.status(500).json({ 57 | error: 'Failed to read file', 58 | message: error.message 59 | }); 60 | } 61 | }); 62 | 63 | // Pipe the file directly to response 64 | readStream.pipe(res); 65 | } 66 | 67 | } catch (error) { 68 | console.error('Error accessing file:', error); 69 | res.status(404).json({ 70 | error: 'File not found', 71 | message: error.message 72 | }); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /test/memory-fixes.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const safeSerialize = require('../lib/utils/safe-serialize'); 3 | 4 | test('safeSerialize handles large arrays without memory issues', (t) => { 5 | // Create a large array that could cause memory issues 6 | const largeArray = new Array(2000).fill(0).map((_, i) => ({ index: i, data: 'test data' })); 7 | 8 | const result = safeSerialize(largeArray); 9 | 10 | // Should be truncated to 1000 items plus truncation message 11 | t.is(result.length, 1001); 12 | t.is(result[1000], '[Array truncated - original length: 2000]'); 13 | t.is(result[0].index, 0); 14 | t.is(result[999].index, 999); 15 | }); 16 | 17 | test('safeSerialize limits stack trace length for Error objects', (t) => { 18 | const error = new Error('Test error'); 19 | // Create a very long stack trace 20 | error.stack = 'Error: Test error\n' + 'at '.repeat(1000) + 'very long stack trace'; 21 | 22 | const result = safeSerialize(error); 23 | 24 | t.is(result.name, 'Error'); 25 | t.is(result.message, 'Test error'); 26 | t.true(result.stack.length <= 2000); 27 | t.is(result.type, 'Error'); 28 | }); 29 | 30 | test('safeSerialize handles objects with many properties', (t) => { 31 | const objWithManyProps = {}; 32 | for (let i = 0; i < 200; i++) { 33 | objWithManyProps[`prop${i}`] = `value${i}`; 34 | } 35 | 36 | const result = safeSerialize(objWithManyProps); 37 | const keys = Object.keys(result); 38 | 39 | // Should be limited to 100 properties plus truncation message 40 | t.true(keys.length <= 101); 41 | t.true(keys.includes('[...truncated]')); 42 | }); 43 | 44 | test('safeSerialize handles Function objects', (t) => { 45 | function namedFunction() { return 'test'; } 46 | const anonymousFunction = () => 'test'; 47 | 48 | const result1 = safeSerialize(namedFunction); 49 | const result2 = safeSerialize(anonymousFunction); 50 | 51 | t.is(result1, '[Function: namedFunction]'); 52 | t.is(result2, '[Function: anonymousFunction]'); // Arrow functions get assigned variable name 53 | }); 54 | 55 | test('safeSerialize handles Buffer objects with size info', (t) => { 56 | if (typeof Buffer !== 'undefined') { 57 | const buffer = Buffer.from('Hello World', 'utf8'); 58 | const result = safeSerialize(buffer); 59 | 60 | t.is(result, '[Buffer: 11 bytes]'); 61 | } else { 62 | t.pass('Buffer not available in this environment'); 63 | } 64 | }); 65 | 66 | test('safeSerialize backwards compatibility with existing maxDepth parameter', (t) => { 67 | const circularObj = { name: 'test' }; 68 | circularObj.self = circularObj; 69 | 70 | // Should work the same as before with existing parameter 71 | const result = safeSerialize(circularObj, 50); 72 | 73 | t.is(result.name, 'test'); 74 | t.is(result.self, '[Circular Reference]'); 75 | }); -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import '@/assets/tailwind.css'; 2 | import axios from 'axios'; 3 | import Buefy from 'buefy'; 4 | import Vue from 'vue'; 5 | import VueHighlightJS from 'vue-highlightjs'; 6 | import VueRouter from 'vue-router'; 7 | import VueSocketIO from 'vue-socket.io'; 8 | import Vuex from 'vuex'; 9 | import App from './App.vue'; 10 | 11 | 12 | import routes from './routes'; 13 | 14 | 15 | Vue.use(VueRouter); 16 | Vue.use(Vuex); 17 | Vue.use(Buefy); 18 | Vue.use(VueHighlightJS); 19 | 20 | const store = require('./store').default; 21 | 22 | 23 | (async () => { 24 | let wsConnection; 25 | 26 | // Support reverse proxy configurations by checking for base URL 27 | const baseUrl = window.location.origin; 28 | const isReverseProxy = window.location.pathname !== '/'; 29 | 30 | if (isReverseProxy) { 31 | // Use relative paths for reverse proxy setups 32 | wsConnection = baseUrl.replace('http', 'ws'); 33 | } else { 34 | // Standard configuration - fetch port info with retry logic 35 | let retryCount = 0; 36 | const maxRetries = 3; 37 | 38 | while (retryCount < maxRetries) { 39 | try { 40 | const response = await axios.get('/api/ports', { timeout: 5000 }); 41 | const data = await response.data; 42 | wsConnection = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.hostname}:${data.wsPort}`; 43 | console.log('✅ Successfully fetched WebSocket port info:', data); 44 | break; 45 | } catch (err) { 46 | retryCount++; 47 | console.warn(`⚠️ Failed to fetch port info (attempt ${retryCount}/${maxRetries}):`, err.message); 48 | 49 | if (retryCount >= maxRetries) { 50 | console.warn('🔄 Using fallback WebSocket connection to same origin'); 51 | // Fallback to same origin if port fetch fails after retries 52 | wsConnection = baseUrl.replace('http', 'ws'); 53 | } else { 54 | // Wait before retrying 55 | await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); 56 | } 57 | } 58 | } 59 | } 60 | 61 | console.log('🔌 Connecting to WebSocket:', wsConnection); 62 | 63 | Vue.use(new VueSocketIO({ 64 | debug: true, 65 | connection: wsConnection, 66 | vuex: { 67 | store, 68 | actionPrefix: 'SOCKET_', 69 | mutationPrefix: 'SOCKET_' 70 | }, 71 | options: { 72 | // Add connection options for better reliability 73 | timeout: 10000, 74 | reconnection: true, 75 | reconnectionAttempts: 5, 76 | reconnectionDelay: 1000, 77 | forceNew: false 78 | } 79 | })); 80 | })(); 81 | Vue.config.productionTip = false; 82 | 83 | const router = new VueRouter({ 84 | mode: 'hash', 85 | routes 86 | }); 87 | 88 | new Vue({ 89 | router, 90 | render: h => h(App), 91 | store, 92 | }).$mount('#app'); 93 | -------------------------------------------------------------------------------- /test/server-startup.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | test('Server startup script has proper error handling', (t) => { 6 | const startupScript = path.join(__dirname, '..', 'bin', 'codecept-ui.js'); 7 | const content = fs.readFileSync(startupScript, 'utf8'); 8 | 9 | // Check for error handling patterns 10 | t.true(content.includes('httpServer.on(\'error\''), 'Should have HTTP server error handling'); 11 | t.true(content.includes('wsServer.on(\'error\''), 'Should have WebSocket server error handling'); 12 | t.true(content.includes('EADDRINUSE'), 'Should handle port already in use error'); 13 | t.true(content.includes('gracefulShutdown'), 'Should have graceful shutdown handling'); 14 | t.true(content.includes('SIGINT'), 'Should handle SIGINT signal'); 15 | t.true(content.includes('SIGTERM'), 'Should handle SIGTERM signal'); 16 | }); 17 | 18 | test('Frontend has retry logic for port fetching', (t) => { 19 | const mainScript = path.join(__dirname, '..', 'src', 'main.js'); 20 | const content = fs.readFileSync(mainScript, 'utf8'); 21 | 22 | // Check for retry logic patterns 23 | t.true(content.includes('maxRetries'), 'Should have maximum retry limit'); 24 | t.true(content.includes('retryCount'), 'Should track retry attempts'); 25 | t.true(content.includes('Successfully fetched WebSocket port info'), 'Should log successful port fetch'); 26 | t.true(content.includes('Failed to fetch port info'), 'Should log failed port fetch attempts'); 27 | t.true(content.includes('setTimeout'), 'Should have delay between retries'); 28 | }); 29 | 30 | test('Socket.IO configuration includes reliability improvements', (t) => { 31 | const startupScript = path.join(__dirname, '..', 'bin', 'codecept-ui.js'); 32 | const content = fs.readFileSync(startupScript, 'utf8'); 33 | 34 | // Check for Socket.IO reliability configurations 35 | t.true(content.includes('pingTimeout'), 'Should configure ping timeout'); 36 | t.true(content.includes('pingInterval'), 'Should configure ping interval'); 37 | t.true(content.includes('connectTimeout'), 'Should configure connection timeout'); 38 | t.true(content.includes('allowRequest'), 'Should have custom connection validation'); 39 | }); 40 | 41 | test('Frontend Socket.IO has connection options for reliability', (t) => { 42 | const mainScript = path.join(__dirname, '..', 'src', 'main.js'); 43 | const content = fs.readFileSync(mainScript, 'utf8'); 44 | 45 | // Check for Socket.IO client options 46 | t.true(content.includes('options:'), 'Should have Socket.IO client options'); 47 | t.true(content.includes('reconnection: true'), 'Should enable reconnection'); 48 | t.true(content.includes('reconnectionAttempts'), 'Should configure reconnection attempts'); 49 | t.true(content.includes('reconnectionDelay'), 'Should configure reconnection delay'); 50 | }); -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 87 | 88 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 129 | -------------------------------------------------------------------------------- /lib/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const router = express.Router(); 4 | 5 | const { getPort } = require('./../config/env'); 6 | 7 | const getSnapshotHtml = require('./get-snapshot-html'); 8 | const getSnapshotImage = require('./get-snapshot-image'); 9 | const listActions = require('./list-actions'); 10 | const listScenarios = require('./list-scenarios'); 11 | const getScenario = require('./get-scenario'); 12 | const getFile = require('./get-file'); 13 | const runScenario = require('./run-scenario'); 14 | const runNew = require('./new-test'); 15 | const stopScenario = require('./stop'); 16 | const runScenarioParallel = require('./run-scenario-parallel'); 17 | const openTestInEditor = require('./open-test-in-editor'); 18 | const saveTestRun = require('./save-testrun'); 19 | const getTestRun = require('./get-testrun'); 20 | // const listProfiles = require('./list-profiles'); 21 | const getSettings = require('./get-settings'); 22 | const getConfig = require('./get-config'); 23 | const storeSettings = require('./store-settings'); 24 | const getScenarioStatus = require('./get-scenario-status'); 25 | const getSteps = require('./get-steps'); 26 | const getPageObjects = require('./get-page-objects'); 27 | const editor = require('./editor'); 28 | 29 | const jsonParser = bodyParser.json({ limit: '50mb' }); 30 | 31 | router.get('/steps', getSteps); 32 | router.get('/page-objects', getPageObjects); 33 | router.get('/snapshots/html/:id', getSnapshotHtml); 34 | router.get('/snapshots/screenshot/:id', getSnapshotImage); 35 | router.post('/file', jsonParser, getFile); 36 | 37 | router.get('/scenario-status', getScenarioStatus); 38 | 39 | router.get('/scenarios', listScenarios); 40 | router.get('/actions', listActions); 41 | router.get('/scenarios/:id', getScenario); 42 | 43 | router.post('/scenarios/run', jsonParser, runScenario); 44 | router.post('/scenarios/grep/:grep/run', jsonParser, runScenario); 45 | router.post('/scenarios/:id/run', jsonParser, runScenario); 46 | router.post('/scenarios/stop', jsonParser, stopScenario); 47 | router.post('/run-new', jsonParser, runNew); 48 | router.post('/scenarios/:grep/run-parallel', jsonParser, runScenarioParallel); 49 | router.get('/tests/:file/open', openTestInEditor); 50 | 51 | // Code Editor API endpoints 52 | router.get('/editor/scenario/:file/:line', editor.getScenarioSource); 53 | router.put('/editor/scenario/:file/:line', jsonParser, editor.updateScenario); 54 | router.get('/editor/file/:file', editor.getFileContent); 55 | router.put('/editor/file/:file', jsonParser, editor.updateFileContent); 56 | router.get('/editor/autocomplete', editor.getAutocompleteSuggestions); 57 | 58 | router.get('/testruns/:id', getTestRun); 59 | router.put('/testruns/:id', jsonParser, saveTestRun); 60 | 61 | router.get('/config', getConfig); 62 | router.get('/settings', getSettings); 63 | router.put('/settings', jsonParser, storeSettings); 64 | // router.get('/profiles', listProfiles); 65 | 66 | router.get('/ports', (req, res) => { 67 | res.json({ 68 | port: getPort('application'), 69 | wsPort: getPort('ws'), 70 | }); 71 | }); 72 | 73 | module.exports = router; 74 | -------------------------------------------------------------------------------- /src/components/RunButton.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 95 | 100 | -------------------------------------------------------------------------------- /lib/codeceptjs/single-session.helper.js: -------------------------------------------------------------------------------- 1 | // Try to get Helper from codeceptjs, fallback to a mock for tests 2 | let Helper; 3 | try { 4 | // eslint-disable-next-line no-undef 5 | Helper = codecept_helper; 6 | } catch (error) { 7 | // Fallback for testing environment 8 | Helper = class MockHelper { 9 | constructor() {} 10 | _init() {} 11 | _before() {} 12 | _after() {} 13 | _passed() {} 14 | _failed() {} 15 | }; 16 | } 17 | 18 | const { getSettings } = require('../model/settings-repository'); 19 | const { container } = require('codeceptjs'); 20 | 21 | const supportedHelpers = [ 22 | 'TestCafe', 23 | 'Protractor', 24 | 'Puppeteer', 25 | 'Nightmare', 26 | 'WebDriver', 27 | 'Playwright' 28 | ]; 29 | 30 | class SingleSessionHelper extends Helper { 31 | constructor(options) { 32 | super(options); 33 | this.helper = null; 34 | this.enabled = global.isElectron || false; 35 | } 36 | 37 | _init() { 38 | const helpers = container.helpers(); 39 | for (const supportedHelper of supportedHelpers) { 40 | const helper = helpers[supportedHelper]; 41 | if (!helper) continue; 42 | this.helper = helper; 43 | break; 44 | } 45 | } 46 | 47 | async _beforeSuite() { 48 | const { isSingleSession } = getSettings(); 49 | if (isSingleSession) this.enabled = true; 50 | if (!this.enabled || !this.helper) return; 51 | this.helper.options.manualStart = true; 52 | this.helper.options.restart = false; 53 | 54 | await this._startBrowserIfNotRunning(); 55 | } 56 | 57 | _afterSuite() { 58 | if (!this.enabled || !this.helper) return; 59 | 60 | // Proper cleanup when single session is disabled 61 | const { isSingleSession } = getSettings(); 62 | if (!isSingleSession && this.helper.isRunning) { 63 | // Close browser when single session is disabled 64 | this._closeBrowser(); 65 | } else { 66 | // Don't close browser in single session mode, but mark as not running 67 | this.helper.isRunning = false; 68 | } 69 | } 70 | 71 | async _closeBrowser() { 72 | if (!this.helper) return; 73 | 74 | try { 75 | // Gracefully close the browser 76 | if (this.helper._stopBrowser) { 77 | await this.helper._stopBrowser(); 78 | } else if (this.helper.browser && this.helper.browser.close) { 79 | await this.helper.browser.close(); 80 | } else if (this.helper.page && this.helper.page.close) { 81 | await this.helper.page.close(); 82 | } 83 | this.helper.isRunning = false; 84 | } catch (err) { 85 | // Force cleanup on error 86 | this.helper.isRunning = false; 87 | } 88 | } 89 | 90 | async _startBrowserIfNotRunning() { 91 | if (!this.helper) return; 92 | 93 | try { 94 | await this.helper.grabCurrentUrl(); 95 | } catch (err) { 96 | await this.helper._startBrowser(); 97 | } 98 | this.helper.isRunning = true; 99 | } 100 | 101 | // Method to force cleanup all browser instances 102 | async forceCleanup() { 103 | await this._closeBrowser(); 104 | } 105 | 106 | } 107 | 108 | module.exports = SingleSessionHelper; 109 | -------------------------------------------------------------------------------- /test/browser-cleanup.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const SingleSessionHelper = require('../lib/codeceptjs/single-session.helper'); 3 | 4 | // Mock the settings repository 5 | const mockSettings = { 6 | isSingleSession: true 7 | }; 8 | 9 | const mockHelper = { 10 | isRunning: false, 11 | _stopBrowser: async () => {}, 12 | browser: { 13 | close: async () => {} 14 | }, 15 | page: { 16 | close: async () => {} 17 | }, 18 | grabCurrentUrl: async () => 'http://localhost', 19 | _startBrowser: async () => {} 20 | }; 21 | 22 | test('SingleSessionHelper properly closes browser when single session disabled', async (t) => { 23 | const helper = new SingleSessionHelper(); 24 | helper.helper = mockHelper; 25 | helper.enabled = true; 26 | 27 | // Mock settings to disable single session 28 | const originalGetSettings = require('../lib/model/settings-repository').getSettings; 29 | require('../lib/model/settings-repository').getSettings = () => ({ isSingleSession: false }); 30 | 31 | helper.helper.isRunning = true; 32 | 33 | await helper._afterSuite(); 34 | 35 | t.false(helper.helper.isRunning, 'Browser should be marked as not running'); 36 | 37 | // Restore original function 38 | require('../lib/model/settings-repository').getSettings = originalGetSettings; 39 | }); 40 | 41 | test('SingleSessionHelper does not close browser when single session enabled', async (t) => { 42 | const helper = new SingleSessionHelper(); 43 | helper.helper = mockHelper; 44 | helper.enabled = true; 45 | 46 | // Mock settings to enable single session 47 | const originalGetSettings = require('../lib/model/settings-repository').getSettings; 48 | require('../lib/model/settings-repository').getSettings = () => ({ isSingleSession: true }); 49 | 50 | helper.helper.isRunning = true; 51 | 52 | await helper._afterSuite(); 53 | 54 | t.false(helper.helper.isRunning, 'Browser should still be marked as not running for cleanup'); 55 | 56 | // Restore original function 57 | require('../lib/model/settings-repository').getSettings = originalGetSettings; 58 | }); 59 | 60 | test('forceCleanup method exists and works', async (t) => { 61 | const helper = new SingleSessionHelper(); 62 | helper.helper = mockHelper; 63 | 64 | t.is(typeof helper.forceCleanup, 'function', 'forceCleanup method should exist'); 65 | 66 | await t.notThrowsAsync(helper.forceCleanup(), 'forceCleanup should not throw'); 67 | }); 68 | 69 | test('_closeBrowser handles different helper types gracefully', async (t) => { 70 | const helper = new SingleSessionHelper(); 71 | 72 | // Test with _stopBrowser method 73 | helper.helper = { 74 | _stopBrowser: async () => {}, 75 | isRunning: true 76 | }; 77 | await t.notThrowsAsync(helper._closeBrowser()); 78 | 79 | // Test with browser.close method 80 | helper.helper = { 81 | browser: { close: async () => {} }, 82 | isRunning: true 83 | }; 84 | await t.notThrowsAsync(helper._closeBrowser()); 85 | 86 | // Test with page.close method 87 | helper.helper = { 88 | page: { close: async () => {} }, 89 | isRunning: true 90 | }; 91 | await t.notThrowsAsync(helper._closeBrowser()); 92 | 93 | // Test with no helper 94 | helper.helper = null; 95 | await t.notThrowsAsync(helper._closeBrowser()); 96 | }); -------------------------------------------------------------------------------- /src/components/pages/PageObjectsPage.vue: -------------------------------------------------------------------------------- 1 |