Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix/ai html updates #3962

Merged
merged 3 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity, I saw const aiInstance = null; at line 19 and it is not assigned, does this code here alway return the 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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we remove ^?

"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