codeceptjs 2.1.3 → 2.2.1

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.
Files changed (173) hide show
  1. package/CHANGELOG.md +125 -37
  2. package/README.md +15 -22
  3. package/bin/codecept.js +4 -1
  4. package/docs/acceptance.md +44 -1
  5. package/docs/advanced.md +1 -1
  6. package/docs/angular.md +6 -9
  7. package/docs/basics.md +388 -75
  8. package/docs/bdd.md +4 -3
  9. package/docs/best.md +1 -1
  10. package/docs/books.md +31 -0
  11. package/docs/build/Appium.js +215 -176
  12. package/docs/build/Nightmare.js +618 -489
  13. package/docs/build/Polly.js +189 -0
  14. package/docs/build/Protractor.js +747 -608
  15. package/docs/build/Puppeteer.js +914 -633
  16. package/docs/build/REST.js +1 -1
  17. package/docs/build/TestCafe.js +1835 -0
  18. package/docs/build/WebDriver.js +861 -805
  19. package/docs/build/WebDriverIO.js +616 -617
  20. package/docs/changelog.md +410 -316
  21. package/docs/commands.md +6 -6
  22. package/docs/community-helpers.md +2 -0
  23. package/docs/detox.md +235 -0
  24. package/docs/examples.md +23 -0
  25. package/docs/helpers/ApiDataFactory.md +11 -10
  26. package/docs/helpers/Appium.md +130 -61
  27. package/docs/helpers/Detox.md +579 -0
  28. package/docs/helpers/FileSystem.md +2 -1
  29. package/docs/helpers/Mochawesome.md +1 -0
  30. package/docs/helpers/Nightmare.md +348 -128
  31. package/docs/helpers/Polly.md +85 -0
  32. package/docs/helpers/Protractor.md +451 -184
  33. package/docs/helpers/Puppeteer-firefox.md +55 -0
  34. package/docs/helpers/Puppeteer.md +619 -183
  35. package/docs/helpers/REST.md +17 -16
  36. package/docs/helpers/SeleniumWebdriver.md +9 -8
  37. package/docs/helpers/TestCafe.md +1168 -0
  38. package/docs/helpers/WebDriver.md +600 -291
  39. package/docs/helpers/WebDriverIO.md +393 -278
  40. package/docs/helpers.md +37 -18
  41. package/docs/locators.md +2 -0
  42. package/docs/mobile-react-native-locators.md +64 -0
  43. package/docs/mobile.md +5 -0
  44. package/docs/plugins.md +54 -13
  45. package/docs/puppeteer.md +74 -26
  46. package/docs/quickstart.md +47 -12
  47. package/docs/react.md +67 -0
  48. package/docs/reports.md +1 -1
  49. package/docs/{webapi/_keys.mustache → shared/keys.mustache} +0 -0
  50. package/docs/shared/react.mustache +1 -0
  51. package/docs/testcafe.md +157 -0
  52. package/docs/videos.md +19 -0
  53. package/docs/webapi/amOnPage.mustache +1 -1
  54. package/docs/webapi/appendField.mustache +2 -2
  55. package/docs/webapi/attachFile.mustache +2 -2
  56. package/docs/webapi/checkOption.mustache +2 -2
  57. package/docs/webapi/clearCookie.mustache +1 -1
  58. package/docs/webapi/clearField.mustache +1 -1
  59. package/docs/webapi/click.mustache +2 -2
  60. package/docs/webapi/clickLink.mustache +3 -3
  61. package/docs/webapi/dontSee.mustache +6 -3
  62. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +7 -1
  63. package/docs/webapi/dontSeeCookie.mustache +5 -1
  64. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +6 -1
  65. package/docs/webapi/dontSeeElement.mustache +5 -1
  66. package/docs/webapi/dontSeeElementInDOM.mustache +5 -1
  67. package/docs/webapi/dontSeeInCurrentUrl.mustache +1 -1
  68. package/docs/webapi/dontSeeInField.mustache +7 -2
  69. package/docs/webapi/dontSeeInSource.mustache +5 -1
  70. package/docs/webapi/dontSeeInTitle.mustache +5 -1
  71. package/docs/webapi/doubleClick.mustache +2 -2
  72. package/docs/webapi/downloadFile.mustache +2 -2
  73. package/docs/webapi/dragAndDrop.mustache +2 -2
  74. package/docs/webapi/dragSlider.mustache +2 -2
  75. package/docs/webapi/executeAsyncScript.mustache +1 -1
  76. package/docs/webapi/executeScript.mustache +1 -1
  77. package/docs/webapi/fillField.mustache +2 -2
  78. package/docs/webapi/grabAttributeFrom.mustache +3 -2
  79. package/docs/webapi/grabBrowserLogs.mustache +3 -1
  80. package/docs/webapi/grabCookie.mustache +2 -1
  81. package/docs/webapi/grabCssPropertyFrom.mustache +3 -2
  82. package/docs/webapi/grabCurrentUrl.mustache +3 -1
  83. package/docs/webapi/grabDataFromPerformanceTiming.mustache +19 -0
  84. package/docs/webapi/grabHTMLFrom.mustache +2 -1
  85. package/docs/webapi/grabNumberOfOpenTabs.mustache +4 -2
  86. package/docs/webapi/grabNumberOfVisibleElements.mustache +3 -2
  87. package/docs/webapi/grabPageScrollPosition.mustache +3 -1
  88. package/docs/webapi/grabSource.mustache +3 -1
  89. package/docs/webapi/grabTextFrom.mustache +2 -1
  90. package/docs/webapi/grabTitle.mustache +3 -1
  91. package/docs/webapi/grabValueFrom.mustache +2 -1
  92. package/docs/webapi/moveCursorTo.mustache +3 -3
  93. package/docs/webapi/pressKey.mustache +1 -1
  94. package/docs/webapi/resizeWindow.mustache +2 -2
  95. package/docs/webapi/rightClick.mustache +2 -2
  96. package/docs/webapi/saveScreenshot.mustache +3 -3
  97. package/docs/webapi/say.mustache +2 -2
  98. package/docs/webapi/scrollPageToBottom.mustache +1 -1
  99. package/docs/webapi/scrollPageToTop.mustache +1 -1
  100. package/docs/webapi/scrollTo.mustache +3 -3
  101. package/docs/webapi/see.mustache +2 -2
  102. package/docs/webapi/seeAttributesOnElements.mustache +3 -3
  103. package/docs/webapi/seeCheckboxIsChecked.mustache +2 -1
  104. package/docs/webapi/seeCookie.mustache +1 -1
  105. package/docs/webapi/seeCssPropertiesOnElements.mustache +2 -2
  106. package/docs/webapi/seeCurrentUrlEquals.mustache +1 -1
  107. package/docs/webapi/seeElement.mustache +1 -1
  108. package/docs/webapi/seeElementInDOM.mustache +1 -1
  109. package/docs/webapi/seeInCurrentUrl.mustache +1 -1
  110. package/docs/webapi/seeInField.mustache +2 -2
  111. package/docs/webapi/seeInSource.mustache +1 -1
  112. package/docs/webapi/seeInTitle.mustache +5 -1
  113. package/docs/webapi/seeNumberOfElements.mustache +10 -0
  114. package/docs/webapi/seeNumberOfVisibleElements.mustache +2 -2
  115. package/docs/webapi/selectOption.mustache +2 -2
  116. package/docs/webapi/setCookie.mustache +1 -1
  117. package/docs/webapi/switchTo.mustache +6 -1
  118. package/docs/webapi/uncheckOption.mustache +2 -2
  119. package/docs/webapi/wait.mustache +1 -2
  120. package/docs/webapi/waitForDetached.mustache +3 -3
  121. package/docs/webapi/waitForElement.mustache +2 -2
  122. package/docs/webapi/waitForEnabled.mustache +1 -1
  123. package/docs/webapi/waitForFunction.mustache +3 -3
  124. package/docs/webapi/waitForInvisible.mustache +3 -3
  125. package/docs/webapi/waitForText.mustache +3 -3
  126. package/docs/webapi/waitForValue.mustache +3 -3
  127. package/docs/webapi/waitForVisible.mustache +3 -3
  128. package/docs/webapi/waitInUrl.mustache +2 -2
  129. package/docs/webapi/waitNumberOfVisibleElements.mustache +3 -3
  130. package/docs/webapi/waitToHide.mustache +3 -3
  131. package/docs/webapi/waitUntil.mustache +3 -3
  132. package/docs/webapi/waitUrlEquals.mustache +2 -2
  133. package/docs/webdriver.md +453 -0
  134. package/lib/codecept.js +11 -9
  135. package/lib/command/definitions.js +183 -30
  136. package/lib/command/gherkin/snippets.js +29 -9
  137. package/lib/command/init.js +31 -9
  138. package/lib/command/run-multiple.js +46 -59
  139. package/lib/command/utils.js +1 -1
  140. package/lib/container.js +30 -4
  141. package/lib/data/dataScenarioConfig.js +18 -0
  142. package/lib/helper/Appium.js +24 -24
  143. package/lib/helper/Nightmare.js +81 -84
  144. package/lib/helper/Polly.js +189 -0
  145. package/lib/helper/Protractor.js +96 -86
  146. package/lib/helper/Puppeteer.js +238 -113
  147. package/lib/helper/REST.js +1 -1
  148. package/lib/helper/TestCafe.js +1257 -0
  149. package/lib/helper/WebDriver.js +217 -277
  150. package/lib/helper/WebDriverIO.js +75 -75
  151. package/lib/helper/clientscripts/nightmare.js +8 -0
  152. package/lib/helper/extras/React.js +55 -0
  153. package/lib/helper/testcafe/testControllerHolder.js +42 -0
  154. package/lib/helper/testcafe/testcafe-utils.js +63 -0
  155. package/lib/history.js +39 -0
  156. package/lib/hooks.js +25 -1
  157. package/lib/interfaces/gherkin.js +17 -1
  158. package/lib/interfaces/scenarioConfig.js +2 -2
  159. package/lib/listener/config.js +3 -3
  160. package/lib/locator.js +6 -0
  161. package/lib/pause.js +22 -1
  162. package/lib/plugin/allure.js +63 -0
  163. package/lib/plugin/autoLogin.js +65 -16
  164. package/lib/plugin/puppeteerCoverage.js +6 -1
  165. package/lib/plugin/stepByStepReport.js +4 -3
  166. package/lib/scenario.js +23 -17
  167. package/lib/step.js +5 -2
  168. package/lib/ui.js +1 -1
  169. package/lib/utils.js +70 -20
  170. package/package.json +20 -19
  171. package/translations/de-DE.js +69 -0
  172. package/translations/index.js +1 -0
  173. package/docs/video.md +0 -26
