Skip to content

Commit

Permalink
Bugfix/ai html updates (#3962)
Browse files Browse the repository at this point in the history
* fixed heal plugin

* fixed tests

---------

Co-authored-by: kobenguyent <[email protected]>
  • Loading branch information
DavertMik and kobenguyent authored Dec 4, 2023
1 parent 3b84d7a commit aa52de8
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 30 deletions.
15 changes: 12 additions & 3 deletions lib/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const htmlConfig = {
html: {},
};

const aiInstance = null;

class AiAssistant {
constructor() {
this.config = config.get('ai', defaultConfig);
Expand All @@ -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,
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions lib/html.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,7 +11,7 @@ function minifyHtml(html) {
removeStyleLinkTypeAttributes: true,
collapseBooleanAttributes: true,
useShortDoctype: true,
}).toString();
});
}

const defaultHtmlOpts = {
Expand Down
9 changes: 6 additions & 3 deletions lib/pause.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, *>} [passedObject]
Expand All @@ -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`));
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
47 changes: 40 additions & 7 deletions lib/plugin/heal.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const output = require('../output');
const supportedHelpers = require('./standardActingHelpers');

const defaultConfig = {
healTries: 1,
healLimit: 2,
healSteps: [
'click',
Expand Down Expand Up @@ -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 = [];

Expand All @@ -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');
Expand Down Expand Up @@ -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;
});
});

Expand Down Expand Up @@ -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({
Expand All @@ -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();
};
17 changes: 12 additions & 5 deletions lib/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ let errFn;
let queueId = 0;
let sessionId = null;
let asyncErr = null;
let ignoredErrs = [];

let tasks = [];
let oldPromises = [];
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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
}
Expand All @@ -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);
}
});
},
Expand All @@ -274,6 +277,10 @@ module.exports = {
});
},

ignoreErr(err) {
ignoredErrs.push(err);
},

/**
* @param {*} err
* @inner
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions test/unit/ai_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div><a data-qa="ok">Hey</a></div>';
const ai = new AiAssistant();
ai.setHtmlContext(html);
await ai.setHtmlContext(html);
expect(ai.html).to.include('<a>Hey</a>');

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

const ai2 = new AiAssistant();
ai2.setHtmlContext(html);
await ai2.setHtmlContext(html);
expect(ai2.html).to.include('<a data-qa="ok">Hey</a>');
});
});
10 changes: 5 additions & 5 deletions test/unit/html_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ 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);
let doc = new Dom().parseFromString(result);
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);
Expand All @@ -66,7 +66,7 @@ describe('HTML module', () => {
expect(result).to.include('<button');
});

it('should keep menu bar', () => {
it('should keep menu bar', async () => {
html = `<div class="mainnav-menu-body">
<ul>
<li>
Expand All @@ -88,7 +88,7 @@ describe('HTML module', () => {
</li>
</ul>
</div>`;
const result = minifyHtml(removeNonInteractiveElements(html, opts));
const result = await minifyHtml(removeNonInteractiveElements(html, opts));
expect(result).to.include('<button');
expect(result).to.include('<a');
expect(result).to.include('<svg');
Expand Down Expand Up @@ -133,7 +133,7 @@ describe('HTML module', () => {
// console.log(html);
const result = removeNonInteractiveElements(html, opts);
result.should.include('<svg class="md-icon md-icon-check-bold');
// console.log(minifyHtml(result));
// console.log(await minifyHtml(result));
});
});

Expand Down

0 comments on commit aa52de8

Please sign in to comment.