codeceptjs 3.5.15 → 3.6.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/lib/locator.js CHANGED
@@ -3,7 +3,7 @@ const { sprintf } = require('sprintf-js');
3
3
 
4
4
  const { xpathLocator } = require('./utils');
5
5
 
6
- const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow'];
6
+ const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'pw'];
7
7
  /** @class */
8
8
  class Locator {
9
9
  /**
@@ -51,6 +51,9 @@ class Locator {
51
51
  if (isShadow(locator)) {
52
52
  this.type = 'shadow';
53
53
  }
54
+ if (isPlaywrightLocator(locator)) {
55
+ this.type = 'pw';
56
+ }
54
57
 
55
58
  Locator.filters.forEach(f => f(locator, this));
56
59
  }
@@ -71,6 +74,8 @@ class Locator {
71
74
  return this.value;
72
75
  case 'shadow':
73
76
  return { shadow: this.value };
77
+ case 'pw':
78
+ return { pw: this.value };
74
79
  }
75
80
  return this.value;
76
81
  }
@@ -115,6 +120,13 @@ class Locator {
115
120
  return this.type === 'css';
116
121
  }
117
122
 
123
+ /**
124
+ * @returns {boolean}
125
+ */
126
+ isPlaywrightLocator() {
127
+ return this.type === 'pw';
128
+ }
129
+
118
130
  /**
119
131
  * @returns {boolean}
120
132
  */
@@ -522,6 +534,16 @@ function removePrefix(xpath) {
522
534
  .replace(/^(\.|\/)+/, '');
523
535
  }
524
536
 
537
+ /**
538
+ * @private
539
+ * check if the locator is a Playwright locator
540
+ * @param {string} locator
541
+ * @returns {boolean}
542
+ */
543
+ function isPlaywrightLocator(locator) {
544
+ return locator.includes('_react') || locator.includes('_vue') || locator.includes('data-testid');
545
+ }
546
+
525
547
  /**
526
548
  * @private
527
549
  * @param {CodeceptJS.LocatorOrString} locator
@@ -1,33 +1,19 @@
1
1
  const debug = require('debug')('codeceptjs:heal');
2
2
  const colors = require('chalk');
3
- const Container = require('../container');
4
- const AiAssistant = require('../ai');
5
3
  const recorder = require('../recorder');
6
4
  const event = require('../event');
7
5
  const output = require('../output');
8
- const supportedHelpers = require('./standardActingHelpers');
6
+ const heal = require('../heal');
7
+ const store = require('../store');
9
8
 
10
9
  const defaultConfig = {
11
- healTries: 1,
12
10
  healLimit: 2,
13
- healSteps: [
14
- 'click',
15
- 'fillField',
16
- 'appendField',
17
- 'selectOption',
18
- 'attachFile',
19
- 'checkOption',
20
- 'uncheckOption',
21
- 'doubleClick',
22
- ],
23
11
  };
24
12
 
25
13
  /**
26
- * Self-healing tests with OpenAI.
14
+ * Self-healing tests with AI.
27
15
  *
28
- * This plugin is experimental and requires OpenAI API key.
29
- *
30
- * To use it you need to set OPENAI_API_KEY env variable and enable plugin inside the config.
16
+ * Read more about heaking in [Self-Healing Tests](https://codecept.io/heal/)
31
17
  *
32
18
  * ```js
33
19
  * plugins: {
@@ -40,22 +26,15 @@ const defaultConfig = {
40
26
  * More config options are available:
41
27
  *
42
28
  * * `healLimit` - how many steps can be healed in a single test (default: 2)
43
- * * `healSteps` - which steps can be healed (default: all steps that interact with UI, see list below)
44
- *
45
- * Steps to heal:
46
- *
47
- * * `click`
48
- * * `fillField`
49
- * * `appendField`
50
- * * `selectOption`
51
- * * `attachFile`
52
- * * `checkOption`
53
- * * `uncheckOption`
54
- * * `doubleClick`
55
29
  *
56
30
  */
57
31
  module.exports = function (config = {}) {
58
- const aiAssistant = AiAssistant.getInstance();
32
+ if (store.debugMode && !process.env.DEBUG) {
33
+ event.dispatcher.on(event.test.failed, () => {
34
+ output.plugin('heal', 'Healing is disabled in --debug mode, use DEBUG="codeceptjs:heal" to enable it in debug mode');
35
+ });
36
+ return;
37
+ }
59
38
 
60
39
  let currentTest = null;
61
40
  let currentStep = null;
@@ -64,8 +43,6 @@ module.exports = function (config = {}) {
64
43
  let healTries = 0;
65
44
  let isHealing = false;
66
45
 
67
- const healSuggestions = [];
68
-
69
46
  config = Object.assign(defaultConfig, config);
70
47
 
71
48
  event.dispatcher.on(event.test.before, (test) => {
@@ -78,63 +55,27 @@ module.exports = function (config = {}) {
78
55
 
79
56
  event.dispatcher.on(event.step.after, (step) => {
80
57
  if (isHealing) return;
81
- const store = require('../store');
82
- if (store.debugMode) return;
58
+ if (healTries >= config.healLimit) return; // out of limit
59
+
60
+ if (!heal.hasCorrespondingRecipes(step)) return;
61
+
83
62
  recorder.catchWithoutStop(async (err) => {
84
63
  isHealing = true;
85
64
  if (caughtError === err) throw err; // avoid double handling
86
65
  caughtError = err;
87
- if (!aiAssistant.isEnabled) {
88
- output.print(colors.yellow('Heal plugin can\'t operate, AI assistant is disabled. Please set OPENAI_API_KEY env variable to enable it.'));
89
- throw err;
90
- }
91
- if (!currentStep) throw err;
92
- if (!config.healSteps.includes(currentStep.name)) throw err;
93
- const test = currentTest;
94
-
95
- if (healTries >= config.healTries) {
96
- output.print(colors.bold.red(`Healing failed for ${config.healTries} time(s)`));
97
- output.print('AI couldn\'t identify the correct solution');
98
- output.print('Probably the entire flow has changed and the test should be updated');
99
-
100
- throw err;
101
- }
102
66
 
103
- if (healedSteps >= config.healLimit) {
104
- output.print(colors.bold.red(`Can't heal more than ${config.healLimit} step(s) in a test`));
105
- output.print('Entire flow can be broken, please check it manually');
106
- output.print('or increase healing limit in heal plugin config');
107
-
108
- throw err;
109
- }
67
+ const test = currentTest;
110
68
 
111
69
  recorder.session.start('heal');
112
- const helpers = Container.helpers();
113
- let helper;
114
-
115
- for (const helperName of supportedHelpers) {
116
- if (Object.keys(helpers).indexOf(helperName) > -1) {
117
- helper = helpers[helperName];
118
- }
119
- }
120
70
 
121
- if (!helper) throw err; // no helpers for html
122
-
123
- const step = test.steps[test.steps.length - 1];
124
71
  debug('Self-healing started', step.toCode());
125
72
 
126
- const currentOutputLevel = output.level();
127
- output.level(0);
128
- const html = await helper.grabHTMLFrom('body');
129
- output.level(currentOutputLevel);
130
-
131
- if (!html) throw err;
73
+ await heal.healStep(step, err, { test });
132
74
 
133
75
  healTries++;
134
- await aiAssistant.setHtmlContext(html);
135
- await tryToHeal(step, err);
136
76
 
137
77
  recorder.add('close healing session', () => {
78
+ recorder.reset();
138
79
  recorder.session.restore('heal');
139
80
  recorder.ignoreErr(err);
140
81
  });
@@ -145,7 +86,7 @@ module.exports = function (config = {}) {
145
86
  });
146
87
 
147
88
  event.dispatcher.on(event.all.result, () => {
148
- if (!healSuggestions.length) return;
89
+ if (!heal.fixes?.length) return;
149
90
 
150
91
  const { print } = output;
151
92
 
@@ -153,16 +94,20 @@ module.exports = function (config = {}) {
153
94
  print('===================');
154
95
  print(colors.bold.green('Self-Healing Report:'));
155
96
 
156
- print(`${colors.bold(healSuggestions.length)} step(s) were healed by AI`);
97
+ print(`${colors.bold(heal.fixes.length)} ${heal.fixes.length === 1 ? 'step was' : 'steps were'} healed`);
98
+
99
+ const suggestions = heal.fixes.filter(fix => fix.recipe && heal.recipes[fix.recipe].suggest);
100
+
101
+ if (!suggestions.length) return;
157
102
 
158
103
  let i = 1;
159
104
  print('');
160
105
  print('Suggested changes:');
161
106
  print('');
162
107
 
163
- for (const suggestion of healSuggestions) {
164
- print(`${i}. To fix ${colors.bold.blue(suggestion.test.title)}`);
165
- print('Replace the failed code with:');
108
+ for (const suggestion of suggestions) {
109
+ print(`${i}. To fix ${colors.bold.magenta(suggestion.test?.title)}`);
110
+ print(' Replace the failed code:', colors.gray(`(suggested by ${colors.bold(suggestion.recipe)})`));
166
111
  print(colors.red(`- ${suggestion.step.toCode()}`));
167
112
  print(colors.green(`+ ${suggestion.snippet}`));
168
113
  print(suggestion.step.line());
@@ -170,40 +115,4 @@ module.exports = function (config = {}) {
170
115
  i++;
171
116
  }
172
117
  });
173
-
174
- async function tryToHeal(failedStep, err) {
175
- output.debug(`Running OpenAI to heal ${failedStep.toCode()} step`);
176
-
177
- const codeSnippets = await aiAssistant.healFailedStep(failedStep, err, currentTest);
178
-
179
- output.debug(`Received ${codeSnippets.length} suggestions from OpenAI`);
180
- const I = Container.support('I'); // eslint-disable-line
181
-
182
- for (const codeSnippet of codeSnippets) {
183
- try {
184
- debug('Executing', codeSnippet);
185
- recorder.catch((e) => {
186
- console.log(e);
187
- });
188
- await eval(codeSnippet); // eslint-disable-line
189
-
190
- healSuggestions.push({
191
- test: currentTest,
192
- step: failedStep,
193
- snippet: codeSnippet,
194
- });
195
-
196
- recorder.add('healed', () => output.print(colors.bold.green(' Code healed successfully')));
197
- healedSteps++;
198
- return;
199
- } catch (err) {
200
- debug('Failed to execute code', err);
201
- recorder.ignoreErr(err); // healing ded not help
202
- // recorder.catch(() => output.print(colors.bold.red(' Failed healing code')));
203
- }
204
- }
205
-
206
- output.debug(`Couldn't heal the code for ${failedStep.toCode()}`);
207
- }
208
- return recorder.promise();
209
118
  };
package/lib/recorder.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const debug = require('debug')('codeceptjs:recorder');
2
2
  const promiseRetry = require('promise-retry');
3
+ const chalk = require('chalk');
3
4
  const { printObjectProperties } = require('./utils');
4
5
  const { log } = require('./output');
5
6
 
@@ -179,7 +180,7 @@ module.exports = {
179
180
  return;
180
181
  }
181
182
  tasks.push(taskName);
182
- debug(`${currentQueue()}Queued | ${taskName}`);
183
+ debug(chalk.gray(`${currentQueue()} Queued | ${taskName}`));
183
184
 
184
185
  return promise = Promise.resolve(promise).then((res) => {
185
186
  // prefer options for non-conditional retries
@@ -190,11 +191,14 @@ module.exports = {
190
191
  return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer));
191
192
  }
192
193
 
194
+ debug(`${currentQueue()} Running | ${taskName}`);
195
+
193
196
  const retryRules = this.retries.slice().reverse();
194
197
  return promiseRetry(Object.assign(defaultRetryOptions, retryOpts), (retry, number) => {
195
198
  if (number > 1) log(`${currentQueue()}Retrying... Attempt #${number}`);
196
199
  const [promise, timer] = getTimeoutPromise(timeout, taskName);
197
200
  return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer)).catch((err) => {
201
+ if (ignoredErrs.includes(err)) return;
198
202
  for (const retryObj of retryRules) {
199
203
  if (!retryObj.when) return retry(err);
200
204
  if (retryObj.when && retryObj.when(err)) return retry(err);
@@ -229,7 +233,7 @@ module.exports = {
229
233
  */
230
234
  catch(customErrFn) {
231
235
  const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50);
232
- debug(`${currentQueue()}Queued | catch with error handler ${fnDescription || ''}`);
236
+ debug(chalk.gray(`${currentQueue()} Queued | catch with error handler ${fnDescription || ''}`));
233
237
  return promise = promise.catch((err) => {
234
238
  log(`${currentQueue()}Error | ${err} ${fnDescription}...`);
235
239
  if (!(err instanceof Error)) { // strange things may happen
@@ -253,7 +257,7 @@ module.exports = {
253
257
  const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50);
254
258
  return promise = promise.catch((err) => {
255
259
  if (ignoredErrs.includes(err)) return; // already caught
256
- log(`${currentQueue()}Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`);
260
+ log(`${currentQueue()} Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`);
257
261
  if (!(err instanceof Error)) { // strange things may happen
258
262
  err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`); // we should be prepared for them
259
263
  }
@@ -272,7 +276,9 @@ module.exports = {
272
276
  */
273
277
 
274
278
  throw(err) {
279
+ if (ignoredErrs.includes(err)) return promise; // already caught
275
280
  return this.add(`throw error: ${err.message}`, () => {
281
+ if (ignoredErrs.includes(err)) return; // already caught
276
282
  throw err;
277
283
  });
278
284
  },
@@ -313,8 +319,8 @@ module.exports = {
313
319
  * @inner
314
320
  */
315
321
  stop() {
316
- if (process.env.DEBUG) debug(this.toString());
317
- log(`${currentQueue()}Stopping recording promises`);
322
+ debug(this.toString());
323
+ log(`${currentQueue()} Stopping recording promises`);
318
324
  running = false;
319
325
  },
320
326
 
package/lib/store.js CHANGED
@@ -7,6 +7,8 @@ const store = {
7
7
  debugMode: false,
8
8
  /** @type {boolean} */
9
9
  timeouts: true,
10
+ /** @type {boolean} */
11
+ dryRun: false,
10
12
  };
11
13
 
12
14
  module.exports = store;
@@ -0,0 +1,39 @@
1
+ const { heal, ai } = require('codeceptjs');
2
+
3
+ heal.addRecipe('ai', {
4
+ priority: 10,
5
+ prepare: {
6
+ html: ({ I }) => I.grabHTMLFrom('body'),
7
+ },
8
+ steps: [
9
+ 'click',
10
+ 'fillField',
11
+ 'appendField',
12
+ 'selectOption',
13
+ 'attachFile',
14
+ 'checkOption',
15
+ 'uncheckOption',
16
+ 'doubleClick',
17
+ ],
18
+ fn: async (args) => {
19
+ return ai.healFailedStep(args);
20
+ },
21
+ });
22
+
23
+ heal.addRecipe('clickAndType', {
24
+ priority: 1,
25
+ steps: [
26
+ 'fillField',
27
+ 'appendField',
28
+ ],
29
+ fn: async ({ step }) => {
30
+ const locator = step.args[0];
31
+ const text = step.args[1];
32
+
33
+ return ({ I }) => {
34
+ I.click(locator);
35
+ I.wait(1); // to open modal or something
36
+ I.type(text);
37
+ };
38
+ },
39
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "3.5.15",
3
+ "version": "3.6.0",
4
4
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
5
5
  "keywords": [
6
6
  "acceptance",
@@ -72,7 +72,7 @@
72
72
  "@codeceptjs/helper": "2.0.1",
73
73
  "@cucumber/cucumber-expressions": "17",
74
74
  "@cucumber/gherkin": "26",
75
- "@cucumber/messages": "24.0.1",
75
+ "@cucumber/messages": "24.1.0",
76
76
  "@xmldom/xmldom": "0.8.10",
77
77
  "acorn": "8.11.3",
78
78
  "arrify": "2.0.1",
@@ -90,7 +90,7 @@
90
90
  "css-to-xpath": "0.1.0",
91
91
  "csstoxpath": "1.6.0",
92
92
  "devtools": "8.33.1",
93
- "envinfo": "7.11.0",
93
+ "envinfo": "7.11.1",
94
94
  "escape-string-regexp": "4.0.0",
95
95
  "figures": "3.2.0",
96
96
  "fn-args": "4.0.0",
@@ -104,11 +104,10 @@
104
104
  "lodash.merge": "4.6.2",
105
105
  "mkdirp": "1.0.4",
106
106
  "mocha": "10.3.0",
107
- "monocart-coverage-reports": "2.7.1",
107
+ "monocart-coverage-reports": "2.7.4",
108
108
  "ms": "2.1.3",
109
- "openai": "3.2.1",
110
109
  "ora-classic": "5.4.2",
111
- "pactum": "3.6.1",
110
+ "pactum": "3.6.6",
112
111
  "parse-function": "5.6.10",
113
112
  "parse5": "7.1.2",
114
113
  "promise-retry": "1.1.1",
@@ -124,12 +123,12 @@
124
123
  "@faker-js/faker": "7.6.0",
125
124
  "@pollyjs/adapter-puppeteer": "6.0.6",
126
125
  "@pollyjs/core": "5.1.0",
127
- "@types/chai": "4.3.11",
126
+ "@types/chai": "4.3.12",
128
127
  "@types/inquirer": "9.0.3",
129
- "@types/node": "20.11.16",
130
- "@wdio/sauce-service": "8.32.3",
128
+ "@types/node": "20.11.30",
129
+ "@wdio/sauce-service": "8.35.1",
131
130
  "@wdio/selenium-standalone-service": "8.3.2",
132
- "@wdio/utils": "8.28.8",
131
+ "@wdio/utils": "8.33.1",
133
132
  "@xmldom/xmldom": "0.8.10",
134
133
  "apollo-server-express": "2.25.3",
135
134
  "chai-as-promised": "7.1.1",
@@ -142,15 +141,15 @@
142
141
  "eslint-plugin-import": "2.29.1",
143
142
  "eslint-plugin-mocha": "6.3.0",
144
143
  "expect": "29.7.0",
145
- "express": "4.18.3",
144
+ "express": "4.19.2",
146
145
  "graphql": "14.6.0",
147
146
  "husky": "8.0.3",
148
147
  "inquirer-test": "2.0.1",
149
148
  "jsdoc": "3.6.11",
150
149
  "jsdoc-typeof-plugin": "1.0.0",
151
150
  "json-server": "0.10.1",
152
- "playwright": "1.41.1",
153
- "puppeteer": "22.4.1",
151
+ "playwright": "1.43.0",
152
+ "puppeteer": "22.6.3",
154
153
  "qrcode-terminal": "0.12.0",
155
154
  "rosie": "2.1.1",
156
155
  "runok": "0.9.3",
@@ -159,13 +158,13 @@
159
158
  "testcafe": "3.5.0",
160
159
  "ts-morph": "21.0.1",
161
160
  "ts-node": "10.9.2",
162
- "tsd": "^0.30.7",
161
+ "tsd": "^0.31.0",
163
162
  "tsd-jsdoc": "2.5.0",
164
163
  "typedoc": "0.25.12",
165
164
  "typedoc-plugin-markdown": "3.17.1",
166
165
  "typescript": "5.3.3",
167
166
  "wdio-docker-service": "1.5.0",
168
- "webdriverio": "8.33.1",
167
+ "webdriverio": "8.35.1",
169
168
  "xml2js": "0.6.2",
170
169
  "xpath": "0.0.34"
171
170
  },
@@ -442,8 +442,8 @@ declare namespace CodeceptJS {
442
442
  | { react: string }
443
443
  | { vue: string }
444
444
  | { shadow: string[] }
445
- | { custom: string };
446
-
445
+ | { custom: string }
446
+ | { pw: string };
447
447
  interface CustomLocators {}
448
448
  interface OtherLocators { props?: object }
449
449
  type LocatorOrString =