package/lib/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const getFunctionArguments = require('fn-args');
4
+ const deepClone = require('lodash.clonedeep');
4
5
  const { convertColorToRGBA, isColorProperty } = require('./colorUtils');
5
6
 
6
7
  function isObject(item) {
@@ -26,6 +27,8 @@ function deepMerge(target, source) {
26
27
 
27
28
  module.exports.deepMerge = deepMerge;
28
29
 
30
+ module.exports.deepClone = deepClone;
31
+
29
32
  const isGenerator = module.exports.isGenerator = function (fn) {
30
33
  return fn.constructor.name === 'GeneratorFunction';
31
34
  };
@@ -281,27 +284,74 @@ module.exports.screenshotOutputFolder = function (fileName) {
281
284
  return path.join(global.codecept_dir, fileName);
282
285
  };
283
286
 
284
- module.exports.fileToBase64Zip = async function (localPath) {
285
- const archiver = require('archiver');
286
-
287
- const zipData = [];
288
- const source = fs.createReadStream(localPath);
289
-
290
- return new Promise((resolve, reject) => {
291
- archiver('zip')
292
- .on('error', (e) => { throw new Error(e); })
293
- .on('data', data => zipData.push(data))
294
- .on('end', () => resolve(Buffer.concat(zipData).toString('base64')))
295
- .append(source, { name: path.basename(localPath) })
296
- .finalize((err) => {
297
- if (err) {
298
- reject(err);
299
- }
300
- });
301
- });
302
- };
303
-
304
287
  module.exports.beautify = function (code) {
305
288
  const format = require('js-beautify').js;
306
289
  return format(code, { indent_size: 2, space_in_empty_paren: true });
307
290
  };
291
+
292
+ function shouldAppendBaseUrl(url) {
293
+ return !/^\w+\:\/\//.test(url);
294
+ }
295
+
296
+ function trimUrl(url) {
297
+ const firstChar = url.substr(1);
298
+ if (firstChar === '/') {
299
+ url = url.slice(1);
300
+ }
301
+ return url;
302
+ }
303
+
304
+ function joinUrl(baseUrl, url) {
305
+ return shouldAppendBaseUrl(url) ? `${baseUrl}/${trimUrl(url)}` : url;
306
+ }
307
+
308
+ module.exports.appendBaseUrl = function (baseUrl = '', oneOrMoreUrls) {
309
+ // Remove '/' if it's at the end of baseUrl
310
+ const lastChar = baseUrl.substr(-1);
311
+ if (lastChar === '/') {
312
+ baseUrl = baseUrl.slice(0, -1);
313
+ }
314
+
315
+ if (!Array.isArray(oneOrMoreUrls)) {
316
+ return joinUrl(baseUrl, oneOrMoreUrls);
317
+ }
318
+ return oneOrMoreUrls.map(url => joinUrl(baseUrl, url));
319
+ };
320
+
321
+ /**
322
+ * Recursively search key in object and replace it's value.
323
+ *
324
+ * @param {*} obj source object for replacing
325
+ * @param {string} key key to search
326
+ * @param {*} value value to set for key
327
+ */
328
+ module.exports.replaceValueDeep = function replaceValueDeep(obj, key, value) {
329
+ if (!obj) return;
330
+
331
+ if (obj instanceof Array) {
332
+ for (const i in obj) {
333
+ replaceValueDeep(obj[i], key, value);
334
+ }
335
+ }
336
+
337
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
338
+ obj[key] = value;
339
+ }
340
+
341
+ if (typeof obj === 'object' && obj !== null) {
342
+ const children = Object.values(obj);
343
+ for (const child of children) {
344
+ replaceValueDeep(child, key, value);
345
+ }
346
+ }
347
+ return obj;
348
+ };
349
+
350
+ module.exports.ansiRegExp = function ({ onlyFirst = false } = {}) {
351
+ const pattern = [
352
+ '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
353
+ '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))',
354
+ ].join('|');
355
+
356
+ return new RegExp(pattern, onlyFirst ? undefined : 'g');
357
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "description": "Modern Era Acceptance Testing Framework for NodeJS",
5
5
  "keywords": [
6
6
  "acceptance",
@@ -37,61 +37,62 @@
37
37
  "test": "mocha test/unit --recursive && mocha test/runner --recursive"
38
38
  },
39
39
  "dependencies": {
40
+ "@codeceptjs/detox-helper": "^1.0.1",
40
41
  "allure-js-commons": "^1.3.2",
41
42
  "archiver": "^3.0.0",
42
- "axios": "^0.18.0",
43
+ "axios": "^0.19.0",
43
44
  "chalk": "^1.1.3",
44
45
  "commander": "^2.20.0",
45
46
  "css-to-xpath": "^0.1.0",
46
- "cucumber-expressions": "^6.0.1",
47
+ "cucumber-expressions": "^6.6.2",
47
48
  "escape-string-regexp": "^1.0.3",
48
49
  "figures": "^2.0.0",
49
50
  "fn-args": "^4.0.0",
51
+ "fs-extra": "^8.0.1",
50
52
  "gherkin": "^5.1.0",
51
53
  "glob": "^6.0.1",
52
- "inquirer": "^6.3.1",
53
- "js-beautify": "^1.9.1",
54
+ "inquirer": "^6.4.1",
55
+ "js-beautify": "^1.10.0",
56
+ "lodash.clonedeep": "^4.5.0",
54
57
  "lodash.merge": "^4.6.1",
55
58
  "mkdirp": "^0.5.1",
56
59
  "mocha": "^4.1.0",
57
- "mocha-junit-reporter": "^1.22.0",
60
+ "mocha-junit-reporter": "^1.23.0",
58
61
  "parse-function": "^5.2.10",
59
62
  "promise-retry": "^1.1.1",
60
63
  "requireg": "^0.1.8",
64
+ "resq": "^1.5.0",
61
65
  "sprintf-js": "^1.1.1"
62
66
  },
63
67
  "devDependencies": {
68
+ "@pollyjs/adapter-puppeteer": "^2.5.0",
69
+ "@pollyjs/core": "^2.5.0",
64
70
  "@types/inquirer": "^0.0.35",
65
- "@types/node": "^8.10.48",
66
- "@wdio/sauce-service": "^5.8.0",
67
- "@wdio/selenium-standalone-service": "^5.8.0",
68
- "@wdio/utils": "^5.8.0",
71
+ "@types/node": "^8.10.49",
72
+ "@wdio/sauce-service": "^5.10.8",
73
+ "@wdio/selenium-standalone-service": "^5.9.3",
74
+ "@wdio/utils": "^5.9.3",
69
75
  "chai": "^3.4.1",
70
76
  "chai-as-promised": "^5.2.0",
71
77
  "co-mocha": "^1.2",
72
78
  "documentation": "^8.1.2",
73
79
  "eslint": "^4.17.0",
74
80
  "eslint-config-airbnb-base": "^12.1.0",
75
- "eslint-plugin-import": "^2.17.2",
81
+ "eslint-plugin-import": "^2.18.0",
76
82
  "eslint-plugin-mocha": "^5.3.0",
77
83
  "faker": "^4.1.0",
78
- "gulp": "^4.0.1",
79
- "gulp-append-prepend": "^1.0.8",
80
- "gulp-documentation": "^3.2.1",
81
- "gulp-mustache": "^2.2.0",
82
84
  "husky": "^1.2.1",
83
85
  "json-server": "^0.10.1",
84
86
  "nightmare": "^3.0.2",
85
- "nyc": "^11.9.0",
86
87
  "protractor": "^5.4.1",
87
- "puppeteer": "^1.15.0",
88
+ "puppeteer": "^1.18.1",
88
89
  "rosie": "^1.6.0",
89
90
  "sinon": "^1.17.2",
90
91
  "sinon-chai": "^2.14.0",
92
+ "testcafe": "^1.2.1",
91
93
  "typescript": "^2.9.2",
92
- "unirest": "^0.5.1",
93
94
  "wdio-docker-service": "^1.5.0",
94
- "webdriverio": "^5.8.0",
95
+ "webdriverio": "^5.10.9",
95
96
  "xmldom": "^0.1.27",
96
97
  "xpath": "0.0.27"
97
98
  },
@@ -0,0 +1,69 @@
1
+ module.exports = {
2
+ I: 'Ich',
3
+ actions: {
4
+ amOutsideAngularApp: 'befinde_mich_außerhalb_der_angular_app',
5
+ amInsideAngularApp: 'bedinde_mich_innerhalb_der_angular_app',
6
+ waitForElement: 'warte_auf_element',
7
+ waitForClickable: 'warte_bis_clickbar',
8
+ waitForVisible: 'warte_bis_sichtbar',
9
+ waitForEnabled: 'warte_bis_enabled',
10
+ waitForInvisible: 'warte_bis_nicht_mehr_sichtbar',
11
+ waitInUrl: 'warte_auf_url',
12
+ waitForText: 'warte_auf_text',
13
+ moveTo: 'bewege_den_cursor_zu',
14
+ refresh: 'lade_die_seite_erneut',
15
+ refreshPage: 'lade_die_seite_erneut',
16
+ haveModule: 'habe_modul',
17
+ resetModule: 'setze_modul_zurück',
18
+ amOnPage: 'bin_auf_seite',
19
+ click: 'clicke',
20
+ doubleClick: 'doppelclicke',
21
+ see: 'sehe',
22
+ dontSee: 'sehe_nicht',
23
+ selectOption: 'wähle_option',
24
+ fillField: 'fülle_das_feld',
25
+ pressKey: 'drücke',
26
+ triggerMouseEvent: 'triggere_ein_mouseevent',
27
+ attachFile: 'füge_datei_hinzu',
28
+ seeInField: 'sehe_in_feld',
29
+ dontSeeInField: 'sehe_nicht_in_feld',
30
+ appendField: 'hänge_an_in_feld',
31
+ checkOption: 'checke_das_optionsfeld',
32
+ seeCheckboxIsChecked: 'sehe_dass_option_gecheckt_ist',
33
+ dontSeeCheckboxIsChecked: 'sehe_nicht_dass_option_gecheckt_ist',
34
+ grabTextFrom: 'hole_text_aus',
35
+ grabValueFrom: 'hole_wert_aus',
36
+ grabAttributeFrom: 'hole_attribut_aus',
37
+ seeInTitle: 'sehe_in_seitentitel',
38
+ dontSeeInTitle: 'sehe_nicht_in_seitentitel',
39
+ grabTitle: 'hole_seitentitel',
40
+ seeElement: 'sehe_element',
41
+ dontSeeElement: 'sehe_nicht_element',
42
+ seeInSource: 'sehe_im_html',
43
+ dontSeeInSource: 'sehe_nicht_im_html',
44
+ executeScript: 'führe_javascript_aus',
45
+ executeAsyncScript: 'führe_asynchrones_javascript_aus',
46
+ seeInCurrentUrl: 'sehe_in_aktueller_url',
47
+ dontSeeInCurrentUrl: 'sehe_nicht_in_aktueller_url',
48
+ seeCurrentUrlEquals: 'sehe_dass_url_gleich',
49
+ dontSeeCurrentUrlEquals: 'sehe_dass_url_ungleich',
50
+ saveScreenshot: 'speichere_screenshot',
51
+ setCookie: 'setze_cookie',
52
+ clearCookie: 'lösche_cookie',
53
+ seeCookie: 'sehe_cookie',
54
+ dontSeeCookie: 'sehe_nicht_cookie',
55
+ grabCookie: 'hole_cookie',
56
+ resizeWindow: 'ändere_fenstergröße',
57
+ wait: 'warte',
58
+ haveHeader: 'verwende_http_header',
59
+ clearField: 'lösche_feld',
60
+ dontSeeElementInDOM: 'sehe_nicht_element_in_dom',
61
+ moveCursorTo: 'bewege_den_cursor_zu',
62
+ scrollTo: 'scrolle_zu',
63
+ sendGetRequest: 'mache_einen_get_request',
64
+ sendPutRequest: 'mache_einen_put_request',
65
+ sendDeleteRequest: 'mache_einen_delete_request',
66
+ sendPostRequest: 'mache_einen_post_request',
67
+ switchTo: 'wechlse_in_iframe',
68
+ },
69
+ };
@@ -5,3 +5,4 @@ exports['pl-PL'] = require('./pl-PL');
5
5
  exports['zh-CN'] = require('./zh-CN');
6
6
  exports['zh-TW'] = require('./zh-TW');
7
7
  exports['ja-JP'] = require('./ja-JP');
8
+ exports['de-DE'] = require('./de-DE');
package/docs/video.md DELETED
@@ -1,26 +0,0 @@
1
- ---
2
- id: video
3
- title: Tutorial Videos
4
- ---
5
-
6
- Educational videos provided by our community member **[@ontytoom](http://github.com/ontytoom)**.
7
-
8
-
9
- ### [1. Installation](https://www.youtube.com/watch?v=FPFG1rBNJ64)
10
-
11
-
12
- <iframe width="854" height="480" src="https://www.youtube.com/embed/FPFG1rBNJ64" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
13
-
14
-
15
-
16
- ### [2. Creating a Test](https://www.youtube.com/watch?v=mdQZjL3h9d0)
17
-
18
-
19
- <iframe width="854" height="480" src="https://www.youtube.com/embed/mdQZjL3h9d0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
20
-
21
-
22
- ### [3. Using Page Objects](https://www.youtube.com/watch?v=s677_6VctjQ)
23
-
24
-
25
- <iframe width="854" height="480" src="https://www.youtube.com/embed/s677_6VctjQ" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
26
-