Skip to content

Commit 3680d3a

Browse files
authored
AI features for CodeceptJS (#3713)
* added heal and checkerrors plugins * tested interactive pause and heal plugin, replaced gpt model * lint fixes * fixed typings * updated xmldom
1 parent ec6bce9 commit 3680d3a

22 files changed

+6016
-28
lines changed

.eslintrc.json

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"env": {
44
"node": true
55
},
6+
"parserOptions": {
7+
"ecmaVersion": 2020
8+
},
69
"rules": {
710
"func-names": 0,
811
"no-use-before-define": 0,

docs/changelog.md

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ layout: Section
77

88
# Releases
99

10+
## 3.4.1
11+
12+
* Updated mocha to v 10.2. Fixes [#3591](https://github.com/codeceptjs/CodeceptJS/issues/3591)
13+
* Fixes executing a faling Before hook. Resolves [#3592](https://github.com/codeceptjs/CodeceptJS/issues/3592)
14+
1015
## 3.4.0
1116

1217
* **Updated to latest mocha and modern Cucumber**

docs/plugins.md

+12
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,18 @@ I.click('=sign-up'); // matches => [data-qa=sign-up],[data-test=sign-up]
479479
480480
- `config`
481481
482+
## debugErrors
483+
484+
Creates screenshot on failure. Screenshot is saved into `output` directory.
485+
486+
Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
487+
488+
This plugin is **enabled by default**.
489+
490+
### Parameters
491+
492+
- `config`
493+
482494
## eachElement
483495
484496
Provides `eachElement` global function to iterate over found elements to perform actions on them.

examples/codecept.config.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ exports.config = {
22
output: './output',
33
helpers: {
44
Playwright: {
5-
url: 'http://localhost',
5+
url: 'http://github.com',
66
browser: 'chromium',
7-
restart: 'context',
7+
// restart: 'context',
8+
// show: false,
89
// timeout: 5000,
910
windowSize: '1600x1200',
1011
// video: true,
@@ -52,6 +53,9 @@ exports.config = {
5253
autoDelay: {
5354
enabled: false,
5455
},
56+
heal: {
57+
enabled: true,
58+
},
5559
retryFailedStep: {
5660
enabled: false,
5761
},

examples/github_test.js

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ Before(({ Smth }) => {
55
Smth.openGitHub();
66
});
77

8+
Scenario('Incorrect search for Codeceptjs', ({ I }) => {
9+
I.fillField('.search', 'CodeceptJS');
10+
I.pressKey('Enter');
11+
I.see('Supercharged End 2 End Testing');
12+
});
13+
814
Scenario('Visit Home Page @retry', async ({ I }) => {
915
// .retry({ retries: 3, minTimeout: 1000 })
1016
I.retry(2).see('GitHub');

jsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"compilerOptions": {
3-
"target": "es2017",
3+
"target": "es2019",
44
"module": "commonjs"
55
}
66
}

lib/ai.js

+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
const { Configuration, OpenAIApi } = require('openai');
2+
const debug = require('debug')('codeceptjs:ai');
3+
const config = require('./config');
4+
const output = require('./output');
5+
const { removeNonInteractiveElements, minifyHtml, splitByChunks } = require('./html');
6+
7+
const defaultConfig = {
8+
model: 'gpt-3.5-turbo-16k',
9+
temperature: 0.1,
10+
};
11+
12+
const htmlConfig = {
13+
maxLength: null,
14+
simplify: true,
15+
minify: true,
16+
interactiveElements: ['a', 'input', 'button', 'select', 'textarea', 'option'],
17+
textElements: ['label', 'h1', 'h2'],
18+
allowedAttrs: ['id', 'for', 'class', 'name', 'type', 'value', 'aria-labelledby', 'aria-label', 'label', 'placeholder', 'title', 'alt', 'src', 'role'],
19+
allowedRoles: ['button', 'checkbox', 'search', 'textbox', 'tab'],
20+
};
21+
22+
class AiAssistant {
23+
constructor() {
24+
this.config = config.get('openai', defaultConfig);
25+
this.htmlConfig = this.config.html || htmlConfig;
26+
delete this.config.html;
27+
this.html = null;
28+
this.response = null;
29+
30+
this.isEnabled = !!process.env.OPENAI_API_KEY;
31+
32+
if (!this.isEnabled) return;
33+
34+
const configuration = new Configuration({
35+
apiKey: process.env.OPENAI_API_KEY,
36+
});
37+
38+
this.openai = new OpenAIApi(configuration);
39+
}
40+
41+
setHtmlContext(html) {
42+
let processedHTML = html;
43+
44+
if (this.htmlConfig.simplify) {
45+
processedHTML = removeNonInteractiveElements(processedHTML, {
46+
interactiveElements: this.htmlConfig.interactiveElements,
47+
allowedAttrs: this.htmlConfig.allowedAttrs,
48+
allowedRoles: this.htmlConfig.allowedRoles,
49+
});
50+
}
51+
if (this.htmlConfig.minify) processedHTML = minifyHtml(processedHTML);
52+
if (this.htmlConfig.maxLength) processedHTML = splitByChunks(processedHTML, this.htmlConfig.maxLength)[0];
53+
54+
debug(processedHTML);
55+
56+
this.html = processedHTML;
57+
}
58+
59+
getResponse() {
60+
return this.response || '';
61+
}
62+
63+
mockResponse(response) {
64+
this.mockedResponse = response;
65+
}
66+
67+
async createCompletion(messages) {
68+
if (!this.openai) return;
69+
70+
debug(messages);
71+
72+
if (this.mockedResponse) return this.mockedResponse;
73+
74+
this.response = null;
75+
76+
try {
77+
const completion = await this.openai.createChatCompletion({
78+
...this.config,
79+
messages,
80+
});
81+
82+
this.response = completion?.data?.choices[0]?.message?.content;
83+
84+
debug(this.response);
85+
86+
return this.response;
87+
} catch (err) {
88+
debug(err.response);
89+
output.print('');
90+
output.error(`OpenAI error: ${err.message}`);
91+
output.error(err?.response?.data?.error?.code);
92+
output.error(err?.response?.data?.error?.message);
93+
return '';
94+
}
95+
}
96+
97+
async healFailedStep(step, err, test) {
98+
if (!this.isEnabled) return [];
99+
if (!this.html) throw new Error('No HTML context provided');
100+
101+
const messages = [
102+
{ role: 'user', content: 'As a test automation engineer I am testing web application using CodeceptJS.' },
103+
{ role: 'user', content: `I want to heal a test that fails. Here is the list of executed steps: ${test.steps.join(', ')}` },
104+
{ role: 'user', content: `Propose how to adjust ${step.toCode()} step to fix the test.` },
105+
{ role: 'user', content: 'Use locators in order of preference: semantic locator by text, CSS, XPath. Use codeblocks marked with ```.' },
106+
{ role: 'user', content: `Here is the error message: ${err.message}` },
107+
{ role: 'user', content: `Here is HTML code of a page where the failure has happened: \n\n${this.html}` },
108+
];
109+
110+
const response = await this.createCompletion(messages);
111+
if (!response) return [];
112+
113+
return parseCodeBlocks(response);
114+
}
115+
116+
async writeSteps(input) {
117+
if (!this.isEnabled) return;
118+
if (!this.html) throw new Error('No HTML context provided');
119+
120+
const snippets = [];
121+
122+
const messages = [
123+
{
124+
role: 'user',
125+
content: `I am test engineer writing test in CodeceptJS
126+
I have opened web page and I want to use CodeceptJS to ${input} on this page
127+
Provide me valid CodeceptJS code to accomplish it
128+
Use only locators from this HTML: \n\n${this.html}`,
129+
},
130+
{ role: 'user', content: 'Propose only CodeceptJS steps code. Do not include Scenario or Feature into response' },
131+
132+
// old prompt
133+
// { role: 'user', content: 'I want to click button Submit using CodeceptJS on this HTML page: <html><body><button>Submit</button></body></html>' },
134+
// { role: 'assistant', content: '```js\nI.click("Submit");\n```' },
135+
// { role: 'user', content: 'I want to click button Submit using CodeceptJS on this HTML page: <html><body><button>Login</button></body></html>' },
136+
// { role: 'assistant', content: 'No suggestions' },
137+
// { role: 'user', content: `Now I want to ${input} on this HTML page using CodeceptJS code` },
138+
// { role: 'user', content: `Provide me with CodeceptJS code to achieve this on THIS page.` },
139+
];
140+
const response = await this.createCompletion(messages);
141+
if (!response) return;
142+
snippets.push(...parseCodeBlocks(response));
143+
144+
debug(snippets[0]);
145+
146+
return snippets[0];
147+
}
148+
}
149+
150+
function parseCodeBlocks(response) {
151+
// Regular expression pattern to match code snippets
152+
const codeSnippetPattern = /```(?:javascript|js|typescript|ts)?\n([\s\S]+?)\n```/g;
153+
154+
// Array to store extracted code snippets
155+
const codeSnippets = [];
156+
157+
// Iterate over matches and extract code snippets
158+
let match;
159+
while ((match = codeSnippetPattern.exec(response)) !== null) {
160+
codeSnippets.push(match[1]);
161+
}
162+
163+
// Remove "Scenario", "Feature", and "require()" lines
164+
const modifiedSnippets = codeSnippets.map(snippet => {
165+
const lines = snippet.split('\n').map(line => line.trim());
166+
167+
const filteredLines = lines.filter(line => !line.includes('I.amOnPage') && !line.startsWith('Scenario') && !line.startsWith('Feature') && !line.includes('= require('));
168+
169+
return filteredLines.join('\n');
170+
// remove snippets that move from current url
171+
}); // .filter(snippet => !line.includes('I.amOnPage'));
172+
173+
return modifiedSnippets.filter(snippet => !!snippet);
174+
}
175+
176+
module.exports = AiAssistant;

lib/cli.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ class Cli extends Base {
168168
}
169169

170170
// display artifacts in debug mode
171-
if (test.artifacts && Object.keys(test.artifacts).length) {
171+
if (test?.artifacts && Object.keys(test.artifacts).length) {
172172
log += `\n${output.styles.bold('Artifacts:')}`;
173173
for (const artifact of Object.keys(test.artifacts)) {
174174
log += `\n- ${artifact}: ${test.artifacts[artifact]}`;

lib/command/interactive.js

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
const { getConfig, getTestRoot } = require('./utils');
22
const recorder = require('../recorder');
33
const Codecept = require('../codecept');
4+
const Container = require('../container');
45
const event = require('../event');
56
const output = require('../output');
7+
const webHelpers = require('../plugin/standardActingHelpers');
68

79
module.exports = async function (path, options) {
810
// Backward compatibility for --profile
@@ -29,9 +31,21 @@ module.exports = async function (path, options) {
2931
});
3032
event.emit(event.test.before, {
3133
title: '',
34+
artifacts: {},
3235
});
36+
37+
const enabledHelpers = Container.helpers();
38+
for (const helperName of Object.keys(enabledHelpers)) {
39+
if (webHelpers.includes(helperName)) {
40+
const I = enabledHelpers[helperName];
41+
recorder.add(() => I.amOnPage('/'));
42+
recorder.catchWithoutStop(e => output.print(`Error while loading home page: ${e.message}}`));
43+
break;
44+
}
45+
}
3346
require('../pause')();
34-
recorder.add(() => event.emit(event.test.after));
47+
// recorder.catchWithoutStop((err) => console.log(err.stack));
48+
recorder.add(() => event.emit(event.test.after, {}));
3549
recorder.add(() => event.emit(event.suite.after, {}));
3650
recorder.add(() => event.emit(event.all.result, {}));
3751
recorder.add(() => codecept.teardown());

0 commit comments

Comments
 (0)