codeceptjs 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +9 -7
- package/bin/codecept.js +1 -1
- package/docs/ai.md +246 -0
- package/docs/build/Appium.js +47 -7
- package/docs/build/JSONResponse.js +4 -4
- package/docs/build/Nightmare.js +3 -1
- package/docs/build/OpenAI.js +122 -0
- package/docs/build/Playwright.js +193 -45
- package/docs/build/Protractor.js +3 -1
- package/docs/build/Puppeteer.js +45 -12
- package/docs/build/REST.js +15 -5
- package/docs/build/TestCafe.js +3 -1
- package/docs/build/WebDriver.js +30 -5
- package/docs/changelog.md +70 -0
- package/docs/helpers/Appium.md +152 -147
- package/docs/helpers/JSONResponse.md +4 -4
- package/docs/helpers/Nightmare.md +2 -0
- package/docs/helpers/OpenAI.md +70 -0
- package/docs/helpers/Playwright.md +194 -152
- package/docs/helpers/Puppeteer.md +6 -0
- package/docs/helpers/REST.md +6 -5
- package/docs/helpers/TestCafe.md +2 -0
- package/docs/helpers/WebDriver.md +10 -4
- package/docs/mobile.md +49 -2
- package/docs/parallel.md +56 -0
- package/docs/plugins.md +87 -33
- package/docs/secrets.md +6 -0
- package/docs/tutorial.md +5 -5
- package/docs/webapi/appendField.mustache +2 -0
- package/docs/webapi/type.mustache +3 -0
- package/lib/ai.js +171 -0
- package/lib/cli.js +1 -1
- package/lib/codecept.js +4 -0
- package/lib/command/dryRun.js +9 -1
- package/lib/command/generate.js +46 -3
- package/lib/command/init.js +13 -1
- package/lib/command/interactive.js +15 -1
- package/lib/command/run-workers.js +2 -1
- package/lib/container.js +13 -3
- package/lib/helper/Appium.js +45 -7
- package/lib/helper/JSONResponse.js +4 -4
- package/lib/helper/Nightmare.js +1 -1
- package/lib/helper/OpenAI.js +122 -0
- package/lib/helper/Playwright.js +190 -38
- package/lib/helper/Protractor.js +1 -1
- package/lib/helper/Puppeteer.js +40 -12
- package/lib/helper/REST.js +15 -5
- package/lib/helper/TestCafe.js +1 -1
- package/lib/helper/WebDriver.js +25 -5
- package/lib/helper/scripts/highlightElement.js +20 -0
- package/lib/html.js +258 -0
- package/lib/listener/retry.js +2 -1
- package/lib/pause.js +73 -17
- package/lib/plugin/debugErrors.js +67 -0
- package/lib/plugin/fakerTransform.js +4 -6
- package/lib/plugin/heal.js +179 -0
- package/lib/plugin/screenshotOnFail.js +11 -2
- package/lib/plugin/wdio.js +4 -12
- package/lib/recorder.js +4 -4
- package/lib/scenario.js +6 -4
- package/lib/secret.js +5 -4
- package/lib/step.js +6 -1
- package/lib/ui.js +4 -3
- package/lib/utils.js +4 -0
- package/lib/workers.js +57 -9
- package/package.json +26 -14
- package/translations/ja-JP.js +9 -9
- package/typings/index.d.ts +43 -9
- package/typings/promiseBasedTypes.d.ts +124 -24
- package/typings/types.d.ts +138 -30
package/lib/helper/REST.js
CHANGED
|
@@ -34,7 +34,8 @@ const config = {};
|
|
|
34
34
|
* endpoint: 'http://site.com/api',
|
|
35
35
|
* prettyPrintJson: true,
|
|
36
36
|
* onRequest: (request) => {
|
|
37
|
-
*
|
|
37
|
+
* request.headers.auth = '123';
|
|
38
|
+
* }
|
|
38
39
|
* }
|
|
39
40
|
* }
|
|
40
41
|
*}
|
|
@@ -136,6 +137,15 @@ class REST extends Helper {
|
|
|
136
137
|
request.auth = this.headers.auth;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
if (typeof request.data === 'object') {
|
|
141
|
+
const returnedValue = {};
|
|
142
|
+
for (const [key, value] of Object.entries(request.data)) {
|
|
143
|
+
returnedValue[key] = value;
|
|
144
|
+
if (value instanceof Secret) returnedValue[key] = value.getMasked();
|
|
145
|
+
}
|
|
146
|
+
_debugRequest.data = returnedValue;
|
|
147
|
+
}
|
|
148
|
+
|
|
139
149
|
if (request.data instanceof Secret) {
|
|
140
150
|
_debugRequest.data = '*****';
|
|
141
151
|
request.data = (typeof request.data === 'object' && !(request.data instanceof Secret)) ? { ...request.data.toString() } : request.data.toString();
|
|
@@ -198,7 +208,7 @@ class REST extends Helper {
|
|
|
198
208
|
* ```
|
|
199
209
|
*
|
|
200
210
|
* @param {*} url
|
|
201
|
-
* @param {object} [headers={}] - the headers object to be sent. By default it is sent as an empty object
|
|
211
|
+
* @param {object} [headers={}] - the headers object to be sent. By default, it is sent as an empty object
|
|
202
212
|
*
|
|
203
213
|
* @returns {Promise<*>} response
|
|
204
214
|
*/
|
|
@@ -222,8 +232,8 @@ class REST extends Helper {
|
|
|
222
232
|
* ```
|
|
223
233
|
*
|
|
224
234
|
* @param {*} url
|
|
225
|
-
* @param {*} [payload={}] - the payload to be sent. By default it is sent as an empty object
|
|
226
|
-
* @param {object} [headers={}] - the headers object to be sent. By default it is sent as an empty object
|
|
235
|
+
* @param {*} [payload={}] - the payload to be sent. By default, it is sent as an empty object
|
|
236
|
+
* @param {object} [headers={}] - the headers object to be sent. By default, it is sent as an empty object
|
|
227
237
|
*
|
|
228
238
|
* @returns {Promise<*>} response
|
|
229
239
|
*/
|
|
@@ -317,7 +327,7 @@ class REST extends Helper {
|
|
|
317
327
|
* ```
|
|
318
328
|
*
|
|
319
329
|
* @param {*} url
|
|
320
|
-
* @param {object} [headers={}] - the headers object to be sent. By default it is sent as an empty object
|
|
330
|
+
* @param {object} [headers={}] - the headers object to be sent. By default, it is sent as an empty object
|
|
321
331
|
*
|
|
322
332
|
* @returns {Promise<*>} response
|
|
323
333
|
*/
|
package/lib/helper/TestCafe.js
CHANGED
package/lib/helper/WebDriver.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
|
|
7
7
|
const Helper = require('@codeceptjs/helper');
|
|
8
|
+
const crypto = require('crypto');
|
|
8
9
|
const stringIncludes = require('../assert/include').includes;
|
|
9
10
|
const { urlEquals, equals } = require('../assert/equal');
|
|
10
11
|
const { debug } = require('../output');
|
|
@@ -27,6 +28,8 @@ const {
|
|
|
27
28
|
const ElementNotFound = require('./errors/ElementNotFound');
|
|
28
29
|
const ConnectionRefused = require('./errors/ConnectionRefused');
|
|
29
30
|
const Locator = require('../locator');
|
|
31
|
+
const { highlightElement } = require('./scripts/highlightElement');
|
|
32
|
+
const store = require('../store');
|
|
30
33
|
|
|
31
34
|
const SHADOW = 'shadow';
|
|
32
35
|
const webRoot = 'body';
|
|
@@ -39,7 +42,7 @@ const webRoot = 'body';
|
|
|
39
42
|
* @typedef WebDriverConfig
|
|
40
43
|
* @type {object}
|
|
41
44
|
* @prop {string} url - base url of website to be tested.
|
|
42
|
-
* @prop {string} browser
|
|
45
|
+
* @prop {string} browser - Browser in which to perform testing.
|
|
43
46
|
* @prop {string} [basicAuth] - (optional) the basic authentication to pass to base url. Example: {username: 'username', password: 'password'}
|
|
44
47
|
* @prop {string} [host=localhost] - WebDriver host to connect.
|
|
45
48
|
* @prop {number} [port=4444] - WebDriver port to connect.
|
|
@@ -57,6 +60,7 @@ const webRoot = 'body';
|
|
|
57
60
|
* @prop {object} [desiredCapabilities] Selenium's [desired capabilities](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities).
|
|
58
61
|
* @prop {boolean} [manualStart=false] - do not start browser before a test, start it manually inside a helper with `this.helpers["WebDriver"]._startBrowser()`.
|
|
59
62
|
* @prop {object} [timeouts] [WebDriver timeouts](http://webdriver.io/docs/timeouts.html) defined as hash.
|
|
63
|
+
* @prop {boolean} [highlightElement] - highlight the interacting elements
|
|
60
64
|
*/
|
|
61
65
|
const config = {};
|
|
62
66
|
|
|
@@ -822,7 +826,7 @@ class WebDriver extends Helper {
|
|
|
822
826
|
}
|
|
823
827
|
|
|
824
828
|
/**
|
|
825
|
-
* Find a checkbox by providing human
|
|
829
|
+
* Find a checkbox by providing human-readable text:
|
|
826
830
|
*
|
|
827
831
|
* ```js
|
|
828
832
|
* this.helpers['WebDriver']._locateCheckable('I agree with terms and conditions').then // ...
|
|
@@ -835,7 +839,7 @@ class WebDriver extends Helper {
|
|
|
835
839
|
}
|
|
836
840
|
|
|
837
841
|
/**
|
|
838
|
-
* Find a clickable element by providing human
|
|
842
|
+
* Find a clickable element by providing human-readable text:
|
|
839
843
|
*
|
|
840
844
|
* ```js
|
|
841
845
|
* const els = await this.helpers.WebDriver._locateClickable('Next page');
|
|
@@ -850,7 +854,7 @@ class WebDriver extends Helper {
|
|
|
850
854
|
}
|
|
851
855
|
|
|
852
856
|
/**
|
|
853
|
-
* Find field elements by providing human
|
|
857
|
+
* Find field elements by providing human-readable text:
|
|
854
858
|
*
|
|
855
859
|
* ```js
|
|
856
860
|
* this.helpers['WebDriver']._locateFields('Your email').then // ...
|
|
@@ -914,6 +918,7 @@ class WebDriver extends Helper {
|
|
|
914
918
|
assertElementExists(res, locator, 'Clickable element');
|
|
915
919
|
}
|
|
916
920
|
const elem = usingFirstElement(res);
|
|
921
|
+
highlightActiveElement.call(this, elem);
|
|
917
922
|
return this.browser[clickMethod](getElementId(elem));
|
|
918
923
|
}
|
|
919
924
|
|
|
@@ -932,6 +937,7 @@ class WebDriver extends Helper {
|
|
|
932
937
|
assertElementExists(res, locator, 'Clickable element');
|
|
933
938
|
}
|
|
934
939
|
const elem = usingFirstElement(res);
|
|
940
|
+
highlightActiveElement.call(this, elem);
|
|
935
941
|
|
|
936
942
|
return this.executeScript((el) => {
|
|
937
943
|
if (document.activeElement instanceof HTMLElement) {
|
|
@@ -959,6 +965,7 @@ class WebDriver extends Helper {
|
|
|
959
965
|
}
|
|
960
966
|
|
|
961
967
|
const elem = usingFirstElement(res);
|
|
968
|
+
highlightActiveElement.call(this, elem);
|
|
962
969
|
return elem.doubleClick();
|
|
963
970
|
}
|
|
964
971
|
|
|
@@ -1024,6 +1031,7 @@ class WebDriver extends Helper {
|
|
|
1024
1031
|
const res = await findFields.call(this, field);
|
|
1025
1032
|
assertElementExists(res, field, 'Field');
|
|
1026
1033
|
const elem = usingFirstElement(res);
|
|
1034
|
+
highlightActiveElement.call(this, elem);
|
|
1027
1035
|
return elem.setValue(value.toString());
|
|
1028
1036
|
}
|
|
1029
1037
|
|
|
@@ -1035,7 +1043,8 @@ class WebDriver extends Helper {
|
|
|
1035
1043
|
const res = await findFields.call(this, field);
|
|
1036
1044
|
assertElementExists(res, field, 'Field');
|
|
1037
1045
|
const elem = usingFirstElement(res);
|
|
1038
|
-
|
|
1046
|
+
highlightActiveElement.call(this, elem);
|
|
1047
|
+
return elem.addValue(value.toString());
|
|
1039
1048
|
}
|
|
1040
1049
|
|
|
1041
1050
|
/**
|
|
@@ -1046,6 +1055,7 @@ class WebDriver extends Helper {
|
|
|
1046
1055
|
const res = await findFields.call(this, field);
|
|
1047
1056
|
assertElementExists(res, field, 'Field');
|
|
1048
1057
|
const elem = usingFirstElement(res);
|
|
1058
|
+
highlightActiveElement.call(this, elem);
|
|
1049
1059
|
return elem.clearValue(getElementId(elem));
|
|
1050
1060
|
}
|
|
1051
1061
|
|
|
@@ -1056,6 +1066,7 @@ class WebDriver extends Helper {
|
|
|
1056
1066
|
const res = await findFields.call(this, select);
|
|
1057
1067
|
assertElementExists(res, select, 'Selectable field');
|
|
1058
1068
|
const elem = usingFirstElement(res);
|
|
1069
|
+
highlightActiveElement.call(this, elem);
|
|
1059
1070
|
|
|
1060
1071
|
if (!Array.isArray(option)) {
|
|
1061
1072
|
option = [option];
|
|
@@ -1122,6 +1133,7 @@ class WebDriver extends Helper {
|
|
|
1122
1133
|
assertElementExists(res, field, 'Checkable');
|
|
1123
1134
|
const elem = usingFirstElement(res);
|
|
1124
1135
|
const elementId = getElementId(elem);
|
|
1136
|
+
highlightActiveElement.call(this, elem);
|
|
1125
1137
|
|
|
1126
1138
|
const isSelected = await this.browser.isElementSelected(elementId);
|
|
1127
1139
|
if (isSelected) return Promise.resolve(true);
|
|
@@ -1141,6 +1153,7 @@ class WebDriver extends Helper {
|
|
|
1141
1153
|
assertElementExists(res, field, 'Checkable');
|
|
1142
1154
|
const elem = usingFirstElement(res);
|
|
1143
1155
|
const elementId = getElementId(elem);
|
|
1156
|
+
highlightActiveElement.call(this, elem);
|
|
1144
1157
|
|
|
1145
1158
|
const isSelected = await this.browser.isElementSelected(elementId);
|
|
1146
1159
|
if (!isSelected) return Promise.resolve(true);
|
|
@@ -1879,6 +1892,7 @@ class WebDriver extends Helper {
|
|
|
1879
1892
|
*/
|
|
1880
1893
|
async type(keys, delay = null) {
|
|
1881
1894
|
if (!Array.isArray(keys)) {
|
|
1895
|
+
keys = keys.toString();
|
|
1882
1896
|
keys = keys.split('');
|
|
1883
1897
|
}
|
|
1884
1898
|
if (delay) {
|
|
@@ -2873,6 +2887,12 @@ function isModifierKey(key) {
|
|
|
2873
2887
|
return unicodeModifierKeys.includes(key);
|
|
2874
2888
|
}
|
|
2875
2889
|
|
|
2890
|
+
function highlightActiveElement(element) {
|
|
2891
|
+
if (!this.options.enableHighlight && !store.debugMode) return;
|
|
2892
|
+
|
|
2893
|
+
highlightElement(element, this.browser);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2876
2896
|
function prepareLocateFn(context) {
|
|
2877
2897
|
if (!context) return this._locate.bind(this);
|
|
2878
2898
|
return (l) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module.exports.highlightElement = (element, context) => {
|
|
2
|
+
const clientSideHighlightFn = el => {
|
|
3
|
+
const style = '0px 0px 4px 3px rgba(255, 0, 0, 0.7)';
|
|
4
|
+
const prevStyle = el.style.boxShadow;
|
|
5
|
+
el.style.boxShadow = style;
|
|
6
|
+
setTimeout(() => el.style.boxShadow = prevStyle, 2000);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
// Playwright, Puppeteer
|
|
11
|
+
context.evaluate(clientSideHighlightFn, element);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
// WebDriver
|
|
14
|
+
try {
|
|
15
|
+
context.execute(clientSideHighlightFn, element);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
// ignore
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
package/lib/html.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const { parse, serialize } = require('parse5');
|
|
2
|
+
const { minify } = require('html-minifier');
|
|
3
|
+
|
|
4
|
+
function minifyHtml(html) {
|
|
5
|
+
return minify(html, {
|
|
6
|
+
collapseWhitespace: true,
|
|
7
|
+
removeComments: true,
|
|
8
|
+
removeEmptyAttributes: true,
|
|
9
|
+
removeRedundantAttributes: true,
|
|
10
|
+
removeScriptTypeAttributes: true,
|
|
11
|
+
removeStyleLinkTypeAttributes: true,
|
|
12
|
+
collapseBooleanAttributes: true,
|
|
13
|
+
useShortDoctype: true,
|
|
14
|
+
}).toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultHtmlOpts = {
|
|
18
|
+
interactiveElements: ['a', 'input', 'button', 'select', 'textarea', 'option'],
|
|
19
|
+
textElements: ['label', 'h1', 'h2'],
|
|
20
|
+
allowedAttrs: ['id', 'for', 'class', 'name', 'type', 'value', 'tabindex', 'aria-labelledby', 'aria-label', 'label', 'placeholder', 'title', 'alt', 'src', 'role'],
|
|
21
|
+
allowedRoles: ['button', 'checkbox', 'search', 'textbox', 'tab'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function removeNonInteractiveElements(html, opts = {}) {
|
|
25
|
+
opts = { ...defaultHtmlOpts, ...opts };
|
|
26
|
+
const {
|
|
27
|
+
interactiveElements,
|
|
28
|
+
textElements,
|
|
29
|
+
allowedAttrs,
|
|
30
|
+
allowedRoles,
|
|
31
|
+
} = opts;
|
|
32
|
+
|
|
33
|
+
// Parse the HTML into a document tree
|
|
34
|
+
const document = parse(html);
|
|
35
|
+
|
|
36
|
+
const trashHtmlClasses = /^(text-|color-|flex-|float-|v-|ember-|d-|border-)/;
|
|
37
|
+
// Array to store interactive elements
|
|
38
|
+
const removeElements = ['path', 'script'];
|
|
39
|
+
|
|
40
|
+
function isFilteredOut(node) {
|
|
41
|
+
if (removeElements.includes(node.nodeName)) return true;
|
|
42
|
+
if (node.attrs) {
|
|
43
|
+
if (node.attrs.find(attr => attr.name === 'role' && attr.value === 'tooltip')) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Function to check if an element is interactive
|
|
49
|
+
function isInteractive(element) {
|
|
50
|
+
if (element.nodeName === 'input' && element.attrs.find(attr => attr.name === 'type' && attr.value === 'hidden')) return false;
|
|
51
|
+
if (interactiveElements.includes(element.nodeName)) return true;
|
|
52
|
+
if (element.attrs) {
|
|
53
|
+
if (element.attrs.find(attr => attr.name === 'contenteditable')) return true;
|
|
54
|
+
if (element.attrs.find(attr => attr.name === 'tabindex')) return true;
|
|
55
|
+
const role = element.attrs.find(attr => attr.name === 'role');
|
|
56
|
+
if (role && allowedRoles.includes(role.value)) return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasMeaningfulText(node) {
|
|
62
|
+
if (textElements.includes(node.nodeName)) return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasInteractiveDescendant(node) {
|
|
67
|
+
if (!node.childNodes) return false;
|
|
68
|
+
let result = false;
|
|
69
|
+
|
|
70
|
+
for (const childNode of node.childNodes) {
|
|
71
|
+
if (isInteractive(childNode) || hasMeaningfulText(childNode)) return true;
|
|
72
|
+
result = result || hasInteractiveDescendant(childNode);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Function to remove non-interactive elements recursively
|
|
79
|
+
function removeNonInteractive(node) {
|
|
80
|
+
if (node.nodeName !== '#document') {
|
|
81
|
+
const parent = node.parentNode;
|
|
82
|
+
const index = parent.childNodes.indexOf(node);
|
|
83
|
+
|
|
84
|
+
if (isFilteredOut(node)) {
|
|
85
|
+
parent.childNodes.splice(index, 1);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// keep texts for interactive elements
|
|
90
|
+
if ((isInteractive(parent) || hasMeaningfulText(parent)) && node.nodeName === '#text') {
|
|
91
|
+
node.value = node.value.trim().slice(0, 200);
|
|
92
|
+
if (!node.value) return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
// if parent is interactive, we may need child element to match
|
|
98
|
+
!isInteractive(parent)
|
|
99
|
+
&& !isInteractive(node)
|
|
100
|
+
&& !hasInteractiveDescendant(node)
|
|
101
|
+
&& !hasMeaningfulText(node)) {
|
|
102
|
+
parent.childNodes.splice(index, 1);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (node.attrs) {
|
|
108
|
+
// Filter and keep allowed attributes, accessibility attributes
|
|
109
|
+
node.attrs = node.attrs.filter(attr => {
|
|
110
|
+
const { name, value } = attr;
|
|
111
|
+
if (name === 'class') {
|
|
112
|
+
// Remove classes containing digits
|
|
113
|
+
attr.value = value.split(' ')
|
|
114
|
+
// remove classes containing digits/
|
|
115
|
+
.filter(className => !/\d/.test(className))
|
|
116
|
+
// remove popular trash classes
|
|
117
|
+
.filter(className => !className.match(trashHtmlClasses))
|
|
118
|
+
// remove classes with : and __ in them
|
|
119
|
+
.filter(className => !className.match(/(:|__)/))
|
|
120
|
+
.join(' ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return allowedAttrs.includes(name);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (node.childNodes) {
|
|
128
|
+
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
|
129
|
+
const childNode = node.childNodes[i];
|
|
130
|
+
removeNonInteractive(childNode);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Remove non-interactive elements starting from the root element
|
|
137
|
+
removeNonInteractive(document);
|
|
138
|
+
|
|
139
|
+
// Serialize the modified document tree back to HTML
|
|
140
|
+
const serializedHTML = serialize(document);
|
|
141
|
+
|
|
142
|
+
return serializedHTML;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function scanForErrorMessages(html, errorClasses = []) {
|
|
146
|
+
// Parse the HTML into a document tree
|
|
147
|
+
const document = parse(html);
|
|
148
|
+
|
|
149
|
+
// Array to store error messages
|
|
150
|
+
const errorMessages = [];
|
|
151
|
+
|
|
152
|
+
// Function to recursively scan for error classes and messages
|
|
153
|
+
function scanErrors(node) {
|
|
154
|
+
if (node.attrs) {
|
|
155
|
+
const classAttr = node.attrs.find(attr => attr.name === 'class');
|
|
156
|
+
if (classAttr && classAttr.value) {
|
|
157
|
+
const classNameChunks = classAttr.value.split(' ');
|
|
158
|
+
const errorClassFound = errorClasses.some(errorClass => classNameChunks.includes(errorClass));
|
|
159
|
+
if (errorClassFound && node.childNodes) {
|
|
160
|
+
const errorMessage = sanitizeTextContent(node);
|
|
161
|
+
errorMessages.push(errorMessage);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (node.childNodes) {
|
|
167
|
+
for (const childNode of node.childNodes) {
|
|
168
|
+
scanErrors(childNode);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Start scanning for error classes and messages from the root element
|
|
174
|
+
scanErrors(document);
|
|
175
|
+
|
|
176
|
+
return errorMessages;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sanitizeTextContent(node) {
|
|
180
|
+
if (node.nodeName === '#text') {
|
|
181
|
+
return node.value.trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let sanitizedText = '';
|
|
185
|
+
|
|
186
|
+
if (node.childNodes) {
|
|
187
|
+
for (const childNode of node.childNodes) {
|
|
188
|
+
sanitizedText += sanitizeTextContent(childNode);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return sanitizedText;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildPath(node, path = '') {
|
|
196
|
+
const tag = node.nodeName;
|
|
197
|
+
let attributes = '';
|
|
198
|
+
|
|
199
|
+
if (node.attrs) {
|
|
200
|
+
attributes = node.attrs
|
|
201
|
+
.map(attr => `${attr.name}="${attr.value}"`)
|
|
202
|
+
.join(' ');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!tag.startsWith('#') && tag !== 'body' && tag !== 'html') {
|
|
206
|
+
path += `<${node.nodeName}${node.attrs ? ` ${attributes}` : ''}>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!node.childNodes) return path;
|
|
210
|
+
|
|
211
|
+
const children = node.childNodes.filter(child => !child.nodeName.startsWith('#'));
|
|
212
|
+
|
|
213
|
+
if (children.length) {
|
|
214
|
+
return buildPath(children[children.length - 1], path);
|
|
215
|
+
}
|
|
216
|
+
return path;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function splitByChunks(text, chunkSize) {
|
|
220
|
+
chunkSize -= 20;
|
|
221
|
+
const chunks = [];
|
|
222
|
+
for (let i = 0; i < text.length; i += chunkSize) {
|
|
223
|
+
chunks.push(text.slice(i, i + chunkSize));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const regex = /<\s*\w+(?:\s+\w+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^>\s]+)))*\s*$/;
|
|
227
|
+
|
|
228
|
+
// append tag to chunk if it was split out
|
|
229
|
+
for (const index in chunks) {
|
|
230
|
+
const nextIndex = parseInt(index, 10) + 1;
|
|
231
|
+
if (!chunks[nextIndex]) break;
|
|
232
|
+
|
|
233
|
+
const currentChunk = chunks[index];
|
|
234
|
+
const nextChunk = chunks[nextIndex];
|
|
235
|
+
|
|
236
|
+
const lastTag = currentChunk.match(regex);
|
|
237
|
+
if (lastTag) {
|
|
238
|
+
chunks[nextIndex] = lastTag[0] + nextChunk;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const path = buildPath(parse(currentChunk));
|
|
242
|
+
if (path) {
|
|
243
|
+
chunks[nextIndex] = path + chunks[nextIndex];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (chunks[nextIndex].includes('<html')) continue;
|
|
247
|
+
chunks[nextIndex] = `<html><body>${chunks[nextIndex]}</body></html>`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return chunks.map(chunk => chunk.trim());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
scanForErrorMessages,
|
|
255
|
+
removeNonInteractiveElements,
|
|
256
|
+
splitByChunks,
|
|
257
|
+
minifyHtml,
|
|
258
|
+
};
|
package/lib/listener/retry.js
CHANGED
|
@@ -44,6 +44,7 @@ module.exports = function () {
|
|
|
44
44
|
if (!retryConfig) return;
|
|
45
45
|
|
|
46
46
|
if (Number.isInteger(+retryConfig)) {
|
|
47
|
+
if (test.retries() === -1) test.retries(retryConfig);
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -59,7 +60,7 @@ module.exports = function () {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
if (config.Scenario) {
|
|
62
|
-
if (
|
|
63
|
+
if (test.retries() === -1) test.retries(config.Scenario);
|
|
63
64
|
output.log(`Retries: ${config.Scenario}`);
|
|
64
65
|
}
|
|
65
66
|
}
|
package/lib/pause.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const colors = require('chalk');
|
|
2
2
|
const readline = require('readline');
|
|
3
|
+
const ora = require('ora-classic');
|
|
4
|
+
const debug = require('debug')('codeceptjs:pause');
|
|
3
5
|
|
|
4
6
|
const container = require('./container');
|
|
5
7
|
const history = require('./history');
|
|
6
8
|
const store = require('./store');
|
|
9
|
+
const AiAssistant = require('./ai');
|
|
7
10
|
const recorder = require('./recorder');
|
|
8
11
|
const event = require('./event');
|
|
9
12
|
const output = require('./output');
|
|
@@ -15,6 +18,8 @@ let nextStep;
|
|
|
15
18
|
let finish;
|
|
16
19
|
let next;
|
|
17
20
|
let registeredVariables = {};
|
|
21
|
+
const aiAssistant = new AiAssistant();
|
|
22
|
+
|
|
18
23
|
/**
|
|
19
24
|
* Pauses test execution and starts interactive shell
|
|
20
25
|
*/
|
|
@@ -45,6 +50,14 @@ function pauseSession(passedObject = {}) {
|
|
|
45
50
|
output.print(colors.yellow(` - Press ${colors.bold('TAB')} twice to see all available commands`));
|
|
46
51
|
output.print(colors.yellow(` - Type ${colors.bold('exit')} + Enter to exit the interactive shell`));
|
|
47
52
|
output.print(colors.yellow(` - Prefix ${colors.bold('=>')} to run js commands ${colors.bold(vars)}`));
|
|
53
|
+
|
|
54
|
+
if (aiAssistant.isEnabled) {
|
|
55
|
+
output.print(colors.blue(` ${colors.bold('OpenAI is enabled! (experimental)')} Write what you want and make OpenAI run it`));
|
|
56
|
+
output.print(colors.blue(' Please note, only HTML fragments with interactive elements are sent to OpenAI'));
|
|
57
|
+
output.print(colors.blue(' Ideas: ask it to fill forms for you or to click'));
|
|
58
|
+
} else {
|
|
59
|
+
output.print(colors.blue(` Enable OpenAI assistant by setting ${colors.bold('OPENAI_API_KEY')} env variable`));
|
|
60
|
+
}
|
|
48
61
|
}
|
|
49
62
|
rl = readline.createInterface(process.stdin, process.stdout, completer);
|
|
50
63
|
|
|
@@ -59,9 +72,10 @@ function pauseSession(passedObject = {}) {
|
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
/* eslint-disable */
|
|
62
|
-
function parseInput(cmd) {
|
|
75
|
+
async function parseInput(cmd) {
|
|
63
76
|
rl.pause();
|
|
64
77
|
next = false;
|
|
78
|
+
recorder.session.start('pause');
|
|
65
79
|
store.debugMode = false;
|
|
66
80
|
if (cmd === '') next = true;
|
|
67
81
|
if (!cmd || cmd === 'resume' || cmd === 'exit') {
|
|
@@ -74,37 +88,78 @@ function parseInput(cmd) {
|
|
|
74
88
|
for (const k of Object.keys(registeredVariables)) {
|
|
75
89
|
eval(`var ${k} = registeredVariables['${k}'];`); // eslint-disable-line no-eval
|
|
76
90
|
}
|
|
91
|
+
|
|
92
|
+
let executeCommand = Promise.resolve();
|
|
93
|
+
|
|
94
|
+
const getCmd = () => {
|
|
95
|
+
debug('Command:', cmd)
|
|
96
|
+
return cmd;
|
|
97
|
+
};
|
|
98
|
+
|
|
77
99
|
store.debugMode = true;
|
|
78
100
|
let isCustomCommand = false;
|
|
79
101
|
let lastError = null;
|
|
102
|
+
let isAiCommand = false;
|
|
103
|
+
let $res;
|
|
80
104
|
try {
|
|
81
105
|
const locate = global.locate; // enable locate in this context
|
|
82
106
|
const I = container.support('I');
|
|
83
107
|
if (cmd.trim().startsWith('=>')) {
|
|
84
108
|
isCustomCommand = true;
|
|
85
109
|
cmd = cmd.trim().substring(2, cmd.length);
|
|
110
|
+
} else if (aiAssistant.isEnabled && !cmd.match(/^\w+\(/) && cmd.includes(' ')) {
|
|
111
|
+
const currentOutputLevel = output.level();
|
|
112
|
+
output.level(0);
|
|
113
|
+
const res = I.grabSource();
|
|
114
|
+
isAiCommand = true;
|
|
115
|
+
executeCommand = executeCommand.then(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const html = await res;
|
|
118
|
+
aiAssistant.setHtmlContext(html);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
output.print(output.styles.error(' ERROR '), 'Can\'t get HTML context', err.stack);
|
|
121
|
+
return;
|
|
122
|
+
} finally {
|
|
123
|
+
output.level(currentOutputLevel);
|
|
124
|
+
}
|
|
125
|
+
// aiAssistant.mockResponse("```js\nI.click('Sign in');\n```");
|
|
126
|
+
const spinner = ora("Processing OpenAI request...").start();
|
|
127
|
+
cmd = await aiAssistant.writeSteps(cmd);
|
|
128
|
+
spinner.stop();
|
|
129
|
+
output.print('');
|
|
130
|
+
output.print(colors.blue(aiAssistant.getResponse()));
|
|
131
|
+
output.print('');
|
|
132
|
+
return cmd;
|
|
133
|
+
})
|
|
86
134
|
} else {
|
|
87
135
|
cmd = `I.${cmd}`;
|
|
88
136
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (isCustomCommand) {
|
|
94
|
-
console.log(val);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
if (cmd.startsWith('I.see') || cmd.startsWith('I.dontSee')) {
|
|
98
|
-
output.print(output.styles.success(' OK '), cmd);
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (cmd.startsWith('I.grab')) {
|
|
102
|
-
output.print(output.styles.debug(val));
|
|
103
|
-
}
|
|
137
|
+
executeCommand = executeCommand.then(async () => {
|
|
138
|
+
const cmd = getCmd();
|
|
139
|
+
if (!cmd) return;
|
|
140
|
+
return eval(cmd); // eslint-disable-line no-eval
|
|
104
141
|
}).catch((err) => {
|
|
142
|
+
debug(err);
|
|
143
|
+
if (isAiCommand) return;
|
|
105
144
|
if (!lastError) output.print(output.styles.error(' ERROR '), err.message);
|
|
145
|
+
debug(err.stack)
|
|
146
|
+
|
|
106
147
|
lastError = err.message;
|
|
107
|
-
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const val = await executeCommand;
|
|
151
|
+
|
|
152
|
+
if (isCustomCommand) {
|
|
153
|
+
if (val !== undefined) console.log('Result', '$res=', val); // eslint-disable-line
|
|
154
|
+
$res = val;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (cmd?.startsWith('I.see') || cmd?.startsWith('I.dontSee')) {
|
|
158
|
+
output.print(output.styles.success(' OK '), cmd);
|
|
159
|
+
}
|
|
160
|
+
if (cmd?.startsWith('I.grab')) {
|
|
161
|
+
output.print(output.styles.debug(val));
|
|
162
|
+
}
|
|
108
163
|
|
|
109
164
|
history.push(cmd); // add command to history when successful
|
|
110
165
|
} catch (err) {
|
|
@@ -117,6 +172,7 @@ function parseInput(cmd) {
|
|
|
117
172
|
// pop latest command from history because it failed
|
|
118
173
|
history.pop();
|
|
119
174
|
|
|
175
|
+
if (isAiCommand) return;
|
|
120
176
|
if (!lastError) output.print(output.styles.error(' FAIL '), msg);
|
|
121
177
|
lastError = err.message;
|
|
122
178
|
});
|