Skip to content

Commit e48051c

Browse files
AI: Generate PageObject, added types, shell improvement (#4319)
* added types for ai * fix: types complaint * added gen page objects, improved types, improved shell * fixed tests --------- Co-authored-by: kobenguyent <[email protected]>
1 parent d8f45eb commit e48051c

File tree

7 files changed

+311
-37
lines changed

7 files changed

+311
-37
lines changed

docs/ai.md

+103-4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ So, instead of asking "write me a test" it can ask "write a test for **this** pa
2323
CodeceptJS AI can do the following:
2424

2525
* 🏋️‍♀️ **assist writing tests** in `pause()` or interactive shell mode
26+
* 📃 **generate page objects** in `pause()` or interactive shell mode
2627
* 🚑 **self-heal failing tests** (can be used on CI)
2728
* 💬 send arbitrary prompts to AI provider from any tested page attaching its HTML contents
2829

@@ -260,15 +261,29 @@ By evaluating this information you will be able to check how effective AI can be
260261
261262
### Arbitrary GPT Prompts
262263
263-
What if you want to take ChatGPT on the journey of test automation and ask it questions while browsing pages?
264+
What if you want to take AI on the journey of test automation and ask it questions while browsing pages?
264265
265-
This is possible with the new `AI` helper. Enable it in your config and it will automatically attach to Playwright, WebDriver, or another web helper you use. It includes the following methods:
266+
This is possible with the new `AI` helper. Enable it in your config file in `helpers` section:
267+
268+
```js
269+
// inside codecept.conf
270+
helpers: {
271+
// Playwright, Puppeteer, or WebDrver helper should be enabled too
272+
Playwright: {
273+
},
274+
275+
AI: {}
276+
}
277+
```
278+
279+
AI helper will be automatically attached to Playwright, WebDriver, or another web helper you use. It includes the following methods:
266280
267281
* `askGptOnPage` - sends GPT prompt attaching the HTML of the page. Large pages will be split into chunks, according to `chunkSize` config. You will receive responses for all chunks.
268282
* `askGptOnPageFragment` - sends GPT prompt attaching the HTML of the specific element. This method is recommended over `askGptOnPage` as you can reduce the amount of data to be processed.
269283
* `askGptGeneralPrompt` - sends GPT prompt without HTML.
284+
* `askForPageObject` - creates PageObject for you, explained in next section.
270285
271-
OpenAI helper won't remove non-interactive elements, so it is recommended to manually control the size of the sent HTML.
286+
`askGpt` methods won't remove non-interactive elements, so it is recommended to manually control the size of the sent HTML.
272287
273288
Here are some good use cases for this helper:
274289
@@ -282,7 +297,84 @@ Here are some good use cases for this helper:
282297
const pageDoc = await I.askGptOnPageFragment('Act as technical writer, describe what is this page for', '#container');
283298
```
284299
285-
As of now, those use cases do not apply to test automation but maybe you can apply them to your testing setup.
300+
As of now, those use cases do not apply to test automation but maybe you can apply them to your testing setup.
301+
302+
## Generate PageObjects
303+
304+
Last but not the least. AI helper can be used to quickly prototype PageObjects on pages browsed within interactive session.
305+
306+
![](/img/ai_page_object.png)
307+
308+
Enable AI helper as explained in previous section and launch shell:
309+
310+
```
311+
npx codeceptjs shell --ai
312+
```
313+
314+
Also this is availble from `pause()` if AI helper is enabled,
315+
316+
Ensure that browser is started in window mode, then browse the web pages on your site.
317+
On a page you want to create PageObject execute `askForPageObject()` command. The only required parameter is the name of a page:
318+
319+
```js
320+
I.askForPageObject('login')
321+
```
322+
323+
This command sends request to AI provider should create valid CodeceptJS PageObject.
324+
Run it few times or switch AI provider if response is not satisfactory to you.
325+
326+
> You can change the style of PageObject and locator preferences by adjusting prompt in a config file
327+
328+
When completed successfully, page object is saved to **output** directory and loaded into the shell as `page` variable so locators and methods can be checked on the fly.
329+
330+
If page object has `signInButton` locator you can quickly check it by typing:
331+
332+
```js
333+
I.click(page.signInButton)
334+
```
335+
336+
If page object has `clickForgotPassword` method you can execute it as:
337+
338+
```js
339+
=> page.clickForgotPassword()
340+
```
341+
342+
```shell
343+
Page object for login is saved to .../output/loginPage-1718579784751.js
344+
Page object registered for this session as `page` variable
345+
Use `=>page.methodName()` in shell to run methods of page object
346+
Use `click(page.locatorName)` to check locators of page object
347+
348+
I.=>page.clickSignUp()
349+
I.click(page.signUpLink)
350+
I.=> page.enterPassword('asdasd')
351+
I.=> page.clickSignIn()
352+
```
353+
354+
You can improve prompt by passing custom request as a second parameter:
355+
356+
```js
357+
I.askForPageObject('login', 'implement signIn(username, password) method')
358+
```
359+
360+
To generate page object for the part of a page, pass in root locator as third parameter.
361+
362+
```js
363+
I.askForPageObject('login', '', '#auth')
364+
```
365+
366+
In this case, all generated locators, will use `#auth` as their root element.
367+
368+
Don't aim for perfect PageObjects but find a good enough one, which you can use for writing your tests.
369+
All created page objects are considered temporary, that's why saved to `output` directory.
370+
371+
Rename created PageObject to remove timestamp and move it from `output` to `pages` folder and include it into codecept.conf file:
372+
373+
```js
374+
include: {
375+
loginPage: "./pages/loginPage.js",
376+
// ...
377+
```
286378
287379
## Advanced Configuration
288380
@@ -315,6 +407,7 @@ ai: {
315407
prompts: {
316408
writeStep: (html, input) => [{ role: 'user', content: 'As a test engineer...' }]
317409
healStep: (html, { step, error, prevSteps }) => [{ role: 'user', content: 'As a test engineer...' }]
410+
generatePageObject: (html, extraPrompt = '', rootLocator = null) => [{ role: 'user', content: 'As a test engineer...' }]
318411
}
319412
}
320413
```
@@ -392,3 +485,9 @@ To debug AI features run tests with `DEBUG="codeceptjs:ai"` flag. This will prin
392485
```
393486
DEBUG="codeceptjs:ai" npx codeceptjs run --ai
394487
```
488+
489+
or if you run it in shell mode:
490+
491+
```
492+
DEBUG="codeceptjs:ai" npx codeceptjs shell --ai
493+
```

docs/pageobjects.md

+2
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ module.exports = function() {
5656
5757
## PageObject
5858

59+
> ✨ CodeceptJS can [generate PageObjects using AI](/ai#generate-pageobjects). It fetches all interactive elements from a page, generates locators and methods page and writes JS code. Generated page object can be tested on the fly within the same browser session.
60+
5961
If an application has different pages (login, admin, etc) you should use a page object.
6062
CodeceptJS can generate a template for it with the following command:
6163

lib/ai.js

+47-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,36 @@ const defaultPrompts = {
3131
Here is HTML code of a page where the failure has happened: \n\n${html}`,
3232
}];
3333
},
34+
35+
generatePageObject: (html, extraPrompt = '', rootLocator = null) => [{
36+
role: 'user',
37+
content: `As a test automation engineer I am creating a Page Object for a web application using CodeceptJS.
38+
Here is an sample page object:
39+
40+
const { I } = inject();
41+
42+
module.exports = {
43+
44+
// setting locators
45+
element1: '#selector',
46+
element2: '.selector',
47+
element3: locate().withText('text'),
48+
49+
// seting methods
50+
doSomethingOnPage(params) {
51+
// ...
52+
},
53+
}
54+
55+
I want to generate a Page Object for the page I provide.
56+
Write JavaScript code in similar manner to list all locators on the page.
57+
Use locators in order of preference: by text (use locate().withText()), label, CSS, XPath.
58+
Avoid TailwindCSS, Bootstrap or React style formatting classes in locators.
59+
Add methods to to interact with page when needed.
60+
${extraPrompt}
61+
${rootLocator ? `All provided elements are inside '${rootLocator}'. Declare it as root variable and for every locator use locate(...).inside(root)` : ''}
62+
Add only locators from this HTML: \n\n${html}`,
63+
}],
3464
};
3565

