codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
308 lines (243 loc) • 9.31 kB
JavaScript
const debug = require('debug')('codeceptjs:ai')
const output = require('./output')
const event = require('./event')
const { removeNonInteractiveElements, minifyHtml, splitByChunks } = require('./html')
const defaultHtmlConfig = {
maxLength: 50000,
simplify: true,
minify: true,
html: {},
}
const defaultPrompts = {
writeStep: (html, input) => [
{
role: 'user',
content: `I am test engineer writing test in CodeceptJS
I have opened web page and I want to use CodeceptJS to ${input} on this page
Provide me valid CodeceptJS code to accomplish it
Use only locators from this HTML: \n\n${html}`,
},
],
healStep: (html, { step, error, prevSteps }) => {
return [
{
role: 'user',
content: `As a test automation engineer I am testing web application using CodeceptJS.
I want to heal a test that fails. Here is the list of executed steps: ${prevSteps.map(s => s.toString()).join(', ')}
Propose how to adjust ${step.toCode()} step to fix the test.
Use locators in order of preference: semantic locator by text, CSS, XPath. Use codeblocks marked with \`\`\`
Here is the error message: ${error.message}
Here is HTML code of a page where the failure has happened: \n\n${html}`,
},
]
},
generatePageObject: (html, extraPrompt = '', rootLocator = null) => [
{
role: 'user',
content: `As a test automation engineer I am creating a Page Object for a web application using CodeceptJS.
Here is an sample page object:
const { I } = inject();
module.exports = {
// setting locators
element1: '#selector',
element2: '.selector',
element3: locate().withText('text'),
// seting methods
doSomethingOnPage(params) {
// ...
},
}
I want to generate a Page Object for the page I provide.
Write JavaScript code in similar manner to list all locators on the page.
Use locators in order of preference: by text (use locate().withText()), label, CSS, XPath.
Avoid TailwindCSS, Bootstrap or React style formatting classes in locators.
Add methods to to interact with page when needed.
${extraPrompt}
${rootLocator ? `All provided elements are inside '${rootLocator}'. Declare it as root variable and for every locator use locate(...).inside(root)` : ''}
Add only locators from this HTML: \n\n${html}`,
},
],
}
class AiAssistant {
constructor() {
this.totalTime = 0
this.numTokens = 0
this.reset()
this.connectToEvents()
}
enable(config = {}) {
debug('Enabling AI assistant')
this.isEnabled = true
const { html, prompts, ...aiConfig } = config
this.config = Object.assign(this.config, aiConfig)
this.htmlConfig = Object.assign(defaultHtmlConfig, html)
this.prompts = Object.assign(defaultPrompts, prompts)
debug('Config', this.config)
}
reset() {
this.numTokens = 0
this.isEnabled = false
this.config = {
maxTokens: 1000000,
request: null,
response: parseCodeBlocks,
// lets limit token usage to 1M
}
this.minifiedHtml = null
this.response = null
this.totalTime = 0
}
disable() {
this.isEnabled = false
}
connectToEvents() {
event.dispatcher.on(event.all.result, () => {
if (this.isEnabled && this.numTokens > 0) {
const numTokensK = Math.ceil(this.numTokens / 1000)
const maxTokensK = Math.ceil(this.config.maxTokens / 1000)
output.print(`AI assistant took ${this.totalTime}s and used ~${numTokensK}K input tokens. Tokens limit: ${maxTokensK}K`)
}
})
}
checkRequestFn() {
if (!this.isEnabled) {
debug('AI assistant is disabled')
return
}
if (this.config.request) return
const noRequestErrorMessage = `
No request function is set for AI assistant.
[!] AI request was decoupled from CodeceptJS. To connect to OpenAI or other AI service.
Please implement your own request function and set it in the config.
Example (connect to OpenAI):
ai: {
request: async (messages) => {
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: process.env['OPENAI_API_KEY'] })
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages,
});
return response?.data?.choices[0]?.message?.content;
}
}
`.trim()
throw new Error(noRequestErrorMessage)
}
async setHtmlContext(html) {
let processedHTML = html
if (this.htmlConfig.simplify) {
processedHTML = removeNonInteractiveElements(processedHTML, this.htmlConfig)
}
if (this.htmlConfig.minify) processedHTML = await minifyHtml(processedHTML)
if (this.htmlConfig.maxLength) processedHTML = splitByChunks(processedHTML, this.htmlConfig.maxLength)[0]
this.minifiedHtml = processedHTML
}
getResponse() {
return this.response || ''
}
async createCompletion(messages) {
if (!this.isEnabled) return ''
try {
this.checkRequestFn()
debug('Request', messages)
this.response = null
this.calculateTokens(messages)
const startTime = process.hrtime()
this.response = await this.config.request(messages)
const endTime = process.hrtime(startTime)
const executionTimeInSeconds = endTime[0] + endTime[1] / 1e9
this.totalTime += Math.round(executionTimeInSeconds)
debug('AI response time', executionTimeInSeconds)
debug('Response', this.response)
this.stopWhenReachingTokensLimit()
return this.response
} catch (err) {
debug(err.response)
output.print('')
output.error(`AI service error: ${err.message}`)
if (err?.response?.data?.error?.code) output.error(err?.response?.data?.error?.code)
if (err?.response?.data?.error?.message) output.error(err?.response?.data?.error?.message)
this.stopWhenReachingTokensLimit()
return ''
}
}
async healFailedStep(failureContext) {
if (!this.isEnabled) return []
if (!failureContext.html) throw new Error('No HTML context provided')
await this.setHtmlContext(failureContext.html)
if (!this.minifiedHtml) {
debug('HTML context is empty after removing non-interactive elements & minification')
return []
}
const response = await this.createCompletion(this.prompts.healStep(this.minifiedHtml, failureContext))
if (!response) return []
return this.config.response(response)
}
/**
*
* @param {*} extraPrompt
* @param {*} locator
* @returns
*/
async generatePageObject(extraPrompt = null, locator = null) {
if (!this.isEnabled) return []
if (!this.minifiedHtml) throw new Error('No HTML context provided')
const response = await this.createCompletion(this.prompts.generatePageObject(this.minifiedHtml, locator, extraPrompt))
if (!response) return []
return this.config.response(response)
}
calculateTokens(messages) {
// we implement naive approach for calculating tokens with no extra requests
// this approach was tested via https://platform.openai.com/tokenizer
// we need it to display current tokens usage so users could analyze effectiveness of AI
const inputString = messages
.map(m => m.content)
.join(' ')
.trim()
const numWords = (inputString.match(/[^\s\-:=]+/g) || []).length
// 2.5 token is constant for average HTML input
const tokens = numWords * 2.5
this.numTokens += tokens
return tokens
}
stopWhenReachingTokensLimit() {
if (this.numTokens < this.config.maxTokens) return
output.print(`AI assistant has reached the limit of ${this.config.maxTokens} tokens in this session. It will be disabled now`)
this.disable()
}
async writeSteps(input) {
if (!this.isEnabled) return
if (!this.minifiedHtml) throw new Error('No HTML context provided')
const snippets = []
const response = await this.createCompletion(this.prompts.writeStep(this.minifiedHtml, input))
if (!response) return
snippets.push(...this.config.response(response))
debug(snippets[0])
return snippets[0]
}
}
function parseCodeBlocks(response) {
// Regular expression pattern to match code snippets
const codeSnippetPattern = /```(?:javascript|js|typescript|ts)?\n([\s\S]+?)\n```/g
// Array to store extracted code snippets
const codeSnippets = []
response = response
.split('\n')
.map(line => line.trim())
.join('\n')
// Iterate over matches and extract code snippets
let match
while ((match = codeSnippetPattern.exec(response)) !== null) {
codeSnippets.push(match[1])
}
// Remove "Scenario", "Feature", and "require()" lines
const modifiedSnippets = codeSnippets.map(snippet => {
const lines = snippet.split('\n')
const filteredLines = lines.filter(line => !line.includes('I.amOnPage') && !line.startsWith('Scenario') && !line.startsWith('Feature') && !line.includes('= require('))
return filteredLines.join('\n')
// remove snippets that move from current url
}) // .filter(snippet => !line.includes('I.amOnPage'));
return modifiedSnippets.filter(snippet => !!snippet)
}
module.exports = new AiAssistant()