Skip to content

Commit aa52de8

Browse files
Bugfix/ai html updates (#3962)
* fixed heal plugin * fixed tests --------- Co-authored-by: kobenguyent <[email protected]>
1 parent 3b84d7a commit aa52de8

File tree

8 files changed

+82
-30
lines changed

8 files changed

+82
-30
lines changed

lib/ai.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const htmlConfig = {
1616
html: {},
1717
};
1818

19+
const aiInstance = null;
20+
1921
class AiAssistant {
2022
constructor() {
2123
this.config = config.get('ai', defaultConfig);
@@ -26,7 +28,10 @@ class AiAssistant {
2628

2729
this.isEnabled = !!process.env.OPENAI_API_KEY;
2830

29-
if (!this.isEnabled) return;
31+
if (!this.isEnabled) {
32+
debug('No OpenAI API key provided. AI assistant is disabled.');
33+
return;
34+
}
3035

3136
const configuration = new Configuration({
3237
apiKey: process.env.OPENAI_API_KEY,
@@ -35,13 +40,17 @@ class AiAssistant {
3540
this.openai = new OpenAIApi(configuration);
3641
}
3742

38-
setHtmlContext(html) {
43+
static getInstance() {
44+
return aiInstance || new AiAssistant();
45+
}
46+
47+
async setHtmlContext(html) {
3948
let processedHTML = html;
4049

4150
if (this.htmlConfig.simplify) {
4251
processedHTML = removeNonInteractiveElements(processedHTML, this.htmlConfig);
4352
}
44-
if (this.htmlConfig.minify) processedHTML = minifyHtml(processedHTML);
53+
if (this.htmlConfig.minify) processedHTML = await minifyHtml(processedHTML);
4554
if (this.htmlConfig.maxLength) processedHTML = splitByChunks(processedHTML, this.htmlConfig.maxLength)[0];
4655

4756
debug(processedHTML);

lib/html.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const { parse, serialize } = require('parse5');
2-
const { minify } = require('html-minifier');
2+
const { minify } = require('html-minifier-terser');
33

4-
function minifyHtml(html) {
4+
async function minifyHtml(html) {
55
return minify(html, {
66
collapseWhitespace: true,
77
removeComments: true,
@@ -11,7 +11,7 @@ function minifyHtml(html) {
1111
removeStyleLinkTypeAttributes: true,
1212
collapseBooleanAttributes: true,
1313
useShortDoctype: true,
14-
}).toString();
14+
});
1515
}
1616

1717
const defaultHtmlOpts = {

lib/pause.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ let nextStep;
1818
let finish;
1919
let next;
2020
let registeredVariables = {};
21-
const aiAssistant = new AiAssistant();
22-
21+
let aiAssistant;
2322
/**
2423
* Pauses test execution and starts interactive shell
2524
* @param {Object<string, *>} [passedObject]
@@ -45,6 +44,8 @@ function pauseSession(passedObject = {}) {
4544
let vars = Object.keys(registeredVariables).join(', ');
4645
if (vars) vars = `(vars: ${vars})`;
4746

47+
aiAssistant = AiAssistant.getInstance();
48+
4849
output.print(colors.yellow(' Interactive shell started'));
4950
output.print(colors.yellow(' Use JavaScript syntax to try steps in action'));
5051
output.print(colors.yellow(` - Press ${colors.bold('ENTER')} to run the next step`));
@@ -102,7 +103,9 @@ async function parseInput(cmd) {
102103
let isAiCommand = false;
103104
let $res;
104105
try {
106+
// eslint-disable-next-line
105107
const locate = global.locate; // enable locate in this context
108+
// eslint-disable-next-line
106109
const I = container.support('I');
107110
if (cmd.trim().startsWith('=>')) {
108111
isCustomCommand = true;
@@ -115,7 +118,7 @@ async function parseInput(cmd) {
115118
executeCommand = executeCommand.then(async () => {
116119
try {
117120
const html = await res;
118-
aiAssistant.setHtmlContext(html);
121+
await aiAssistant.setHtmlContext(html);
119122
} catch (err) {
120123
output.print(output.styles.error(' ERROR '), 'Can\'t get HTML context', err.stack);
121124
return;

lib/plugin/heal.js

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const output = require('../output');
88
const supportedHelpers = require('./standardActingHelpers');
99

1010
const defaultConfig = {
11+
healTries: 1,
1112
healLimit: 2,
1213
healSteps: [
1314
'click',
@@ -54,11 +55,14 @@ const defaultConfig = {
5455
*
5556
*/
5657
module.exports = function (config = {}) {
57-
const aiAssistant = new AiAssistant();
58+
const aiAssistant = AiAssistant.getInstance();
5859

5960
let currentTest = null;
6061
let currentStep = null;
6162
let healedSteps = 0;
63+
let caughtError;
64+
let healTries = 0;
65+
let isHealing = false;
6266

6367
const healSuggestions = [];
6468

@@ -67,20 +71,35 @@ module.exports = function (config = {}) {
6771
event.dispatcher.on(event.test.before, (test) => {
6872
currentTest = test;
6973
healedSteps = 0;
74+
caughtError = null;
7075
});
7176

7277
event.dispatcher.on(event.step.started, step => currentStep = step);
7378

74-
event.dispatcher.on(event.step.before, () => {
79+
event.dispatcher.on(event.step.after, (step) => {
80+
if (isHealing) return;
7581
const store = require('../store');
7682
if (store.debugMode) return;
77-
7883
recorder.catchWithoutStop(async (err) => {
79-
if (!aiAssistant.isEnabled) throw err;
84+
isHealing = true;
85+
if (caughtError === err) throw err; // avoid double handling
86+
caughtError = err;
87+
if (!aiAssistant.isEnabled) {
88+
output.print(colors.yellow('Heal plugin can\'t operate, AI assistant is disabled. Please set OPENAI_API_KEY env variable to enable it.'));
89+
throw err;
90+
}
8091
if (!currentStep) throw err;
8192
if (!config.healSteps.includes(currentStep.name)) throw err;
8293
const test = currentTest;
8394

95+
if (healTries >= config.healTries) {
96+
output.print(colors.bold.red(`Healing failed for ${config.healTries} time(s)`));
97+
output.print('AI couldn\'t identify the correct solution');
98+
output.print('Probably the entire flow has changed and the test should be updated');
99+
100+
throw err;
101+
}
102+
84103
if (healedSteps >= config.healLimit) {
85104
output.print(colors.bold.red(`Can't heal more than ${config.healLimit} step(s) in a test`));
86105
output.print('Entire flow can be broken, please check it manually');
@@ -111,9 +130,17 @@ module.exports = function (config = {}) {
111130

112131
if (!html) throw err;
113132

114-
aiAssistant.setHtmlContext(html);
133+
healTries++;
134+
await aiAssistant.setHtmlContext(html);
115135
await tryToHeal(step, err);
116-
recorder.session.restore();
136+
137+
recorder.add('close healing session', () => {
138+
recorder.session.restore('heal');
139+
recorder.ignoreErr(err);
140+
});
141+
await recorder.promise();
142+
143+
isHealing = false;
117144
});
118145
});
119146

@@ -155,6 +182,9 @@ module.exports = function (config = {}) {
155182
for (const codeSnippet of codeSnippets) {
156183
try {
157184
debug('Executing', codeSnippet);
185+
recorder.catch((e) => {
186+
console.log(e);
187+
});
158188
await eval(codeSnippet); // eslint-disable-line
159189

160190
healSuggestions.push({
@@ -163,14 +193,17 @@ module.exports = function (config = {}) {
163193
snippet: codeSnippet,
164194
});
165195

166-
output.print(colors.bold.green(' Code healed successfully'));
196+
recorder.add('healed', () => output.print(colors.bold.green(' Code healed successfully')));
167197
healedSteps++;
168198
return;
169199
} catch (err) {
170200
debug('Failed to execute code', err);
201+
recorder.ignoreErr(err); // healing ded not help
202+
// recorder.catch(() => output.print(colors.bold.red(' Failed healing code')));
171203
}
172204
}
173205

174206
output.debug(`Couldn't heal the code for ${failedStep.toCode()}`);
175207
}
208+
return recorder.promise();
176209
};

lib/recorder.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ let errFn;
1111
let queueId = 0;
1212
let sessionId = null;
1313
let asyncErr = null;
14+
let ignoredErrs = [];
1415

1516
let tasks = [];
1617
let oldPromises = [];
@@ -93,6 +94,7 @@ module.exports = {
9394
promise = Promise.resolve();
9495
oldPromises = [];
9596
tasks = [];
97+
ignoredErrs = [];
9698
this.session.running = false;
9799
// reset this retries makes the retryFailedStep plugin won't work if there is Before/BeforeSuit block due to retries is undefined on Scenario
98100
// this.retries = [];
@@ -226,9 +228,10 @@ module.exports = {
226228
* @inner
227229
*/
228230
catch(customErrFn) {
229-
debug(`${currentQueue()}Queued | catch with error handler`);
231+
const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50);
232+
debug(`${currentQueue()}Queued | catch with error handler ${fnDescription || ''}`);
230233
return promise = promise.catch((err) => {
231-
log(`${currentQueue()}Error | ${err}`);
234+
log(`${currentQueue()}Error | ${err} ${fnDescription}...`);
232235
if (!(err instanceof Error)) { // strange things may happen
233236
err = new Error(`[Wrapped Error] ${printObjectProperties(err)}`); // we should be prepared for them
234237
}
@@ -247,15 +250,15 @@ module.exports = {
247250
* @inner
248251
*/
249252
catchWithoutStop(customErrFn) {
253+
const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50);
250254
return promise = promise.catch((err) => {
251-
log(`${currentQueue()}Error | ${err}`);
255+
if (ignoredErrs.includes(err)) return; // already caught
256+
log(`${currentQueue()}Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`);
252257
if (!(err instanceof Error)) { // strange things may happen
253258
err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`); // we should be prepared for them
254259
}
255260
if (customErrFn) {
256261
return customErrFn(err);
257-
} if (errFn) {
258-
return errFn(err);
259262
}
260263
});
261264
},
@@ -274,6 +277,10 @@ module.exports = {
274277
});
275278
},
276279

280+
ignoreErr(err) {
281+
ignoredErrs.push(err);
282+
},
283+
277284
/**
278285
* @param {*} err
279286
* @inner

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"fn-args": "4.0.0",
8989
"fs-extra": "8.1.0",
9090
"glob": "6.0.1",
91-
"html-minifier": "4.0.0",
91+
"html-minifier-terser": "^7.2.0",
9292
"inquirer": "6.5.2",
9393
"joi": "17.11.0",
9494
"js-beautify": "1.14.11",

test/unit/ai_test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ const config = require('../../lib/config');
55
describe('AI module', () => {
66
beforeEach(() => config.reset());
77

8-
it('should be externally configurable', () => {
8+
it('should be externally configurable', async () => {
99
const html = '<div><a data-qa="ok">Hey</a></div>';
1010
const ai = new AiAssistant();
11-
ai.setHtmlContext(html);
11+
await ai.setHtmlContext(html);
1212
expect(ai.html).to.include('<a>Hey</a>');
1313

1414
config.create({
@@ -20,7 +20,7 @@ describe('AI module', () => {
2020
});
2121

2222
const ai2 = new AiAssistant();
23-
ai2.setHtmlContext(html);
23+
await ai2.setHtmlContext(html);
2424
expect(ai2.html).to.include('<a data-qa="ok">Hey</a>');
2525
});
2626
});

test/unit/html_test.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,15 @@ describe('HTML module', () => {
3535
});
3636

3737
describe('#removeNonInteractiveElements', () => {
38-
it('should cut out all non-interactive elements from GitHub HTML', () => {
38+
it('should cut out all non-interactive elements from GitHub HTML', async () => {
3939
// Call the function with the loaded HTML
4040
html = fs.readFileSync(path.join(__dirname, '../data/github.html'), 'utf8');
4141
const result = removeNonInteractiveElements(html, opts);
4242
let doc = new Dom().parseFromString(result);
4343
const nodes = xpath.select('//input[@name="q"]', doc);
4444
expect(nodes).to.have.length(1);
4545
expect(result).not.to.include('Let’s build from here');
46-
const minified = minifyHtml(result);
46+
const minified = await minifyHtml(result);
4747
doc = new Dom().parseFromString(minified);
4848
const nodes2 = xpath.select('//input[@name="q"]', doc);
4949
expect(nodes2).to.have.length(1);
@@ -66,7 +66,7 @@ describe('HTML module', () => {
6666
expect(result).to.include('<button');
6767
});
6868

69-
it('should keep menu bar', () => {
69+
it('should keep menu bar', async () => {
7070
html = `<div class="mainnav-menu-body">
7171
<ul>
7272
<li>
@@ -88,7 +88,7 @@ describe('HTML module', () => {
8888
</li>
8989
</ul>
9090
</div>`;
91-
const result = minifyHtml(removeNonInteractiveElements(html, opts));
91+
const result = await minifyHtml(removeNonInteractiveElements(html, opts));
9292
expect(result).to.include('<button');
9393
expect(result).to.include('<a');
9494
expect(result).to.include('<svg');
@@ -133,7 +133,7 @@ describe('HTML module', () => {
133133
// console.log(html);
134134
const result = removeNonInteractiveElements(html, opts);
135135
result.should.include('<svg class="md-icon md-icon-check-bold');
136-
// console.log(minifyHtml(result));
136+
// console.log(await minifyHtml(result));
137137
});
138138
});
139139

0 commit comments

Comments
 (0)