3666
class AiAssistant {
@@ -182,10 +212,26 @@ class AiAssistant {
182212
return this.config.response(response);
183213
}
184214

215+
/**
216+
*
217+
* @param {*} extraPrompt
218+
* @param {*} locator
219+
* @returns
220+
*/
221+
async generatePageObject(extraPrompt = null, locator = null) {
222+
if (!this.isEnabled) return [];
223+
if (!this.minifiedHtml) throw new Error('No HTML context provided');
224+
225+
const response = await this.createCompletion(this.prompts.generatePageObject(this.minifiedHtml, locator, extraPrompt));
226+
if (!response) return [];
227+
228+
return this.config.response(response);
229+
}
230+
185231
calculateTokens(messages) {
186232
// we implement naive approach for calculating tokens with no extra requests
187233
// this approach was tested via https://platform.openai.com/tokenizer
188-
// we need it to display current usage tokens usage so users could analyze effectiveness of AI
234+
// we need it to display current tokens usage so users could analyze effectiveness of AI
189235

190236
const inputString = messages.map(m => m.content).join(' ').trim();
191237
const numWords = (inputString.match(/[^\s\-:=]+/g) || []).length;

lib/helper/AI.js

+84-7
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
const Helper = require('@codeceptjs/helper');
2+
const ora = require('ora-classic');
3+
const fs = require('fs');
4+
const path = require('path');
25
const ai = require('../ai');
36
const standardActingHelpers = require('../plugin/standardActingHelpers');
47
const Container = require('../container');
58
const { splitByChunks, minifyHtml } = require('../html');
9+
const { beautify } = require('../utils');
10+
const output = require('../output');
11+
const { registerVariable } = require('../pause');
612

713
/**
814
* AI Helper for CodeceptJS.
915
*
1016
* This helper class provides integration with the AI GPT-3.5 or 4 language model for generating responses to questions or prompts within the context of web pages. It allows you to interact with the GPT-3.5 model to obtain intelligent responses based on HTML fragments or general prompts.
1117
* This helper should be enabled with any web helpers like Playwright or Puppeteer or WebDrvier to ensure the HTML context is available.
1218
*
19+
* Use it only in development mode. It is recommended to run it only inside pause() mode.
20+
*
1321
* ## Configuration
1422
*
1523
* This helper should be configured in codecept.json or codecept.conf.js
@@ -66,9 +74,9 @@ class AI extends Helper {
6674

6775
if (htmlChunks.length > 1) messages.push({ role: 'user', content: 'If action is not possible on this page, do not propose anything, I will send another HTML fragment' });
6876

69-
const response = await this.aiAssistant.createCompletion(messages);
77+
const response = await this._processAIRequest(messages);
7078

71-
console.log(response);
79+
output.print(response);
7280

7381
responses.push(response);
7482
}
@@ -96,15 +104,15 @@ class AI extends Helper {
96104
{ role: 'user', content: `Within this HTML: ${minifyHtml(html)}` },
97105
];
98106

99-
const response = await this.aiAssistant.createCompletion(messages);
107+
const response = await this._processAIRequest(messages);
100108

101-
console.log(response);
109+
output.print(response);
102110

103111
return response;
104112
}
105113

106114
/**
107-
* Send a general request to ChatGPT and return response.
115+
* Send a general request to AI and return response.
108116
* @param {string} prompt
109117
* @returns {Promise<string>} - A Promise that resolves to the generated response from the GPT model.
110118
*/
@@ -113,10 +121,79 @@ class AI extends Helper {
113121
{ role: 'user', content: prompt },
114122
];
115123

116-
const response = await this.aiAssistant.createCompletion(messages);
124+
const response = await this._processAIRequest(messages);
125+
126+
output.print(response);
127+
128+
return response;
129+
}
130+
131+
/**
132+
* Generates PageObject for current page using AI.
133+
*
134+
* It saves the PageObject to the output directory. You can review the page object and adjust it as needed and move to pages directory.
135+
* Prompt can be customized in a global config file.
136+
*
137+
* ```js
138+
* // create page object for whole page
139+
* I.askForPageObject('home');
140+
*
141+
* // create page object with extra prompt
142+
* I.askForPageObject('home', 'implement signIn(username, password) method');
143+
*
144+
* // create page object for a specific element
145+
* I.askForPageObject('home', null, '.detail');
146+
* ```
147+
*
148+
* Asks for a page object based on the provided page name, locator, and extra prompt.
149+
*
150+
* @async
151+
* @param {string} pageName - The name of the page to retrieve the object for.
152+
* @param {string|null} [extraPrompt=null] - An optional extra prompt for additional context or information.
153+
* @param {string|null} [locator=null] - An optional locator to find a specific element on the page.
154+
* @returns {Promise<Object>} A promise that resolves to the requested page object.
155+
*/
156+
async askForPageObject(pageName, extraPrompt = null, locator = null) {
157+
const html = locator ? await this.helper.grabHTMLFrom(locator) : await this.helper.grabSource();
158+
159+
const spinner = ora(' Processing AI request...').start();
160+
await this.aiAssistant.setHtmlContext(html);
161+
const response = await this.aiAssistant.generatePageObject(extraPrompt, locator);
162+
spinner.stop();
163+
164+
if (!response[0]) {
165+
output.error('No response from AI');
166+
return '';
167+
}
168+
169+
const code = beautify(response[0]);
117170

118-
console.log(response);
171+
output.print('----- Generated PageObject ----');
172+
output.print(code);
173+
output.print('-------------------------------');
119174

175+
const fileName = path.join(output_dir, `${pageName}Page-${Date.now()}.js`);
176+
177+
output.print(output.styles.bold(`Page object for ${pageName} is saved to ${output.styles.bold(fileName)}`));
178+
fs.writeFileSync(fileName, code);
179+
180+
try {
181+
registerVariable('page', require(fileName));
182+
output.success('Page object registered for this session as `page` variable');
183+
output.print('Use `=>page.methodName()` in shell to run methods of page object');
184+
output.print('Use `click(page.locatorName)` to check locators of page object');
185+
} catch (err) {
186+
output.error('Error while registering page object');
187+
output.error(err.message);
188+
}
189+
190+
return code;
191+
}
192+
193+
async _processAIRequest(messages) {
194+
const spinner = ora(' Processing AI request...').start();
195+
const response = await this.aiAssistant.createCompletion(messages);
196+
spinner.stop();
120197
return response;
121198
}
122199
}

