diff --git a/lib/ai.js b/lib/ai.js index c8a0c313b..e1e90575e 100644 --- a/lib/ai.js +++ b/lib/ai.js @@ -16,6 +16,8 @@ const htmlConfig = { html: {}, }; +const aiInstance = null; + class AiAssistant { constructor() { this.config = config.get('ai', defaultConfig); @@ -26,7 +28,10 @@ class AiAssistant { this.isEnabled = !!process.env.OPENAI_API_KEY; - if (!this.isEnabled) return; + if (!this.isEnabled) { + debug('No OpenAI API key provided. AI assistant is disabled.'); + return; + } const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY, @@ -35,13 +40,17 @@ class AiAssistant { this.openai = new OpenAIApi(configuration); } - setHtmlContext(html) { + static getInstance() { + return aiInstance || new AiAssistant(); + } + + async setHtmlContext(html) { let processedHTML = html; if (this.htmlConfig.simplify) { processedHTML = removeNonInteractiveElements(processedHTML, this.htmlConfig); } - if (this.htmlConfig.minify) processedHTML = minifyHtml(processedHTML); + if (this.htmlConfig.minify) processedHTML = await minifyHtml(processedHTML); if (this.htmlConfig.maxLength) processedHTML = splitByChunks(processedHTML, this.htmlConfig.maxLength)[0]; debug(processedHTML); diff --git a/lib/html.js b/lib/html.js index 107571c41..64aa86280 100644 --- a/lib/html.js +++ b/lib/html.js @@ -1,7 +1,7 @@ const { parse, serialize } = require('parse5'); -const { minify } = require('html-minifier'); +const { minify } = require('html-minifier-terser'); -function minifyHtml(html) { +async function minifyHtml(html) { return minify(html, { collapseWhitespace: true, removeComments: true, @@ -11,7 +11,7 @@ function minifyHtml(html) { removeStyleLinkTypeAttributes: true, collapseBooleanAttributes: true, useShortDoctype: true, - }).toString(); + }); } const defaultHtmlOpts = { diff --git a/lib/pause.js b/lib/pause.js index 6e74810f3..5205f8253 100644 --- a/lib/pause.js +++ b/lib/pause.js @@ -18,8 +18,7 @@ let nextStep; let finish; let next; let registeredVariables = {}; -const aiAssistant = new AiAssistant(); - +let aiAssistant; /** * Pauses test execution and starts interactive shell * @param {Object} [passedObject] @@ -45,6 +44,8 @@ function pauseSession(passedObject = {}) { let vars = Object.keys(registeredVariables).join(', '); if (vars) vars = `(vars: ${vars})`; + aiAssistant = AiAssistant.getInstance(); + output.print(colors.yellow(' Interactive shell started')); output.print(colors.yellow(' Use JavaScript syntax to try steps in action')); output.print(colors.yellow(` - Press ${colors.bold('ENTER')} to run the next step`)); @@ -102,7 +103,9 @@ async function parseInput(cmd) { let isAiCommand = false; let $res; try { + // eslint-disable-next-line const locate = global.locate; // enable locate in this context + // eslint-disable-next-line const I = container.support('I'); if (cmd.trim().startsWith('=>')) { isCustomCommand = true; @@ -115,7 +118,7 @@ async function parseInput(cmd) { executeCommand = executeCommand.then(async () => { try { const html = await res; - aiAssistant.setHtmlContext(html); + await aiAssistant.setHtmlContext(html); } catch (err) { output.print(output.styles.error(' ERROR '), 'Can\'t get HTML context', err.stack); return; diff --git a/lib/plugin/heal.js b/lib/plugin/heal.js index 89ef9157b..5cbbc4b9c 100644 --- a/lib/plugin/heal.js +++ b/lib/plugin/heal.js @@ -8,6 +8,7 @@ const output = require('../output'); const supportedHelpers = require('./standardActingHelpers'); const defaultConfig = { + healTries: 1, healLimit: 2, healSteps: [ 'click', @@ -54,11 +55,14 @@ const defaultConfig = { * */ module.exports = function (config = {}) { - const aiAssistant = new AiAssistant(); + const aiAssistant = AiAssistant.getInstance(); let currentTest = null; let currentStep = null; let healedSteps = 0; + let caughtError; + let healTries = 0; + let isHealing = false; const healSuggestions = []; @@ -67,20 +71,35 @@ module.exports = function (config = {}) { event.dispatcher.on(event.test.before, (test) => { currentTest = test; healedSteps = 0; + caughtError = null; }); event.dispatcher.on(event.step.started, step => currentStep = step); - event.dispatcher.on(event.step.before, () => { + event.dispatcher.on(event.step.after, (step) => { + if (isHealing) return; const store = require('../store'); if (store.debugMode) return; - recorder.catchWithoutStop(async (err) => { - if (!aiAssistant.isEnabled) throw err; + isHealing = true; + if (caughtError === err) throw err; // avoid double handling + caughtError = err; + if (!aiAssistant.isEnabled) { + output.print(colors.yellow('Heal plugin can\'t operate, AI assistant is disabled. Please set OPENAI_API_KEY env variable to enable it.')); + throw err; + } if (!currentStep) throw err; if (!config.healSteps.includes(currentStep.name)) throw err; const test = currentTest; + if (healTries >= config.healTries) { + output.print(colors.bold.red(`Healing failed for ${config.healTries} time(s)`)); + output.print('AI couldn\'t identify the correct solution'); + output.print('Probably the entire flow has changed and the test should be updated'); + + throw err; + } + if (healedSteps >= config.healLimit) { output.print(colors.bold.red(`Can't heal more than ${config.healLimit} step(s) in a test`)); output.print('Entire flow can be broken, please check it manually'); @@ -111,9 +130,17 @@ module.exports = function (config = {}) { if (!html) throw err; - aiAssistant.setHtmlContext(html); + healTries++; + await aiAssistant.setHtmlContext(html); await tryToHeal(step, err); - recorder.session.restore(); + + recorder.add('close healing session', () => { + recorder.session.restore('heal'); + recorder.ignoreErr(err); + }); + await recorder.promise(); + + isHealing = false; }); }); @@ -155,6 +182,9 @@ module.exports = function (config = {}) { for (const codeSnippet of codeSnippets) { try { debug('Executing', codeSnippet); + recorder.catch((e) => { + console.log(e); + }); await eval(codeSnippet); // eslint-disable-line healSuggestions.push({ @@ -163,14 +193,17 @@ module.exports = function (config = {}) { snippet: codeSnippet, }); - output.print(colors.bold.green(' Code healed successfully')); + recorder.add('healed', () => output.print(colors.bold.green(' Code healed successfully'))); healedSteps++; return; } catch (err) { debug('Failed to execute code', err); + recorder.ignoreErr(err); // healing ded not help + // recorder.catch(() => output.print(colors.bold.red(' Failed healing code'))); } } output.debug(`Couldn't heal the code for ${failedStep.toCode()}`); } + return recorder.promise(); }; diff --git a/lib/recorder.js b/lib/recorder.js index f30e8f80c..49b309c27 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -11,6 +11,7 @@ let errFn; let queueId = 0; let sessionId = null; let asyncErr = null; +let ignoredErrs = []; let tasks = []; let oldPromises = []; @@ -93,6 +94,7 @@ module.exports = { promise = Promise.resolve(); oldPromises = []; tasks = []; + ignoredErrs = []; this.session.running = false; // reset this retries makes the retryFailedStep plugin won't work if there is Before/BeforeSuit block due to retries is undefined on Scenario // this.retries = []; @@ -226,9 +228,10 @@ module.exports = { * @inner */ catch(customErrFn) { - debug(`${currentQueue()}Queued | catch with error handler`); + const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50); + debug(`${currentQueue()}Queued | catch with error handler ${fnDescription || ''}`); return promise = promise.catch((err) => { - log(`${currentQueue()}Error | ${err}`); + log(`${currentQueue()}Error | ${err} ${fnDescription}...`); if (!(err instanceof Error)) { // strange things may happen err = new Error(`[Wrapped Error] ${printObjectProperties(err)}`); // we should be prepared for them } @@ -247,15 +250,15 @@ module.exports = { * @inner */ catchWithoutStop(customErrFn) { + const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50); return promise = promise.catch((err) => { - log(`${currentQueue()}Error | ${err}`); + if (ignoredErrs.includes(err)) return; // already caught + log(`${currentQueue()}Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`); if (!(err instanceof Error)) { // strange things may happen err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`); // we should be prepared for them } if (customErrFn) { return customErrFn(err); - } if (errFn) { - return errFn(err); } }); }, @@ -274,6 +277,10 @@ module.exports = { }); }, + ignoreErr(err) { + ignoredErrs.push(err); + }, + /** * @param {*} err * @inner diff --git a/package.json b/package.json index 81cd23a70..f0c0a5d38 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "fn-args": "4.0.0", "fs-extra": "8.1.0", "glob": "6.0.1", - "html-minifier": "4.0.0", + "html-minifier-terser": "^7.2.0", "inquirer": "6.5.2", "joi": "17.11.0", "js-beautify": "1.14.11", diff --git a/test/unit/ai_test.js b/test/unit/ai_test.js index 1ca9652d9..c0fbfb10d 100644 --- a/test/unit/ai_test.js +++ b/test/unit/ai_test.js @@ -5,10 +5,10 @@ const config = require('../../lib/config'); describe('AI module', () => { beforeEach(() => config.reset()); - it('should be externally configurable', () => { + it('should be externally configurable', async () => { const html = '
Hey
'; const ai = new AiAssistant(); - ai.setHtmlContext(html); + await ai.setHtmlContext(html); expect(ai.html).to.include('Hey'); config.create({ @@ -20,7 +20,7 @@ describe('AI module', () => { }); const ai2 = new AiAssistant(); - ai2.setHtmlContext(html); + await ai2.setHtmlContext(html); expect(ai2.html).to.include('Hey'); }); }); diff --git a/test/unit/html_test.js b/test/unit/html_test.js index 5ee70f446..f02d29ad7 100644 --- a/test/unit/html_test.js +++ b/test/unit/html_test.js @@ -35,7 +35,7 @@ describe('HTML module', () => { }); describe('#removeNonInteractiveElements', () => { - it('should cut out all non-interactive elements from GitHub HTML', () => { + it('should cut out all non-interactive elements from GitHub HTML', async () => { // Call the function with the loaded HTML html = fs.readFileSync(path.join(__dirname, '../data/github.html'), 'utf8'); const result = removeNonInteractiveElements(html, opts); @@ -43,7 +43,7 @@ describe('HTML module', () => { const nodes = xpath.select('//input[@name="q"]', doc); expect(nodes).to.have.length(1); expect(result).not.to.include('Let’s build from here'); - const minified = minifyHtml(result); + const minified = await minifyHtml(result); doc = new Dom().parseFromString(minified); const nodes2 = xpath.select('//input[@name="q"]', doc); expect(nodes2).to.have.length(1); @@ -66,7 +66,7 @@ describe('HTML module', () => { expect(result).to.include(' { + it('should keep menu bar', async () => { html = ``; - const result = minifyHtml(removeNonInteractiveElements(html, opts)); + const result = await minifyHtml(removeNonInteractiveElements(html, opts)); expect(result).to.include(' { // console.log(html); const result = removeNonInteractiveElements(html, opts); result.should.include('