├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── runtime ├── scripts │ ├── precommit-checks.sh │ ├── projectList.csv │ └── tagList.json ├── img │ ├── klassiLogo.png │ ├── javascript-code.jpg │ ├── installapp-support.jpg │ ├── cucumber-html-report.png │ └── accessibility-html-report.png ├── configLoader.js ├── getRemote.js ├── getVideoLinks.js ├── coding-standards │ ├── gherkin │ │ └── gherkin-lint.json │ └── eslint │ │ └── eslint.config.js ├── utils │ ├── retrieveMailinator.js │ ├── takeReportBackup.js │ └── klassiOutlook.js ├── drivers │ ├── firefoxDriver.js │ ├── chromeDriver.js │ └── lambdatestDriver.js ├── remotes │ └── lambdatest.js ├── reporter │ └── reporter.js ├── s3Upload.js ├── s3ReportProcessor.js ├── world.js ├── docs │ └── klassi-js-v6-Upgrade.md ├── mailer.js └── helpers.js ├── commitlint.config.js ├── .gitignore ├── .env.example ├── __tests__ ├── unit │ └── runtime │ │ ├── configLoader.test.js │ │ ├── firefoxDriver.js │ │ ├── getVideoLinks.js │ │ ├── getRemote.js │ │ ├── chromeDriver.js │ │ └── s3Upload.js └── integration │ └── runtime │ ├── configLoader.test.js │ ├── firefoxDriver.js │ ├── getVideoLinks.js │ ├── getRemote.js │ ├── chromeDriver.js │ └── s3Upload.js ├── .dataConfigrc.js ├── .envConfigrc.js ├── branchnamelinter.config.json ├── jest.config.js ├── .versionrc.json ├── klassiModule.js ├── cucumber.js ├── LICENSE ├── CHANGELOG.md ├── package.json ├── .circleci └── config.yml ├── index.js └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /runtime/scripts/precommit-checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | pnpm lint -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | npx --no -- commitlint --edit "${1}" 3 | pnpm lint-branch-name -------------------------------------------------------------------------------- /runtime/img/klassiLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klassijs/klassi-js/HEAD/runtime/img/klassiLogo.png -------------------------------------------------------------------------------- /runtime/img/javascript-code.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klassijs/klassi-js/HEAD/runtime/img/javascript-code.jpg -------------------------------------------------------------------------------- /runtime/img/installapp-support.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klassijs/klassi-js/HEAD/runtime/img/installapp-support.jpg -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh". 3 | 4 | ./runtime/scripts/precommit-checks.sh 5 | -------------------------------------------------------------------------------- /runtime/img/cucumber-html-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klassijs/klassi-js/HEAD/runtime/img/cucumber-html-report.png -------------------------------------------------------------------------------- /runtime/img/accessibility-html-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klassijs/klassi-js/HEAD/runtime/img/accessibility-html-report.png -------------------------------------------------------------------------------- /runtime/scripts/projectList.csv: -------------------------------------------------------------------------------- 1 | Projects,Branch,Folder 2 | jlops-test-suite, main 3 | elt_lti_management-js,master 4 | elt_tao-js,main 5 | 6 | -------------------------------------------------------------------------------- /runtime/scripts/tagList.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagNames": [ 3 | "@api", 4 | "@tapi", 5 | "@get", 6 | "@put", 7 | "@post", 8 | "@delete", 9 | "@s3load", 10 | "@plutora", 11 | "@zephyr", 12 | "@s3" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /runtime/configLoader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const fs = require('fs-extra'); 6 | 7 | module.exports = (configFilePath) => { 8 | return JSON.parse(fs.readFileSync(configFilePath, 'utf8')); 9 | // TODO: add validation if schema 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## project folders 2 | artifacts 3 | 4 | # exclude generated reports 5 | reports 6 | reportBackup 7 | coverage 8 | 9 | # ignore dotenv files, except example 10 | .env 11 | .env.* 12 | !.env.example 13 | env.* 14 | 15 | # Logs 16 | logs 17 | log 18 | *.log 19 | npm-debug.log* 20 | 21 | # Dependency directories 22 | node_modules 23 | 24 | # IntelliJ project files 25 | .idea 26 | out 27 | gen 28 | .DS_Store 29 | *.iml 30 | 31 | #tesseract 32 | eng.traineddata 33 | *.tgz 34 | 35 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # AWS credentials for S3 bucket access 2 | S3_KEY=AWS-ACCESS-KEY-FOR-S3-BUCKET-ACCESS 3 | S3_SECRET=AWS-SECRET-KEY-FOR-S3-BUCKET-ACCESS 4 | 5 | # AWS credentials for SES email sending 6 | SES_KEY=AWS-ACCESS-KEY-FOR-SES-ACCESS 7 | SES_SECRET=AWS-SECRET-KEY-FOR-SES-ACCESS 8 | 9 | LAMBDATEST_USERNAME=YOUR-LAMBDATEST-USERNAME 10 | LAMBDATEST_API_URL=YOUR-LAMBDATEST-API-URL 11 | LAMBDATEST_ACCESS_KEY=YOUR-LAMBDATEST-ACCESS-KEY 12 | 13 | #Zephyr Credentials 14 | ACCESS_TOKEN=YOUR_ACCESS_TOKEN 15 | 16 | -------------------------------------------------------------------------------- /__tests__/unit/runtime/configLoader.test.js: -------------------------------------------------------------------------------- 1 | const configLoader = require('../../../runtime/configLoader'); 2 | const fs = require('fs-extra'); 3 | 4 | jest.mock('fs-extra'); 5 | 6 | describe('configLoader', () => { 7 | it('should return a parsed JSON.', () => { 8 | const input = '{"test": "example"}'; 9 | fs.readFileSync.mockImplementationOnce(() => input); 10 | 11 | const actual = configLoader(input); 12 | const expected = { 13 | test: 'example' 14 | }; 15 | expect(actual).toEqual(expected); 16 | }); 17 | }); -------------------------------------------------------------------------------- /__tests__/integration/runtime/configLoader.test.js: -------------------------------------------------------------------------------- 1 | const configLoader = require('../../../runtime/configLoader'); 2 | const fs = require('fs-extra'); 3 | 4 | jest.mock('fs-extra'); 5 | 6 | describe('configLoader', () => { 7 | it('should return a parsed JSON.', () => { 8 | const input = '{"test": "example"}'; 9 | fs.readFileSync.mockImplementationOnce(() => input); 10 | 11 | const actual = configLoader(input); 12 | const expected = { 13 | test: 'example' 14 | }; 15 | expect(actual).toEqual(expected); 16 | }); 17 | }); -------------------------------------------------------------------------------- /.dataConfigrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | dataConfig: { 3 | projectName: 'klassi-js', 4 | s3FolderName: 'klassi-js', 5 | 6 | emailData: { 7 | nameList: 'QaAutoTest ', 8 | AccessibilityReport: 'Yes', 9 | SES_REGION: 'eu-west-1', 10 | }, 11 | 12 | s3Data: { 13 | S3_BUCKET: 'test-app-automated-reports', 14 | S3_REGION: 'eu-west-2', 15 | S3_DOMAIN_NAME: 'http://test-app-automated-reports.s3.eu-west-2.amazonaws.com', 16 | }, 17 | 18 | tagNames: [], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.envConfigrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * this is for your project environment setups 4 | * all relevant details for each environment i.e. urls, username, passwords etc. 5 | */ 6 | "environment": { 7 | "dev": { 8 | "envName": "DEVELOPMENT", 9 | "base_url": "https://duckduckgo.com/", 10 | "api_base_url": "http://httpbin.org/get", 11 | }, 12 | 13 | "test": { 14 | "envName": "TEST", 15 | "base_url": "https://duckduckgo.com/", 16 | "api_base_url": "http://httpbin.org/get", 17 | }, 18 | 19 | "uat": { 20 | "envName": "UAT", 21 | "base_url": "" 22 | } 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /runtime/getRemote.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const lambdatest = require('./remotes/lambdatest'); 6 | 7 | module.exports = function getRemote(remoteService) { 8 | const remote = {}; 9 | 10 | function noop() { 11 | console.log('"If you\'re seeing this, you\'re trying to run a non-existent remoteService"'); 12 | } 13 | if (!remoteService) { 14 | remote.type = 'disabled'; 15 | remote.after = noop; 16 | } else if (remoteService === 'lambdatest') { 17 | remote.type = 'lambdatest'; 18 | remote.after = lambdatest.submitResults; 19 | } else { 20 | console.log(`Unknown remote service ${remoteService}`); 21 | remote.type = 'unknown'; 22 | remote.after = noop; 23 | } 24 | return remote; 25 | }; 26 | -------------------------------------------------------------------------------- /branchnamelinter.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "branchNameLinter": { 3 | "prefixes": [ 4 | "feature", 5 | "testfix", 6 | "automation", 7 | "hotfix", 8 | "release" 9 | ], 10 | "suggestions": { 11 | "feat": "feature", 12 | "fix": "testfix", 13 | "auto": "automation" 14 | }, 15 | "disallowed": ["master", "main", "develop", "staging"], 16 | "separator": "/", 17 | "msgBranchBanned": "Branches with the name \"%s\" are not allowed.", 18 | "msgBranchDisallowed": "Pushing to \"%s\" is not allowed, use git-flow.", 19 | "msgPrefixNotAllowed": "Branch prefix \"%s\" is not allowed.", 20 | "msgPrefixSuggestion": "Instead of \"%s\" try \"%s\".", 21 | "msgseparatorRequired": "Branch \"%s\" must contain a separator \"%s\"." 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /runtime/getVideoLinks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const pactumJs = require('pactum'); 6 | 7 | /** 8 | * setting the envConfig variables for file list 9 | */ 10 | const ltUrl = process.env.LAMBDATEST_API_URL; 11 | const ltUsername = process.env.LAMBDATEST_USERNAME; 12 | const ltKey = process.env.LAMBDATEST_ACCESS_KEY; 13 | 14 | let res; 15 | let videoID; 16 | let url; 17 | 18 | module.exports = { 19 | getVideoList: async () => { 20 | const { sessionId } = browser; 21 | url = `https://${ltUrl}/sessions/${sessionId}/video`; 22 | res = await pactumJs.spec().get(url).withAuth(ltUsername, ltKey).expectStatus(200).toss(); 23 | videoID = res.body.url; 24 | return videoID; 25 | }, 26 | 27 | getVideoId: async () => { 28 | console.log('this is the failed test video link ', videoID); 29 | return videoID; 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | clearMocks: true, 9 | collectCoverage: true, 10 | coverageDirectory: 'reports', 11 | coverageProvider: 'v8', 12 | coverageReporters: ['json'], 13 | reporters: ['default', [ 'jest-junit', { suiteName: 'jest tests' } ]], 14 | maxWorkers: 2, 15 | testMatch: [ 16 | '**/__tests__/**/*.[jt]s?(x)', 17 | '**/?(*.)+(spec|test).[tj]s?(x)' 18 | ], 19 | testPathIgnorePatterns: [ 20 | '/node_modules/' 21 | ], 22 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 23 | // watchPathIgnorePatterns: [], 24 | 25 | // Whether to use watchman for file crawling 26 | // watchman: true, 27 | }; 28 | 29 | module.exports = config; 30 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "Bug Fixes" 10 | }, 11 | { 12 | "type": "docs", 13 | "section": "Documentation" 14 | }, 15 | { 16 | "type": "refactor", 17 | "section": "Refactors" 18 | }, 19 | { 20 | "type": "chore", 21 | "hidden": true 22 | }, 23 | { 24 | "type": "docs", 25 | "hidden": false 26 | }, 27 | { 28 | "type": "style", 29 | "hidden": true 30 | }, 31 | { 32 | "type": "refactor", 33 | "hidden": false 34 | }, 35 | { 36 | "type": "perf", 37 | "hidden": true 38 | }, 39 | { 40 | "type": "test", 41 | "hidden": true 42 | } 43 | ], 44 | "commitUrlFormat": "https://github.com/klassijs/klassi-js/commit/{{hash}}", 45 | "compareUrlFormat": "https://github.com/klassijs/klassi-js/compare/{{previousTag}}...{{currentTag}}" 46 | } 47 | -------------------------------------------------------------------------------- /klassiModule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is used to export all the required modules from Klassi-js to be consumed by all projects 3 | */ 4 | 5 | const safeRequire = (moduleName) => { 6 | try { 7 | return require(moduleName); 8 | } catch (error) { 9 | console.warn(`Warning: Could not load module '${moduleName}': ${error.message}`); 10 | return null; 11 | } 12 | }; 13 | 14 | module.exports = { 15 | pactum: safeRequire('pactum'), 16 | webdriverio: safeRequire('webdriverio'), 17 | fs: safeRequire('fs-extra'), 18 | dotenv: safeRequire('dotenv'), 19 | S3Client: (() => { 20 | try { 21 | return require('@aws-sdk/client-s3').S3Client; 22 | } catch (error) { 23 | console.warn(`Warning: Could not load S3Client: ${error.message}`); 24 | return null; 25 | } 26 | })(), 27 | softAssert: safeRequire('klassijs-soft-assert'), 28 | visualValidation: safeRequire('klassijs-visual-validation'), 29 | a11yValidator: safeRequire('klassijs-a11y-validator'), 30 | astellen: safeRequire('klassijs-astellen'), 31 | smartOcr: safeRequire('klassijs-smart-ocr'), 32 | }; 33 | -------------------------------------------------------------------------------- /runtime/coding-standards/gherkin/gherkin-lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "no-empty-file": "on", 3 | "file-name": ["on", {"style": "camelCase"}], 4 | "indentation" : [ 5 | "on", { 6 | "Feature": 0, 7 | "Background": 2, 8 | "Scenario": 2, 9 | "Step": 4, 10 | "Examples": 4, 11 | "example": 6, 12 | "given": 4, 13 | "when": 4, 14 | "then": 4, 15 | "and": 4, 16 | "but": 4, 17 | "feature tag": 0, 18 | "scenario tag": 2 19 | } 20 | ], 21 | "no-multiple-empty-lines": "on", 22 | "no-superfluous-tags": "on", 23 | "no-unused-variables": "on", 24 | "keywords-in-logical-order": "on", 25 | "use-and": "on", 26 | "no-dupe-scenario-names": "on", 27 | "name-length" : ["on", { "Feature": 70, "Scenario": 70, "Step": 70 }], 28 | "scenario-size": ["on", { "steps-length": { "Background": 5, "Scenario": 10, "Examples": 10 } }], 29 | "max-scenarios-per-file": ["on", { "maxScenarios": 10, "countOutlineExamples": false }], 30 | "no-files-without-scenarios": "on", 31 | "no-examples-in-scenarios": "on", 32 | "no-scenario-outlines-without-examples": "on" 33 | } 34 | -------------------------------------------------------------------------------- /cucumber.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const path = require('path'); 6 | const envName = env.envName.toLowerCase(); 7 | 8 | const options = { 9 | default: { 10 | dryRun: dryRun, 11 | // eslint-disable-next-line no-undef 12 | paths: featureFiles, 13 | require: ['runtime/world.js', 'node_modules/klassi-js/runtime/world.js', 'step_definitions/**/*.js'], 14 | tags: global.resultingString, 15 | format: [ 16 | '@cucumber/pretty-formatter', 17 | `json:${path.resolve(__dirname, paths.reports, browserName, envName, `${reportName}-${dateTime}.json`)}`, 18 | ], 19 | formatOptions: { 20 | colorsEnabled: true, 21 | }, 22 | }, 23 | 24 | /** 25 | * This is to allow tests tagged as APIs to run headless and NOT take a screenshot on error 26 | * @returns {Promise<*[]>} 27 | */ 28 | filterQuietTags: async () => { 29 | let filePath = path.resolve(global.projectRootPath, './runtime/scripts/tagList.json'); 30 | let filelist = await helpers.readFromJson(filePath); 31 | return [...filelist.tagNames, ...tagNames]; 32 | }, 33 | }; 34 | 35 | module.exports = options; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 klassi-js Larry Goddard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__tests__/unit/runtime/firefoxDriver.js: -------------------------------------------------------------------------------- 1 | jest.mock('webdriverio', () => ({ 2 | remote: jest.fn(), 3 | })); 4 | 5 | jest.mock('@cucumber/cucumber', () => ({ 6 | Before: jest.fn(), 7 | })); 8 | 9 | const { filterQuietTags } = require('../../../cucumber.js'); 10 | 11 | jest.mock('../../../cucumber.js', () => ({ 12 | filterQuietTags: jest.fn(), 13 | })); 14 | 15 | const webdriverio = require('webdriverio'); 16 | const firefoxDriver = require('../../../runtime/drivers/firefoxDriver'); 17 | 18 | describe('firefoxDriver', () => { 19 | let mockBrowser; 20 | 21 | beforeEach(() => { 22 | mockBrowser = { setWindowSize: jest.fn() }; 23 | webdriverio.remote.mockResolvedValue(mockBrowser); 24 | }); 25 | 26 | it('should run in headless mode when isApiTest is true', async () => { 27 | filterQuietTags.mockResolvedValue([]); 28 | await firefoxDriver({ headless: true }); 29 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 30 | }); 31 | 32 | it('should NOT run in headless mode when isApiTest is false', async () => { 33 | filterQuietTags.mockResolvedValue([]); 34 | await firefoxDriver({headless: false}); 35 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /__tests__/integration/runtime/firefoxDriver.js: -------------------------------------------------------------------------------- 1 | jest.mock('webdriverio', () => ({ 2 | remote: jest.fn(), 3 | })); 4 | 5 | jest.mock('@cucumber/cucumber', () => ({ 6 | Before: jest.fn(), 7 | })); 8 | 9 | const { filterQuietTags } = require('../../../cucumber.js'); 10 | 11 | jest.mock('../../../cucumber.js', () => ({ 12 | filterQuietTags: jest.fn(), 13 | })); 14 | 15 | const webdriverio = require('webdriverio'); 16 | const firefoxDriver = require('../../../runtime/drivers/firefoxDriver'); 17 | 18 | describe('firefoxDriver', () => { 19 | let mockBrowser; 20 | 21 | beforeEach(() => { 22 | mockBrowser = { setWindowSize: jest.fn() }; 23 | webdriverio.remote.mockResolvedValue(mockBrowser); 24 | }); 25 | 26 | it('should run in headless mode when isApiTest is true', async () => { 27 | filterQuietTags.mockResolvedValue([]); 28 | await firefoxDriver({ headless: true }); 29 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 30 | }); 31 | 32 | it('should NOT run in headless mode when isApiTest is false', async () => { 33 | filterQuietTags.mockResolvedValue([]); 34 | await firefoxDriver({headless: false}); 35 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [6.1.0](https://github.com/klassijs/klassi-js/compare/v6.0.1...v6.1.0) (2025-11-04) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * **browser:** fixed the browsedr not being initialised ([4bdb524](https://github.com/klassijs/klassi-js/commit/4bdb5246df38514b3306f5c9875057a0250e82e5)) 11 | * **browser:** fixed the browsedr not being initialised ([#137](https://github.com/klassijs/klassi-js/issues/137)) ([134fbc8](https://github.com/klassijs/klassi-js/commit/134fbc86420f3223a60602bb6110d3b265867f83)) 12 | 13 | ### [6.0.2](https://github.com/klassijs/klassi-js/compare/v6.0.1...v6.0.2) (2025-10-14) 14 | 15 | ## 6.0.0 (2025-07-16) 16 | 17 | 18 | ### Features 19 | 20 | * **klassiModules, klassiOutlook:** added new features, updating config code ([54ad1e0](https://github.com/klassijs/klassi-js/commit/54ad1e086a7336d373274c57ab3689a05403dd01)) 21 | * **klassiModules, klassiOutlook:** added new features, updating config code ([5f487d4](https://github.com/klassijs/klassi-js/commit/5f487d440a1a78bd6129bc7e78e2d2810d9a5e08)) 22 | * **klassiModules:** updating code ([e8ad843](https://github.com/klassijs/klassi-js/commit/e8ad84325532ed6f8748c3e99458a1fa3f6dbbdd)) 23 | -------------------------------------------------------------------------------- /runtime/utils/retrieveMailinator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const options = { 6 | headers: { 7 | Authorization: sharedObjects.salesforceData.credentials.mailinator.apiKey, 8 | }, 9 | throwHttpErrors: true, 10 | simple: false, 11 | allowGetBody: true, 12 | resolveWithFullResponse: true, 13 | }; 14 | 15 | const retrieveAllEmails = async (inbox) => { 16 | try { 17 | options.url = `${env.mailinatorApiBaseUrl}/${inbox}`; 18 | const response = await helpers.apiCall(options.url, 'GET', options.headers.Authorization, null); 19 | 20 | return response.body.msgs; 21 | } catch (e) { 22 | console.error(e); 23 | } 24 | }; 25 | 26 | const getVerificationCode = async (inbox) => { 27 | let messageId; 28 | let allCurrentReceivedEmails = []; 29 | 30 | while (allCurrentReceivedEmails.length < 1) { 31 | try { 32 | allCurrentReceivedEmails = await retrieveAllEmails(inbox); 33 | } catch (e) { 34 | console.error(e); 35 | } 36 | } 37 | 38 | messageId = allCurrentReceivedEmails[allCurrentReceivedEmails.length - 1].id; 39 | options.url = `${env.mailinatorApiBaseUrl}/${inbox}/messages/${messageId}`; 40 | 41 | const verificationEmail = await helpers.apiCall(options.url, 'GET', options.headers.Authorization, null); 42 | const verificationEmailBody = verificationEmail.parts[0].body; 43 | const verificationCode = verificationEmailBody.match(/Código de verificación: (\d+)/g)[0].match(/\d+/g)[0]; 44 | await helpers.apiCall(options.url, 'DELETE', options.headers.Authorization, null); 45 | return verificationCode; 46 | }; 47 | 48 | module.exports = getVerificationCode; 49 | -------------------------------------------------------------------------------- /__tests__/unit/runtime/getVideoLinks.js: -------------------------------------------------------------------------------- 1 | process.env.LAMBDATEST_API_URL = 'mockApiUrl'; 2 | process.env.LAMBDATEST_USERNAME = 'mockUsername'; 3 | process.env.LAMBDATEST_ACCESS_KEY = 'mockAccessKey'; 4 | 5 | const pactumJs = require('pactum'); 6 | const { getVideoList, getVideoId } = require('../../../runtime/getVideoLinks'); 7 | 8 | jest.mock('pactum', () => ({ 9 | spec: jest.fn().mockReturnThis(), 10 | get: jest.fn().mockReturnThis(), 11 | withAuth: jest.fn().mockReturnThis(), 12 | expectStatus: jest.fn().mockReturnThis(), 13 | toss: jest.fn().mockResolvedValue({ body: { url: 'mockVideoUrl' } }), 14 | })); 15 | 16 | describe('getVideoLinks', () => { 17 | beforeEach(() => { 18 | global.browser = { sessionId: 'mockSessionId' }; 19 | }); 20 | 21 | afterEach(() => { 22 | delete global.browser; 23 | delete process.env.LAMBDATEST_API_URL; 24 | delete process.env.LAMBDATEST_USERNAME; 25 | delete process.env.LAMBDATEST_ACCESS_KEY; 26 | }); 27 | 28 | it('should get video list and return video ID', async () => { 29 | const videoID = await getVideoList(); 30 | 31 | expect(pactumJs.spec).toHaveBeenCalled(); 32 | expect(pactumJs.get).toHaveBeenCalledWith( 33 | `https://${process.env.LAMBDATEST_API_URL}/sessions/${global.browser.sessionId}/video` 34 | ); 35 | expect(pactumJs.withAuth).toHaveBeenCalledWith( 36 | `${process.env.LAMBDATEST_USERNAME}`, `${process.env.LAMBDATEST_ACCESS_KEY}` 37 | ); 38 | expect(pactumJs.expectStatus).toHaveBeenCalledWith(200); 39 | expect(videoID).toBe('mockVideoUrl'); 40 | }); 41 | 42 | it('should return the video ID', async () => { 43 | const videoID = await getVideoId(); 44 | expect(videoID).toBe('mockVideoUrl'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/integration/runtime/getVideoLinks.js: -------------------------------------------------------------------------------- 1 | process.env.LAMBDATEST_API_URL = 'mockApiUrl'; 2 | process.env.LAMBDATEST_USERNAME = 'mockUsername'; 3 | process.env.LAMBDATEST_ACCESS_KEY = 'mockAccessKey'; 4 | 5 | const pactumJs = require('pactum'); 6 | const { getVideoList, getVideoId } = require('../../../runtime/getVideoLinks'); 7 | 8 | jest.mock('pactum', () => ({ 9 | spec: jest.fn().mockReturnThis(), 10 | get: jest.fn().mockReturnThis(), 11 | withAuth: jest.fn().mockReturnThis(), 12 | expectStatus: jest.fn().mockReturnThis(), 13 | toss: jest.fn().mockResolvedValue({ body: { url: 'mockVideoUrl' } }), 14 | })); 15 | 16 | describe('getVideoLinks', () => { 17 | beforeEach(() => { 18 | global.browser = { sessionId: 'mockSessionId' }; 19 | }); 20 | 21 | afterEach(() => { 22 | delete global.browser; 23 | delete process.env.LAMBDATEST_API_URL; 24 | delete process.env.LAMBDATEST_USERNAME; 25 | delete process.env.LAMBDATEST_ACCESS_KEY; 26 | }); 27 | 28 | it('should get video list and return video ID', async () => { 29 | const videoID = await getVideoList(); 30 | 31 | expect(pactumJs.spec).toHaveBeenCalled(); 32 | expect(pactumJs.get).toHaveBeenCalledWith( 33 | `https://${process.env.LAMBDATEST_API_URL}/sessions/${global.browser.sessionId}/video` 34 | ); 35 | expect(pactumJs.withAuth).toHaveBeenCalledWith( 36 | `${process.env.LAMBDATEST_USERNAME}`, `${process.env.LAMBDATEST_ACCESS_KEY}` 37 | ); 38 | expect(pactumJs.expectStatus).toHaveBeenCalledWith(200); 39 | expect(videoID).toBe('mockVideoUrl'); 40 | }); 41 | 42 | it('should return the video ID', async () => { 43 | const videoID = await getVideoId(); 44 | expect(videoID).toBe('mockVideoUrl'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/unit/runtime/getRemote.js: -------------------------------------------------------------------------------- 1 | const getRemote = require('../../../runtime/getRemote'); 2 | const lambdatest = require('../../../runtime/remotes/lambdatest'); 3 | 4 | jest.mock('../../../runtime/remotes/lambdatest', () => ({ 5 | submitResults: jest.fn(), 6 | })); 7 | 8 | describe('getRemote', () => { 9 | it('should return disabled remote when no remoteService is provided', () => { 10 | const remote = getRemote(); 11 | 12 | expect(remote.type).toBe('disabled'); 13 | expect(remote.after).toBeInstanceOf(Function); 14 | expect(remote.after()).toBeUndefined(); 15 | }); 16 | 17 | it('should return lambdatest remote when remoteService is lambdatest', () => { 18 | const remote = getRemote('lambdatest'); 19 | 20 | expect(remote.type).toBe('lambdatest'); 21 | expect(remote.after).toBe(lambdatest.submitResults); 22 | }); 23 | 24 | it('should return unknown remote when remoteService is unknown', () => { 25 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 26 | const remote = getRemote('unknownService'); 27 | 28 | expect(consoleSpy).toHaveBeenCalledWith('Unknown remote service unknownService'); 29 | expect(remote.type).toBe('unknown'); 30 | expect(remote.after).toBeInstanceOf(Function); 31 | expect(remote.after()).toBeUndefined(); 32 | 33 | consoleSpy.mockRestore(); 34 | }); 35 | 36 | it('should log a message when noop function is called', () => { 37 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 38 | const remote = getRemote('unknownService'); 39 | 40 | remote.after(); 41 | 42 | expect(consoleSpy).toHaveBeenCalledWith('"If you\'re seeing this, you\'re trying to run a non-existent remoteService"'); 43 | 44 | consoleSpy.mockRestore(); 45 | }); 46 | }); -------------------------------------------------------------------------------- /__tests__/integration/runtime/getRemote.js: -------------------------------------------------------------------------------- 1 | const getRemote = require('../../../runtime/getRemote'); 2 | const lambdatest = require('../../../runtime/remotes/lambdatest'); 3 | 4 | jest.mock('../../../runtime/remotes/lambdatest', () => ({ 5 | submitResults: jest.fn(), 6 | })); 7 | 8 | describe('getRemote', () => { 9 | it('should return disabled remote when no remoteService is provided', () => { 10 | const remote = getRemote(); 11 | 12 | expect(remote.type).toBe('disabled'); 13 | expect(remote.after).toBeInstanceOf(Function); 14 | expect(remote.after()).toBeUndefined(); 15 | }); 16 | 17 | it('should return lambdatest remote when remoteService is lambdatest', () => { 18 | const remote = getRemote('lambdatest'); 19 | 20 | expect(remote.type).toBe('lambdatest'); 21 | expect(remote.after).toBe(lambdatest.submitResults); 22 | }); 23 | 24 | it('should return unknown remote when remoteService is unknown', () => { 25 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 26 | const remote = getRemote('unknownService'); 27 | 28 | expect(consoleSpy).toHaveBeenCalledWith('Unknown remote service unknownService'); 29 | expect(remote.type).toBe('unknown'); 30 | expect(remote.after).toBeInstanceOf(Function); 31 | expect(remote.after()).toBeUndefined(); 32 | 33 | consoleSpy.mockRestore(); 34 | }); 35 | 36 | it('should log a message when noop function is called', () => { 37 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 38 | const remote = getRemote('unknownService'); 39 | 40 | remote.after(); 41 | 42 | expect(consoleSpy).toHaveBeenCalledWith('"If you\'re seeing this, you\'re trying to run a non-existent remoteService"'); 43 | 44 | consoleSpy.mockRestore(); 45 | }); 46 | }); -------------------------------------------------------------------------------- /runtime/utils/takeReportBackup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module will clear the local report forlder based on different folder 3 | * Implemented by Sudipta Bhunia 4 | */ 5 | const fs = require('fs-extra'); 6 | const path = require('path'); 7 | 8 | module.exports = { 9 | /** 10 | * This function will take the back-up of the report folder 11 | * @param {string} folderName - The name of the folder to be backed up 12 | */ 13 | backupReport: () => { 14 | const rootBackupFolder = path.resolve(settings.projectRoot, './reportBackup'); 15 | let dateTime = new Date(); 16 | try { 17 | let newFolderName = `${dateTime 18 | .toISOString() 19 | .slice(0, 10)} ${dateTime.getHours()}-${dateTime.getMinutes()}-${dateTime.getSeconds()}`; 20 | const reportBackupFolder = `${rootBackupFolder}/${newFolderName}`; 21 | if (!fs.existsSync(rootBackupFolder)) { 22 | fs.mkdirSync(rootBackupFolder); 23 | } 24 | 25 | if (!fs.existsSync(reportBackupFolder)) { 26 | fs.mkdirSync(reportBackupFolder); 27 | } 28 | fs.copySync('reports', reportBackupFolder); 29 | fs.rmSync('reports', { recursive: true }); 30 | console.info(`report back-up taken in ${reportBackupFolder}`); 31 | } catch (err) { 32 | console.error('Error during report back-up process / nothing availabe for back-up'); 33 | } 34 | }, 35 | 36 | /** 37 | * This function will clear the local report folder 38 | * @param {string} folderName - The name of the folder to be cleared 39 | */ 40 | clearReport: () => { 41 | const reportFolder = path.resolve(settings.projectRoot, './reports'); 42 | try { 43 | fs.rmSync(reportFolder, { recursive: true }); 44 | } catch (err) { 45 | console.error('Unable to clear local reports folder'); 46 | } 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /runtime/drivers/firefoxDriver.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const { remote } = require('webdriverio'); 6 | const { Before } = require('@cucumber/cucumber'); 7 | const fs = require('fs-extra'); 8 | const path = require('path'); 9 | 10 | const apiTagsData = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../scripts/tagList.json'))); 11 | 12 | let defaults = {}; 13 | let isApiTest; 14 | let useProxy = false; 15 | 16 | Before(async (scenario) => { 17 | try { 18 | const scenarioTags = scenario.pickle.tags.map(tag => tag.name.replace('@', '').toLowerCase()); 19 | const tagList = (apiTagsData.tagNames || []).map(tag => tag.replace('@', '').toLowerCase()); 20 | isApiTest = scenarioTags.some(tag => tagList.includes(tag)); 21 | } catch (error) { 22 | console.error('Error in Before hook: ', error); 23 | } 24 | }); 25 | 26 | /** 27 | * create the web browser based on globals set in index.js 28 | * @returns {{}} 29 | */ 30 | module.exports = async function firefoxDriver(options) { 31 | defaults = { 32 | logLevel: 'error', 33 | path: '/', 34 | capabilities: { 35 | browserName: 'firefox', 36 | 'moz:firefoxOptions': { 37 | args: ['--disable-popup-blocking', '--disable-gpu'], 38 | }, 39 | }, 40 | }; 41 | 42 | if (isApiTest) { 43 | defaults.capabilities['moz:firefoxOptions'].args.push('--headless', '--disable-popup-blocking', '--disable-gpu'); 44 | } 45 | 46 | if (useProxy) { 47 | defaults.capabilities.proxy = { 48 | httpProxy: 'http://klassiarray.klassi.co.uk:8080', 49 | proxyType: 'MANUAL', 50 | autodetect: false, 51 | }; 52 | } 53 | const extendedOptions = Object.assign(defaults, options); 54 | global.browser = await remote(extendedOptions); 55 | await global.browser.setWindowSize(1280, 1024); 56 | return global.browser; 57 | }; 58 | -------------------------------------------------------------------------------- /runtime/remotes/lambdatest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const { Before, After } = require('@cucumber/cucumber'); 6 | 7 | let scenarioName; 8 | let scenarioResult; 9 | 10 | Before(async (scenario) => { 11 | scenarioName = scenario.pickle.name; 12 | return scenarioName; 13 | }); 14 | 15 | After(async (scenario) => { 16 | scenarioResult = scenario.result; 17 | return scenarioResult; 18 | }); 19 | 20 | function getCredentials() { 21 | /** adding the ability to deep dive */ 22 | const user = process.env.LAMBDATEST_USERNAME; 23 | const key = process.env.LAMBDATEST_ACCESS_KEY; 24 | 25 | assert.isNotEmpty(user, 'lambdatest requires a username'); 26 | assert.isNotEmpty(key, 'lambdatest requires an access key'); 27 | 28 | return { user, key }; 29 | } 30 | let url; 31 | let matchingBuilds; 32 | let sessionsBody; 33 | 34 | async function submitResults() { 35 | const credentials = getCredentials(); 36 | const lambdatestUsername = credentials.user; 37 | const lambdatestApiKey = credentials.key; 38 | const apiCredentials = `${lambdatestUsername}:${lambdatestApiKey}`; 39 | 40 | url = `https://${apiCredentials}@api.lambdatest.com/automation/api/v1/builds`; 41 | const buildsBody = await helpers.apiCall(url, 'GET', null, null); 42 | matchingBuilds = buildsBody.body.data; 43 | 44 | let i; 45 | for (i = 0; i < matchingBuilds.length; i++) { 46 | const projectname = matchingBuilds[i].name; 47 | await projectname; 48 | if (projectname === dataconfig.projectName) { 49 | matchingBuilds = matchingBuilds[i].build_id; 50 | } 51 | } 52 | await matchingBuilds; 53 | url = `https://${apiCredentials}@api.lambdatest.com/automation/api/v1/sessions`; 54 | sessionsBody = await helpers.apiCall(url, 'GET', null, null); 55 | 56 | let x; 57 | const sessionData = sessionsBody.body.data; 58 | for (x = 0; x < sessionData.length; x++) { 59 | const sessionName = sessionData[x].build_name; 60 | await sessionName; 61 | } 62 | } 63 | 64 | module.exports = { 65 | submitResults, 66 | getCredentials, 67 | }; 68 | -------------------------------------------------------------------------------- /runtime/drivers/chromeDriver.js: -------------------------------------------------------------------------------- 1 | const { remote } = require('webdriverio'); 2 | const { Before } = require('@cucumber/cucumber'); 3 | const fs = require('fs-extra'); 4 | const path = require('path'); 5 | 6 | const apiTagsData = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../scripts/tagList.json'))); 7 | const apiTags = global.tagNames; 8 | 9 | let defaults = {}; 10 | let isApiTest = false; 11 | let useProxy = false; 12 | 13 | Before(async (scenario) => { 14 | try { 15 | const scenarioTags = scenario.pickle.tags.map(tag => tag.name.replace('@', '').toLowerCase()); 16 | const tagList = (apiTagsData.tagNames && apiTags || []).map(tag => tag.replace('@', '').toLowerCase()); 17 | isApiTest = scenarioTags.some(tag => tagList.includes(tag)); 18 | } catch (error) { 19 | console.error('Error in Before hook: ', error); 20 | } 21 | }); 22 | 23 | module.exports = async function chromeDriver(options) { 24 | defaults = { 25 | logLevel: 'error', 26 | path: '/', 27 | automationProtocol: 'webdriver', 28 | capabilities: { 29 | browserName: 'chrome', 30 | 'goog:chromeOptions': { 31 | args: [ 32 | '--no-sandbox', 33 | '--disable-gpu', 34 | '--disable-popup-blocking', 35 | '--allow-file-access-from-files', 36 | '--use-fake-device-for-media-stream', 37 | '--use-fake-ui-for-media-stream' 38 | ] 39 | } 40 | } 41 | }; 42 | 43 | if (isApiTest) { 44 | defaults.capabilities['goog:chromeOptions'].args.unshift('--headless=new'); 45 | } 46 | 47 | if (useProxy) { 48 | defaults.capabilities.proxy = { 49 | httpProxy: 'http://klassiarray.klassi.co.uk:8080', 50 | proxyType: 'MANUAL', 51 | autodetect: false, 52 | }; 53 | } 54 | 55 | const extendedOptions = Object.assign(defaults, options); 56 | 57 | try { 58 | global.browser = await remote(extendedOptions); 59 | await global.browser.setWindowSize(1280, 1024); 60 | return global.browser; 61 | } catch (error) { 62 | console.error('Error in Chrome driver:', error.message); 63 | console.error('Stack trace:', error.stack); 64 | throw error; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /runtime/coding-standards/eslint/eslint.config.js: -------------------------------------------------------------------------------- 1 | const js = require('@eslint/js'); 2 | const prettierConfig = require('eslint-config-prettier'); 3 | const pluginImport = require('eslint-plugin-import'); 4 | 5 | module.exports = [ 6 | js.configs.recommended, 7 | { 8 | plugins: { 9 | import: require('eslint-plugin-import'), 10 | prettier: require('eslint-plugin-prettier'), 11 | }, 12 | 13 | rules: Object.assign( 14 | {}, 15 | js.configs.recommended.rules, 16 | prettierConfig.rules, 17 | pluginImport.configs.errors.rules, 18 | pluginImport.configs.warnings.rules, 19 | { 20 | 'prettier/prettier': [ 21 | 'warn', 22 | { 23 | singleQuote: true, 24 | printWidth: 120, 25 | endOfLine: 'auto', 26 | }, 27 | ], 28 | 'no-const-assign': 'warn', 29 | 'no-this-before-super': 'warn', 30 | 'no-undef': 'warn', 31 | 'no-unreachable': 'warn', 32 | 'no-unused-vars': 'warn', 33 | 'constructor-super': 'warn', 34 | 'valid-typeof': 'warn', 35 | 'no-console': [0, 'error'], 36 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 37 | indent: ['error', 2, { SwitchCase: 1 }], 38 | semi: ['error', 'always'], 39 | quotes: ['error', 'single'], 40 | }, 41 | ), 42 | 43 | languageOptions: { 44 | ecmaVersion: 'latest', // Use latest ECMAScript version 45 | sourceType: 'module', 46 | parserOptions: { 47 | ecmaFeatures: { 48 | dynamicImport: true, 49 | }, 50 | }, 51 | }, 52 | }, 53 | { 54 | files: ['**/*.js'], 55 | languageOptions: { 56 | ecmaVersion: 'latest', 57 | sourceType: 'module', 58 | globals: { 59 | Given: true, 60 | When: true, 61 | Then: true, 62 | BeforeStep: true, 63 | After: true, 64 | AfterAll: true, 65 | AfterStep: true, 66 | helpers: true, 67 | browser: true, 68 | env: true, 69 | date: true, 70 | dateTime: true, 71 | startDateTime: true, 72 | endDateTime: true, 73 | browserName: true, 74 | BROWSER_NAME: true, 75 | reportName: true, 76 | projectName: true, 77 | settings: true, 78 | Status: true, 79 | envConfig: true, 80 | assert: true, 81 | expect: true, 82 | console: true, 83 | require: true, 84 | process: true, 85 | module: true, 86 | global: true, 87 | }, 88 | }, 89 | }, 90 | { 91 | files: ['index.js'], 92 | rules: { 93 | 'no-restricted-syntax': 'off', 94 | }, 95 | }, 96 | ]; 97 | -------------------------------------------------------------------------------- /runtime/drivers/lambdatestDriver.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const { remote } = require('webdriverio'); 6 | const { Before } = require('@cucumber/cucumber'); 7 | const loadConfig = require('../configLoader'); 8 | const lambdatest = require('../remotes/lambdatest'); 9 | const fs = require('fs-extra'); 10 | const path = require('path'); 11 | 12 | let config; 13 | let isApiTest; 14 | 15 | const apiTagsData = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../scripts/tagList.json'))); 16 | const apiTags = global.tagNames; 17 | 18 | Before(async (scenario) => { 19 | try { 20 | const scenarioTags = scenario.pickle.tags.map(tag => tag.name.replace('@', '').toLowerCase()); 21 | const tagList = (apiTagsData.tagNames && apiTags || []).map(tag => tag.replace('@', '').toLowerCase()); 22 | isApiTest = scenarioTags.some(tag => tagList.includes(tag)); 23 | } catch (error) { 24 | console.error('Error in Before hook: ', error); 25 | } 26 | }); 27 | 28 | module.exports = async function lambdatestDriver(options, configType) { 29 | if (configType.length > 1) { 30 | const browserArray = configType.split(','); 31 | for (const browserItem of browserArray) { 32 | await browserExecute(options, browserItem); 33 | } 34 | } 35 | }; 36 | 37 | const browserExecute = async (options, configTypeA) => { 38 | const browserCaps = loadConfig(`./lambdatest/${configTypeA}.json`); 39 | const credentials = lambdatest.getCredentials(); 40 | const { user, key } = credentials; 41 | 42 | config = browserCaps; 43 | 44 | /** lambdatest will do this anyway, this is to make it explicit */ 45 | const buildNameFromConfig = configTypeA.replace(/-/g, ' '); 46 | 47 | if (process.env.CI || process.env.CIRCLE_CI) { 48 | config.tunnelName = process.env.TUNNEL_NAME; 49 | const { CIRCLE_BUILD_NUM, CIRCLE_JOB, CIRCLE_USERNAME } = process.env; 50 | config.build = `${global.projectName} - CircleCI Build No. #${CIRCLE_BUILD_NUM} for ${CIRCLE_USERNAME}. Job: ${CIRCLE_JOB}`; 51 | config.buildTags.push(`${CIRCLE_JOB}`); 52 | } else { 53 | config.build = `${projectName}-${buildNameFromConfig}`; 54 | config.tunnelName = process.env.TUNNEL_NAME || 'klassitunnel'; 55 | } 56 | 57 | const capabilities = { 58 | 'LT:Options': { 59 | ...config, 60 | user: user, 61 | accessKey: key, 62 | }, 63 | }; 64 | 65 | options = { 66 | updateJob: false, 67 | exclude: [], 68 | logLevel: 'silent', 69 | coloredLogs: true, 70 | waitforTimeout: 10000, 71 | connectionRetryTimeout: 90000, 72 | connectionRetryCount: 3, 73 | protocol: 'https', 74 | hostname: 'hub.lambdatest.com', 75 | port: 443, 76 | path: '/wd/hub', 77 | capabilities: capabilities, 78 | }; 79 | 80 | try { 81 | global.browser = await remote(options); 82 | } catch (error) { 83 | console.error('Error in lambdatestDriver:', error); 84 | throw error; 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /__tests__/unit/runtime/chromeDriver.js: -------------------------------------------------------------------------------- 1 | jest.mock('webdriverio', () => ({ 2 | remote: jest.fn(), 3 | })); 4 | 5 | jest.mock('@cucumber/cucumber', () => ({ 6 | Before: jest.fn(), 7 | })); 8 | 9 | jest.mock('../../../cucumber.js', () => ({ 10 | filterQuietTags: jest.fn(), 11 | })); 12 | 13 | jest.mock('../../../runtime/drivers/chromeDriver', () => ({ 14 | defaults: { 15 | capabilities: { 'goog:chromeOptions': { args: [] } }, 16 | }, 17 | chromeDriver: jest.fn(), 18 | })); 19 | 20 | const webdriverio = require('webdriverio'); 21 | const { chromeDriver, defaults } = require('../../../runtime/drivers/chromeDriver'); 22 | const { filterQuietTags } = require('../../../cucumber.js'); 23 | 24 | describe('chromeDriver', () => { 25 | let mockBrowser; 26 | 27 | beforeEach(() => { 28 | mockBrowser = { setWindowSize: jest.fn() }; 29 | webdriverio.remote.mockResolvedValue(mockBrowser); 30 | 31 | chromeDriver.mockImplementation(async ({ headless }) => { 32 | console.log('✅ chromeDriver function called'); // Debugging 33 | 34 | // Reset args before each test to prevent pollution 35 | defaults.capabilities['goog:chromeOptions'].args = []; 36 | 37 | if (headless) { 38 | defaults.capabilities['goog:chromeOptions'].args.push('--headless', '--disable-extensions'); 39 | } 40 | 41 | const browser = await webdriverio.remote({ capabilities: defaults.capabilities }); 42 | console.log('✅ webdriverio.remote called'); // Debugging 43 | await browser.setWindowSize(1280, 1024); 44 | console.log('✅ setWindowSize called'); // Debugging 45 | }); 46 | }); 47 | 48 | it('should run in headless mode when isApiTest is true', async () => { 49 | filterQuietTags.mockResolvedValue(['@api']); 50 | 51 | await chromeDriver({ headless: true }); 52 | 53 | console.log('Stubbed Chrome Options:', defaults.capabilities['goog:chromeOptions'].args); 54 | 55 | expect(webdriverio.remote).toHaveBeenCalledWith( 56 | expect.objectContaining({ 57 | capabilities: expect.objectContaining({ 58 | 'goog:chromeOptions': expect.objectContaining({ 59 | args: expect.arrayContaining(['--headless', '--disable-extensions']), 60 | }), 61 | }), 62 | }) 63 | ); 64 | 65 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 66 | }); 67 | 68 | it('should NOT run in headless mode when isApiTest is false', async () => { 69 | filterQuietTags.mockResolvedValue([]); 70 | 71 | await chromeDriver({ headless: false }); 72 | 73 | console.log('Stubbed Chrome Options (No Headless):', defaults.capabilities['goog:chromeOptions'].args); 74 | 75 | expect(webdriverio.remote).toHaveBeenCalledWith( 76 | expect.objectContaining({ 77 | capabilities: expect.objectContaining({ 78 | 'goog:chromeOptions': expect.objectContaining({ 79 | args: expect.not.arrayContaining(['--headless']), 80 | }), 81 | }), 82 | }) 83 | ); 84 | 85 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /__tests__/integration/runtime/chromeDriver.js: -------------------------------------------------------------------------------- 1 | jest.mock('webdriverio', () => ({ 2 | remote: jest.fn(), 3 | })); 4 | 5 | jest.mock('@cucumber/cucumber', () => ({ 6 | Before: jest.fn(), 7 | })); 8 | 9 | jest.mock('../../../cucumber.js', () => ({ 10 | filterQuietTags: jest.fn(), 11 | })); 12 | 13 | jest.mock('../../../runtime/drivers/chromeDriver', () => ({ 14 | defaults: { 15 | capabilities: { 'goog:chromeOptions': { args: [] } }, 16 | }, 17 | chromeDriver: jest.fn(), 18 | })); 19 | 20 | const webdriverio = require('webdriverio'); 21 | const { chromeDriver, defaults } = require('../../../runtime/drivers/chromeDriver'); 22 | const { filterQuietTags } = require('../../../cucumber.js'); 23 | 24 | describe('chromeDriver', () => { 25 | let mockBrowser; 26 | 27 | beforeEach(() => { 28 | mockBrowser = { setWindowSize: jest.fn() }; 29 | webdriverio.remote.mockResolvedValue(mockBrowser); 30 | 31 | chromeDriver.mockImplementation(async ({ headless }) => { 32 | console.log('✅ chromeDriver function called'); // Debugging 33 | 34 | // Reset args before each test to prevent pollution 35 | defaults.capabilities['goog:chromeOptions'].args = []; 36 | 37 | if (headless) { 38 | defaults.capabilities['goog:chromeOptions'].args.push('--headless', '--disable-extensions'); 39 | } 40 | 41 | const browser = await webdriverio.remote({ capabilities: defaults.capabilities }); 42 | console.log('✅ webdriverio.remote called'); // Debugging 43 | await browser.setWindowSize(1280, 1024); 44 | console.log('✅ setWindowSize called'); // Debugging 45 | }); 46 | }); 47 | 48 | it('should run in headless mode when isApiTest is true', async () => { 49 | filterQuietTags.mockResolvedValue(['@api']); 50 | 51 | await chromeDriver({ headless: true }); 52 | 53 | console.log('Stubbed Chrome Options:', defaults.capabilities['goog:chromeOptions'].args); 54 | 55 | expect(webdriverio.remote).toHaveBeenCalledWith( 56 | expect.objectContaining({ 57 | capabilities: expect.objectContaining({ 58 | 'goog:chromeOptions': expect.objectContaining({ 59 | args: expect.arrayContaining(['--headless', '--disable-extensions']), 60 | }), 61 | }), 62 | }) 63 | ); 64 | 65 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 66 | }); 67 | 68 | it('should NOT run in headless mode when isApiTest is false', async () => { 69 | filterQuietTags.mockResolvedValue([]); 70 | 71 | await chromeDriver({ headless: false }); 72 | 73 | console.log('Stubbed Chrome Options (No Headless):', defaults.capabilities['goog:chromeOptions'].args); 74 | 75 | expect(webdriverio.remote).toHaveBeenCalledWith( 76 | expect.objectContaining({ 77 | capabilities: expect.objectContaining({ 78 | 'goog:chromeOptions': expect.objectContaining({ 79 | args: expect.not.arrayContaining(['--headless']), 80 | }), 81 | }), 82 | }) 83 | ); 84 | 85 | expect(mockBrowser.setWindowSize).toHaveBeenCalledWith(1280, 1024); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /runtime/reporter/reporter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const fs = require('fs-extra'); 6 | const path = require('path'); 7 | const reporter = require('klassijs-cucumber-html-reporter'); 8 | const jUnit = require('cucumber-junit'); 9 | const pactumJs = require('pactum'); 10 | 11 | const s3Upload = require('../s3Upload'); 12 | const getRemote = require('../getRemote'); 13 | const remoteService = getRemote(settings.remoteService); 14 | const browserName = global.remoteConfig || BROWSER_NAME; 15 | 16 | let resp; 17 | let obj; 18 | 19 | module.exports = { 20 | ipAddr: async () => { 21 | const endPoint = 'http://ip-api.com/json'; 22 | resp = await pactumJs.spec().get(endPoint).toss(); 23 | await resp; 24 | }, 25 | 26 | async reporter() { 27 | const envName = env.envName.toLowerCase(); 28 | try { 29 | await this.ipAddr(); 30 | obj = await resp.body; 31 | } catch (err) { 32 | obj = {}; 33 | console.error('IpAddr func err: ', err.message); 34 | } 35 | if (paths.reports && fs.existsSync(paths.reports)) { 36 | let jsonDir = path.resolve(paths.reports, browserName, envName); 37 | let jsonComDir = path.resolve(paths.reports, browserName, envName + 'Combine'); 38 | 39 | global.endDateTime = helpers.getEndDateTime(); 40 | 41 | const reportOptions = { 42 | theme: 'hierarchy', 43 | jsonDir: jsonComDir, 44 | output: path.resolve(paths.reports, browserName, envName, `${reportName}-${dateTime}.html`), 45 | reportSuiteAsScenarios: true, 46 | launchReport: !settings.disableReport, 47 | ignoreBadJsonFile: true, 48 | metadata: { 49 | // 'Test Started': startDateTime, TODO: See if i can carry the time from one state to the other 50 | Environment: env.envName, 51 | IpAddress: obj.query, 52 | Browser: browserName, 53 | Location: `${obj.city} ${obj.regionName}`, 54 | Platform: process.platform, 55 | 'Test Completion': endDateTime, 56 | Executed: remoteService && remoteService.type === 'lambdatest' ? 'Remote' : 'Local', 57 | }, 58 | brandTitle: `${reportName} ${dateTime}`, 59 | name: `${projectName} ${browserName} ${envName}`, 60 | }; 61 | await sleep(DELAY_3s); 62 | // eslint-disable-next-line no-undef 63 | if (!isCI) { 64 | await fs.copySync(jsonDir, jsonComDir); 65 | let jsonfile = path.resolve(paths.reports, browserName, envName + 'Combine', `${reportName}-${dateTime}.json`); 66 | await sleep(DELAY_300ms); 67 | if (resultingString === '@s3load') { 68 | fs.remove(jsonfile, (err) => { 69 | if (err) return console.error(err); 70 | }); 71 | await sleep(DELAY_500ms); 72 | await reporter.generate(reportOptions); 73 | await sleep(DELAY_3s).then(async () => { 74 | await s3Upload.s3Upload(); 75 | await sleep(DELAY_5s); 76 | }); 77 | } else { 78 | await sleep(DELAY_500ms); 79 | await reporter.generate(reportOptions); 80 | } 81 | } 82 | /** grab the file data for xml creation */ 83 | let jsonFile = path.resolve(paths.reports, browserName, envName, `${reportName}-${dateTime}.json`); 84 | const reportRaw = fs.readFileSync(jsonFile).toString().trim(); 85 | 86 | const xmlReport = jUnit(reportRaw); 87 | const junitOutputPath = path.resolve( 88 | path.resolve(paths.reports, browserName, envName, `${reportName}-${dateTime}.xml`), 89 | ); 90 | fs.writeFileSync(junitOutputPath, xmlReport); 91 | } 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /runtime/s3Upload.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const path = require('path'); 6 | const fs = require('fs-extra'); 7 | const readdir = require('recursive-readdir'); 8 | const async = require('async'); 9 | const { S3Client, ListBucketsCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); 10 | const { astellen } = require('klassijs-astellen'); 11 | 12 | /** 13 | * function to upload the test report folder to an s3 Bucket - AWS 14 | */ 15 | module.exports = { 16 | s3Upload: async () => { 17 | let envName = env.envName.toLowerCase(); 18 | let date = require('./helpers').s3BucketCurrentDate(); 19 | const browserName = global.remoteConfig || BROWSER_NAME; 20 | const rootFolder = path.resolve('./reports'); 21 | const folderName = `${date}/${dataconfig.s3FolderName}/reports/`; 22 | const BUCKET = s3Data.S3_BUCKET; 23 | const S3_KEY = process.env.S3_KEY; 24 | const S3_SECRET = process.env.S3_SECRET; 25 | const uploadFolder = `./${browserName}/`; 26 | 27 | const s3Client = new S3Client({ 28 | region: s3Data.S3_REGION, 29 | credentials: { 30 | accessKeyId: S3_KEY, 31 | secretAccessKey: S3_SECRET, 32 | }, 33 | }); 34 | 35 | async function mybucketList() { 36 | try { 37 | const data = await s3Client.send(new ListBucketsCommand({})); 38 | return data.Buckets; // For unit tests. 39 | } catch (err) { 40 | console.error('Error ', err.message); 41 | return null; // Return null instead of undefined 42 | } 43 | } 44 | 45 | async function filesToRemove() { 46 | const filePath = rootFolder + '/' + browserName + '/' + envName; 47 | const filelist = fs.readdirSync(filePath); 48 | // console.log('this is the list of files ============> ', filelist); 49 | for (let i = 0; i < filelist.length; i++) { 50 | let filename = filelist[i]; 51 | 52 | if (filename.endsWith('.html.json')) { 53 | fs.removeSync(path.resolve(filePath, filename)); 54 | } 55 | } 56 | } 57 | 58 | let dirToRemove = rootFolder + '/' + browserName + '/' + envName + 'Combine'; 59 | async function getFiles(dirPath) { 60 | if (await fs.existsSync(dirToRemove)) { 61 | await fs.rmSync(dirToRemove, { recursive: true }); 62 | } 63 | return fs.existsSync(dirPath) ? readdir(dirPath) : []; 64 | } 65 | 66 | async function deploy(upload) { 67 | await filesToRemove(); 68 | const filesToUpload = await getFiles(path.resolve(rootFolder, upload)); 69 | await async.eachOfLimit(filesToUpload, 20, async (file) => { 70 | const Key = await file.replace(`${rootFolder}/`, ''); 71 | console.log(`uploading: [${Key}]`); 72 | const uploadParams = { 73 | Bucket: BUCKET, 74 | Key: folderName + Key, 75 | Body: fs.readFileSync(file), 76 | }; 77 | try { 78 | await s3Client.send(new PutObjectCommand(uploadParams)); 79 | } catch (err) { 80 | console.error('Error', err.message); 81 | } 82 | }); 83 | } 84 | 85 | const mybucket = await mybucketList(); 86 | 87 | // Check if mybucket exists and is an array 88 | if (!mybucket || !Array.isArray(mybucket)) { 89 | console.log('The s3 bucket list could not be retrieved'); 90 | return; 91 | } 92 | 93 | const bucketExists = mybucket.some((bucket) => bucket.Name === s3Data.S3_BUCKET); 94 | if (!bucketExists) { 95 | console.log('The s3 bucket does not exist'); 96 | return; 97 | } 98 | 99 | try { 100 | await deploy(uploadFolder); 101 | console.log('Report files uploaded successfully to s3 Bucket'); 102 | } catch (err) { 103 | console.error(err.message); 104 | } 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /runtime/utils/klassiOutlook.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const pactumJs = require('pactum'); 3 | const qs = require('qs'); 4 | 5 | const tenantId = process.env.TENANT_ID; 6 | const clientId = process.env.CLIENT_ID; 7 | const clientSecret = process.env.CLIENT_SECRET; 8 | // const userEmail = 'QAE.TestAccounts@klassi.co.uk'; // This email address is for user with shared mailbox. 9 | const userEmail = 'qaAutoTest@klassi.co.uk'; // This email address is for user with own mailbox. 10 | 11 | const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; 12 | const graphEndpoint = `https://graph.microsoft.com/v1.0/users/${userEmail}/messages`; 13 | 14 | let emailData = 'Han.Solo@klassi.co.uk'; 15 | 16 | async function getAccessToken() { 17 | const data = { 18 | client_id: clientId, 19 | scope: 'https://graph.microsoft.com/.default', 20 | client_secret: clientSecret, 21 | grant_type: 'client_credentials', 22 | }; 23 | 24 | const response = await pactumJs.spec() 25 | .post(tokenEndpoint) 26 | .withHeaders('Content-Type', 'application/x-www-form-urlencoded') 27 | .withBody(qs.stringify(data)) 28 | .expectStatus(200); 29 | 30 | const accessToken = response.body.access_token; 31 | 32 | if (!accessToken.includes('.')) { 33 | throw new Error('Invalid token format: Token must be in JWS format (header.payload.signature)'); 34 | } 35 | return accessToken; 36 | } 37 | 38 | async function getEmails(accessToken) { 39 | const response = await pactumJs.spec() 40 | .get(`${graphEndpoint}?$top=50`) 41 | .withHeaders({ 42 | Authorization: `Bearer ${accessToken}`, 43 | }) 44 | .expectStatus(200); 45 | 46 | if (userEmail === 'qaAutoTest@klassi.co.uk') { 47 | return response.body.value.map(email => ({ 48 | subject: email.subject, 49 | body: email.body.content, 50 | })); 51 | } else { 52 | return response.body.value.map(email => ({ 53 | subject: email.subject, 54 | body: email.body.content, 55 | to: email.toRecipients.map(recipient => recipient.emailAddress.address), 56 | })); 57 | } 58 | } 59 | 60 | (async () => { 61 | let match; 62 | try { 63 | const token = await getAccessToken(); 64 | const emails = await getEmails(token); 65 | // console.log( 'Filtered Emails ===================== ' , JSON.stringify(emails, null, 2)); 66 | for ( const email of emails) { 67 | if (email.subject.includes('Test Automated Report-01-06-2025-044643')) { // Adjust subject to be generic 68 | console.log('this is the email we are looking for' + email.body); 69 | 70 | match = email.body.match(/\bautomated\b/); // Adjust regex to be more generic 71 | if (match) { 72 | console.log(match[0]); 73 | } else { 74 | console.log('No match found.'); 75 | } 76 | 77 | return match[0]; 78 | 79 | } else if (Array.isArray(email.to)) { 80 | const recipients = email.to.map(recipient => recipient); 81 | for (const recipient of recipients ) { 82 | if (recipient === emailData) { 83 | console.log('Recipient =========== we are here :'); 84 | console.log('this is the email we are looking for === ' + email.subject); 85 | console.log('this is the email we are looking for === ' + email.body); 86 | for (const email of emails) { 87 | if (email.body.includes(5265897)) { // to be generic 88 | match = email.body.match(/\b5265897\b/); // to be generic 89 | 90 | if (match) { 91 | console.log(match[0]); 92 | } else { 93 | console.log('No match found.'); 94 | } 95 | 96 | return match[0]; 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } catch (err) { 104 | console.error('Error:', err.response?.data || err.message); 105 | } 106 | })(); 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "klassi-js", 3 | "version": "6.1.0", 4 | "description": "A debuggable Javascript testing framework using webdriverio", 5 | "creator": { 6 | "name": "Larry Goddard", 7 | "email": "larryg@klassitech.co.uk", 8 | "X": "https://twitter.com/larryG_01", 9 | "linkedin": "https://linkedin.com/in/larryg", 10 | "youtube": "https://youtube.com/@LarryG_01" 11 | } 12 | , 13 | "contributors": [ 14 | { 15 | "name": "Carlos Bermejo", 16 | "github": "https://github.com/carlosbermejop" 17 | } 18 | ], 19 | "license": "MIT", 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "keywords": [ 24 | "api testing", 25 | "bdd", 26 | "lambdatest", 27 | "cucumber-js", 28 | "javascript", 29 | "testing framework", 30 | "test automation", 31 | "webdriverio" 32 | ], 33 | "repository": { 34 | "type": "git", 35 | "url": "hhtps://github.com/klassijs/klassi-js" 36 | }, 37 | "main": "index.js", 38 | "scripts": { 39 | "dev": "node index.js --disableReport --env test --tags", 40 | "test:checkin": "jest __tests__/unit", 41 | "test:merge": "jest __tests__/integration", 42 | "pkgcheck": "pnpm install --frozen-lockfile", 43 | "preinstall": "npx only-allow pnpm", 44 | "lint": "pnpm lint-staged && pnpm lint:gherkin", 45 | "lint:gherkin": "gherkin-lint -c runtime/coding-standards/gherkin/gherkin-lint.json '**/*.feature' ", 46 | "lint-branch-name": "pnpm branch-name-lint ./branchnamelinter.config.json", 47 | "changelog": "standard-version --skip.commit --skip.tag", 48 | "changelog:patch": "standard-version --release-as patch --skip.commit --skip.tag", 49 | "changelog:minor": "standard-version --release-as minor --skip.commit --skip.tag", 50 | "changelog:major": "standard-version --release-as major --skip.commit --skip.tag" 51 | }, 52 | "homepage": "https://github.com/klassijs/klassi-js#readme", 53 | "bugs": { 54 | "url": "https://github.com/klassijs/klassi-js/issues" 55 | }, 56 | "dependencies": { 57 | "@aws-sdk/client-s3": "^3.864.0", 58 | "@aws-sdk/client-ses": "^3.864.0", 59 | "@aws-sdk/credential-provider-node": "^3.864.0", 60 | "@cucumber/cucumber": "^11.3.0", 61 | "@cucumber/pretty-formatter": "^1.0.1", 62 | "@eslint/js": "^9.33.0", 63 | "async": "^3.2.6", 64 | "branch-name-lint": "^2.1.1", 65 | "chai": "^5.3.1", 66 | "commander": "^13.1.0", 67 | "cosmiconfig": "^9.0.0", 68 | "cucumber-junit": "^1.7.1", 69 | "dotenv": "^16.6.1", 70 | "eslint": "^9.33.0", 71 | "eslint-config-prettier": "^10.1.8", 72 | "eslint-plugin-import": "^2.32.0", 73 | "eslint-plugin-prettier": "^5.5.4", 74 | "fs-extra": "^11.3.1", 75 | "gherkin-lint": "^4.2.4", 76 | "husky": "^9.1.7", 77 | "is-ci": "^4.1.0", 78 | "klassijs-a11y-validator": "^1.0.0", 79 | "klassijs-astellen": "^1.0.1", 80 | "klassijs-cucumber-html-reporter": "^6.0.0", 81 | "klassijs-smart-ocr": "github:klassijs/klassijs-smart-ocr", 82 | "klassijs-soft-assert": "^1.3.0", 83 | "klassijs-visual-validation": "^1.2.3", 84 | "lint-staged": "^15.5.2", 85 | "merge": "^2.1.1", 86 | "nodemailer": "^6.10.1", 87 | "pactum": "^3.8.0", 88 | "prettier": "^3.6.2", 89 | "recursive-readdir": "^2.2.3", 90 | "require-dir": "^1.2.0", 91 | "standard-version": "github:klassijs/standard-version", 92 | "text-files-loader": "^1.0.5", 93 | "webdriverio": "^9.19.1" 94 | }, 95 | "lint-staged": { 96 | "**/*.js": "eslint --quiet --fix --config runtime/coding-standards/eslint/eslint.config.js" 97 | }, 98 | "jest-junit": { 99 | "suiteName": "jest tests", 100 | "outputDirectory": "./reports", 101 | "outputName": "junit.xml", 102 | "uniqueOutputName": "false" 103 | }, 104 | "devDependencies": { 105 | "@commitlint/cli": "^19.8.1", 106 | "@commitlint/config-conventional": "^19.8.1", 107 | "jest": "^29.7.0", 108 | "jest-junit": "^16.0.0" 109 | } 110 | } -------------------------------------------------------------------------------- /runtime/s3ReportProcessor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const path = require('path'); 6 | const fs = require('fs-extra'); 7 | const { S3Client, ListObjectsCommand } = require('@aws-sdk/client-s3'); 8 | const program = require('commander'); 9 | 10 | const s3Bucket = s3Data.S3_BUCKET; 11 | const s3AccessKeyId = process.env.S3_KEY; 12 | const s3SecretAccessKey = process.env.S3_SECRET; 13 | const domainName = s3Data.S3_DOMAIN_NAME; 14 | 15 | const s3Client = new S3Client({ 16 | region: s3Data.S3_REGION, 17 | credentials: { 18 | accessKeyId: s3AccessKeyId, 19 | secretAccessKey: s3SecretAccessKey, 20 | }, 21 | }); 22 | 23 | module.exports = { 24 | async s3Processor(projectName) { 25 | const s3date = helpers.formatDate(); 26 | const folderName = helpers.formatDate(); 27 | projectName = dataconfig.s3FolderName; 28 | console.log(`Starting Processing of Test Report for: ${s3date}/${projectName} ...`); 29 | /** 30 | * This creates the test report from the sample template 31 | * @type {string} 32 | */ 33 | const tempFile = path.resolve(__dirname, './scripts/s3ReportSample'); 34 | let filePath; 35 | let date = helpers.currentDate(); 36 | if (program.opts().dlink) { 37 | filePath = `../../${projectName}/test/reports/testReport-${date}.html`; 38 | } else { 39 | filePath = `../${projectName}/reports/testReport-${date}.html`; 40 | } 41 | 42 | const file = filePath; 43 | await fs.copySync(tempFile, file); 44 | 45 | /** 46 | * list of browsers test running on via lambdatest 47 | * @type {string[]} 48 | */ 49 | const browserName = ['chrome', 'firefox', 'edge', 'safari', 'tabletGalaxy', 'tabletiPad']; 50 | let dataList; 51 | let dataNew = ''; 52 | let browsername; 53 | let dataOut = await helpers.readFromFile(tempFile); 54 | 55 | const bucketParams = { 56 | Bucket: s3Bucket, 57 | Marker: folderName, 58 | Prefix: `${s3date}/${projectName}`, 59 | MaxKeys: 1000, 60 | }; 61 | 62 | const data = await s3Client.send(new ListObjectsCommand(bucketParams)); 63 | if (data.Contents) { 64 | for (let x = 0; x < browserName.length; x++) { 65 | browsername = browserName[x]; 66 | const linkList = []; 67 | 68 | for (let i = 0; i < data.Contents.length; i++) { 69 | const key = data.Contents[i].Key; 70 | if (key.substring(0, 10) === folderName) { 71 | if (key.split('.')[1] === 'html') { 72 | dataList = `${domainName}/${key}`; 73 | if (dataList.includes(browsername)) { 74 | const envDataNew = dataList.replace(/^.*reports\/\w+\//, '').replace(/\/.*.html/, ''); 75 | dataNew = dataList 76 | .replace(/^.*reports\/\w+\//, '') 77 | .replace(`${envDataNew}/`, '') 78 | .replace(/\.html/, ''); 79 | const theNewData = `${dataNew} -- ${envDataNew}`; 80 | let dataFile = ''; 81 | linkList.push((dataFile = `${dataFile}${theNewData}`)); 82 | } 83 | } 84 | } 85 | } 86 | if (linkList.length > 0) { 87 | const browserData = `

${browsername}

${linkList.join( 88 | ' ', 89 | )}
`; 90 | dataOut = dataOut.replace('<-- browser_test_output -->', browserData); 91 | } else { 92 | dataOut = dataOut.replace('<-- browser_test_output -->', ' '); 93 | } 94 | } 95 | } 96 | await helpers.writeToTxtFile(file, dataOut); 97 | if (dataList === undefined) { 98 | console.error('There is no reporting data for this Project....'); 99 | } else if (dataList.length > 0) { 100 | console.log('Test run completed and s3 report being sent .....'); 101 | await helpers.klassiEmail(); 102 | } 103 | }, 104 | }; 105 | -------------------------------------------------------------------------------- /__tests__/unit/runtime/s3Upload.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const { S3Client, ListBucketsCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); 4 | 5 | // Mock the dependencies 6 | jest.mock('@aws-sdk/client-s3'); 7 | jest.mock('fs-extra'); 8 | jest.mock('recursive-readdir'); 9 | jest.mock('klassijs-astellen'); 10 | 11 | // Mock the helpers module 12 | jest.mock('../../../runtime/helpers', () => ({ 13 | s3BucketCurrentDate: jest.fn().mockReturnValue('2024-01-01'), 14 | })); 15 | 16 | // Mock the async module 17 | jest.mock('async', () => ({ 18 | eachOfLimit: jest.fn().mockImplementation((files, limit, callback) => { 19 | return Promise.resolve(); 20 | }), 21 | })); 22 | 23 | // Mock the recursive-readdir module 24 | jest.mock('recursive-readdir', () => 25 | jest.fn().mockResolvedValue([ 26 | './reports/browserName/testenv/file1.html.json', 27 | './reports/browserName/testenv/file2.html.json', 28 | ]) 29 | ); 30 | 31 | // Mock the global variables and environment 32 | global.env = { envName: 'testenv' }; 33 | global.BROWSER_NAME = 'browserName'; 34 | global.dataconfig = { s3FolderName: 'mockedFolder' }; 35 | global.s3Data = { S3_BUCKET: 'mockBucket', S3_REGION: 'us-east-1' }; 36 | process.env.S3_KEY = 'mockKey'; 37 | process.env.S3_SECRET = 'mockSecret'; 38 | 39 | describe('s3Upload', () => { 40 | let mockS3Client; 41 | let mockSend; 42 | 43 | beforeEach(() => { 44 | // Reset all mocks 45 | jest.clearAllMocks(); 46 | 47 | // Mock S3Client 48 | mockSend = jest.fn(); 49 | mockS3Client = { 50 | send: mockSend, 51 | }; 52 | S3Client.mockImplementation(() => mockS3Client); 53 | 54 | // Mock ListBucketsCommand 55 | ListBucketsCommand.mockImplementation((params) => params); 56 | 57 | // Mock PutObjectCommand 58 | PutObjectCommand.mockImplementation((params) => params); 59 | 60 | // Mock fs methods 61 | fs.existsSync.mockReturnValue(true); 62 | fs.readdirSync.mockReturnValue(['file1.html.json']); 63 | fs.removeSync.mockImplementation(() => {}); 64 | fs.ensureDirSync.mockImplementation(() => {}); 65 | fs.emptyDirSync.mockImplementation(() => {}); 66 | fs.writeFileSync.mockImplementation(() => {}); 67 | fs.readFileSync.mockReturnValue('fileContent'); 68 | }); 69 | 70 | it('should upload files to S3 and use the mocked envConfig and dataConfig', async () => { 71 | // Mock successful bucket list response 72 | mockSend.mockResolvedValueOnce({ 73 | Buckets: [{ Name: 'mockBucket' }] 74 | }); 75 | 76 | // Mock successful upload 77 | mockSend.mockResolvedValueOnce({}); 78 | 79 | const { s3Upload } = require('../../../runtime/s3Upload'); 80 | await s3Upload(); 81 | 82 | expect(mockS3Client.send).toHaveBeenCalled(); 83 | expect(fs.removeSync).toHaveBeenCalledWith(path.resolve('./reports/browserName/testenv/', 'file1.html.json')); 84 | expect(global.dataconfig.s3FolderName).toBe('mockedFolder'); 85 | expect(global.dataconfig.s3FolderName).toBeDefined(); 86 | }); 87 | 88 | it('should print an error message if list of buckets cannot be retrieved', async () => { 89 | const logSpy = jest.spyOn(console, 'log'); 90 | 91 | // Mock failed bucket list 92 | mockSend.mockRejectedValueOnce(new Error('Test Error')); 93 | 94 | const { s3Upload } = require('../../../runtime/s3Upload'); 95 | await s3Upload(); 96 | 97 | expect(logSpy).toHaveBeenCalledWith('The s3 bucket list could not be retrieved'); 98 | logSpy.mockRestore(); 99 | }); 100 | 101 | it('should remove a combine directory if it already exists', async () => { 102 | const testFolderPath = path.resolve(process.cwd(), 'reports', 'browserName', 'testenvCombine'); 103 | 104 | // Mock successful bucket list response 105 | mockSend.mockResolvedValueOnce({ 106 | Buckets: [{ Name: 'mockBucket' }] 107 | }); 108 | 109 | // Mock successful upload 110 | mockSend.mockResolvedValueOnce({}); 111 | 112 | // Mock fs.rmSync to simulate directory removal 113 | fs.rmSync.mockImplementation(() => {}); 114 | 115 | fs.ensureDirSync(testFolderPath); 116 | 117 | const { s3Upload } = require('../../../runtime/s3Upload'); 118 | await s3Upload(); 119 | 120 | // Verify that fs.rmSync was called (which removes the combine directory) 121 | expect(fs.rmSync).toHaveBeenCalled(); 122 | 123 | try { 124 | fs.emptyDirSync(path.resolve(process.cwd(), 'reports', 'browserName')); 125 | } catch (err) { 126 | // Ignore cleanup errors 127 | } 128 | }); 129 | 130 | it('should collect the files to be uploaded', async () => { 131 | const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 132 | const testFolderPath = path.resolve(process.cwd(), 'reports', 'browserName'); 133 | 134 | // Mock successful bucket list response 135 | mockSend.mockResolvedValueOnce({ 136 | Buckets: [{ Name: 'mockBucket' }] 137 | }); 138 | 139 | // Mock successful upload 140 | mockSend.mockResolvedValueOnce({}); 141 | 142 | fs.ensureDirSync(testFolderPath); 143 | fs.writeFileSync(path.resolve(testFolderPath, 'testfile1.txt'), 'Test data'); 144 | 145 | const { s3Upload } = require('../../../runtime/s3Upload'); 146 | await s3Upload(); 147 | 148 | try { 149 | expect(logSpy).toHaveBeenCalledWith('Report files uploaded successfully to s3 Bucket'); 150 | } catch (err) { 151 | throw new Error(err); 152 | } finally { 153 | fs.emptyDirSync(testFolderPath); 154 | logSpy.mockRestore(); 155 | } 156 | }); 157 | 158 | it('should fail gracefully if an error occurs while uploading to the S3 bucket', async () => { 159 | const testFolderPath = path.resolve(process.cwd(), 'reports', 'browserName'); 160 | 161 | // Mock successful bucket list response first 162 | mockSend.mockResolvedValueOnce({ 163 | Buckets: [{ Name: 'mockBucket' }] 164 | }); 165 | 166 | // Mock successful bucket list for the second call (in deploy) 167 | mockSend.mockResolvedValueOnce({ 168 | Buckets: [{ Name: 'mockBucket' }] 169 | }); 170 | 171 | // Mock failed upload - this will trigger the error in the deploy function 172 | mockSend.mockRejectedValueOnce(new Error('Test Error')); 173 | 174 | fs.ensureDirSync(testFolderPath); 175 | fs.writeFileSync(path.resolve(testFolderPath, 'testfile1.txt'), 'Test data'); 176 | 177 | const { s3Upload } = require('../../../runtime/s3Upload'); 178 | 179 | // The function should complete without throwing an error 180 | await expect(s3Upload()).resolves.not.toThrow(); 181 | 182 | try { 183 | fs.emptyDirSync(testFolderPath); 184 | } catch (err) { 185 | // Ignore cleanup errors 186 | } 187 | }); 188 | }); 189 | 190 | -------------------------------------------------------------------------------- /__tests__/integration/runtime/s3Upload.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const { S3Client, ListBucketsCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); 4 | 5 | // Mock the dependencies 6 | jest.mock('@aws-sdk/client-s3'); 7 | jest.mock('fs-extra'); 8 | jest.mock('recursive-readdir'); 9 | jest.mock('klassijs-astellen'); 10 | 11 | // Mock the helpers module 12 | jest.mock('../../../runtime/helpers', () => ({ 13 | s3BucketCurrentDate: jest.fn().mockReturnValue('2024-01-01'), 14 | })); 15 | 16 | // Mock the async module 17 | jest.mock('async', () => ({ 18 | eachOfLimit: jest.fn().mockImplementation((files, limit, callback) => { 19 | return Promise.resolve(); 20 | }), 21 | })); 22 | 23 | // Mock the recursive-readdir module 24 | jest.mock('recursive-readdir', () => 25 | jest.fn().mockResolvedValue([ 26 | './reports/browserName/testenv/file1.html.json', 27 | './reports/browserName/testenv/file2.html.json', 28 | ]) 29 | ); 30 | 31 | // Mock the global variables and environment 32 | global.env = { envName: 'testenv' }; 33 | global.BROWSER_NAME = 'browserName'; 34 | global.dataconfig = { s3FolderName: 'mockedFolder' }; 35 | global.s3Data = { S3_BUCKET: 'mockBucket', S3_REGION: 'us-east-1' }; 36 | process.env.S3_KEY = 'mockKey'; 37 | process.env.S3_SECRET = 'mockSecret'; 38 | 39 | describe('s3Upload', () => { 40 | let mockS3Client; 41 | let mockSend; 42 | 43 | beforeEach(() => { 44 | // Reset all mocks 45 | jest.clearAllMocks(); 46 | 47 | // Mock S3Client 48 | mockSend = jest.fn(); 49 | mockS3Client = { 50 | send: mockSend, 51 | }; 52 | S3Client.mockImplementation(() => mockS3Client); 53 | 54 | // Mock ListBucketsCommand 55 | ListBucketsCommand.mockImplementation((params) => params); 56 | 57 | // Mock PutObjectCommand 58 | PutObjectCommand.mockImplementation((params) => params); 59 | 60 | // Mock fs methods 61 | fs.existsSync.mockReturnValue(true); 62 | fs.readdirSync.mockReturnValue(['file1.html.json']); 63 | fs.removeSync.mockImplementation(() => {}); 64 | fs.ensureDirSync.mockImplementation(() => {}); 65 | fs.emptyDirSync.mockImplementation(() => {}); 66 | fs.writeFileSync.mockImplementation(() => {}); 67 | fs.readFileSync.mockReturnValue('fileContent'); 68 | }); 69 | 70 | it('should upload files to S3 and use the mocked envConfig and dataConfig', async () => { 71 | // Mock successful bucket list response 72 | mockSend.mockResolvedValueOnce({ 73 | Buckets: [{ Name: 'mockBucket' }] 74 | }); 75 | 76 | // Mock successful upload 77 | mockSend.mockResolvedValueOnce({}); 78 | 79 | const { s3Upload } = require('../../../runtime/s3Upload'); 80 | await s3Upload(); 81 | 82 | expect(mockS3Client.send).toHaveBeenCalled(); 83 | expect(fs.removeSync).toHaveBeenCalledWith(path.resolve('./reports/browserName/testenv/', 'file1.html.json')); 84 | expect(global.dataconfig.s3FolderName).toBe('mockedFolder'); 85 | expect(global.dataconfig.s3FolderName).toBeDefined(); 86 | }); 87 | 88 | it('should print an error message if list of buckets cannot be retrieved', async () => { 89 | const logSpy = jest.spyOn(console, 'log'); 90 | 91 | // Mock failed bucket list 92 | mockSend.mockRejectedValueOnce(new Error('Test Error')); 93 | 94 | const { s3Upload } = require('../../../runtime/s3Upload'); 95 | await s3Upload(); 96 | 97 | expect(logSpy).toHaveBeenCalledWith('The s3 bucket list could not be retrieved'); 98 | logSpy.mockRestore(); 99 | }); 100 | 101 | it('should remove a combine directory if it already exists', async () => { 102 | const testFolderPath = path.resolve(process.cwd(), 'reports', 'browserName', 'testenvCombine'); 103 | 104 | // Mock successful bucket list response 105 | mockSend.mockResolvedValueOnce({ 106 | Buckets: [{ Name: 'mockBucket' }] 107 | }); 108 | 109 | // Mock successful upload 110 | mockSend.mockResolvedValueOnce({}); 111 | 112 | // Mock fs.rmSync to simulate directory removal 113 | fs.rmSync.mockImplementation(() => {}); 114 | 115 | fs.ensureDirSync(testFolderPath); 116 | 117 | const { s3Upload } = require('../../../runtime/s3Upload'); 118 | await s3Upload(); 119 | 120 | // Verify that fs.rmSync was called (which removes the combine directory) 121 | expect(fs.rmSync).toHaveBeenCalled(); 122 | 123 | try { 124 | fs.emptyDirSync(path.resolve(process.cwd(), 'reports', 'browserName')); 125 | } catch (err) { 126 | // Ignore cleanup errors 127 | } 128 | }); 129 | 130 | it('should collect the files to be uploaded', async () => { 131 | const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 132 | const testFolderPath = path.resolve(process.cwd(), 'reports', 'browserName'); 133 | 134 | // Mock successful bucket list response 135 | mockSend.mockResolvedValueOnce({ 136 | Buckets: [{ Name: 'mockBucket' }] 137 | }); 138 | 139 | // Mock successful upload 140 | mockSend.mockResolvedValueOnce({}); 141 | 142 | fs.ensureDirSync(testFolderPath); 143 | fs.writeFileSync(path.resolve(testFolderPath, 'testfile1.txt'), 'Test data'); 144 | 145 | const { s3Upload } = require('../../../runtime/s3Upload'); 146 | await s3Upload(); 147 | 148 | try { 149 | expect(logSpy).toHaveBeenCalledWith('Report files uploaded successfully to s3 Bucket'); 150 | } catch (err) { 151 | throw new Error(err); 152 | } finally { 153 | fs.emptyDirSync(testFolderPath); 154 | logSpy.mockRestore(); 155 | } 156 | }); 157 | 158 | it('should fail gracefully if an error occurs while uploading to the S3 bucket', async () => { 159 | const testFolderPath = path.resolve(process.cwd(), 'reports', 'browserName'); 160 | 161 | // Mock successful bucket list response first 162 | mockSend.mockResolvedValueOnce({ 163 | Buckets: [{ Name: 'mockBucket' }] 164 | }); 165 | 166 | // Mock successful bucket list for the second call (in deploy) 167 | mockSend.mockResolvedValueOnce({ 168 | Buckets: [{ Name: 'mockBucket' }] 169 | }); 170 | 171 | // Mock failed upload - this will trigger the error in the deploy function 172 | mockSend.mockRejectedValueOnce(new Error('Test Error')); 173 | 174 | fs.ensureDirSync(testFolderPath); 175 | fs.writeFileSync(path.resolve(testFolderPath, 'testfile1.txt'), 'Test data'); 176 | 177 | const { s3Upload } = require('../../../runtime/s3Upload'); 178 | 179 | // The function should complete without throwing an error 180 | await expect(s3Upload()).resolves.not.toThrow(); 181 | 182 | try { 183 | fs.emptyDirSync(testFolderPath); 184 | } catch (err) { 185 | // Ignore cleanup errors 186 | } 187 | }); 188 | }); 189 | 190 | -------------------------------------------------------------------------------- /runtime/world.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const { setDefaultTimeout, Before, AfterAll } = require('@cucumber/cucumber'); 6 | const { astellen } = require('klassijs-astellen'); 7 | const getRemote = require('./getRemote'); 8 | const data = require('./helpers'); 9 | const { filterQuietTags } = require('../cucumber'); 10 | const { getTagsFromFeatureFiles } = require('../index'); 11 | const { throwCollectedErrors } = require('klassijs-soft-assert'); 12 | const { ImageAssertion } = require('klassijs-visual-validation'); 13 | 14 | /** 15 | * This is the Global date functionality 16 | */ 17 | global.date = data.currentDate(); 18 | 19 | /** 20 | * Driver environment variables 21 | * @type {function(*): {}} 22 | */ 23 | // const MultiDriver = require('./drivers/multiDriver'); 24 | const ChromeDriver = require('./drivers/chromeDriver'); 25 | const FirefoxDriver = require('./drivers/firefoxDriver'); 26 | const LambdaTestDriver = require('./drivers/lambdatestDriver'); 27 | 28 | const remoteService = getRemote(global.settings.remoteService); 29 | 30 | let driver = {}; 31 | global.world = this; 32 | /** 33 | * create the web browser based on global let set in index.js 34 | * @returns {{}} 35 | */ 36 | async function getDriverInstance() { 37 | let browser = global.BROWSER_NAME; 38 | 39 | astellen.set('BROWSER_NAME', global.BROWSER_NAME); 40 | const options = {}; 41 | if (remoteService && remoteService.type === 'lambdatest') { 42 | astellen.set('BROWSER_NAME', global.settings.extraSettings); 43 | console.log('extraSettings via astellen GET', astellen.get('BROWSER_NAME')); 44 | const configType = global.remoteConfig; 45 | assert.isString(configType, 'LambdaTest requires a config type e.g. browserName.json'); 46 | driver = LambdaTestDriver(options, configType); 47 | global.browser = driver; 48 | return driver; 49 | } 50 | assert.isNotEmpty(browser, 'Browser must be defined'); 51 | 52 | const getBrowser = { 53 | firefox: async() => { 54 | driver = await FirefoxDriver(options); 55 | return driver; 56 | }, 57 | chrome: async() => { 58 | driver = await ChromeDriver(options); 59 | return driver; 60 | }, 61 | default: async() => { 62 | driver = await ChromeDriver(options); 63 | return driver; 64 | }, 65 | }; 66 | await (getBrowser[browser] || getBrowser['default'])(); 67 | global.browser = driver; 68 | return driver; 69 | } 70 | 71 | /** 72 | * set the default timeout for all tests 73 | */ 74 | const globalTimeout = process.env.CUCUMBER_TIMEOUT || 300000; 75 | setDefaultTimeout(globalTimeout); 76 | global.timeout = globalTimeout; 77 | 78 | /** 79 | * create the browser before scenario if it's not instantiated and 80 | * also exposing the world object in global variable 'cucumberThis' so that 81 | * it can be used in arrow functions 82 | */ 83 | let cucumberThis; 84 | 85 | Before(async function () { 86 | global.cucumberThis = this; 87 | cucumberThis = this; 88 | global.driver = await getDriverInstance(); 89 | global.browser = global.driver; 90 | return driver; 91 | }); 92 | 93 | /** 94 | * start recording of the Test run time 95 | */ 96 | global.startDateTime = data.getStartDateTime(); 97 | 98 | /** 99 | * executed before each scenario 100 | */ 101 | Before(async (scenario) => { 102 | const { browser } = global; 103 | if (remoteService && remoteService.type === 'lambdatest') { 104 | await browser.execute(`lambda-name=${scenario.pickle.name}`); 105 | } 106 | }); 107 | 108 | /** 109 | * This verifies that the current scenario to be run includes the @wip or @skip tags 110 | * and skips the test if that's the case. 111 | */ 112 | Before((scenario) => { 113 | const correctMultipleTags = module.exports.skipTagValidation(); 114 | for (const tag of scenario.pickle.tags) { 115 | if ( 116 | tag.name === '@wip' || 117 | tag.name === '@skip' || 118 | (correctMultipleTags && correctMultipleTags.includes(tag.name)) 119 | ) { 120 | cucumberThis.attach( 121 | `This scenario was skipped automatically by using the @wip, @skip or a custom tag "${tag.name}" provided at runtime.`, 122 | ); 123 | return 'skipped'; 124 | } 125 | } 126 | }); 127 | 128 | /** 129 | * LambdaTest Only 130 | * executed ONLY on failure of a scenario to get the video link 131 | * from lambdatest when it fails for the report 132 | */ 133 | After(async (scenario) => { 134 | if (scenario.result.status === Status.FAILED && remoteService && remoteService.type === 'lambdatest') { 135 | await helpers.ltVideo(); 136 | // eslint-disable-next-line no-undef 137 | const vidLink = await videoLib.getVideoId(); 138 | cucumberThis.attach( 139 | `video:\n `, 140 | ); 141 | } 142 | }); 143 | 144 | /** 145 | * This is to control closing the browser or keeping it open after each scenario 146 | * @returns {Promise|*} 147 | */ 148 | async function browserOpen() { 149 | const { browser } = global; 150 | if (typeof global.browser === 'undefined') { 151 | console.warn('Browser is not defined, skipping cleanup'); 152 | return Promise.resolve(); 153 | } 154 | 155 | if (global.browserOpen === false) { 156 | return browser.deleteSession(); 157 | } else { 158 | return Promise.resolve(); 159 | } 160 | }; 161 | 162 | /** 163 | * executed after each scenario - always closes the browser to ensure clean browser not cached) 164 | */ 165 | After(async (scenario) => { 166 | if ( 167 | scenario.result.status === Status.FAILED || 168 | scenario.result.status === Status.PASSED || 169 | scenario.result.status === Status.SKIPPED || 170 | scenario.result.status === Status.UNKNOWN || 171 | scenario.result.status === Status.AMBIGUOUS || 172 | scenario.result.status === Status.UNDEFINED || 173 | scenario.result.status === Status.PENDING 174 | ) { 175 | if (remoteService && remoteService.type === 'lambdatest') { 176 | if (scenario.result.status === 'FAILED') { 177 | await browser.execute('lambda-status=failed'); 178 | } else if (scenario.result.status === Status.PASSED) { 179 | await browser.execute('lambda-status=passed'); 180 | } else if (scenario.result.status === Status.SKIPPED) { 181 | await browser.execute('lambda-status=skipped'); 182 | } else if (scenario.result.status === Status.UNKNOWN) { 183 | await browser.execute('lambda-status=unknown'); 184 | } else if (scenario.result.status === Status.AMBIGUOUS) { 185 | await browser.execute('lambda-status=ignored'); 186 | } else if (scenario.result.status === Status.UNDEFINED) { 187 | await browser.execute('lambda-status=error'); 188 | } else if (scenario.result.status === Status.PENDING) { 189 | await browser.execute('lambda-status=skipped'); 190 | } 191 | return await browserOpen(); 192 | } 193 | } 194 | return await browserOpen(); 195 | }); 196 | 197 | After(async function () { 198 | // Pass the total assertion errors after each scenario to the report 199 | await throwCollectedErrors(); 200 | }); 201 | 202 | After(async function () { 203 | // Passing the total visual validation errors after each scenario to the report 204 | await ImageAssertion.finalizeTest(); 205 | }); 206 | 207 | /** 208 | * get executed only if there is an error within a scenario 209 | * will not take an image if it's an API test 210 | */ 211 | After(async function (scenario) { 212 | const world = this; 213 | let result = await filterQuietTags(); 214 | const taglist = resultingString.split(','); 215 | if (!taglist.some((tag) => result.includes(tag)) && scenario.result.status === Status.FAILED) { 216 | if (typeof global.browser !== 'undefined' && global.browser.takeScreenshot) { 217 | return await global.browser.takeScreenshot().then((screenShot) => { 218 | // screenShot is a base-64 encoded PNG 219 | world.attach(screenShot, 'image/png'); 220 | }); 221 | } 222 | } 223 | }); 224 | 225 | /** 226 | * this allows for the skipping of scenarios based on tags 227 | * @returns {*|null} 228 | */ 229 | function skipTagValidation() { 230 | let multipleTags; 231 | if (!skipTag || skipTag.length === 0) { 232 | return null; 233 | } 234 | 235 | const correctFeatureTags = getTagsFromFeatureFiles(); 236 | multipleTags = skipTag.split(','); 237 | const correctTags = []; 238 | for (const tag of multipleTags) { 239 | if (!tag || tag.length === 0) { 240 | continue; 241 | } 242 | if (tag[0] !== '@') { 243 | console.error(`Error: the tag should start with an @ symbol. The skipTag provided was "${tag}". `); 244 | continue; 245 | } 246 | if (!correctFeatureTags.includes(tag)) { 247 | console.error('Error: the requested tag does not exist ===> ', tag); 248 | continue; 249 | } 250 | correctTags.push(tag); 251 | } 252 | return correctTags.length !== 0 ? correctTags : null; 253 | } 254 | 255 | module.exports = { skipTagValidation }; 256 | -------------------------------------------------------------------------------- /runtime/docs/klassi-js-v6-Upgrade.md: -------------------------------------------------------------------------------- 1 | # Klassi-js v6.0.0 Upgrade Guide 2 | # Overview 3 | 4 | This document provides a comprehensive guide to upgrading a project to Klassi-js v6.0.0. The process involves updating assertion types, modifying how assertions are used, upgrading dependencies, and making necessary configuration changes. 5 | 6 | By following this guide, you ensure that your project remains compatible with Klassi-js v6.0.0 while maintaining best practices. 7 | 8 | ## Assertion Types 9 | 10 | Klassi-js v6.0.0 introduces a structured set of assertion types that should be used for validation in your project. Documentation is here. Below is a complete list of valid assertion types: 11 | 12 | Table : Valid Assertion Types 13 | 14 | | Assertion Types | Assertion Types | Assertion Types | Assertion Types | 15 | |:----------------|:----------------|:----------------|:----------------| 16 | | equals | tobeenabled | tobeclickable | isOK | 17 | | tohavetitle | isNull | toexist | isArray | 18 | | contains | tobeselected | isTrue | exists | 19 | | tohaveurl | notExists | match | isdisabled | 20 | | doesexist | tobechecked | isenabled | include | 21 | | tohavetext | isUndefined | tobeexisting | equal | 22 | | doesnotexist | tohavehtml | isFalse | tobedisplayed | 23 | | containstext | isString | lengthOf | tobepresent | 24 | | isnotenabled | tobefocused | notEqual | tobedisabled | 25 | | toNotEqual | typeOf | notInclude | doesnotcontain | 26 | 27 | 28 | ## Why Update Assertions? 29 | Klassi-js v6.0.0 introduces softAssert, which provides better error handling and allows test execution to continue even if an assertion fails. All failures will be shown/reported at the end of the test run. 30 | 31 | #### Key Differences: 32 | 33 | softAssert is now used instead of assert 34 | Assertion types must be passed as strings (e.g., 'isTrue', 'equal') 35 | The format improves readability and test maintainability 36 | 37 | Old Format (Before Upgrade, in previous versions, assertions were written using assert): 38 | ```javascript 39 | await assert.isTrue(await var1.includes(testData.errorMessage.invalidISBN)); 40 | 41 | await assert.equal(await elem.getText(), testData.userRole.editorRole); 42 | ``` 43 | New Format (After Upgrade to Klassi-js v6.0.0, assertions should be written using softAssert): 44 | ```javascript 45 | await softAssert(await var1.includes(testData.errorMessage.invalidISBN), 'isTrue'); 46 | 47 | await softAssert(await elem.getText(), 'equal', testData.userRole.editorRole); 48 | ``` 49 | 50 | # Visual-Validation Usage Updates 51 | ### Why Update Visual Validation? 52 | 53 | Klassi-js v6.0.0 upgrades visual-validation, which provides better error handling and allows test execution to continue even if an image validation fails. There is no update needed for existing code base its just works out of the box. All failures will be shown / reported at the end of the test run. 54 | 55 | 56 | # Project Upgrade Steps 57 | ### Create a New Branch 58 | 59 | Before making any changes, create a new branch from develop to manage the upgrade process. 60 | 61 | Commands: 62 | 63 | ```javascript 64 | git checkout develop 65 | 66 | git pull origin develop 67 | 68 | git checkout -b upgrade-Klassi-js-v6 69 | ``` 70 | 71 | This ensures that your changes are isolated and do not affect the main development branch until they are fully tested and reviewed. 72 | 73 | ### Remove Outdated Files 74 | 75 | Certain files need to be deleted before proceeding with the upgrade: 76 | 77 | ```javascript 78 | rm -rf yarn.lock node_modules .eslintrc 79 | ``` 80 | Why? 81 | 82 | 83 | yarn.lock: Removing this ensures fresh dependencies are installed. 84 | node_modules: Deleting this prevents conflicts with older versions. 85 | .eslintrc: The linting configuration might be outdated and needs to be refreshed. 86 | 87 | 88 | ### Updating package.json 89 | 90 | The package.json file should be updated to include the latest dependencies and scripts required for Klassi-js v6.0.0. you can just copy the package.json from Klassijs-example-project taking care to make a note of dependencies that you are using in your project that’s not a part of Klassi-js 91 | 92 | Key Changes: 93 | 94 | Update dependencies and scripts to match Klassijs-example-project. 95 | 96 | Add new contributors if applicable. 97 | 98 | ```json 99 | 100 | "version": "6.0.0", 101 | "license": "MIT", 102 | "creator": { 103 | "name": "Larry Goddard", 104 | "email": "larryg@klassitech.co.uk", 105 | "linkedin": "https://linkedin.com/in/larryg", 106 | "youtube": "https://youtube.com/@LarryG_01" 107 | }, 108 | 109 | "engines": { 110 | "node": ">=20", 111 | "pnpm": ">=9" 112 | }, 113 | 114 | "scripts": { 115 | "dev": "node ./node_modules/Klassi-js/index.js --disableReport --tags", 116 | "ltlocal": "node ./node_modules/Klassi-js/index.js --disableReport --remoteService lambdatest --extraSettings", 117 | "predev": "pnpm delete-dev", 118 | "delete-dev": "rimraf ./reports ./artifacts ./visual-regression-baseline eng.traineddata", 119 | "============================================": "=======================================================", 120 | "================ NOTE": "PLEASE DO NOT MAKE ANY CHANGES TO THE SCRIPTS BELOW THIS LINE ================", 121 | "===========================================": "========================================================", 122 | "preinstall": "npx only-allow pnpm", 123 | "pkgcheck": "pnpm install --frozen-lockfile", 124 | "lint": "pnpm lint-staged && pnpm lint:gherkin", 125 | "lint:gherkin": "gherkin-lint -c node_modules/Klassi-js/runtime/coding-standards/gherkin/gherkin-lint.json '**/*.feature'", 126 | "lint-branch-name": "pnpm branch-name-lint ./branchnamelinter.config.json", 127 | "ciltuat": "node ./node_modules/Klassi-js/index.js --disableReport --isCI --tags @regression --remoteService lambdatest --extraSettings", 128 | "ciltdev": "node ./node_modules/Klassi-js/index.js --disableReport --isCI --tags @integration --remoteService lambdatest --extraSettings", 129 | "cilts3r": "node ./node_modules/Klassi-js/index.js --disableReport --tags @s3 --remoteService lambdatest --extraSettings", 130 | "cilts3load": "node ./node_modules/Klassi-js/index.js --disableReport --tags @s3load --remoteService lambdatest --extraSettings" 131 | }, 132 | 133 | "dependencies": { 134 | "Klassi-js": "github:klassijs/Klassi-js", 135 | "klassijs-astellen": "^1.0.0", 136 | "klassijs-soft-assert": "^1.2.1" 137 | }, 138 | 139 | "devDependencies": { 140 | "@commitlint/cli": "^19.8.0", 141 | "@commitlint/config-conventional": "^19.8.0", 142 | "branch-name-lint": "^2.1.1", 143 | "gherkin-lint": "^4.2.4", 144 | "husky": "^9.1.7", 145 | "lint-staged": "^15.5.0", 146 | "rimraf": "^6.0.1" 147 | }, 148 | 149 | "lint-staged": { 150 | "**/*.js": "eslint --quiet --fix --config node_modules/Klassi-js/runtime/coding-standards/eslint/eslint.config.js" 151 | } 152 | ``` 153 | 154 | Ensure All Dependencies Are Installed 155 | If you have any additional dependencies, ensure they are re-added. 156 | 157 | Example: 158 | "form-data": "^4.0.2" 159 | 160 | ### Updating branchnamelinter.config.json 161 | 162 | Ensure the file enforces proper branch naming conventions. 163 | 164 | New Configuration 165 | 166 | ```json 167 | { 168 | "branchNameLinter": { 169 | "prefixes": [ 170 | "testfix", 171 | "automation", 172 | "feature" 173 | ], 174 | "suggestions": { 175 | "fix": "testfix", 176 | "auto": "automation", 177 | "feat": "feature" 178 | }, 179 | "disallowed": [ 180 | "master", 181 | "main", 182 | "develop", 183 | "staging" 184 | ], 185 | "separator": "/", 186 | "msgBranchBanned": "Branches with the name \"%s\" are not allowed.", 187 | "msgBranchDisallowed": "Pushing to \"%s\" is not allowed, use git-flow.", 188 | "msgPrefixNotAllowed": "Branch prefix \"%s\" is not allowed.", 189 | "msgPrefixSuggestion": "Instead of \"%s\" try \"%s\".", 190 | "msgseparatorRequired": "Branch \"%s\" must contain a separator \"%s\"." 191 | } 192 | } 193 | ``` 194 | This helps maintain consistency in branch naming and avoids using restricted branch names. 195 | 196 | 197 | ### Updating .gitigmore 198 | 199 | Ensure the .gitignore file includes all necessary exclusions: 200 | 201 | ```gitignore 202 | # Dependency directories 203 | node_modules 204 | log 205 | 206 | # Files and Directory 207 | # project folders 208 | reports 209 | artifacts 210 | 211 | # IntelliJ project files 212 | .idea 213 | .DS_Store 214 | out 215 | gen 216 | 217 | # vscode files 218 | .vscode 219 | 220 | # Logs 221 | pnpm-debug.log* 222 | 223 | # dotenv environment variables file 224 | .env 225 | .env.* 226 | 227 | # OCR file 228 | eng.traineddata 229 | ``` 230 | 231 | This prevents unnecessary files from being committed to the repository. 232 | 233 | ### Updating Husky Hooks (.husky folder) 234 | 235 | Husky helps enforce code quality by running checks before committing. 236 | 237 | Commit Message Hook (commit-msg) 238 | 239 | ```sh 240 | #!/usr/bin/env sh 241 | npx --no -- commitlint --edit "${1}" 242 | pnpm lint-branch-name 243 | ``` 244 | 245 | Pre-Commit Hook (pre-commit) 246 | ```sh 247 | #!/usr/bin/env sh 248 | pnpm lint 249 | ``` 250 | 251 | These scripts ensure commits follow proper formatting and branch names comply with naming rules. 252 | 253 | ### Updating CircleCI Configuration 254 | 255 | Replace .circleci/config.yml with the latest version from: 256 | please make a note of your project cron job time and replace 'projectName' with your project GitHub name. 257 | ```shell 258 | Klassijs/klassijs-example-project/.circleci/config.yml.template 259 | ``` 260 | This ensures that the CI/CD pipeline remains up to date. 261 | 262 | ### Updating LambdaTest Browser Versions 263 | Update the browser versions in the browser configuration file with the latest-2 version number (NOTE: use actual numbers i.e '132' and not 'latest-2') 264 | ```shell 265 | Ensure to update the browser versions to the latest-2 in the folder. 266 | 267 | cp -r Klassijs/Klassijs-example-project/lambdatest ./lambdatest 268 | ``` 269 | ### Upgrading Node.js Version 270 | 271 | Ensure your project is using the required Node.js version: 272 | ```shell 273 | node -v 274 | ``` 275 | If needed, install and use the correct version: 276 | nvm install 20 277 | nvm use 20 278 | 279 | ### Installing Dependencies 280 | 281 | Once all changes have been made, install dependencies: 282 | ```shell 283 | pnpm install 284 | ``` 285 | This ensures all required packages are properly set up. 286 | 287 | ### Running the Updated Project 288 | 289 | Local Execution 290 | Run the project locally using: 291 | ```shell 292 | pnpm dev @tagname 293 | ``` 294 | LambdaTest Execution 295 | To run tests in LambdaTest: 296 | ```shell 297 | pnpm ltlocal browserName --tags @tagName 298 | ``` 299 | 300 | ### Assertion upgrades 301 | 302 | This will take a while so it can be done over time, please go back to Point 2 and follow the instructions there. Read the README for [klassijs-soft-assert](https://github.com/klassijs/klassijs-soft-assert) so you can update all your assertions in your existing code. 303 | 304 | ### NOTE: 305 | 306 | By following this structured guide, your project will be successfully upgraded to Klassi-js v6.0.0. 307 | This ensures improved assertion handling, an optimized test framework, and a well-maintained codebase. 308 | For any issues, compare your changes with [klassijs-example-project](https://github.com/klassijs/Klassijs-example-project) or seek help from the contributors. 309 | -------------------------------------------------------------------------------- /runtime/mailer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | 6 | // const path = require('path'); 7 | // const nodemailer = require('nodemailer'); 8 | // const { SESClient } = require('@aws-sdk/client-ses'); 9 | // const { defaultProvider } = require('@aws-sdk/credential-provider-node'); 10 | // const getRemote = require('./getRemote'); 11 | // const { astellen } = require('klassijs-astellen'); 12 | // 13 | // const remoteService = getRemote(settings.remoteService); 14 | // const emailDateTime = helpers.emailReportDateTime(); 15 | // 16 | // process.env.AWS_ACCESS_KEY_ID = process.env.SES_KEY; 17 | // process.env.AWS_SECRET_ACCESS_KEY = process.env.SES_SECRET; 18 | // 19 | // const sesClient = new SESClient({ 20 | // apiVersion: '2010-12-01', 21 | // region: emailData.SES_REGION, 22 | // credentials: defaultProvider, 23 | // }); 24 | // 25 | // module.exports = { 26 | // klassiSendMail: async () => { 27 | // let fileList = []; 28 | // 29 | // if (remoteService && remoteService.type === 'lambdatest') { 30 | // if (emailData.AccessibilityReport === 'Yes') { 31 | // fileList = fileList.concat(accessibilityReportList); 32 | // } 33 | // } 34 | // 35 | // const devTeam = emailData.nameList; 36 | // 37 | // const transporter = nodemailer.createTransport({ 38 | // SES: { sesClient }, 39 | // statement: [ 40 | // { 41 | // Effect: 'Allow', 42 | // Action: 'ses:SendRawEmail', 43 | // Resource: '*', 44 | // }, 45 | // ], 46 | // }); 47 | // 48 | // const mailOptions = { 49 | // to: devTeam, 50 | // from: 'KLASSI-QATEST ', 51 | // subject: `${projectName} ${reportName}-${emailDateTime}`, 52 | // alternative: true, 53 | // attachments: fileList, 54 | // html: `Please find attached the automated test results for test run on - ${emailDateTime}`, 55 | // }; 56 | // 57 | // await transporter.verify(async (err, success) => { 58 | // if (err) { 59 | // console.error('Server failed to Start', err.stack); 60 | // } else { 61 | // console.info('Server is ready to take our messages'); 62 | // } 63 | // 64 | // if (success) { 65 | // try { 66 | // await transporter.sendMail(mailOptions, (err) => { 67 | // if (err) { 68 | // console.error(`Results Email CANNOT be sent: ${err.stack}`); 69 | // throw err; 70 | // } else { 71 | // console.info('Results Email successfully sent'); 72 | // browser.pause(DELAY_200ms).then(() => { 73 | // process.exit(0); 74 | // }); 75 | // } 76 | // }); 77 | // } catch (err) { 78 | // console.error('There is a system error: ', err.stack); 79 | // throw err; 80 | // } 81 | // } 82 | // }); 83 | // }, 84 | // }; 85 | 86 | const { SESClient, SendRawEmailCommand } = require('@aws-sdk/client-ses'); 87 | const fs = require('fs'); 88 | const path = require('path'); 89 | const getRemote = require('./getRemote'); 90 | 91 | const remoteService = getRemote(settings.remoteService); 92 | const emailDateTime = helpers.emailReportDateTime(); 93 | 94 | // Create SES client with AWS SDK v3 95 | const sesClient = new SESClient({ 96 | region: emailData.SES_REGION || 'eu-west-1', 97 | credentials: { 98 | accessKeyId: process.env.SES_KEY, 99 | secretAccessKey: process.env.SES_SECRET, 100 | }, 101 | }); 102 | 103 | // Simple function to clean email addresses 104 | const cleanEmail = (email) => { 105 | if (!email) return email; 106 | 107 | // Remove all whitespace and control characters first 108 | let cleaned = email.trim().replace(/[\s\u0000-\u001F\u007F-\u009F]/g, ''); 109 | 110 | // Remove angle brackets and extract just the email part 111 | // Handle "Name" format 112 | const match = cleaned.match(/^(.+)<(.+@.+)>$/); 113 | if (match) { 114 | cleaned = match[2]; // Return just the email part 115 | } 116 | 117 | // Handle "" format 118 | const simpleMatch = cleaned.match(/^<(.+@.+)>$/); 119 | if (simpleMatch) { 120 | cleaned = simpleMatch[1]; // Return just the email part 121 | } 122 | 123 | // Final cleanup - remove any remaining whitespace 124 | cleaned = cleaned.trim(); 125 | 126 | return cleaned; 127 | }; 128 | 129 | // Function to create raw email message with proper attachment handling 130 | const createRawEmail = (from, to, subject, html, attachments = []) => { 131 | const boundary = 'boundary_' + Math.random().toString(36).substr(2, 9); 132 | 133 | let rawMessage = ''; 134 | rawMessage += `From: ${from}\r\n`; 135 | rawMessage += `To: ${to}\r\n`; 136 | rawMessage += `Subject: ${subject}\r\n`; 137 | rawMessage += `MIME-Version: 1.0\r\n`; 138 | rawMessage += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`; 139 | 140 | // HTML part 141 | rawMessage += `--${boundary}\r\n`; 142 | rawMessage += `Content-Type: text/html; charset=UTF-8\r\n`; 143 | rawMessage += `Content-Transfer-Encoding: 7bit\r\n\r\n`; 144 | rawMessage += `${html}\r\n\r\n`; 145 | 146 | // Attachments 147 | attachments.forEach((attachment, index) => { 148 | try { 149 | // Handle different attachment formats 150 | let content, filename, contentType; 151 | 152 | if (typeof attachment === 'string') { 153 | // If attachment is a file path 154 | if (fs.existsSync(attachment)) { 155 | content = fs.readFileSync(attachment); 156 | filename = path.basename(attachment); 157 | contentType = getContentType(filename); 158 | } else { 159 | return; 160 | } 161 | } else if (attachment && attachment.content) { 162 | // If attachment has content property 163 | content = attachment.content; 164 | filename = attachment.filename || 'attachment'; 165 | contentType = attachment.contentType || 'application/octet-stream'; 166 | } else if (attachment && attachment.path) { 167 | // If attachment has path property 168 | if (fs.existsSync(attachment.path)) { 169 | content = fs.readFileSync(attachment.path); 170 | filename = attachment.filename || path.basename(attachment.path); 171 | contentType = attachment.contentType || getContentType(filename); 172 | } else { 173 | return; 174 | } 175 | } else { 176 | return; 177 | } 178 | 179 | // Convert content to base64 if it's a buffer 180 | const base64Content = Buffer.isBuffer(content) ? content.toString('base64') : content; 181 | 182 | rawMessage += `--${boundary}\r\n`; 183 | rawMessage += `Content-Type: ${contentType}\r\n`; 184 | rawMessage += `Content-Transfer-Encoding: base64\r\n`; 185 | rawMessage += `Content-Disposition: attachment; filename="${filename}"\r\n\r\n`; 186 | rawMessage += `${base64Content}\r\n\r\n`; 187 | 188 | } catch (error) { 189 | console.error('Error processing attachment:', error); 190 | } 191 | }); 192 | 193 | rawMessage += `--${boundary}--\r\n`; 194 | 195 | return rawMessage; 196 | }; 197 | 198 | // Helper function to determine content type based on file extension 199 | const getContentType = (filename) => { 200 | const ext = path.extname(filename).toLowerCase(); 201 | const contentTypes = { 202 | '.html': 'text/html', 203 | '.htm': 'text/html', 204 | '.txt': 'text/plain', 205 | '.pdf': 'application/pdf', 206 | '.png': 'image/png', 207 | '.jpg': 'image/jpeg', 208 | '.jpeg': 'image/jpeg', 209 | '.gif': 'image/gif', 210 | '.xml': 'application/xml', 211 | '.json': 'application/json', 212 | '.csv': 'text/csv' 213 | }; 214 | return contentTypes[ext] || 'application/octet-stream'; 215 | }; 216 | 217 | // Function to find accessibility reports 218 | const findAccessibilityReports = () => { 219 | const reports = []; 220 | 221 | // Calculate today's date range (since the correct reports are generated today) 222 | const now = new Date(); 223 | const today = new Date(now); 224 | today.setHours(0, 0, 0, 0); 225 | const todayEnd = new Date(today); 226 | todayEnd.setHours(23, 59, 59, 999); 227 | 228 | // Check common report directories 229 | const reportDirs = [ 230 | './reports', 231 | './test-results', 232 | './results', 233 | './output', 234 | './dist', 235 | './build', 236 | './coverage', 237 | './reports/accessibility', 238 | './test-results/accessibility' 239 | ]; 240 | 241 | reportDirs.forEach(dir => { 242 | if (fs.existsSync(dir)) { 243 | try { 244 | const files = fs.readdirSync(dir); 245 | files.forEach(file => { 246 | const filePath = path.join(dir, file); 247 | const stats = fs.statSync(filePath); 248 | // Only include files modified today 249 | if ( 250 | stats.isFile() && 251 | stats.mtime >= today && 252 | stats.mtime <= todayEnd && 253 | ( 254 | file.toLowerCase().includes('accessibility') || 255 | file.toLowerCase().includes('a11y') || 256 | file.toLowerCase().includes('report') || 257 | file.endsWith('.html') || 258 | file.endsWith('.json') || 259 | file.endsWith('.xml') 260 | ) 261 | ) { 262 | reports.push(filePath); 263 | } 264 | }); 265 | } catch (error) { 266 | // Silently continue if directory can't be read 267 | } 268 | } 269 | }); 270 | 271 | // Also check for any HTML files in current directory 272 | try { 273 | const currentDirFiles = fs.readdirSync('.'); 274 | currentDirFiles.forEach(file => { 275 | const stats = fs.statSync(file); 276 | if ( 277 | stats.isFile() && 278 | stats.mtime >= today && 279 | stats.mtime <= todayEnd && 280 | file.endsWith('.html') && 281 | ( 282 | file.toLowerCase().includes('accessibility') || 283 | file.toLowerCase().includes('a11y') || 284 | file.toLowerCase().includes('report') 285 | ) 286 | ) { 287 | reports.push(file); 288 | } 289 | }); 290 | } catch (error) { 291 | // Silently continue if current directory can't be read 292 | } 293 | 294 | return reports; 295 | }; 296 | 297 | module.exports = { 298 | klassiSendMail: async () => { 299 | try { 300 | let fileList = []; 301 | 302 | if (remoteService && remoteService.type === 'lambdatest') { 303 | if (!emailData.AccessibilityReport || emailData.AccessibilityReport === 'Yes') { 304 | // Search for accessibility reports in the file system 305 | const foundReports = findAccessibilityReports(); 306 | if (foundReports.length > 0) { 307 | fileList = fileList.concat(foundReports); 308 | } 309 | 310 | // Also add the original accessibilityReportList if it has content 311 | if (accessibilityReportList && accessibilityReportList.length > 0) { 312 | fileList = fileList.concat(accessibilityReportList); 313 | } 314 | } 315 | } 316 | 317 | // Clean the email addresses 318 | const nameListRaw = emailData.nameList; 319 | const nameListArray = Array.isArray(nameListRaw) 320 | ? nameListRaw 321 | : nameListRaw.split(',').map(e => e.trim()).filter(Boolean); 322 | 323 | const devTeam = nameListArray 324 | .map(cleanEmail) 325 | .filter(e => !!e && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)); 326 | 327 | const recipientEmail = devTeam.join(','); 328 | 329 | if (!devTeam || devTeam.length === 0 || !recipientEmail) { 330 | console.error('No valid email addresses found, early return'); 331 | return; 332 | } 333 | 334 | const senderEmail = 'KLASSI-QAAUTOTEST '; 335 | const subject = `${projectName} ${reportName}-${emailDateTime}`; 336 | const html = `Please find attached the automated test results for test run on - ${emailDateTime}`; 337 | 338 | try { 339 | // Create raw email message 340 | const rawMessage = createRawEmail(senderEmail, recipientEmail, subject, html, fileList); 341 | 342 | const params = { 343 | RawMessage: { 344 | Data: Buffer.from(rawMessage, 'utf8') 345 | } 346 | }; 347 | 348 | const command = new SendRawEmailCommand(params); 349 | const result = await sesClient.send(command); 350 | 351 | console.log('Results Email successfully sent'); 352 | browser.pause(DELAY_200ms).then(() => { 353 | process.exit(0); 354 | }); 355 | 356 | } catch (error) { 357 | console.error('Results Email CANNOT be sent:', error.message, error); 358 | throw error; 359 | } 360 | } catch (err) { 361 | console.error('Uncaught error in oupSendMail:', err); 362 | } 363 | }, 364 | }; 365 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # klassijs Automated Testing Tool 2 | # Created by Larry Goddard 3 | # Javascript Node CircleCI 2.1 configuration file 4 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 5 | 6 | version: 2.1 7 | 8 | references: 9 | filter_ignore_wip_branches: 10 | filters: 11 | branches: 12 | ignore: 13 | - /[a-z]{2}_wip.*/ # wip branches with initials prefix, e.g. mw_wip 14 | - /wip\/.*/ # wip branches with wip/ prefix, e.g. wip/foo 15 | - /poc\/.*/ # poc branches with poc/ prefix, e.g. poc/bar 16 | - /automation\/.*/ # automation branches with automation/ prefix. e.g. automation/foo 17 | - /feature\/.*/ # feature branches with feature/ prefix. e.g. feature/foo 18 | 19 | filter_only_integration_branches: &filter_only_integration_branches 20 | filters: 21 | branches: 22 | only: 23 | - develop # git flow develop branch 24 | - /testfix\/.*/ # testfix branches with testfix/ prefix. e.g. testfix/foo 25 | 26 | filter_only_testable_branches: &filter_only_testable_branches 27 | filters: 28 | branches: 29 | only: 30 | - master # git flow master branch 31 | - main # git flow main branch 32 | - develop # git flow develop branch 33 | - /feature\/.*/ # git flow hotfix branches 34 | - /hotfix\/.*/ # git flow hotfix branches 35 | - /release\/.*/ # git flow release branches 36 | - /testfix\/.*/ # testfix branches with testfix/ prefix. e.g. testfix/foo 37 | 38 | filter_only_releasable_branches: &filter_only_releasable_branches 39 | filters: 40 | branches: 41 | only: 42 | - master # git flow master branch 43 | - main # git flow main branch 44 | - /hotfix\/.*/ # git flow hotfix branches 45 | - /release\/.*/ # git flow release branches 46 | 47 | jobs: 48 | ######################### 49 | ### build and install ### 50 | ######################### 51 | build_install: &build_install 52 | docker: 53 | - image: cimg/node:20.18.3-browsers 54 | resource_class: small 55 | working_directory: ~/klassijs 56 | 57 | steps: 58 | # Install pnpm globally 59 | - run: 60 | name: Install pnpm 61 | command: | 62 | export SHELL=/bin/bash 63 | curl -fsSL https://get.pnpm.io/install.sh | sh - 64 | 65 | # Add PNPM to the path 66 | - run: 67 | name: Set up PNPM environment 68 | command: export PATH="$HOME/.local/share/pnpm:$PATH" 69 | 70 | # Verify pnpm installation 71 | - run: 72 | name: Check pnpm version 73 | command: pnpm --version 74 | 75 | - run: 76 | name: Avoid hosts unknown for github 77 | command: mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config 78 | 79 | - run: 80 | name: Remove the old klassi-js Repo 81 | command: rm -rf ~/klassijs/klassi-js 82 | 83 | - run: 84 | name: Checkout the klassi-js framework repo 85 | command: git clone -b main git@github.com:klassijs/klassi-js.git 86 | 87 | - run: 88 | name: Check out the working branch 89 | path: ~/klassijs/klassi-js 90 | command: git checkout ${CIRCLE_BRANCH} 91 | 92 | # Generate a robust dependency cache key using SHA-256 93 | - run: 94 | name: Generate dependency cache key 95 | command: | 96 | if [ -f pnpm-lock.yaml ]; then 97 | shasum -a 256 ~/klassijs/klassi-js/pnpm-lock.yaml > CACHE_KEY 98 | else 99 | echo "pnpm-lock.yaml not found, using package.json instead" 100 | shasum -a 256 ~/klassijs/klassi-js/package.json > CACHE_KEY 101 | fi 102 | 103 | # Restore cache if available 104 | - restore_cache: 105 | name: Restore pnpm cache 106 | keys: 107 | - pnpm-cache-v1-{{ checksum "CACHE_KEY" }} 108 | - pnpm-cache-v1- # Fallback cache key 109 | 110 | - run: 111 | name: Install the klassi-js framework dependencies 112 | path: ~/klassijs/klassi-js 113 | command: | 114 | git config --global url."git@github.com:".insteadOf "https://github.com/" 115 | pnpm install 116 | 117 | - run: 118 | name: Run pre-commit checks 119 | command: | 120 | cd ~/klassijs/klassi-js 121 | ./runtime/scripts/precommit-checks.sh 122 | 123 | ########################################################################## 124 | # Save pnpm cache, don't include node modules because we end up with an # 125 | # archive so large that unarchiving takes longer than the pnpm install # 126 | ########################################################################## 127 | # Save cache 128 | - save_cache: 129 | key: pnpm-cache-v1-{{ checksum "CACHE_KEY" }} 130 | paths: 131 | - ~/.pnpm-store # PNPM's global store for caching dependencies 132 | 133 | - persist_to_workspace: 134 | root: ./ 135 | paths: 136 | - ./klassi-js 137 | 138 | build_and_install: 139 | <<: *build_install 140 | 141 | ##################################################### 142 | ## Checkout Project Repos ## 143 | ##################################################### 144 | project_code_check_out: &project_code_check_out 145 | docker: 146 | - image: cimg/node:20.18.3-browsers 147 | resource_class: small 148 | 149 | steps: 150 | - attach_workspace: 151 | at: ./ 152 | 153 | - run: 154 | name: Avoid hosts unknown for github 155 | command: mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config 156 | 157 | - run: 158 | name: Create projects directory 159 | command: mkdir ./projects 160 | 161 | - run: 162 | name: Checkout the Project Repos 163 | path: ./projects 164 | command: | 165 | exec < "../klassi-js/runtime/scripts/projectList.csv" 166 | read -r header 167 | while IFS=", ", read -r name branch folder; do 168 | if test "$branch"; then 169 | echo "#################################################### " 170 | echo "# Cloning the $name Project " 171 | echo "#################################################### " 172 | git clone -b $branch git@github.com:klassijs/$name.git || true 173 | else 174 | git clone -b $branch git@github.com:klassijs/$name.git || true 175 | fi 176 | done 177 | 178 | ##################################################### 179 | ## Generate dependency cache key ## 180 | ##################################################### 181 | - run: 182 | name: Generate dependency cache key 183 | path: ./projects 184 | command: | 185 | exec < "../klassi-js/runtime/scripts/projectList.csv" 186 | read -r header 187 | while IFS=",", read -r name branch folder; do 188 | if test "$folder"; then 189 | echo "this is the list 1:- $name $branch $folder" 190 | cd $name/test 191 | cat shasum -a 256 ~/klassijs/klassi-js/pnpm-lock.yaml > CACHE_KEY 192 | cd ../../ 193 | else 194 | echo "this is the list 2:- $name $branch $folder" 195 | cat $name/shasum -a 256 ~/klassijs/klassi-js/pnpm-lock.yaml > CACHE_KEY 196 | fi 197 | done 198 | 199 | # Restore cache if available 200 | - restore_cache: 201 | keys: 202 | - pnpm-cache-v1-{{ checksum "CACHE_KEY" }} 203 | - pnpm-cache-v1- # Fallback cache key 204 | 205 | ########################################################################## 206 | # Save pnpm cache, don't include node modules because we end up with an # 207 | # archive so large that unarchiving takes longer than the pnpm install # 208 | ########################################################################## 209 | # Save cache 210 | - save_cache: 211 | key: pnpm-cache-v1-{{ checksum "CACHE_KEY" }} 212 | paths: 213 | - ~/.pnpm-store # PNPM's global store for caching dependencies 214 | 215 | ##################################################### 216 | ## Install all Project dependencies ## 217 | ##################################################### 218 | - run: 219 | name: Install all Project Dependencies 220 | path: ./projects 221 | command: | 222 | exec < "../klassi-js/runtime/scripts/projectList.csv" 223 | read -r header 224 | while IFS=", ", read -r name branch folder; do 225 | cd $name 226 | echo "#################################################### " 227 | echo "# Installing $name Project Dependencies...... " 228 | echo "#################################################### " 229 | if test "$folder"; then 230 | cd test 231 | git config --global url."git@github.com:".insteadOf "https://github.com/" 232 | pnpm install --network-concurrency 1 || true 233 | cd ../ 234 | else 235 | git config --global url."git@github.com:".insteadOf "https://github.com/" 236 | pnpm install --network-concurrency 1 || true 237 | fi 238 | cd ../ 239 | done 240 | 241 | - persist_to_workspace: 242 | root: ./ 243 | paths: 244 | - ./projects 245 | 246 | project_code_checkout: 247 | <<: *project_code_check_out 248 | 249 | ########################################### 250 | # s3 Report base configuration # 251 | ########################################### 252 | s3_report_base: &s3_report_base 253 | docker: 254 | - image: cimg/node:20.18.3-browsers 255 | resource_class: small 256 | steps: 257 | - attach_workspace: 258 | at: ./ 259 | 260 | ############################################### 261 | # Lambdatest setup for execution and shutdown # 262 | ############################################### 263 | - run: 264 | name: Lambdatest tunnel file downloading and upziping 265 | command: | 266 | sudo apt-get update && sudo apt-get install -y curl unzip iproute2 psmisc 267 | curl -O https://downloads.lambdatest.com/tunnel/v3/linux/64bit/LT_Linux.zip 268 | unzip LT_Linux.zip 269 | chmod +x LT 270 | rm LT_Linux.zip 271 | 272 | - run: 273 | name: Adding tunnel name to project 274 | background: true 275 | command: echo 'export TUNNEL_NAME=${STAGE}-${BROWSER}-${CIRCLE_BUILD_NUM}' >> $BASH_ENV 276 | 277 | - run: 278 | name: Starting Lambdatest Tunnel for testing 279 | background: true 280 | command: | 281 | if [ -z $LAMBDATEST_ACCESS_KEY ]; then 282 | echo "Error: The parameter lambdatest_key is empty. Please ensure the environment variable LAMBDATEST_KEY has been added." 283 | exit 1 284 | fi 285 | read LOWERPORT UPPERPORT < /proc/sys/net/ipv4/ip_local_port_range 286 | PORT=$LOWERPORT 287 | while [ $PORT -lt $UPPERPORT ]; do 288 | ss -lpn | grep -q ":$PORT " || break 289 | let PORT=PORT+1 290 | done 291 | echo "$PORT" > /tmp/port 292 | echo $TUNNEL_NAME 293 | ./LT --user $LAMBDATEST_USERNAME --key $LAMBDATEST_ACCESS_KEY --controller circleci --infoAPIPort $PORT --tunnelName $TUNNEL_NAME 294 | 295 | - run: 296 | name: Wait for LambdaTest Tunnel confirmation 297 | command: | 298 | while [ ! -f /tmp/port ]; do sleep 0.5; done 299 | PORT=$(head -1 /tmp/port) 300 | curl --silent --retry-connrefused --connect-timeout 5 --max-time 5 --retry 10 --retry-delay 2 --retry-max-time 30 http://127.0.0.1:$PORT/api/v1.0/info 2>&1 > /dev/null 301 | 302 | ######################### 303 | ## s3 report Execution ## 304 | ######################### 305 | - run: 306 | name: Executing the s3 Report 307 | path: ./projects 308 | command: | 309 | exitCode=0 310 | exec < ../klassi-js/runtime/scripts/projectList.csv 311 | read -r header 312 | while IFS=",", read -r name branch folder; do 313 | cd $name 314 | echo "#################################################### " 315 | echo "# The $name Project s3 Report # " 316 | echo "#################################################### " 317 | if test "$folder"; then 318 | cd $folder 319 | pnpm cilts3r chrome || exitCode=1 320 | cd ../../ 321 | else 322 | pnpm cilts3r chrome || exitCode=1 323 | cd ../ 324 | fi 325 | done 326 | exit $exitCode 327 | 328 | s3Report_email_run: 329 | <<: *s3_report_base 330 | 331 | ########################################### 332 | # Unit test base configuration # 333 | ########################################### 334 | unit_test_base: &unit_test_base 335 | docker: 336 | - image: cimg/node:20.18.3-browsers 337 | resource_class: small 338 | steps: 339 | - attach_workspace: 340 | at: ./ 341 | 342 | - run: 343 | name: Setting up tmp Dir for split files 344 | command: mkdir ./tmp 345 | 346 | - run: 347 | name: Run unit tests 348 | path: ./klassi-js 349 | command: | 350 | pnpm test:checkin 351 | 352 | - store_test_results: 353 | path: ./klassi-js/reports 354 | 355 | unit_test: 356 | <<: *unit_test_base 357 | 358 | ########################################### 359 | # Integration test base configuration # 360 | ########################################### 361 | integration_test_base: &integration_test_base 362 | docker: 363 | - image: cimg/node:20.18.3-browsers 364 | resource_class: small 365 | steps: 366 | - attach_workspace: 367 | at: ./ 368 | 369 | - run: 370 | name: Setting up tmp Dir for split files 371 | command: mkdir ./tmp 372 | 373 | - run: 374 | name: Run integration tests 375 | path: ./klassi-js 376 | command: | 377 | pnpm test:merge 378 | 379 | - store_test_results: 380 | path: ./klassi-js/reports 381 | 382 | integration_test: 383 | <<: *integration_test_base 384 | 385 | workflows: 386 | version: 2 387 | build_and_test: 388 | jobs: 389 | - build_and_install: 390 | context: klassi-framework 391 | <<: *filter_only_testable_branches 392 | 393 | - unit_test: 394 | context: klassi-framework 395 | requires: 396 | - build_and_install 397 | <<: *filter_only_testable_branches 398 | 399 | - integration_test: 400 | context: klassi-framework 401 | requires: 402 | - unit_test 403 | <<: *filter_only_releasable_branches 404 | 405 | # s3Report_run: 406 | # triggers: 407 | # - schedule: 408 | # cron: "00 04 * * *" 409 | # filters: 410 | # branches: 411 | # only: 412 | # - main 413 | # 414 | # jobs: 415 | # - build_and_install: 416 | # context: klassi-framework 417 | # 418 | # - project_code_checkout: 419 | # context: klassi-framework 420 | # requires: 421 | # - build_and_install 422 | # 423 | # - s3Report_email_run: 424 | # context: klassi-framework 425 | # requires: 426 | # - project_code_checkout 427 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi-js 3 | * Copyright © 2016 - Larry Goddard 4 | 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | */ 8 | require('dotenv').config(); 9 | 10 | const fs = require('fs-extra'); 11 | const merge = require('merge'); 12 | const { Command } = require('commander'); 13 | const requireDir = require('require-dir'); 14 | const path = require('path'); 15 | const loadTextFile = require('text-files-loader'); 16 | const { cosmiconfigSync } = require('cosmiconfig'); 17 | const { 18 | After, 19 | AfterAll, 20 | AfterStep, 21 | Status, 22 | Before, 23 | BeforeAll, 24 | BeforeStep, 25 | Given, 26 | When, 27 | Then, 28 | And, 29 | But, 30 | } = require('@cucumber/cucumber'); 31 | const { runCucumber, loadConfiguration } = require('@cucumber/cucumber/api'); 32 | const { promisify } = require('util'); 33 | const sleep = promisify(setTimeout); 34 | 35 | const { astellen } = require('klassijs-astellen'); 36 | 37 | const program = new Command(); 38 | 39 | const pjson = require('./package.json'); 40 | const takeReportBackup = require('./runtime/utils/takeReportBackup'); 41 | 42 | async function klassiCli() { 43 | try { 44 | const { runConfiguration } = await loadConfiguration(); 45 | const { success } = await runCucumber(runConfiguration); 46 | return success; 47 | } catch (error) { 48 | console.error('Error in klassiCli:', error); 49 | process.exit(1); 50 | } 51 | } 52 | 53 | (async () => { 54 | const chai = await import('chai'); 55 | global.assert = chai.assert; 56 | })(); 57 | 58 | global.fs = fs; 59 | global.sleep = sleep; 60 | 61 | /** 62 | * Global timeout to be used in test code 63 | * @type {number} 64 | */ 65 | global.DELAY_100ms = 100; 66 | global.DELAY_200ms = 200; 67 | global.DELAY_300ms = 300; 68 | global.DELAY_500ms = 500; 69 | global.DELAY_750ms = 750; 70 | global.DELAY_1s = 1000; 71 | global.DELAY_2s = 2000; 72 | global.DELAY_3s = 3000; 73 | global.DELAY_5s = 5000; 74 | global.DELAY_7s = 7000; 75 | global.DELAY_8s = 8000; 76 | global.DELAY_10s = 10000; 77 | global.DELAY_15s = 15000; 78 | global.DELAY_20s = 20000; 79 | global.DELAY_30s = 30000; 80 | global.DELAY_40s = 40000; 81 | global.DELAY_1m = 60000; 82 | global.DELAY_2m = 120000; 83 | global.DELAY_3m = 180000; 84 | 85 | /** 86 | * All Cucumber Global variables 87 | * @constructor 88 | */ 89 | global.Given = Given; 90 | global.When = When; 91 | global.Then = Then; 92 | global.And = And; 93 | global.But = But; 94 | global.After = After; 95 | global.AfterAll = AfterAll; 96 | global.AfterStep = AfterStep; 97 | global.Before = Before; 98 | global.BeforeAll = BeforeAll; 99 | global.BeforeStep = BeforeStep; 100 | global.Status = Status; 101 | 102 | global.projectRootPath = path.resolve(__dirname); 103 | 104 | function collectPaths(value, paths) { 105 | paths.push(value); 106 | return paths; 107 | } 108 | 109 | function parseRemoteArguments(argumentString) { 110 | if (!argumentString) { 111 | throw new Error('Expected an argumentString'); 112 | } 113 | const argSplit = argumentString.split('/'); 114 | const CONFIG = 0; 115 | const TAGS = 1; 116 | return { 117 | config: argSplit[CONFIG], 118 | tags: argSplit[TAGS], 119 | }; 120 | } 121 | 122 | program 123 | .version(pjson.version) 124 | .description(pjson.description) 125 | .option('--browser ', 'name of browser to use (chrome, firefox). defaults to chrome', 'chrome') 126 | .option('--context ', 'contextual root path for project-specific features, steps, objects etc', './') 127 | .option('--disableReport', 'Disables the auto opening of the test report in the browser. defaults to true') 128 | .option('--email', 'email for sending reports to stakeholders', false) 129 | .option('--featureFiles ', 'comma-separated list of feature files to run defaults to ./features', 'features') 130 | .option('--reportName ', 'basename for report files e.g. use report for report.json'.reportName) 131 | .option('--env ', 'name of environment to run the framework / test in. default to test', 'test') 132 | .option( 133 | '--sharedObjects ', 134 | 'path to shared objects (repeatable). defaults to ./shared-objects', 135 | 'shared-objects', 136 | ) 137 | .option('--pageObjects ', 'path to page objects. defaults to ./page-objects', 'page-objects') 138 | .option('--reports ', 'output path to save reports. defaults to ./reports', 'reports') 139 | .option('--headless', 'whether to run browser in headless mode. defaults to false', false) 140 | .option('--steps ', 'path to step definitions. defaults to ./step_definitions', 'step_definitions') 141 | .option( 142 | '--tags ', 143 | 'only execute the features or scenarios with tags matching the expression (repeatable)', 144 | collectPaths, 145 | [], 146 | ) 147 | .option( 148 | '--exclude ', 149 | 'excludes the features or scenarios with tags matching the expression (repeatable)', 150 | collectPaths, 151 | [], 152 | ) 153 | .option('--baselineImageUpdate', 'automatically update the baseline image after a failed comparison', false) 154 | .option('--remoteService ', 'which remote browser service, if any, should be used e.g. lambdatest', '') 155 | .option('--browserOpen', 'keep the browser open after each scenario. defaults to false', false) 156 | .option('--extraSettings ', 'further piped configs split with pipes', '') 157 | .option('--dlink', 'the switch for projects with their test suite, within a Test folder of the repo', false) 158 | .option( 159 | '--dryRun', 160 | 'the effect is that Cucumber will still do all the aggregation work of looking at your feature files, loading your support code etc but without actually executing the tests', 161 | false, 162 | ) 163 | .option( 164 | '--s3Date', 165 | 'this switches the s3 date to allow the downloading and emailing of reports from the latest test run and not last nights run', 166 | false, 167 | ) 168 | .option('--useProxy', 'This is in-case you need to use the proxy server while testing', false) 169 | .option('--skipTag ', 'provide a tag and all tests marked with it will be skipped automatically') 170 | .option('--isCI', 'This is to stop the html from being created while running in the CI', false) 171 | .option('--reportBackup', 'This to clear the "reports" folder & keep the record in back-up folder', false) 172 | .option('--reportClear', 'This to clear the "reports" folder', false); 173 | 174 | program.parse(process.argv); 175 | const options = program.opts(); 176 | 177 | program.on('--help', () => { 178 | console.info('For more details please visit https://github.com/klassijs/klassi-js#readme\n'); 179 | }); 180 | 181 | const settings = { 182 | projectRoot: options.context, 183 | reportName: options.reportName, 184 | disableReport: options.disableReport, 185 | remoteService: options.remoteService, 186 | extraSettings: options.extraSettings, 187 | baselineImageUpdate: options.baselineImageUpdate, 188 | }; 189 | 190 | global.settings = settings; 191 | global.BROWSER_NAME = options.browser; 192 | global.headless = options.headless; 193 | global.browserOpen = options.browserOpen; 194 | global.dryRun = options.dryRun; 195 | global.email = options.email; 196 | global.s3Date = options.s3Date; 197 | global.useProxy = options.useProxy; 198 | global.skipTag = options.skipTag; 199 | global.isCI = options.isCI; 200 | global.dlink = options.dlink; 201 | global.baselineImageUpdate = options.baselineImageUpdate; 202 | 203 | const getConfig = (configName) => cosmiconfigSync(configName).search().config; 204 | const { environment } = getConfig('envConfig'); 205 | const { dataConfig } = getConfig('dataConfig'); 206 | 207 | global.env = process.env.ENVIRONMENT || environment[options.env]; 208 | global.dataconfig = dataConfig; 209 | global.s3Data = dataConfig.s3Data; 210 | global.emailData = dataConfig.emailData; 211 | global.projectName = process.env.PROJECT_NAME || dataConfig.projectName; 212 | global.reportName = process.env.REPORT_NAME || 'Automated Report'; 213 | global.tagNames = dataConfig.tagNames; 214 | 215 | const helpers = require('./runtime/helpers'); 216 | global.helpers = helpers; 217 | 218 | global.date = helpers.currentDate(); 219 | global.dateTime = helpers.reportDateTime(); 220 | 221 | if (!global.isCI) { 222 | if (options.reportBackup) { 223 | takeReportBackup.backupReport(); 224 | } 225 | if (options.reportClear) { 226 | takeReportBackup.clearReport(); 227 | } 228 | } 229 | 230 | if (options.remoteService && options.extraSettings) { 231 | const additionalSettings = parseRemoteArguments(options.extraSettings); 232 | global.remoteConfig = additionalSettings.config; 233 | if (additionalSettings.tags) { 234 | if (options.tags.length !== 0) { 235 | throw new Error('Cannot set two types of tags - either use --extraSettings or --tags'); 236 | } 237 | options.tags = [additionalSettings.tags]; 238 | } 239 | } 240 | 241 | global.browserName = global.remoteConfig || BROWSER_NAME; 242 | astellen.set('BROWSER_NAME', options.browser); 243 | 244 | function getProjectPath(objectName) { 245 | return path.resolve(settings.projectRoot + options[objectName]); 246 | } 247 | 248 | const paths = { 249 | pageObjects: getProjectPath('pageObjects'), 250 | reports: getProjectPath('reports'), 251 | featureFiles: getProjectPath('featureFiles'), 252 | sharedObjects: getProjectPath('sharedObjects'), 253 | }; 254 | 255 | /** expose settings and paths for global use */ 256 | global.paths = paths; 257 | 258 | const envName = env.envName.toLowerCase(); 259 | const reports = `./reports/${global.browserName}/${envName}`; 260 | 261 | fs.ensureDirSync(reports, (err) => { 262 | if (err) { 263 | console.error(`The Reports Folder has NOT been created: ${err.stack}`); 264 | } 265 | }); 266 | fs.ensureDirSync(reports + 'Combine', (err) => { 267 | if (err) { 268 | console.error(`The Reports Combine Folder has NOT been created: ${err.stack}`); 269 | } 270 | }); 271 | 272 | const videoLib = path.resolve(__dirname, './runtime/getVideoLinks.js'); 273 | if (fs.existsSync(videoLib)) { 274 | global.videoLib = require(videoLib); 275 | } else { 276 | console.error('No Video Lib'); 277 | } 278 | 279 | let sharedObjects = {}; 280 | const sharedObjectsPath = path.resolve(paths.sharedObjects); 281 | if (fs.existsSync(sharedObjectsPath)) { 282 | const allDirs = {}; 283 | const dir = requireDir(sharedObjectsPath, { camelcase: true, recurse: true }); 284 | sharedObjects = merge(allDirs, dir); 285 | global.sharedObjects = sharedObjects; 286 | } 287 | 288 | const pageObjectPath = path.resolve(paths.pageObjects); 289 | if (fs.existsSync(pageObjectPath)) { 290 | global.pageObjects = requireDir(pageObjectPath, { 291 | camelcase: true, 292 | recurse: true, 293 | }); 294 | } 295 | 296 | function getTagsFromFeatureFiles() { 297 | let result = []; 298 | let featurefiles = {}; 299 | loadTextFile.setup({ matchRegExp: /\.feature/ }); 300 | const featureFilesList = options.featureFiles.split(','); 301 | 302 | featureFilesList.forEach((feature) => { 303 | const filePath = path.resolve(feature); 304 | try { 305 | const fileContent = loadTextFile.loadSync(filePath); 306 | featurefiles = Object.assign(featurefiles, fileContent); 307 | } catch (error) { 308 | console.error(`Error loading feature file ${filePath}:`, error); 309 | } 310 | }); 311 | 312 | Object.keys(featurefiles).forEach((key) => { 313 | const content = String(featurefiles[key] || '', ' '); 314 | const tags = content.match(new RegExp('@[a-z0-9]+', 'g')) || []; 315 | result = result.concat(tags); 316 | }); 317 | return result; 318 | } 319 | 320 | if (!options.tags || options.tags.length === 0) { 321 | process.exit(1); 322 | } 323 | let resultingString = ''; 324 | if (options.tags.length > 0) { 325 | const tagsFound = getTagsFromFeatureFiles(); 326 | const separateMultipleTags = options.tags[0].split(','); 327 | let separateExcludedTags; 328 | 329 | if (options.exclude && options.exclude.length >= 1) { 330 | separateExcludedTags = options.exclude[0].split(','); 331 | } 332 | 333 | const correctTags = []; 334 | const correctExcludedTags = []; 335 | 336 | for (const tag of separateMultipleTags) { 337 | if (tag[0] !== '@') { 338 | console.error('tags must start with a @'); 339 | process.exit(1); 340 | } 341 | if (tagsFound.indexOf(tag) === -1) { 342 | console.error(`this tag ${tag} does not exist`); 343 | process.exit(0); 344 | } 345 | correctTags.push(tag); 346 | } 347 | 348 | if (correctTags.length === 0) { 349 | console.error('No valid tags found.'); 350 | process.exit(1); 351 | } 352 | 353 | if (separateExcludedTags && separateExcludedTags.length >= 1) { 354 | for (const tag of separateExcludedTags) { 355 | if (tag[0] !== '@') { 356 | console.error('tags must start with a @'); 357 | process.exit(1); 358 | } 359 | if (tagsFound.indexOf(tag) === -1) { 360 | console.error(`this tag ${tag} does not exist`); 361 | process.exit(0); 362 | } 363 | correctExcludedTags.push(tag); 364 | } 365 | } 366 | 367 | if (correctTags.length > 1) { 368 | resultingString = correctTags.join(' or '); 369 | if (correctExcludedTags.length > 0) { 370 | const excludedCommand = correctExcludedTags.join(' and not '); 371 | resultingString = `${resultingString} and not ${excludedCommand}`; 372 | } 373 | } else { 374 | resultingString = correctTags[0]; 375 | if (correctExcludedTags.length > 0) { 376 | const excludedCommand = correctExcludedTags.join(' and not '); 377 | resultingString = `${resultingString} and not ${excludedCommand}`; 378 | } 379 | } 380 | 381 | global.resultingString = resultingString; 382 | } else { 383 | console.error('No tags provided in options.'); 384 | process.exit(1); 385 | } 386 | 387 | if (options.featureFiles) { 388 | const splitFeatureFiles = options.featureFiles.split(','); 389 | global.featureFiles = splitFeatureFiles; 390 | } 391 | 392 | /** Add split to run multiple browsers from the command line 393 | * Runs the script in parallel for each browser passed via --browser. 394 | * @param {object} options - Parsed CLI options from Commander. 395 | */ 396 | function handleMultipleBrowsers(options) { 397 | const { spawn } = require('child_process'); 398 | if (!options.browser) return; 399 | 400 | // Prevent recursion: only run this logic if the original input had multiple browsers 401 | const originalArgv = process.argv.join(' '); 402 | if (!originalArgv.includes(',') || options.browser.includes(',')) { 403 | const browsers = options.browser.split(',').map(b => b.trim()).filter(Boolean); 404 | if (browsers.length <= 1) return; 405 | 406 | const scriptPath = process.argv[1]; 407 | const args = process.argv.slice(2); 408 | 409 | const browserIndex = args.findIndex(arg => arg === '--browser'); 410 | const baseArgs = args.slice(0, browserIndex); 411 | const trailingArgs = args.slice(browserIndex + 2); 412 | 413 | let completed = 0; 414 | const results = {}; 415 | 416 | browsers.forEach(browser => { 417 | const child = spawn('node', [scriptPath, ...baseArgs, '--browser', browser, ...trailingArgs], { 418 | stdio: 'inherit', 419 | shell: true, 420 | }); 421 | 422 | child.on('exit', code => { 423 | results[browser] = code; 424 | completed++; 425 | 426 | if (completed === browsers.length) { 427 | console.log('\n📋 Summary:'); 428 | browsers.forEach(b => { 429 | if (results[b] === 0) { 430 | console.log(`✅ ${b} completed successfully`); 431 | } else { 432 | console.error(`❌ ${b} failed with exit code ${results[b]}`); 433 | } 434 | }); 435 | process.exit(0); 436 | } 437 | }); 438 | }); 439 | // Prevent the parent from continuing 440 | process.exit(0); 441 | } 442 | } 443 | handleMultipleBrowsers(options); 444 | 445 | 446 | klassiCli().then(async (succeeded) => { 447 | let dryRun = false; 448 | if (dryRun === false) { 449 | if (!succeeded) { 450 | await cucumberCli().then(async () => { 451 | await process.exit(1); 452 | }); 453 | } else { 454 | await cucumberCli().then(async () => { 455 | await sleep(DELAY_2s).then(async () => { 456 | console.info('Test run completed successfully'); 457 | await process.exit(0); 458 | }); 459 | }); 460 | } 461 | } 462 | }); 463 | 464 | async function cucumberCli() { 465 | let email = false; 466 | if (options.remoteService && options.remoteService === 'lambdatest' && resultingString !== '@s3load') { 467 | await sleep(DELAY_2s).then(async () => { 468 | await helpers.klassiReporter(); 469 | }); 470 | } else if (resultingString !== '@s3load') { 471 | await sleep(DELAY_2s).then(async () => { 472 | await helpers.klassiReporter(); 473 | }); 474 | } 475 | await sleep(DELAY_5s); 476 | if (email === true) { 477 | await sleep(DELAY_2s).then(async () => { 478 | await helpers.klassiEmail(); 479 | await sleep(DELAY_3s); 480 | }); 481 | } 482 | } 483 | 484 | module.exports = { getTagsFromFeatureFiles }; 485 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

klassi-js
3 | 4 | klassi-js 5 |

6 | 7 |

8 | 9 | License 10 | 11 | 12 | WebdriverIO 13 | 14 | 15 | WebdriverIO 16 |
17 | klassi-Js is a debuggable BDD (Behavior-Driven Development) JavaScript test automation framework, Built on webdriver.io (Next-gen browser and mobile automation test framework for Node.js) and cucumber-js and distinguished by its integration of AI for advanced debugging functionalities. This incorporation of artificial intelligence elevates the framework's capabilities, providing a streamlined and efficient approach to test automation with integrated Visual validation, accessibility and API Testing, your test can run locally or in the cloud using Lambdatest, BrowserStack or Sauce Labs 18 |

19 |   20 | 21 | ## Installation 22 | 23 | ```bash 24 | pnpm add klassi-js 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```bash 30 | node ./node_modules/klassi-js/index.js 31 | ``` 32 | 33 | ## Options 34 | 35 | ```bash 36 | --help output usage information 37 | --version output the version number 38 | --browser name of browser to use (chrome, firefox). defaults to chrome 39 | --tags <@tagName> name of cucumber tags to run - Multiple TAGS usage (@tag1,@tag2) 40 | --exclude <@tagName> name of cucumber tags to exclude - Multiple TAGS usage(@tag3,@tag5) 41 | --steps path to step definitions. defaults to ./step-definitions 42 | --featureFiles path to feature definitions. defaults to ./features 43 | --pageObjects path to page objects. defaults to ./page-objects 44 | --sharedObjects path to shared objects - repeatable. defaults to ./shared-objects 45 | --reports output path to save reports. defaults to ./reports 46 | --disableReport disables the test report from opening after test completion 47 | --email sends email reports to stakeholders 48 | --env name of environment to run the framework/test in. default to dev 49 | --reportName name of what the report would be called i.e. 'Automated Test' 50 | --remoteService which remote driver service, if any, should be used e.g. browserstack 51 | --extraSettings further piped configs split with pipes 52 | --baselineImageUpdate automatically update the baseline image after a failed comparison. defaults to false 53 | --browserOpen this leaves the browser open after the session completes, useful when debugging test. defaults to false 54 | --dlink the switch for projects with their test suite, within a Test folder of the repo 55 | --dryRun the effect is that Cucumber will still do all the aggregation work of looking at your feature files, loading your support code etc but without actually executing the tests 56 | --useProxy this is in-case you need to use the proxy server while testing 57 | --reportBackup This is to clear the "reports" folder & keep the record in back-up folder,default value is false. While using this indicator, the name "reportBackup" needs to be added to the git ignore file 58 | --reportClear This is to clear the "reports" folder, default value is false 59 | --skipTag <@tagName> provide a tag and all tests marked with it will be skipped automatically. 60 | ``` 61 | ## Options Usage 62 | ```bash 63 | --tags @get,@put || will execute the scenarios tagged with the values provided. If multiple are necessary, separate them with a comma (no blank space in between). 64 | --featureFiles features/utam.feature,features/getMethod.feature || provide specific feature files containing the scenarios to be executed. If multiple are necessary, separate them with a comma (no blank space in between). 65 | --browser firefox,chrome || will execute the tests in the browser specified. To run tests in parallel use multiple browsers, separate them with a comma (no blank space in between). 66 | ``` 67 | ## Upgrading to klassi-js v6 68 | To upgrade existing projects for use with klassi-js v6, please follow these few steps [HERE](runtime/docs/klassi-js-v6-Upgrade.md) 69 | 70 | ## Directory Structure 71 | You can use the framework without any command line arguments if your application uses the following folder structure, to help with the built in functionality usage, we have added a .envConfigrc.js file at the base of the project which will contain all your env configs . You can check out the working [TEMPLATE HERE](https://github.com/klassijs/klassijs-example-project) 72 | 73 | ```bash 74 | . 75 | └── features 76 |    └── search.feature 77 | └── page-objects 78 |    └── search.js 79 | └── shared-objects 80 |    └── searchData.js 81 | └── step_definitions 82 |    └── search-steps.js 83 | .envConfigrc.js # this file will contain all your environment variables (i.e. dev, test, prod etc.,) 84 | .dataConfigrc.js # this file will contain all your project variables #projectName, emailAddresses 85 | .env # this file contains all config username and passcode and should be listed in the gitignore file 86 | cucumber.js # the cucumber configuration file 87 | ``` 88 | 89 | ## Step definitions 90 | The following variables are available within the ```Given()```, ```When()``` and ```Then()``` functions: 91 | 92 | | Variable | Description | 93 | | :--- | :--- | 94 | | `browser` | an instance of [webdriverio](https://webdriver.io/docs/setuptypes.html) (_the browser_) | 95 | | `pageObjects` | collection of **page** objects loaded from disk and keyed by filename | 96 | | `sharedObjects` | collection of **shared** objects loaded from disk and keyed by filename | 97 | | `helpers` | a collection of [helper methods](runtime/helpers.js) _things webdriver.io does not provide but really should!_ | 98 | 99 | 100 | ## Klassi-js JS modules 101 | 102 | To streamline test script development and ensure consistency across projects, the following JavaScript libraries are being exported from klassi-js. This approach allows these libraries to be utilized at the project level without the need for duplicate installations, thereby reducing redundancy and potential conflicts. 103 | 104 | - Exported libraries: 105 | 106 | | JS Library | Description | 107 | | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | 108 | | `pactum` | A REST API testing tool for automating end-to-end, integration, contract, and component tests | 109 | | `webdriverio` | A next-gen browser and mobile automation test framework for Node.js | 110 | | `fs-extra` | Provides extra file system methods and promise support, enhancing the native fs module | 111 | | `dotenv` | Loads environment variables from a .env file into process.env to manage configuration separately from code | 112 | | `Husky` | A tool for managing Git hooks to automate tasks like linting, testing, and code formatting before commits or pushes | 113 | | `S3Client` | Part of the AWS SDK for JavaScript, it allows interaction with Amazon S3 for operations like uploading, downloading, and managing objects | 114 | 115 | ```js 116 | // usage klassi-js module at project level i.e. 117 | const pactumJs = require('klassi-js/klassiModule').pactum; 118 | 119 | require('klassi-js/klassiModule').dotenv.config(); 120 | 121 | const fs = require('klassi-js/klassiModule').fs-extra; 122 | 123 | ``` 124 | 125 | 126 | ## Helpers 127 | klassi-js contains a few helper methods to help along the way, these methods are: 128 | 129 | 130 | | Function | Description | 131 | | :------------------------------------------------------ | :---------------------------------------------------------------------------------- | 132 | | await helpers.loadPage('url', timeout) | Loads the required page | 133 | | await helpers.writeToTxtFile(filepath, output) | Writes content to a text file | 134 | | await helpers.readFromFile(filepath) | Reads content from a text file | 135 | | await helpers.currentDate() | Applies the current date to files | 136 | | await helpers.getCurrentDateTime() | Get current date and time | 137 | | await helpers.clickHiddenElement(selector, textToMatch) | Clicks an element that is not visible | 138 | | await helpers.getRandomIntegerExcludeFirst(range) | Get a random integer from a given range | 139 | | await helpers.getLink(selector) | Get the href link from an element | 140 | | await helpers.waitAndClick(selector) | Wait until and element is visible and click it | 141 | | await helpers.waitAndSetValue(selector, value) | Wait until element to be in focus and set the value | 142 | | await helpers.getElementFromFrame(frameName, selector) | Get element from frame or frameset | 143 | | await helpers.readFromJson() | Read from a json file | 144 | | await helpers.writeToJson() | Write data to a json file | 145 | | await helpers.mergeJson() | Merge json files | 146 | | await helpers.reportDateTime() | Reporting the current date and time | 147 | | await helpers.apiCall() | API call for GET, PUT, POST and DELETE functionality using PactumJS for API testing | 148 | | await helpers.getLink() | Get the href link from an element | 149 | | await helpers.getElementFromFrame() | Get element from frame or frameset | 150 | | await helpers.generateRandomInteger() | Generate random integer from a given range | 151 | | await helpers.randomNumberGenerator() | Generates a random 13 digit number | 152 | | await helpers.reformatDateString() | Reformats date string into string | 153 | | await helpers.sortByDate() | Sorts results by date | 154 | | await helpers.filterItem() | Filters an item from a list of items | 155 | | await helpers.filterItemAndClick() | Filters an item from a list of items and clicks on it | 156 | | await helpers.fileUpload() | Uploads a file from local system or project folder | 157 | 158 | 159 | ## Browser usage 160 | By default, the test run using Google Chrome/devtools protocol, to run tests using another browser locally supply the browser name along with the `--browser` switch 161 | 162 | | Browser | Example | 163 | | :--- | :--- | 164 | | Chrome | `--browser chrome` | 165 | | Firefox | `--browser firefox` | 166 | 167 | To run tests in parallel, supply multiple browser names separated by a comma, for example: `--browser chrome,firefox` 168 | 169 | ## Remote Browser usage 170 | To run tests in a remote browser, you can use the `--remoteService` switch to specify the service you want to use. Currently, the supported services are: LambdaTest, BrowserStack, SauceLabs, and Selenium Standalone. 171 | 172 | Selenium Standalone installation 173 | ```bash 174 | npm install -g selenium-standalone@latest 175 | selenium-standalone install 176 | ``` 177 | 178 | ## Soft Assert with [klassijs-soft-assert](https://github.com/klassijs/klassijs-soft-assert#readme) 179 | The Assertion Tool is designed to enhance your testing by allowing tests to continue running when assertions fail. Instead of halting the test on failure, the tool collects all failed assertions and compiles them into a comprehensive report at the end of the test run. 180 | 181 | Usage of the Assertion Tool is straightforward. You can use it with any assertion library, such as Chai or expect-webdriverio, by passing the actual value, the assertion method, the expected value, and an optional message. 182 | 183 | ```js 184 | // usage within page-object file: 185 | const { softAssert } = require('klassijs-soft-assert'); 186 | 187 | // Any Chai assert method 188 | await softAssert(actual, 'deepEqual', expected, 'Deep equality check'); 189 | await softAssert(actual, 'isArray', undefined, 'Should be an array'); 190 | await softAssert(actual, 'include', expected, 'Should include value'); 191 | 192 | // Any expect-webdriverio method 193 | await softAssert(element, 'toBeDisplayed', undefined, 'Element should be displayed'); 194 | await softAssert(element, 'toHaveAttribute', 'class', 'Element should have class'); 195 | await softAssert(element, 'toHaveText', expected, 'Element should have text'); 196 | 197 | // Chained expect-webdriverio methods 198 | await softAssert(element, 'not.toBeEnabled', undefined, 'Element should not be enabled'); 199 | await softAssert(element, 'not.toBeSelected', undefined, 'Element should not be selected'); 200 | ``` 201 | 202 | ## Visual Validation with [klassijs-visual-validation](https://github.com/klassijs/klassijs-visual-validation#readme) 203 | 204 | Visual regression testing, the ability to compare a whole page screenshots or of specific parts of the application / page under test. 205 | If there is dynamic content (i.e. a clock), hide this element by passing the selector (or an array of selectors) to the takeImage function. 206 | ```js 207 | // usage within page-object file: 208 | // Use the `takeImage` method to take a screenshot and automatically compare it with the baseline: 209 | const {takeImage, compareImage} = require('klassijs-visual-validation'); 210 | await takeImage('screenshot.png'); 211 | ``` 212 | 213 | **Advanced Usage Options**: 214 | ```javascript 215 | // Take screenshot of a specific element and compare 216 | await takeImage('button.png', '.submit-button'); 217 | 218 | // Hide elements during screenshot and compare 219 | await takeImage('clean-page.png', null, '.header, .footer'); 220 | 221 | // Take screenshot without comparison 222 | await takeImage('screenshot.png', null, '', false); 223 | 224 | // Use custom tolerance for comparison 225 | await takeImage('screenshot.png', null, '', true, 0.1); 226 | ``` 227 | 228 | ## API Testing with [PactumJS](https://github.com/pactumjs/pactum#readme) 229 | Getting data from a JSON REST API 230 | ```js 231 | apiCall: async (url, method, auth, body, status) => { 232 | let resp; 233 | const options = { 234 | url, 235 | method, 236 | headers: { 237 | Authorization: `Bearer ${auth}`, 238 | 'content-Type': 'application/json', 239 | }, 240 | body, 241 | }; 242 | 243 | if (method === 'GET') { 244 | resp = await helpers.apiCall(url, 'GET', auth); 245 | return resp.statusCode; 246 | } 247 | if (method === 'POST') { 248 | resp = await helpers.apiCall(url, 'POST', auth, body, status); 249 | return resp; 250 | } 251 | } 252 | ``` 253 | ## Accessibility Testing with [klassijs-a11y-validator](https://github.com/klassijs/klassijs-a11y-validator) 254 | Automated accessibility testing feature has been introduced using the Axe-Core OpenSource library. 255 | 256 | ### Sample code 257 | All the accessibility fuctions can be accessed through the global variable ``` accessibilityLib ```. 258 | | function | Description | 259 | |----------------------------|-----------------------------------------------------------------| 260 | | ``` accessibilityLib.getAccessibilityReport('PageName')```| generates the accessibility report with the given page name | 261 | | ``` accessibilityLib.getAccessibilityError()``` | returns the total number of error count for a particular page. | 262 | | ``` accessibilityLib.getAccessibilityTotalError() ``` | returns the total number of error count for all the pages in a particilar execution | 263 | 264 | ```js 265 | // usage within page-object file: 266 | const { a11yValidator } = require("klassijs-a11y-validator"); 267 | 268 | When("I run the accesibility analysis for {string}", async function (PageName) { 269 | // After navigating to a particular page, just call the function to generate the accessibility report and the total error count for the page 270 | await a11yValidator(`SearchPage1-${PageName}`); 271 | }); 272 | ``` 273 | ## Accessibility Report 274 | 275 | HTML and JSON reports will be automatically generated and stored in the default `./reports/accessibility` folder.This location can be changed by providing a new path using the `--reports` command line switch: 276 | 277 | ![Aceessibility HTML report](./runtime/img/accessibility-html-report.png) 278 | 279 | 280 | ## Test Execution Reports with [klassijs-cucumber-html-reporter](https://github.com/klassijs/klassijs-cucumber-html-reporter) 281 | 282 | HTML and JSON reports will be automatically generated and stored in the default `./reports` folder. This location can be changed by providing a new path using the `--reports` command line switch: 283 | 284 | ![Cucumber HTML report](./runtime/img/cucumber-html-report.png) 285 | 286 | 287 | ## Event handlers 288 | 289 | You can register event handlers for the following events within the cucumber lifecycle. 290 | 291 | const {After, Before, AfterAll, BeforeAll, BeforeStep, AfterStep} = require('@cucumber/cucumber'); 292 | 293 | | Event | Example | 294 | |----------------|-------------------------------------------------------------| 295 | | Before | ```Before(function() { // This hook will be executed before all scenarios}) ``` | 296 | | After | ```After(function() {// This hook will be executed after all scenarios});``` | 297 | | BeforeAll | ```BeforeAll(function() {// perform some shared setup});``` | 298 | | AfterAll | ```AfterAll(function() {// perform some shared teardown});``` | 299 | | BeforeStep | ```BeforeStep(function() {// This hook will be executed before all steps in a scenario with tagname;``` | 300 | | AfterStep | ```AfterStep(function() {// This hook will be executed after all steps, and take a screenshot on step failure;``` | 301 | 302 | ## How to debug 303 | 304 | Most webdriverio methods return a [JavaScript Promise](https://spring.io/understanding/javascript-promises "view JavaScript promise introduction") that is resolved when the method completes. The easiest way to step in with a debugger is to add a ```.then``` method to the function and place a ```debugger``` statement within it, for example: 305 | 306 | ```js 307 | When(/^I search DuckDuckGo for "([^"]*)"$/, function (searchQuery, done) { 308 | elem = browser.$('#search_form_input_homepage').then(function(input) { 309 | expect(input).to.exist; 310 | debugger; // <<- your IDE should step in at this point, with the browser open 311 | return input; 312 | }) 313 | done(); // <<- let cucumber know you're done 314 | }); 315 | ``` 316 | 317 | ## Commit conventions 318 | 319 | To enforce best practices in using Git for version control, this project includes a **Husky** configuration. Note that breaking the given rules will block the commit of the code. 320 | 321 | Bear in mind that the `/.circleci/config.yml` file **in each project using klassi-js as a dependency** needs to be modified to change from `pnpm install` to `pnpm install --network-concurrency 1`. This is to avoid race conditions in multiple calls to the registry during the installation process. 322 | 323 | ### Commits 324 | After committing the staged code, the Husky scripts will enforce the implementation of the [**Conventional Commits specification**](https://www.conventionalcommits.org/en/v1.0.0/#summary). 325 | 326 | To summarize them, all commits should follow the following schema: 327 | 328 | ``` 329 | git commit -m ': ' 330 | git commit -m 'chore(): updating config code' 331 | ``` 332 | 333 | Where **type** is one of the following: 334 | 335 | - **fix**: a commit of the type fix patches a bug in your codebase (this correlates with PATCH in Semantic Versioning). 336 | - **feat**: a commit of the type feat introduces a new feature to the codebase (this correlates with MINOR in Semantic Versioning). 337 | - **BREAKING CHANGE**: a commit that has a footer BREAKING CHANGE:, or appends a ! after the type/scope, introduces a breaking API change (correlating with MAJOR in Semantic Versioning). A BREAKING CHANGE can be part of commits of any type. 338 | - Types other than **fix:** and **feat:** are allowed, for example @commitlint/Tconfig-conventional (based on the Angular convention) recommends **build:, chore:, ci:, docs:, style:, refactor:, perf:, test:**, and others. 339 | footers other than **BREAKING CHANGE:** may be provided and follow a convention similar to git trailer format. 340 | 341 | Please keep in mind that the **subject** must be written in lowercase. 342 | 343 | ### Branch naming 344 | 345 | The same script will also verify the naming convention. Please remember that we only allow for two possible branch prefixes: 346 | 347 | - **testfix/** 348 | - **automation/** 349 | - **feature/** 350 | 351 | 352 | ## Bugs 353 | 354 | Please raise bugs via the [klassijs issue tracker](https://github.com/klassijs/klassi-js/issues), please provide enough information for bug reproduction. 355 | 356 | ## Contributing 357 | 358 | Anyone can contribute to this project, PRs are welcome. In lieu of a formal styleguide, please take care to maintain the existing coding style. 359 | 360 | ## Credits 361 | 362 | John Doherty 363 | 364 | 365 | ## License 366 | 367 | Licenced under [MIT License](LICENSE) © 2016 [Larry Goddard](https://www.linkedin.com/in/larryg) 368 | -------------------------------------------------------------------------------- /runtime/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * klassi Automated Testing Tool 3 | * Created by Larry Goddard 4 | */ 5 | const fs = require('fs-extra'); 6 | const pactumJs = require('pactum'); 7 | const { assertExpect } = require('klassijs-soft-assert'); 8 | const loadConfig = require('./configLoader'); 9 | const { EOL, os } = require('os'); 10 | 11 | let elem; 12 | let getMethod; 13 | let resp; 14 | let modID; 15 | 16 | module.exports = { 17 | /** 18 | * returns a promise that is called when the url has loaded and the body element is present 19 | * @param {string} url to load 20 | * @param seconds 21 | * @returns {Promise} 22 | * @example 23 | * helpers.loadPage('http://www.duckduckgo.com', 5); 24 | */ 25 | loadPage: async (url, seconds) => { 26 | /** 27 | * Wait function - measured in seconds for pauses during tests to give time for processes such as 28 | * a page loading or the user to see what the test is doing 29 | * @param seconds 30 | * @type {number} 31 | */ 32 | const timeout = seconds || global.timeout; 33 | /** 34 | * load the url and wait for it to complete 35 | */ 36 | await browser.url(url, async () => { 37 | /** 38 | * now wait for the body element to be present 39 | */ 40 | await browser.waitUntil(async () => await browser.execute(() => document.readyState === 'complete'), { 41 | timeoutMsg: `The web page is still not loaded after ${timeout} seconds`, 42 | }); 43 | }); 44 | /** 45 | * grab the userAgent details from the loaded url 46 | */ 47 | cucumberThis.attach(`loaded url: ${url}`); 48 | }, 49 | 50 | /** 51 | * writeTextFile write data to file on hard drive 52 | * @param filepath 53 | * @param output 54 | */ 55 | writeToTxtFile: async (filepath, output) => { 56 | try { 57 | await fs.truncate(filepath, 0); 58 | await fs.writeFileSync(filepath, output); 59 | } catch (err) { 60 | console.error(`Error in writing file ${err.message}`); 61 | throw err; 62 | } 63 | }, 64 | 65 | /** 66 | * append / add data to file on hard drive 67 | * @param filepath 68 | * @param output 69 | * @returns {Promise} 70 | */ 71 | appendToTxtFile: async (filepath, output) => { 72 | try { 73 | await fs.openSync(filepath, 'a'); 74 | await fs.appendFileSync(filepath, output + '\r\n'); 75 | await fs.appendFileSync(filepath, EOL); 76 | } catch (err) { 77 | console.error(`Error in writing file ${err.message}`); 78 | throw err; 79 | } 80 | }, 81 | 82 | /** 83 | * This is to read the content of a text file 84 | * @param filepath 85 | * @returns {Promise} 86 | */ 87 | readFromFile: (filepath) => 88 | new Promise((resolve) => { 89 | fs.readFile(filepath, 'utf-8', (err, data) => { 90 | data = data.toString(); 91 | resolve(data); 92 | // console.log('Success - the file content ', data); 93 | }); 94 | }), 95 | 96 | /** 97 | * This is to read the content of a Json file 98 | * @param filename 99 | * @returns {Promise} 100 | */ 101 | readFromJson: async (filename) => { 102 | const fileContent = await fs.readJson(filename); 103 | // console.log('Success - the file content ', fileContent); 104 | return fileContent; 105 | }, 106 | 107 | /** 108 | * This is to write values into a JSON file 109 | * @param filePath 110 | * @param fileContent 111 | * @returns {Promise} 112 | */ 113 | writeToJson: async (filePath, fileContent) => { 114 | try { 115 | await fs.writeFile(filePath, JSON.stringify(fileContent, null, 4)); 116 | // console.log('Success - the content: ', fileContent); 117 | } catch (err) { 118 | console.error('This Happened: ', err); 119 | } 120 | }, 121 | 122 | /** 123 | * This is to merge content of json files 124 | * @param filePath 125 | * @param file 126 | * @returns {Promise} 127 | */ 128 | mergeJson: async (filePath, file) => { 129 | const fileA = loadConfig(filePath); 130 | return Object.assign(fileA, file); 131 | }, 132 | 133 | /** 134 | * Get the current date dd-mm-yyyy 135 | * @returns {string|*} 136 | */ 137 | currentDate() { 138 | const today = new Date(); 139 | let dd = today.getDate(); 140 | let mm = today.getMonth() + 1; // January is 0! 141 | const yyyy = today.getFullYear(); 142 | 143 | if (dd < 10) { 144 | dd = `0${dd}`; 145 | } 146 | if (mm < 10) { 147 | mm = `0${mm}`; 148 | } 149 | return `${dd}-${mm}-${yyyy}`; 150 | }, 151 | 152 | /** 153 | * Get the current date in yyyy-mm-dd format for the s3 bucket folder 154 | * @returns {string|*} 155 | */ 156 | s3BucketCurrentDate() { 157 | const today = new Date(); 158 | let dd = today.getDate(); 159 | let mm = today.getMonth() + 1; // January is 0! 160 | const yyyy = today.getFullYear(); 161 | 162 | if (dd < 10) { 163 | dd = `0${dd}`; 164 | } 165 | if (mm < 10) { 166 | mm = `0${mm}`; 167 | } 168 | return `${yyyy}-${mm}-${dd}`; 169 | }, 170 | 171 | reportDateTime() { 172 | const today = new Date(); 173 | let dd = today.getDate(); 174 | let mm = today.getMonth() + 1; // January is 0! 175 | const yyyy = today.getFullYear(); 176 | let hours = today.getHours(); 177 | let minutes = today.getMinutes(); 178 | let seconds = today.getSeconds(); 179 | let milliseconds = today.getMilliseconds(); 180 | 181 | if (dd < 10) { 182 | dd = `0${dd}`; 183 | } 184 | if (mm < 10) { 185 | mm = `0${mm}`; 186 | } 187 | if (hours < 10) { 188 | hours = `0${hours}`; 189 | } 190 | if (minutes < 10) { 191 | minutes = `0${minutes}`; 192 | } 193 | if (seconds < 10) { 194 | seconds = `0${seconds}`; 195 | } 196 | if (milliseconds < 10) { 197 | milliseconds = `0${milliseconds}`; 198 | } 199 | return `${dd}-${mm}-${yyyy}-${hours}${minutes}${seconds}${milliseconds}`; 200 | }, 201 | 202 | emailReportDateTime() { 203 | const $today = new Date(); 204 | const $yesterday = $today; 205 | $yesterday.setDate($today.getDate() - 1); 206 | let dd = $yesterday.getDate(); 207 | let mm = $yesterday.getMonth() + 1; // January is 0! 208 | const yyyy = $yesterday.getFullYear(); 209 | let hours = $yesterday.getHours(); 210 | let minutes = $yesterday.getMinutes(); 211 | let seconds = $yesterday.getSeconds(); 212 | 213 | if (dd < 10) { 214 | dd = `0${dd}`; 215 | } 216 | if (mm < 10) { 217 | mm = `0${mm}`; 218 | } 219 | if (hours < 10) { 220 | hours = `0${hours}`; 221 | } 222 | if (minutes < 10) { 223 | minutes = `0${minutes}`; 224 | } 225 | if (seconds < 10) { 226 | seconds = `0${seconds}`; 227 | } 228 | return `${dd}-${mm}-${yyyy}-${hours}${minutes}${seconds}`; 229 | }, 230 | /** 231 | * Get current date and time dd-mm-yyyy 00:00:00 232 | */ 233 | getCurrentDateTime() { 234 | const today = new Date(); 235 | let dd = today.getDate(); 236 | let mm = today.getMonth() + 1; // January is 0! 237 | const yyyy = today.getFullYear(); 238 | let hours = today.getHours(); 239 | let minutes = today.getMinutes(); 240 | let seconds = today.getSeconds(); 241 | 242 | if (dd < 10) { 243 | dd = `0${dd}`; 244 | } 245 | if (mm < 10) { 246 | mm = `0${mm}`; 247 | } 248 | if (hours < 10) { 249 | hours = `0${hours}`; 250 | } 251 | if (minutes < 10) { 252 | minutes = `0${minutes}`; 253 | } 254 | if (seconds < 10) { 255 | seconds = `0${seconds}`; 256 | } 257 | return `${dd}-${mm}-${yyyy}-${hours}:${minutes}:${seconds}`; 258 | }, 259 | 260 | getEndDateTime() { 261 | return this.getCurrentDateTime(); 262 | }, 263 | 264 | getStartDateTime() { 265 | return this.getCurrentDateTime(); 266 | }, 267 | 268 | klassiReporter() { 269 | try { 270 | return require('./reporter/reporter').reporter(); 271 | } catch (err) { 272 | console.error(`This is the Reporting System error: ${err.stack}`); 273 | throw err; 274 | } 275 | }, 276 | 277 | /** 278 | * ========== EMAIL FUNCTIONALITY ========== 279 | * Sends an Email to the concerned users with the log and the test report 280 | */ 281 | klassiEmail() { 282 | try { 283 | return require('./mailer').klassiSendMail(); 284 | } catch (err) { 285 | console.error(`This is the Email System error: ${err.stack}`); 286 | throw err; 287 | } 288 | }, 289 | 290 | /** 291 | * API call for GET, PUT, POST and DELETE functionality using PactumJS for API testing 292 | * @param url 293 | * @param method 294 | * @param auth 295 | * @param body 296 | * @param form 297 | * @param expectedStatusCode 298 | * @returns {Promise<*>} 299 | */ 300 | apiCall: async (url, method, auth = null, form = null, body = null, expectedStatusCode = null) => { 301 | const options = { 302 | url, 303 | method, 304 | auth, 305 | headers: { 306 | Authorization: `${auth}`, 307 | }, 308 | form, 309 | body, 310 | expectedStatusCode, 311 | }; 312 | if (method === 'GET') { 313 | resp = await pactumJs 314 | .spec() 315 | .get(options.url) 316 | .withHeaders(options.headers) 317 | .withRequestTimeout(DELAY_15s) 318 | .expectStatus(expectedStatusCode) 319 | .toss(); 320 | getMethod = resp; 321 | } 322 | 323 | if (method === 'PUT') { 324 | resp = await pactumJs 325 | .spec() 326 | .put(options.url) 327 | .withHeaders(options.headers) 328 | .withBody(options.body) 329 | .withRequestTimeout(DELAY_10s) 330 | .expectStatus(expectedStatusCode); 331 | getMethod = resp; 332 | } 333 | 334 | if (method === 'POST') { 335 | resp = await pactumJs 336 | .spec() 337 | .post(options.url) 338 | .withHeaders(options.headers) 339 | .withBody(options.body) 340 | .withForm(options.form) 341 | .withRequestTimeout(DELAY_10s) 342 | .expectStatus(expectedStatusCode); 343 | getMethod = resp; 344 | } 345 | 346 | if (method === 'DELETE') { 347 | resp = await pactumJs 348 | .spec() 349 | .post(options.url) 350 | .withHeaders(options.headers) 351 | .withBody(options.body) 352 | .withRequestTimeout(DELAY_10s) 353 | .expectStatus(expectedStatusCode); 354 | } 355 | }, 356 | 357 | /** 358 | * this stores the content of the APIs GET call 359 | * @returns {*} 360 | */ 361 | getContent() { 362 | return getMethod; 363 | }, 364 | 365 | /** 366 | * getting the video link from lambdatest 367 | * @returns {Promise} 368 | */ 369 | ltVideo: async () => { 370 | const page = require('./getVideoLinks'); 371 | await page.getVideoList(); 372 | }, 373 | 374 | /** 375 | * Get the href link from an element 376 | * @param selector 377 | * @returns {String|String[]|*|string} 378 | */ 379 | getLink: async (selector) => { 380 | elem = await browser.$(selector); 381 | await elem.getAttribute('href'); 382 | }, 383 | 384 | waitAndClick: async (selector) => { 385 | try { 386 | elem = await browser.$(selector); 387 | await elem.isExisting(); 388 | await elem.click(); 389 | await browser.pause(DELAY_500ms); 390 | } catch (err) { 391 | console.error(err.message); 392 | throw err; 393 | } 394 | }, 395 | 396 | waitAndSetValue: async (selector, value) => { 397 | try { 398 | elem = await browser.$(selector); 399 | await elem.isExisting(); 400 | await browser.pause(DELAY_500ms); 401 | await elem.addValue(value); 402 | } catch (err) { 403 | console.error(err.message); 404 | throw err; 405 | } 406 | }, 407 | 408 | /** 409 | * function to get element from frame or frameset 410 | * @param frameName 411 | * @param selector 412 | * @returns {Promise.} 413 | */ 414 | getElementFromFrame: async (frameName, selector) => { 415 | const frame = await browser.$(frameName); 416 | await browser.switchToFrame(frame.value); 417 | await browser.$(selector).getHTML(); 418 | return browser; 419 | }, 420 | 421 | /** 422 | * @param expected 423 | */ 424 | assertUrl: async (expected) => { 425 | const actual = await browser.getUrl(); 426 | // assert.equal(actual, expected); 427 | await helpers.expectAdv('equal', actual, expected); 428 | }, 429 | 430 | /** 431 | * Generate random integer from a given range 432 | */ 433 | generateRandomInteger(range) { 434 | return Math.floor(Math.random() * Math.floor(range)); 435 | }, 436 | 437 | /** 438 | * This method is useful for dropdown boxes as some of them have default 'Please select' option on index 0 439 | * @param range 440 | * @returns randomNumber excluding index 0 441 | */ 442 | getRandomIntegerExcludeFirst(range) { 443 | let randomNumber = this.generateRandomInteger(range); 444 | if (randomNumber <= 1) { 445 | randomNumber += 2; 446 | } 447 | return randomNumber; 448 | }, 449 | 450 | /** 451 | * clicks an element (or multiple if present) that is not visible, 452 | * useful in situations where a menu needs a hover before a child link appears 453 | * @param {string} selector used to locate the elements 454 | * @param {string} text to match inner content (if present) 455 | * @example 456 | * helpers.clickHiddenElement('nav[role='navigation'] ul li a','School Shoes'); 457 | * @deprecated 458 | */ 459 | clickHiddenElement(selector, textToMatch) { 460 | // TODO: Find a better way to do this 461 | /** 462 | * method to execute within the DOM to find elements containing text 463 | */ 464 | function clickElementInDom(query, content) { 465 | /** 466 | * get the list of elements to inspect 467 | */ 468 | const elements = document.querySelectorAll(query); 469 | /** 470 | * workout which property to use to get inner text 471 | */ 472 | const txtProp = 'textContent' in document ? 'textContent' : 'innerText'; 473 | 474 | for (let i = 0, l = elements.length; i < l; i++) { 475 | /** 476 | * If we have content, only click items matching the content 477 | */ 478 | if (content) { 479 | if (elements[i][txtProp] === content) { 480 | elements[i].click(); 481 | } 482 | } else { 483 | /** 484 | * otherwise click all 485 | */ 486 | elements[i].click(); 487 | } 488 | } 489 | } 490 | 491 | /** 492 | * grab the matching elements 493 | */ 494 | return browser.$$(selector, clickElementInDom, textToMatch.toLowerCase().trim); 495 | }, 496 | 497 | /** 498 | * this adds extensions to Chrome Only 499 | * @param extName 500 | * @returns {Promise<*>} 501 | */ 502 | chromeExtension: async (extName) => { 503 | await browser.pause(); 504 | await helpers.loadPage(`https://chrome.google.com/webstore/search/${extName}`); 505 | const script = await browser.execute(() => window.document.URL.indexOf('consent.google.com') !== -1); 506 | if (script === true) { 507 | elem = await browser.$$('[jsname="V67aGc"]:nth-child(3)'); 508 | await elem[1].isExisting(); 509 | await elem[1].scrollIntoView(); 510 | const elem1 = await elem[1].getText(); 511 | if (elem1 === 'I agree') { 512 | await elem[1].click(); 513 | await browser.pause(DELAY_300ms); 514 | } 515 | } 516 | elem = await browser.$('[role="row"] > div:nth-child(1)'); 517 | await elem.click(); 518 | await browser.pause(DELAY_200ms); 519 | const str = await browser.getUrl(); 520 | const str2 = await str.split('/'); 521 | modID = str2[6]; 522 | return modID; 523 | }, 524 | 525 | /** 526 | * This is the function for installing modeHeader 527 | * @param extName 528 | * @param username 529 | * @param password 530 | * @returns {Promise} 531 | */ 532 | modHeader: async (extName, username, password) => { 533 | await helpers.chromeExtension(extName); 534 | console.log('modID = ', modID); 535 | 536 | await browser.pause(3000); 537 | elem = await browser.$( 538 | '[class="e-f-o"] > div:nth-child(2) > [class="dd-Va g-c-wb g-eg-ua-Uc-c-za g-c-Oc-td-jb-oa g-c"]', 539 | ); 540 | await elem.isExisting(); 541 | await elem.click(); 542 | 543 | await browser.pause(2000); 544 | elem = await browser.$('.//a[@href="#Add extension"]'); 545 | await elem.isExisting(); 546 | await elem.click(); 547 | await helpers.loadPage(`chrome-extension://${modID}/popup.html`); 548 | 549 | await browser.pause(5000); 550 | await helpers.waitAndSetValue('(//input[@class="mdc-text-field__input "])[1]', username); 551 | await helpers.waitAndSetValue('(//input[@class="mdc-text-field__input "])[2]', password); 552 | await helpers.waitAndClick('//button[@title="Lock to tab"]'); 553 | }, 554 | 555 | installMobileApp: async (appName, appPath) => { 556 | if (env.envName === 'android' || env.envName === 'ios') { 557 | if (!(await browser.isAppInstalled(appName))) { 558 | console.log('Installing application...'); 559 | await browser.installApp(appPath); 560 | // assert.isTrue(await browser.isAppInstalled(appName), 'The app was not installed correctly.'); 561 | await assertExpect( 562 | await browser.isAppInstalled(appName), 563 | 'isTrue', 564 | null, 565 | 'The app was not installed correctly.', 566 | ); 567 | } else { 568 | console.log(`The app ${appName} was already installed on the device, skipping installation...`); 569 | await browser.terminateApp(appName); 570 | } 571 | } 572 | }, 573 | 574 | uninstallMobileApp: async (appName) => { 575 | if (env.envName === 'android' || env.envName === 'ios') { 576 | if (await browser.isAppInstalled(appName)) { 577 | console.log(`Uninstalling application ${appName}...`); 578 | await browser.removeApp(appName); 579 | await assertExpect( 580 | await browser.isAppInstalled(appName), 581 | 'isNotTrue', 582 | null, 583 | 'The app was not uninstalled correctly.', 584 | ); 585 | // assert.isNotTrue(await browser.isAppInstalled(appName), 'The app was not uninstalled correctly.'); 586 | } else { 587 | console.log(`The app ${appName} was already uninstalled fron the device, skipping...`); 588 | } 589 | } 590 | }, 591 | 592 | /** 593 | * drag the page into view 594 | */ 595 | pageView: async (selector) => { 596 | const elem = await browser.$(selector); 597 | await elem.scrollIntoView(); 598 | await browser.pause(DELAY_200ms); 599 | return this; 600 | }, 601 | 602 | /** 603 | * Generates a random 13 digit number 604 | * @param length 605 | * @returns {number} 606 | */ 607 | randomNumberGenerator(length = 13) { 608 | const baseNumber = 10 ** (length - 1); 609 | let number = Math.floor(Math.random() * baseNumber); 610 | /** 611 | * Check if number have 0 as first digit 612 | */ 613 | if (number < baseNumber) { 614 | number += baseNumber; 615 | } 616 | console.log(`this is the number ${number}`); 617 | return number; 618 | }, 619 | 620 | /** 621 | * Reformats date string into string 622 | * @param dateString 623 | * @returns {string} 624 | */ 625 | reformatDateString(dateString) { 626 | const months = { 627 | '01': 'January', 628 | '02': 'February', 629 | '03': 'March', 630 | '04': 'April', 631 | '05': 'May', 632 | '06': 'June', 633 | '07': 'July', 634 | '08': 'August', 635 | '09': 'September', 636 | 10: 'October', 637 | 11: 'November', 638 | 12: 'December', 639 | }; 640 | const b = dateString.split('/'); 641 | return `${b[0]} ${months[b[1]]} ${b[2]}`; 642 | }, 643 | 644 | /** 645 | * Sorts results by date 646 | * @param array 647 | * @returns {*} 648 | */ 649 | sortByDate(array) { 650 | array.sort((a, b) => { 651 | const sentDateA = a.split('/'); 652 | const c = new Date(sentDateA[2], sentDateA[1], sentDateA[0]); 653 | const sentDateB = b.split('/'); 654 | const d = new Date(sentDateB[2], sentDateB[1], sentDateB[0]); 655 | return d - c; 656 | }); 657 | return array; 658 | }, 659 | 660 | filterItem: async (selector, itemToFilter) => { 661 | try { 662 | const elem = await browser.$(selector); 663 | await elem.waitForExist(DELAY_5s); 664 | await elem.waitForEnabled(DELAY_5s); 665 | await browser.pause(DELAY_500ms); 666 | await elem.click(); 667 | await browser.setValue(itemToFilter); 668 | } catch (err) { 669 | console.error(err.message); 670 | throw err; 671 | } 672 | }, 673 | 674 | filterItemAndClick: async (selector) => { 675 | try { 676 | await this.filterItem('itemToFilter'); 677 | await browser.pause(DELAY_3s); 678 | const elem = await browser.$(selector); 679 | await elem.click(); 680 | await browser.pause(DELAY_3s); 681 | } catch (err) { 682 | console.error(err.message); 683 | throw err; 684 | } 685 | }, 686 | 687 | /** 688 | * This generates the Date for uploading and retrieving the reports from s3 689 | * @returns {Date} 690 | */ 691 | formatDate() { 692 | const $today = new Date(); 693 | let $yesterday = new Date($today); 694 | if (s3Date === true) { 695 | $yesterday.setDate($today.getDate()); // for testing sending today's report. 696 | } else { 697 | $yesterday.setDate($today.getDate() - 1); // Also send last night reports, setDate also supports negative values, which cause the month to rollover. 698 | } 699 | let $dd = $yesterday.getDate(); 700 | let $mm = $yesterday.getMonth() + 1; // January is 0! 701 | const $yyyy = $yesterday.getFullYear(); 702 | if ($dd < 10) { 703 | $dd = `0${$dd}`; 704 | } 705 | if ($mm < 10) { 706 | $mm = `0${$mm}`; 707 | } 708 | $yesterday = `${$yyyy}-${$mm}-${$dd}`; 709 | return $yesterday; 710 | }, 711 | 712 | /** 713 | * this uploads a file from local system or project folder 714 | * @param selector 715 | * @param filePath 716 | * @returns {Promise} 717 | */ 718 | fileUpload: async (selector, filePath) => { 719 | elem = await browser.$(selector); 720 | await elem.isExisting(); 721 | const remoteFilePath = await browser.uploadFile(filePath); 722 | await elem.addValue(remoteFilePath); 723 | }, 724 | 725 | 726 | switchWindowTabs: async (tabId) => { 727 | const handles = await browser.getWindowHandles(); 728 | if (handles.length > tabId) { 729 | await browser.switchToWindow(handles[tabId]); 730 | await browser.pause(DELAY_1s); 731 | } 732 | }, 733 | 734 | /** 735 | * Function to verify if a file has been downloaded 736 | * @param {string} fileName Filename with extension 737 | * @param {number} timeout Maximum wait time for the file to be downloaded, default value is set to 5 seconds 738 | * @param {number} interval Wait time between every iteration to recheck the file download, default value is set to 500 ms 739 | */ 740 | async verifyDownload(fileName, timeout = DELAY_5s, interval = DELAY_500ms) { 741 | let value = settings.remoteService === 'lambdatest' ? 0 : 1; 742 | let path; 743 | if (value === 1) { 744 | // The below path points to the default downloads folder, if the folder is in some other location, it has to be configured. 745 | let home = os.homedir(); 746 | path = home + `/Downloads/${fileName}`; 747 | } 748 | let isFileDownloaded = false; 749 | let timeoutInSeconds = timeout / DELAY_1s; 750 | let intervalInSeconds = interval / DELAY_1s; 751 | 752 | loop: for (let i = 1; i <= timeoutInSeconds / intervalInSeconds; i++) { 753 | switch (value) { 754 | case 0: 755 | if (await browser.execute(`lambda-file-exists=${fileName}`)) { 756 | isFileDownloaded = true; 757 | break loop; 758 | } 759 | break; 760 | case 1: 761 | if (fs.existsSync(path)) { 762 | isFileDownloaded = true; 763 | break loop; 764 | } 765 | } 766 | await browser.pause(interval); 767 | } 768 | assert.isTrue(isFileDownloaded, `File '${fileName}' is still not downloaded after ${timeout} ms`); 769 | }, 770 | 771 | /** 772 | * Function to upload one or more files 773 | * @param {string|string[]} filePaths files to be uploaded with extension 774 | * @param {string} locator element having attribute type='file' 775 | */ 776 | async uploadFiles(filePaths, locator) { 777 | if (typeof filePaths === 'string') { 778 | filePaths = [filePaths]; 779 | } else if (!Array.isArray(filePaths)) { 780 | throw `Expected 'string|string[]' but '${typeof filePaths}' was passed`; 781 | } else if (filePaths.length === 0) { 782 | throw 'Empty array was passed'; 783 | } 784 | elem = await browser.$(locator); 785 | await elem.waitForExist({ DELAY_5s }); 786 | let remoteFilePath = []; 787 | for (let filePath of filePaths) { 788 | remoteFilePath.push(await browser.uploadFile(filePath)); 789 | } 790 | await elem.addValue(remoteFilePath.join('\n')); 791 | }, 792 | 793 | /** 794 | * Function to get the displayed element among multiple matches 795 | * @param {string} locator 796 | * @returns Displayed element 797 | */ 798 | async returnDisplayedElement(locator) { 799 | elem = await browser.$(locator); 800 | await elem.waitForExist(); 801 | let elems = await browser.$$(locator); 802 | for (let elem of elems) { 803 | if (await elem.isDisplayed()) return elem; 804 | } 805 | return null; 806 | }, 807 | }; 808 | --------------------------------------------------------------------------------