lib/history.js

+16-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const output = require('./output');
1010
*/
1111
class ReplHistory {
1212
constructor() {
13+
if (global.output_dir) {
14+
this.historyFile = path.join(global.output_dir, 'cli-history');
15+
}
1316
this.commands = [];
1417
}
1518

@@ -21,16 +24,26 @@ class ReplHistory {
2124
this.commands.pop();
2225
}
2326

27+
load() {
28+
if (!this.historyFile) return;
29+
if (!fs.existsSync(this.historyFile)) {
30+
return;
31+
}
32+
33+
const history = fs.readFileSync(this.historyFile, 'utf-8');
34+
return history.split('\n').reverse().filter(line => line.startsWith('I.')).map(line => line.slice(2));
35+
}
36+
2437
save() {
38+
if (!this.historyFile) return;
2539
if (this.commands.length === 0) {
2640
return;
2741
}
2842

29-
const historyFile = path.join(global.output_dir, 'cli-history');
3043
const commandSnippet = `\n\n<<< Recorded commands on ${new Date()}\n${this.commands.join('\n')}`;
31-
fs.appendFileSync(historyFile, commandSnippet);
44+
fs.appendFileSync(this.historyFile, commandSnippet);
3245

33-
output.print(colors.yellow(` Commands have been saved to ${historyFile}`));
46+
output.print(colors.yellow(` Commands have been saved to ${this.historyFile}`));
3447

3548
this.commands = [];
3649
}

0 commit comments

Comments
 (0)