diff --git a/.eslintrc.json b/.eslintrc.json
index 33d9c6e9a..eee9b1b53 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -3,6 +3,9 @@
"env": {
"node": true
},
+ "parserOptions": {
+ "ecmaVersion": 2020
+ },
"rules": {
"func-names": 0,
"no-use-before-define": 0,
diff --git a/docs/changelog.md b/docs/changelog.md
index c8acd2228..fad90b73d 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -7,6 +7,11 @@ layout: Section
# Releases
+## 3.4.1
+
+* Updated mocha to v 10.2. Fixes [#3591](https://github.com/codeceptjs/CodeceptJS/issues/3591)
+* Fixes executing a faling Before hook. Resolves [#3592](https://github.com/codeceptjs/CodeceptJS/issues/3592)
+
## 3.4.0
* **Updated to latest mocha and modern Cucumber**
diff --git a/docs/plugins.md b/docs/plugins.md
index 76a0e4182..524f8f2cc 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -479,6 +479,18 @@ I.click('=sign-up'); // matches => [data-qa=sign-up],[data-test=sign-up]
- `config`
+## debugErrors
+
+Creates screenshot on failure. Screenshot is saved into `output` directory.
+
+Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
+
+This plugin is **enabled by default**.
+
+### Parameters
+
+- `config`
+
## eachElement
Provides `eachElement` global function to iterate over found elements to perform actions on them.
diff --git a/examples/codecept.config.js b/examples/codecept.config.js
index a31e21235..2af92dd88 100644
--- a/examples/codecept.config.js
+++ b/examples/codecept.config.js
@@ -2,9 +2,10 @@ exports.config = {
output: './output',
helpers: {
Playwright: {
- url: 'http://localhost',
+ url: 'http://github.com',
browser: 'chromium',
- restart: 'context',
+ // restart: 'context',
+ // show: false,
// timeout: 5000,
windowSize: '1600x1200',
// video: true,
@@ -52,6 +53,9 @@ exports.config = {
autoDelay: {
enabled: false,
},
+ heal: {
+ enabled: true,
+ },
retryFailedStep: {
enabled: false,
},
diff --git a/examples/github_test.js b/examples/github_test.js
index f567eb73a..a575c5097 100644
--- a/examples/github_test.js
+++ b/examples/github_test.js
@@ -5,6 +5,12 @@ Before(({ Smth }) => {
Smth.openGitHub();
});
+Scenario('Incorrect search for Codeceptjs', ({ I }) => {
+ I.fillField('.search', 'CodeceptJS');
+ I.pressKey('Enter');
+ I.see('Supercharged End 2 End Testing');
+});
+
Scenario('Visit Home Page @retry', async ({ I }) => {
// .retry({ retries: 3, minTimeout: 1000 })
I.retry(2).see('GitHub');
diff --git a/jsconfig.json b/jsconfig.json
index 3eeeb5bdd..7121524da 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "es2017",
+ "target": "es2019",
"module": "commonjs"
}
}
\ No newline at end of file
diff --git a/lib/ai.js b/lib/ai.js
new file mode 100644
index 000000000..183b54ccf
--- /dev/null
+++ b/lib/ai.js
@@ -0,0 +1,176 @@
+const { Configuration, OpenAIApi } = require('openai');
+const debug = require('debug')('codeceptjs:ai');
+const config = require('./config');
+const output = require('./output');
+const { removeNonInteractiveElements, minifyHtml, splitByChunks } = require('./html');
+
+const defaultConfig = {
+ model: 'gpt-3.5-turbo-16k',
+ temperature: 0.1,
+};
+
+const htmlConfig = {
+ maxLength: null,
+ simplify: true,
+ minify: true,
+ interactiveElements: ['a', 'input', 'button', 'select', 'textarea', 'option'],
+ textElements: ['label', 'h1', 'h2'],
+ allowedAttrs: ['id', 'for', 'class', 'name', 'type', 'value', 'aria-labelledby', 'aria-label', 'label', 'placeholder', 'title', 'alt', 'src', 'role'],
+ allowedRoles: ['button', 'checkbox', 'search', 'textbox', 'tab'],
+};
+
+class AiAssistant {
+ constructor() {
+ this.config = config.get('openai', defaultConfig);
+ this.htmlConfig = this.config.html || htmlConfig;
+ delete this.config.html;
+ this.html = null;
+ this.response = null;
+
+ this.isEnabled = !!process.env.OPENAI_API_KEY;
+
+ if (!this.isEnabled) return;
+
+ const configuration = new Configuration({
+ apiKey: process.env.OPENAI_API_KEY,
+ });
+
+ this.openai = new OpenAIApi(configuration);
+ }
+
+ setHtmlContext(html) {
+ let processedHTML = html;
+
+ if (this.htmlConfig.simplify) {
+ processedHTML = removeNonInteractiveElements(processedHTML, {
+ interactiveElements: this.htmlConfig.interactiveElements,
+ allowedAttrs: this.htmlConfig.allowedAttrs,
+ allowedRoles: this.htmlConfig.allowedRoles,
+ });
+ }
+ if (this.htmlConfig.minify) processedHTML = minifyHtml(processedHTML);
+ if (this.htmlConfig.maxLength) processedHTML = splitByChunks(processedHTML, this.htmlConfig.maxLength)[0];
+
+ debug(processedHTML);
+
+ this.html = processedHTML;
+ }
+
+ getResponse() {
+ return this.response || '';
+ }
+
+ mockResponse(response) {
+ this.mockedResponse = response;
+ }
+
+ async createCompletion(messages) {
+ if (!this.openai) return;
+
+ debug(messages);
+
+ if (this.mockedResponse) return this.mockedResponse;
+
+ this.response = null;
+
+ try {
+ const completion = await this.openai.createChatCompletion({
+ ...this.config,
+ messages,
+ });
+
+ this.response = completion?.data?.choices[0]?.message?.content;
+
+ debug(this.response);
+
+ return this.response;
+ } catch (err) {
+ debug(err.response);
+ output.print('');
+ output.error(`OpenAI error: ${err.message}`);
+ output.error(err?.response?.data?.error?.code);
+ output.error(err?.response?.data?.error?.message);
+ return '';
+ }
+ }
+
+ async healFailedStep(step, err, test) {
+ if (!this.isEnabled) return [];
+ if (!this.html) throw new Error('No HTML context provided');
+
+ const messages = [
+ { role: 'user', content: 'As a test automation engineer I am testing web application using CodeceptJS.' },
+ { role: 'user', content: `I want to heal a test that fails. Here is the list of executed steps: ${test.steps.join(', ')}` },
+ { role: 'user', content: `Propose how to adjust ${step.toCode()} step to fix the test.` },
+ { role: 'user', content: 'Use locators in order of preference: semantic locator by text, CSS, XPath. Use codeblocks marked with ```.' },
+ { role: 'user', content: `Here is the error message: ${err.message}` },
+ { role: 'user', content: `Here is HTML code of a page where the failure has happened: \n\n${this.html}` },
+ ];
+
+ const response = await this.createCompletion(messages);
+ if (!response) return [];
+
+ return parseCodeBlocks(response);
+ }
+
+ async writeSteps(input) {
+ if (!this.isEnabled) return;
+ if (!this.html) throw new Error('No HTML context provided');
+
+ const snippets = [];
+
+ const messages = [
+ {
+ role: 'user',
+ content: `I am test engineer writing test in CodeceptJS
+ I have opened web page and I want to use CodeceptJS to ${input} on this page
+ Provide me valid CodeceptJS code to accomplish it
+ Use only locators from this HTML: \n\n${this.html}`,
+ },
+ { role: 'user', content: 'Propose only CodeceptJS steps code. Do not include Scenario or Feature into response' },
+
+ // old prompt
+ // { role: 'user', content: 'I want to click button Submit using CodeceptJS on this HTML page:
' },
+ // { role: 'assistant', content: '```js\nI.click("Submit");\n```' },
+ // { role: 'user', content: 'I want to click button Submit using CodeceptJS on this HTML page: ' },
+ // { role: 'assistant', content: 'No suggestions' },
+ // { role: 'user', content: `Now I want to ${input} on this HTML page using CodeceptJS code` },
+ // { role: 'user', content: `Provide me with CodeceptJS code to achieve this on THIS page.` },
+ ];
+ const response = await this.createCompletion(messages);
+ if (!response) return;
+ snippets.push(...parseCodeBlocks(response));
+
+ debug(snippets[0]);
+
+ return snippets[0];
+ }
+}
+
+function parseCodeBlocks(response) {
+ // Regular expression pattern to match code snippets
+ const codeSnippetPattern = /```(?:javascript|js|typescript|ts)?\n([\s\S]+?)\n```/g;
+
+ // Array to store extracted code snippets
+ const codeSnippets = [];
+
+ // Iterate over matches and extract code snippets
+ let match;
+ while ((match = codeSnippetPattern.exec(response)) !== null) {
+ codeSnippets.push(match[1]);
+ }
+
+ // Remove "Scenario", "Feature", and "require()" lines
+ const modifiedSnippets = codeSnippets.map(snippet => {
+ const lines = snippet.split('\n').map(line => line.trim());
+
+ const filteredLines = lines.filter(line => !line.includes('I.amOnPage') && !line.startsWith('Scenario') && !line.startsWith('Feature') && !line.includes('= require('));
+
+ return filteredLines.join('\n');
+ // remove snippets that move from current url
+ }); // .filter(snippet => !line.includes('I.amOnPage'));
+
+ return modifiedSnippets.filter(snippet => !!snippet);
+}
+
+module.exports = AiAssistant;
diff --git a/lib/cli.js b/lib/cli.js
index c4d59f74b..ff8aa8dfa 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -168,7 +168,7 @@ class Cli extends Base {
}
// display artifacts in debug mode
- if (test.artifacts && Object.keys(test.artifacts).length) {
+ if (test?.artifacts && Object.keys(test.artifacts).length) {
log += `\n${output.styles.bold('Artifacts:')}`;
for (const artifact of Object.keys(test.artifacts)) {
log += `\n- ${artifact}: ${test.artifacts[artifact]}`;
diff --git a/lib/command/interactive.js b/lib/command/interactive.js
index 22419fbc3..e3a33536a 100644
--- a/lib/command/interactive.js
+++ b/lib/command/interactive.js
@@ -1,8 +1,10 @@
const { getConfig, getTestRoot } = require('./utils');
const recorder = require('../recorder');
const Codecept = require('../codecept');
+const Container = require('../container');
const event = require('../event');
const output = require('../output');
+const webHelpers = require('../plugin/standardActingHelpers');
module.exports = async function (path, options) {
// Backward compatibility for --profile
@@ -29,9 +31,21 @@ module.exports = async function (path, options) {
});
event.emit(event.test.before, {
title: '',
+ artifacts: {},
});
+
+ const enabledHelpers = Container.helpers();
+ for (const helperName of Object.keys(enabledHelpers)) {
+ if (webHelpers.includes(helperName)) {
+ const I = enabledHelpers[helperName];
+ recorder.add(() => I.amOnPage('/'));
+ recorder.catchWithoutStop(e => output.print(`Error while loading home page: ${e.message}}`));
+ break;
+ }
+ }
require('../pause')();
- recorder.add(() => event.emit(event.test.after));
+ // recorder.catchWithoutStop((err) => console.log(err.stack));
+ recorder.add(() => event.emit(event.test.after, {}));
recorder.add(() => event.emit(event.suite.after, {}));
recorder.add(() => event.emit(event.all.result, {}));
recorder.add(() => codecept.teardown());
diff --git a/lib/helper/OpenAI.js b/lib/helper/OpenAI.js
new file mode 100644
index 000000000..bd13f784b
--- /dev/null
+++ b/lib/helper/OpenAI.js
@@ -0,0 +1,122 @@
+const Helper = require('@codeceptjs/helper');
+const AiAssistant = require('../ai');
+const standardActingHelpers = require('../plugin/standardActingHelpers');
+const Container = require('../container');
+const { splitByChunks, minifyHtml } = require('../html');
+
+/**
+ * OpenAI Helper for CodeceptJS.
+ *
+ * This helper class provides integration with the OpenAI GPT-3.5 or 4 language model for generating responses to questions or prompts within the context of web pages. It allows you to interact with the GPT-3.5 model to obtain intelligent responses based on HTML fragments or general prompts.
+ * This helper should be enabled with any web helpers like Playwright or Puppeteer or WebDrvier to ensure the HTML context is available.
+ *
+ * ## Configuration
+ *
+ * This helper should be configured in codecept.json or codecept.conf.js
+ *
+ * * `chunkSize`: (optional, default: 80000) - The maximum number of characters to send to the OpenAI API at once. We split HTML fragments by 8000 chars to not exceed token limit. Increase this value if you use GPT-4.
+ */
+class OpenAI extends Helper {
+ constructor(config) {
+ super(config);
+ this.aiAssistant = new AiAssistant();
+
+ this.options = {
+ chunkSize: 80000,
+ };
+ this.options = { ...this.options, ...config };
+
+ const helpers = Container.helpers();
+
+ for (const helperName of standardActingHelpers) {
+ if (Object.keys(helpers).indexOf(helperName) > -1) {
+ this.helper = helpers[helperName];
+ break;
+ }
+ }
+ }
+
+ /**
+ * Asks the OpenAI GPT language model a question based on the provided prompt within the context of the current page's HTML.
+ *
+ * ```js
+ * I.askGptOnPage('what does this page do?');
+ * ```
+ *
+ * @async
+ * @param {string} prompt - The question or prompt to ask the GPT model.
+ * @returns {Promise} - A Promise that resolves to the generated responses from the GPT model, joined by newlines.
+ */
+ async askGptOnPage(prompt) {
+ const html = await this.helper.grabSource();
+
+ const htmlChunks = splitByChunks(html, this.options.chunkSize);
+
+ if (htmlChunks.length > 1) this.debug(`Splitting HTML into ${htmlChunks.length} chunks`);
+
+ const responses = [];
+
+ for (const chunk of htmlChunks) {
+ const messages = [
+ { role: 'user', content: prompt },
+ { role: 'user', content: `Within this HTML: ${minifyHtml(chunk)}` },
+ ];
+
+ if (htmlChunks.length > 1) messages.push({ role: 'user', content: 'If action is not possible on this page, do not propose anything, I will send another HTML fragment' });
+
+ const response = await this.aiAssistant.createCompletion(messages);
+
+ console.log(response);
+
+ responses.push(response);
+ }
+
+ return responses.join('\n\n');
+ }
+
+ /**
+ * Asks the OpenAI GPT-3.5 language model a question based on the provided prompt within the context of a specific HTML fragment on the current page.
+ *
+ * ```js
+ * I.askGptOnPageFragment('describe features of this screen', '.screen');
+ * ```
+ *
+ * @async
+ * @param {string} prompt - The question or prompt to ask the GPT-3.5 model.
+ * @param {string} locator - The locator or selector used to identify the HTML fragment on the page.
+ * @returns {Promise} - A Promise that resolves to the generated response from the GPT model.
+ */
+ async askGptOnPageFragment(prompt, locator) {
+ const html = await this.helper.grabHTMLFrom(locator);
+
+ const messages = [
+ { role: 'user', content: prompt },
+ { role: 'user', content: `Within this HTML: ${minifyHtml(html)}` },
+ ];
+
+ const response = await this.aiAssistant.createCompletion(messages);
+
+ console.log(response);
+
+ return response;
+ }
+
+ /**
+ * Send a general request to ChatGPT and return response.
+ * @param {string} prompt
+ * @returns {Promise} - A Promise that resolves to the generated response from the GPT model.
+ */
+ async askGptGeneralPrompt(prompt) {
+ const messages = [
+ { role: 'user', content: prompt },
+ ];
+
+ const completion = await this.aiAssistant.createCompletion(messages);
+
+ const response = completion?.data?.choices[0]?.message?.content;
+
+ console.log(response);
+
+ return response;
+ }
+}
diff --git a/lib/html.js b/lib/html.js
new file mode 100644
index 000000000..5038c277b
--- /dev/null
+++ b/lib/html.js
@@ -0,0 +1,248 @@
+const { parse, serialize } = require('parse5');
+const { minify } = require('html-minifier');
+
+function minifyHtml(html) {
+ return minify(html, {
+ collapseWhitespace: true,
+ removeComments: true,
+ removeEmptyAttributes: true,
+ removeRedundantAttributes: true,
+ removeScriptTypeAttributes: true,
+ removeStyleLinkTypeAttributes: true,
+ collapseBooleanAttributes: true,
+ useShortDoctype: true,
+ }).toString();
+}
+
+function removeNonInteractiveElements(html, opts = {}) {
+ const {
+ interactiveElements,
+ allowedAttrs,
+ allowedRoles,
+ } = opts;
+
+ // Parse the HTML into a document tree
+ const document = parse(html);
+
+ const trashHtmlClasses = /^(text-|color-|flex-|float-|v-|ember-|d-|border-)/;
+ // Array to store interactive elements
+ const removeElements = ['path', 'script'];
+
+ function isFilteredOut(node) {
+ if (removeElements.includes(node.nodeName)) return true;
+ if (node.attrs) {
+ if (node.attrs.find(attr => attr.name === 'role' && attr.value === 'tooltip')) return true;
+ }
+ return false;
+ }
+
+ // Function to check if an element is interactive
+ function isInteractive(element) {
+ if (element.nodeName === 'input' && element.attrs.find(attr => attr.name === 'type' && attr.value === 'hidden')) return false;
+ if (interactiveElements.includes(element.nodeName)) return true;
+ if (element.attrs) {
+ if (element.attrs.find(attr => attr.name === 'contenteditable')) return true;
+ const role = element.attrs.find(attr => attr.name === 'role');
+ if (role && allowedRoles.includes(role.value)) return true;
+ }
+ return false;
+ }
+
+ function hasMeaningfulText(node) {
+ if (interactiveElements.includes(node.nodeName)) return true;
+ return false;
+ }
+
+ function hasInteractiveDescendant(node) {
+ if (!node.childNodes) return false;
+ let result = false;
+
+ for (const childNode of node.childNodes) {
+ if (isInteractive(childNode)) return true;
+ result = result || hasInteractiveDescendant(childNode);
+ }
+
+ return result;
+ }
+
+ // Function to remove non-interactive elements recursively
+ function removeNonInteractive(node) {
+ if (node.nodeName !== '#document') {
+ const parent = node.parentNode;
+ const index = parent.childNodes.indexOf(node);
+
+ if (isFilteredOut(node)) {
+ parent.childNodes.splice(index, 1);
+ return true;
+ }
+
+ // keep texts for interactive elements
+ if ((isInteractive(parent) || hasMeaningfulText(parent)) && node.nodeName === '#text') {
+ node.value = node.value.trim().slice(0, 200);
+ if (!node.value) return false;
+ return true;
+ }
+
+ if (
+ // if parent is interactive, we may need child element to match
+ !isInteractive(parent)
+ && !isInteractive(node)
+ && !hasInteractiveDescendant(node)
+ && !hasMeaningfulText(node)) {
+ parent.childNodes.splice(index, 1);
+ return true;
+ }
+ }
+
+ if (node.attrs) {
+ // Filter and keep allowed attributes, accessibility attributes
+ node.attrs = node.attrs.filter(attr => {
+ const { name, value } = attr;
+ if (name === 'class') {
+ // Remove classes containing digits
+ attr.value = value.split(' ')
+ // remove classes containing digits/
+ .filter(className => !/\d/.test(className))
+ // remove popular trash classes
+ .filter(className => !className.match(trashHtmlClasses))
+ // remove classes with : in them
+ .filter(className => !className.match(/(:|__)/))
+ .join(' ');
+ }
+
+ return allowedAttrs.includes(name);
+ });
+ }
+
+ if (node.childNodes) {
+ for (let i = node.childNodes.length - 1; i >= 0; i--) {
+ const childNode = node.childNodes[i];
+ removeNonInteractive(childNode);
+ }
+ }
+ return false;
+ }
+
+ // Remove non-interactive elements starting from the root element
+ removeNonInteractive(document);
+
+ // Serialize the modified document tree back to HTML
+ const serializedHTML = serialize(document);
+
+ return serializedHTML;
+}
+
+function scanForErrorMessages(html, errorClasses = ['error', 'danger', 'warning', 'alert', 'warning']) {
+ // Parse the HTML into a document tree
+ const document = parse(html);
+
+ // Array to store error messages
+ const errorMessages = [];
+
+ // Function to recursively scan for error classes and messages
+ function scanErrors(node) {
+ if (node.attrs) {
+ const classAttr = node.attrs.find(attr => attr.name === 'class');
+ if (classAttr && classAttr.value) {
+ const classNameChunks = classAttr.value.split(' ').split('-');
+ const errorClassFound = errorClasses.some(errorClass => classNameChunks.includes(errorClass));
+ if (errorClassFound && node.childNodes) {
+ const errorMessage = sanitizeTextContent(node);
+ errorMessages.push(errorMessage);
+ }
+ }
+ }
+
+ if (node.childNodes) {
+ for (const childNode of node.childNodes) {
+ scanErrors(childNode);
+ }
+ }
+ }
+
+ // Start scanning for error classes and messages from the root element
+ scanErrors(document);
+
+ return errorMessages;
+}
+
+function sanitizeTextContent(node) {
+ if (node.nodeName === '#text') {
+ return node.value.trim();
+ }
+
+ let sanitizedText = '';
+
+ if (node.childNodes) {
+ for (const childNode of node.childNodes) {
+ sanitizedText += sanitizeTextContent(childNode);
+ }
+ }
+
+ return sanitizedText;
+}
+
+function buildPath(node, path = '') {
+ const tag = node.nodeName;
+ let attributes = '';
+
+ if (node.attrs) {
+ attributes = node.attrs
+ .map(attr => `${attr.name}="${attr.value}"`)
+ .join(' ');
+ }
+
+ if (!tag.startsWith('#') && tag !== 'body' && tag !== 'html') {
+ path += `<${node.nodeName}${node.attrs ? ` ${attributes}` : ''}>`;
+ }
+
+ if (!node.childNodes) return path;
+
+ const children = node.childNodes.filter(child => !child.nodeName.startsWith('#'));
+
+ if (children.length) {
+ return buildPath(children[children.length - 1], path);
+ }
+ return path;
+}
+
+function splitByChunks(text, chunkSize) {
+ chunkSize -= 20;
+ const chunks = [];
+ for (let i = 0; i < text.length; i += chunkSize) {
+ chunks.push(text.slice(i, i + chunkSize));
+ }
+
+ const regex = /<\s*\w+(?:\s+\w+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^>\s]+)))*\s*$/;
+
+ // append tag to chunk if it was split out
+ for (const index in chunks) {
+ const nextIndex = parseInt(index, 10) + 1;
+ if (!chunks[nextIndex]) break;
+
+ const currentChunk = chunks[index];
+ const nextChunk = chunks[nextIndex];
+
+ const lastTag = currentChunk.match(regex);
+ if (lastTag) {
+ chunks[nextIndex] = lastTag[0] + nextChunk;
+ }
+
+ const path = buildPath(parse(currentChunk));
+ if (path) {
+ chunks[nextIndex] = path + chunks[nextIndex];
+ }
+
+ if (chunks[nextIndex].includes('${chunks[nextIndex]}`;
+ }
+
+ return chunks.map(chunk => chunk.trim());
+}
+
+module.exports = {
+ scanForErrorMessages,
+ removeNonInteractiveElements,
+ splitByChunks,
+ minifyHtml,
+};
diff --git a/lib/pause.js b/lib/pause.js
index 8c95c57ea..f956e445f 100644
--- a/lib/pause.js
+++ b/lib/pause.js
@@ -1,9 +1,12 @@
const colors = require('chalk');
const readline = require('readline');
+const ora = require('ora-classic');
+const debug = require('debug')('codeceptjs:pause');
const container = require('./container');
const history = require('./history');
const store = require('./store');
+const AiAssistant = require('./ai');
const recorder = require('./recorder');
const event = require('./event');
const output = require('./output');
@@ -15,6 +18,8 @@ let nextStep;
let finish;
let next;
let registeredVariables = {};
+const aiAssistant = new AiAssistant();
+
/**
* Pauses test execution and starts interactive shell
*/
@@ -45,6 +50,14 @@ function pauseSession(passedObject = {}) {
output.print(colors.yellow(` - Press ${colors.bold('TAB')} twice to see all available commands`));
output.print(colors.yellow(` - Type ${colors.bold('exit')} + Enter to exit the interactive shell`));
output.print(colors.yellow(` - Prefix ${colors.bold('=>')} to run js commands ${colors.bold(vars)}`));
+
+ if (aiAssistant.isEnabled) {
+ output.print(colors.blue(` ${colors.bold('OpenAI is enabled! (experimental)')} Write what you want and make OpenAI run it`));
+ output.print(colors.blue(' Please note, only HTML fragments with interactive elements are sent to OpenAI'));
+ output.print(colors.blue(' Ideas: ask it to fill forms for you or to click'));
+ } else {
+ output.print(colors.blue(` Enable OpenAI assistant by setting ${colors.bold('OPENAI_API_KEY')} env variable`));
+ }
}
rl = readline.createInterface(process.stdin, process.stdout, completer);
@@ -59,9 +72,10 @@ function pauseSession(passedObject = {}) {
}
/* eslint-disable */
-function parseInput(cmd) {
+async function parseInput(cmd) {
rl.pause();
next = false;
+ recorder.session.start('pause');
store.debugMode = false;
if (cmd === '') next = true;
if (!cmd || cmd === 'resume' || cmd === 'exit') {
@@ -74,37 +88,73 @@ function parseInput(cmd) {
for (const k of Object.keys(registeredVariables)) {
eval(`var ${k} = registeredVariables['${k}'];`); // eslint-disable-line no-eval
}
+
+ let executeCommand = Promise.resolve();
+
+ const getCmd = () => {
+ debug('Command:', cmd)
+ return cmd;
+ };
+
store.debugMode = true;
let isCustomCommand = false;
let lastError = null;
+ let isAiCommand = false;
+ let $res;
try {
const locate = global.locate; // enable locate in this context
const I = container.support('I');
if (cmd.trim().startsWith('=>')) {
isCustomCommand = true;
cmd = cmd.trim().substring(2, cmd.length);
+ } else if (aiAssistant.isEnabled && !cmd.match(/^\w+\(/) && cmd.includes(' ')) {
+ const res = I.grabSource();
+ isAiCommand = true;
+ const spinner = ora("Processing OpenAI request...").start();
+ executeCommand = executeCommand.then(async () => {
+ try {
+ const html = await res;
+ aiAssistant.setHtmlContext(html);
+ } catch (err) {
+ output.print(output.styles.error(' ERROR '), 'Can\'t get HTML context', err.stack);
+ return;
+ }
+ // aiAssistant.mockResponse("```js\nI.click('Sign in');\n```");
+ cmd = await aiAssistant.writeSteps(cmd);
+ spinner.stop();
+ output.print(aiAssistant.getResponse());
+ output.print('');
+ return cmd;
+ })
} else {
cmd = `I.${cmd}`;
}
- const executeCommand = eval(cmd); // eslint-disable-line no-eval
-
- const result = executeCommand instanceof Promise ? executeCommand : Promise.resolve(executeCommand);
- result.then((val) => {
- if (isCustomCommand) {
- console.log(val);
- return;
- }
- if (cmd.startsWith('I.see') || cmd.startsWith('I.dontSee')) {
- output.print(output.styles.success(' OK '), cmd);
- return;
- }
- if (cmd.startsWith('I.grab')) {
- output.print(output.styles.debug(val));
- }
+ executeCommand = executeCommand.then(async () => {
+ const cmd = getCmd();
+ if (!cmd) return;
+ return eval(cmd); // eslint-disable-line no-eval
}).catch((err) => {
+ debug(err);
+ if (isAiCommand) return;
if (!lastError) output.print(output.styles.error(' ERROR '), err.message);
+ debug(err.stack)
+
lastError = err.message;
- });
+ })
+
+ const val = await executeCommand;
+
+ if (isCustomCommand) {
+ if (val !== undefined) console.log('Result', '$res=', val); // eslint-disable-line
+ $res = val;
+ }
+
+ if (cmd?.startsWith('I.see') || cmd?.startsWith('I.dontSee')) {
+ output.print(output.styles.success(' OK '), cmd);
+ }
+ if (cmd?.startsWith('I.grab')) {
+ output.print(output.styles.debug(val));
+ }
history.push(cmd); // add command to history when successful
} catch (err) {
@@ -117,6 +167,7 @@ function parseInput(cmd) {
// pop latest command from history because it failed
history.pop();
+ if (isAiCommand) return;
if (!lastError) output.print(output.styles.error(' FAIL '), msg);
lastError = err.message;
});
diff --git a/lib/plugin/debugErrors.js b/lib/plugin/debugErrors.js
new file mode 100644
index 000000000..e508d1c64
--- /dev/null
+++ b/lib/plugin/debugErrors.js
@@ -0,0 +1,50 @@
+const Container = require('../container');
+const recorder = require('../recorder');
+const event = require('../event');
+const supportedHelpers = require('./standardActingHelpers');
+const { scanForErrorMessages } = require('../html');
+const { output } = require('..');
+
+/**
+ * Creates screenshot on failure. Screenshot is saved into `output` directory.
+ *
+ * Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
+ *
+ * This plugin is **enabled by default**.
+ *
+ *
+ */
+module.exports = function (config) {
+ const helpers = Container.helpers();
+ let helper;
+
+ for (const helperName of supportedHelpers) {
+ if (Object.keys(helpers).indexOf(helperName) > -1) {
+ helper = helpers[helperName];
+ }
+ }
+
+ if (!helper) return; // no helpers for screenshot
+
+ event.dispatcher.on(event.test.failed, (test) => {
+ recorder.add('HTML snapshot failed test', async () => {
+ try {
+ const currentOutputLevel = output.level();
+ output.level(0);
+ const html = await helper.grabHTMLFrom('body');
+ output.level(currentOutputLevel);
+
+ if (!html) return;
+
+ const errors = scanForErrorMessages(html);
+ if (errors.length) {
+ output.debug('Detected errors in HTML code');
+ errors.forEach((error) => output.debug(error));
+ test.artifacts.errors = errors;
+ }
+ } catch (err) {
+ // not really needed
+ }
+ });
+ });
+};
diff --git a/lib/plugin/heal.js b/lib/plugin/heal.js
new file mode 100644
index 000000000..b29d3fc4a
--- /dev/null
+++ b/lib/plugin/heal.js
@@ -0,0 +1,148 @@
+const debug = require('debug')('codeceptjs:heal');
+const colors = require('chalk');
+const Container = require('../container');
+const AiAssistant = require('../ai');
+const recorder = require('../recorder');
+const event = require('../event');
+const output = require('../output');
+const supportedHelpers = require('./standardActingHelpers');
+
+const defaultConfig = {
+ ignoredSteps: [],
+ healLimit: 2,
+ healSteps: [
+ 'click',
+ 'fillField',
+ 'appendField',
+ 'selectOption',
+ 'attachFile',
+ 'checkOption',
+ 'uncheckOption',
+ 'doubleClick',
+ ],
+};
+
+module.exports = function (config = {}) {
+ const aiAssistant = new AiAssistant();
+
+ let currentTest = null;
+ let currentStep = null;
+ let healedSteps = 0;
+
+ const healSuggestions = [];
+
+ config = Object.assign(defaultConfig, config);
+
+ event.dispatcher.on(event.test.before, (test) => {
+ currentTest = test;
+ healedSteps = 0;
+ });
+
+ event.dispatcher.on(event.step.started, step => currentStep = step);
+
+ event.dispatcher.on(event.step.before, () => {
+ const store = require('../store');
+ if (store.debugMode) return;
+
+ recorder.catchWithoutStop(async (err) => {
+ if (!aiAssistant.isEnabled) throw err;
+ if (!currentStep) throw err;
+ if (!config.healSteps.includes(currentStep.name)) throw err;
+ const test = currentTest;
+
+ if (healedSteps >= config.healLimit) {
+ output.print(colors.bold.red(`Can't heal more than ${config.healLimit} steps in a test`));
+ output.print('Entire flow can be broken, please check it manually');
+ output.print('or increase healing limit in heal plugin config');
+
+ throw err;
+ }
+
+ recorder.session.start('heal');
+ const helpers = Container.helpers();
+ let helper;
+
+ for (const helperName of supportedHelpers) {
+ if (Object.keys(helpers).indexOf(helperName) > -1) {
+ helper = helpers[helperName];
+ }
+ }
+
+ if (!helper) throw err; // no helpers for html
+
+ const step = test.steps[test.steps.length - 1];
+ debug('Self-healing started', step.toCode());
+
+ const currentOutputLevel = output.level();
+ output.level(0);
+ const html = await helper.grabHTMLFrom('body');
+ output.level(currentOutputLevel);
+
+ if (!html) throw err;
+
+ aiAssistant.setHtmlContext(html);
+ await tryToHeal(step, err);
+ recorder.session.restore();
+ });
+ });
+
+ event.dispatcher.on(event.all.result, () => {
+ if (!healSuggestions.length) return;
+
+ const { print } = output;
+
+ print('');
+ print('===================');
+ print(colors.bold.green('Self-Healing Report:'));
+
+ print(`${colors.bold(healSuggestions.length)} steps were healed by AI`);
+
+ let i = 1;
+ print('');
+ print('Suggested changes:');
+ print('');
+
+ for (const suggestion of healSuggestions) {
+ print(`${i}. To fix ${colors.bold.blue(suggestion.test.title)}`);
+ print('Replace the failed code with:');
+ print(colors.red(`- ${suggestion.step.toCode()}`));
+ print(colors.green(`+ ${suggestion.snippet}`));
+ print(suggestion.step.line());
+ print('');
+ i++;
+ }
+ });
+
+ async function tryToHeal(failedStep, err) {
+ output.debug(`Running OpenAPI to heal ${failedStep.toCode()} step`);
+
+ const I = Container.support('I');
+
+ const codeSnippets = await aiAssistant.healFailedStep(
+ failedStep, err, currentTest,
+ );
+
+ output.debug(`Received ${codeSnippets.length} proposals from OpenAI`);
+
+ for (const codeSnippet of codeSnippets) {
+ try {
+ debug('Executing', codeSnippet);
+ await eval(codeSnippet); // eslint-disable-line
+
+ healSuggestions.push({
+ test: currentTest,
+ step: failedStep,
+ snippet: codeSnippet,
+ });
+
+ output.print(colors.bold.green('Code healed successfully'));
+ healedSteps++;
+ return;
+ } catch (err) {
+ debug('Failed to execute code', err);
+ }
+ }
+
+ output.debug(`Couldn't heal the code for ${failedStep.toCode()}`);
+ }
+};
diff --git a/lib/recorder.js b/lib/recorder.js
index 9cf17ff4d..4b6c31eb6 100644
--- a/lib/recorder.js
+++ b/lib/recorder.js
@@ -161,7 +161,7 @@ module.exports = {
* true: it will retries if `retryOpts` set.
* false: ignore `retryOpts` and won't retry.
* @param {number} [timeout]
- * @return {Promise<*> | undefined}
+ * @return {Promise<*>}
* @inner
*/
add(taskName, fn = undefined, force = false, retry = undefined, timeout = undefined) {
@@ -250,9 +250,9 @@ module.exports = {
err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`); // we should be prepared for them
}
if (customErrFn) {
- customErrFn(err);
- } else if (errFn) {
- errFn(err);
+ return customErrFn(err);
+ } if (errFn) {
+ return errFn(err);
}
});
},
diff --git a/package.json b/package.json
index 9da6b1e5c..e694b55c6 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,8 @@
"fn-args": "^4.0.0",
"fs-extra": "^8.1.0",
"glob": "^6.0.1",
+ "html-minifier": "^4.0.0",
+ "i": "^0.3.7",
"inquirer": "^6.5.2",
"joi": "^17.6.0",
"js-beautify": "^1.14.0",
@@ -89,7 +91,11 @@
"mocha": "^10.2.0",
"mocha-junit-reporter": "^1.23.3",
"ms": "^2.1.3",
+ "npm": "^9.6.7",
+ "openai": "^3.2.1",
+ "ora-classic": "^5.4.2",
"parse-function": "^5.6.4",
+ "parse5": "^7.1.2",
"promise-retry": "^1.1.1",
"resq": "^1.10.2",
"sprintf-js": "^1.1.1",
@@ -145,10 +151,11 @@
"wdio-docker-service": "^1.5.0",
"webdriverio": "^8.3.8",
"xml2js": "^0.4.23",
+ "xmldom": "^0.5.0",
"xpath": "0.0.27"
},
"engines": {
- "node": ">=8.9.1",
+ "node": ">=16.0",
"npm": ">=5.6.0"
},
"es6": true
diff --git a/test/data/checkout.html b/test/data/checkout.html
new file mode 100644
index 000000000..e53234b43
--- /dev/null
+++ b/test/data/checkout.html
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+
+
+
+
+ Checkout example for Bootstrap
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Checkout form
+
Below is an example form built entirely with Bootstrap's form controls. Each required form group has a validation state that can be triggered by attempting to submit the form without completing it.
<imgsrc="astro-mona.svg"width="960"height="967"class="home-astro-mona width-full position-absolute bottom-0 height-auto"alt="Mona looking at GitHub activity across the globe">
+ Supercharge collaboration.
+ We provide unlimited repositories, best-in-class version control, and the world’s most powerful open source community—so your team can work more efficiently together.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
GitHub Issues and GitHub Projects supply flexible project management tools that adapt to your team alongside your code.
+ Embed security into the developer workflow.
+ With GitHub, developers can secure their code in minutes and organizations can automatically comply with regulations.
+
+
+
+
+
+
+
+
cmake.yml
+ on: push
+
+
+
+
+
+
+
+
+
+ Build
+
+ 1m 21s
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Steps
+
+
+
+
+
+
+ Initialize CodeQL
+
+ 1m 42s
+
+
+
+
+ Autobuild
+
+ 1m 24s
+
+
+
+
+ Perform CodeQL Analyses
+
+ 1m 36s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
GitHub Advanced Security lets you gain visibility into your security posture, respond to threats proactively, and ship secure applications quickly.
The place for anyone from anywhere to build anything
+
Whether you’re scaling your startup or just learning how to code, GitHub is your home. Join the world’s largest developer platform to build the innovations that empower humanity. Let’s build from here.
1 The Total Economic Impact™ Of GitHub Enterprise Cloud and Advanced Security, a commissioned study conducted by Forrester Consulting, 2022. Results are for a composite organization based on interviewed customers.
+
2 GitHub, Octoverse 2022 The state of open source software.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can’t perform that action at this time.
+
+
+
+
+ You signed in with another tab or window. Reload to refresh your session.
+ You signed out in another tab or window. Reload to refresh your session.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/data/gitlab.html b/test/data/gitlab.html
new file mode 100644
index 000000000..bc9863d49
--- /dev/null
+++ b/test/data/gitlab.html
@@ -0,0 +1,721 @@
+
+ The DevSecOps Platform | GitLab
+
+
+
GitLab named a Leader in the Gartner® Magic Quadrant™ for DevOps
+ Platforms
+Read the report
+
When you visit any website, it may store or retrieve information on your browser, mostly in the form of cookies. This information might be about you, your preferences or your device and is mostly used to make the site work as you expect it to. The information does not usually directly identify you, but it can give you a more personalized web experience. Because we respect your right to privacy, you can choose not to allow some types of cookies. Click on the different category headings to find out more and change our default settings. However, blocking some types of cookies may impact your experience of the site and the services we are able to offer.
+ Cookie Policy
Strictly Necessary Cookies
Always Active
These cookies are necessary for the website to function and cannot be switched off in our systems. They are usually only set in response to actions made by you which amount to a request for services, such as setting your privacy preferences, enabling you to securely log into the site, filling in forms, or using the customer checkout. GitLab processes any personal data collected through these cookies on the basis of our legitimate interest.
Functionality Cookies
These cookies enable helpful but non-essential website functions that improve your website experience. By recognizing you when you return to our website, they may, for example, allow us to personalize our content for you or remember your preferences. If you do not allow these cookies then some or all of these services may not function properly. GitLab processes any personal data collected through these cookies on the basis of your consent
Performance and Analytics Cookies
These cookies allow us and our third-party service providers to recognize and count the number of visitors on our websites and to see how visitors move around our websites when they are using it. This helps us improve our products and ensures that users can easily find what they need on our websites. These cookies usually generate aggregate statistics that are not associated with an individual. To the extent any personal data is collected through these cookies, GitLab processes that data on the basis of your consent.
Targeting and Advertising Cookies
These cookies enable different advertising related functions. They may allow us to record information about your visit to our websites, such as pages visited, links followed, and videos viewed so we can make our websites and the advertising displayed on it more relevant to your interests. They may be set through our website by our advertising partners. They may be used by those companies to build a profile of your interests and show you relevant advertisements on other websites. GitLab processes any personal data collected through these cookies on the basis of your consent.