├── CODEOWNERS ├── setup-env ├── .gitignore ├── src │ ├── index.js │ └── actionInput │ │ ├── index.js │ │ └── inputValidator.js ├── .eslintrc.js ├── config │ └── constants.js ├── action.yml ├── package.json ├── README.md └── test │ └── actionInput │ ├── inputValidator.test.js │ └── index.test.js ├── setup-local ├── .gitignore ├── .eslintrc.js ├── action.yml ├── src │ ├── utils │ │ └── index.js │ ├── artifactsManager.js │ ├── index.js │ ├── actionInput │ │ ├── index.js │ │ └── inputValidator.js │ └── binaryControl.js ├── test │ ├── artifactsManager.test.js │ ├── utils │ │ └── index.test.js │ ├── actionInput │ │ ├── inputValidator.test.js │ │ └── index.test.js │ └── binaryControl.test.js ├── package.json ├── config │ └── constants.js └── README.md ├── browserstack-report-action ├── .gitignore ├── .eslintrc.js ├── src │ ├── utils │ │ ├── TimeManager.js │ │ └── UploadFileForArtifact.js │ ├── services │ │ ├── ReportProcessor.js │ │ └── ReportService.js │ ├── actionInput │ │ ├── index.js │ │ └── inputValidator.js │ └── main.js ├── action.yml ├── config │ └── constants.js ├── package.json ├── test │ └── actionInput │ │ ├── index.test.js │ │ └── inputValidator.test.js └── README.md ├── action.yml ├── .github └── workflows │ ├── setup-env.yml │ ├── setup-local.yml │ └── Semgrep.yml ├── LICENSE └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @browserstack/automate-public-repos 2 | -------------------------------------------------------------------------------- /setup-env/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /setup-local/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /browserstack-report-action/.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Misc 8 | .DS_Store 9 | 10 | # Coverage 11 | coverage/ 12 | .nyc_output/ 13 | -------------------------------------------------------------------------------- /setup-env/src/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const ActionInput = require('./actionInput'); 3 | 4 | /** 5 | * Entry point to initiate the Action. 6 | * 1. Triggers parsing of action input values 7 | * 2. Sets the environment variables required for BrowserStack 8 | */ 9 | const run = async () => { 10 | try { 11 | const inputParser = new ActionInput(); 12 | await inputParser.setEnvVariables(); 13 | } catch (e) { 14 | core.setFailed(`Action Failed: ${e}`); 15 | } 16 | }; 17 | 18 | run(); 19 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'BrowserStack Actions' 2 | description: 'Setup BrowserStack Test Environment' 3 | inputs: 4 | username: 5 | description: 'BrowserStack Username' 6 | required: true 7 | access-key: 8 | description: 'BrowserStack Access Key' 9 | required: true 10 | build-name: 11 | description: 'Build name for the tests' 12 | required: false 13 | project-name: 14 | description: 'Project name for the tests' 15 | required: false 16 | runs: 17 | using: 'node20' 18 | main: 'setup-env/dist/index.js' 19 | branding: 20 | icon: 'check-circle' 21 | color: 'green' 22 | -------------------------------------------------------------------------------- /setup-env/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es2020: true, 5 | mocha: true, 6 | }, 7 | extends: 'airbnb-base', 8 | parserOptions: { 9 | ecmaVersion: 11, 10 | sourceType: 'script', 11 | }, 12 | rules: { 13 | semi: ['error', 'always'], 14 | indent: ['error', 2, { SwitchCase: 1 }], 15 | quotes: 'off', 16 | 'consistent-return': 'off', 17 | 'no-underscore-dangle': 'off', 18 | 'no-case-declarations': 'error', 19 | 'prefer-destructuring': ['error', { object: true, array: false }], 20 | 'no-restricted-syntax': 'off', 21 | 'linebreak-style': 'off', 22 | }, 23 | ignorePatterns: ['dist/index.js'], 24 | }; 25 | -------------------------------------------------------------------------------- /browserstack-report-action/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es2020: true, 5 | mocha: true, 6 | }, 7 | extends: 'airbnb-base', 8 | parserOptions: { 9 | ecmaVersion: 11, 10 | sourceType: 'script', 11 | }, 12 | rules: { 13 | semi: ['error', 'always'], 14 | indent: ['error', 2, { SwitchCase: 1 }], 15 | quotes: 'off', 16 | 'consistent-return': 'off', 17 | 'no-underscore-dangle': 'off', 18 | 'no-case-declarations': 'error', 19 | 'prefer-destructuring': ['error', { object: true, array: false }], 20 | 'no-restricted-syntax': 'off', 21 | 'linebreak-style': 'off', 22 | }, 23 | ignorePatterns: ['dist/index.js'], 24 | }; 25 | -------------------------------------------------------------------------------- /setup-local/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es2020: true, 5 | mocha: true, 6 | }, 7 | extends: 'airbnb-base', 8 | parserOptions: { 9 | ecmaVersion: 11, 10 | sourceType: 'script', 11 | }, 12 | rules: { 13 | semi: ['error', 'always'], 14 | indent: ['error', 2, { SwitchCase: 1 }], 15 | quotes: 'off', 16 | 'consistent-return': 'off', 17 | 'no-underscore-dangle': 'off', 18 | 'no-case-declarations': 'error', 19 | 'prefer-destructuring': ['error', { object: true, array: false }], 20 | 'no-restricted-syntax': 'off', 21 | 'linebreak-style': 'off', 22 | 'no-plusplus': 'off', 23 | }, 24 | ignorePatterns: ['dist/index.js'], 25 | }; 26 | -------------------------------------------------------------------------------- /setup-local/action.yml: -------------------------------------------------------------------------------- 1 | name: 'setup-local' 2 | description: 'Setup BrowserStack Local Binary' 3 | inputs: 4 | local-testing: 5 | description: 'Allows the user to start/stop local testing' 6 | required: true 7 | local-logging-level: 8 | description: 'Logging level of the logs generated by the Local Binary. The logs will be uploaded as artifacts. Defaults to false, i.e. no artifacts will be uploaded' 9 | required: false 10 | default: false 11 | local-identifier: 12 | description: 'Local Identifier for the Local Binary' 13 | required: false 14 | local-args: 15 | description: 'Extra arguments to be passed to the Local Binary' 16 | required: false 17 | runs: 18 | using: 'node20' 19 | main: 'dist/index.js' 20 | -------------------------------------------------------------------------------- /setup-env/config/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | INPUT: { 3 | USERNAME: 'username', 4 | ACCESS_KEY: 'access-key', 5 | BUILD_NAME: 'build-name', 6 | PROJECT_NAME: 'project-name', 7 | GITHUB_TOKEN: 'github-token', 8 | GITHUB_APP: 'github-app', 9 | }, 10 | 11 | ENV_VARS: { 12 | BROWSERSTACK_USERNAME: 'BROWSERSTACK_USERNAME', 13 | BROWSERSTACK_ACCESS_KEY: 'BROWSERSTACK_ACCESS_KEY', 14 | BROWSERSTACK_BUILD_NAME: 'BROWSERSTACK_BUILD_NAME', 15 | BROWSERSTACK_PROJECT_NAME: 'BROWSERSTACK_PROJECT_NAME', 16 | }, 17 | 18 | BROWSERSTACK_INTEGRATIONS: { 19 | DETAILS_API_URL: 'https://integrate.browserstack.com/api/ci-tools/v1/builds/{runId}/rebuild/details?tool=github-actions&as_bot=true', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /setup-local/src/utils/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const tc = require('@actions/tool-cache'); 3 | 4 | class Utils { 5 | static clearEnvironmentVariable(environmentVariable) { 6 | core.exportVariable(environmentVariable, ''); 7 | delete process.env[environmentVariable]; 8 | } 9 | 10 | static checkToolInCache(toolName, version) { 11 | const toolCachePath = tc.find(toolName, version); 12 | return toolCachePath; 13 | } 14 | 15 | static async sleepFor(ms) { 16 | let parsedMilliseconds = parseFloat(ms); 17 | parsedMilliseconds = parsedMilliseconds > 0 ? parsedMilliseconds : 0; 18 | return new Promise((resolve) => setTimeout(resolve, parsedMilliseconds)); 19 | } 20 | } 21 | 22 | module.exports = Utils; 23 | -------------------------------------------------------------------------------- /setup-env/action.yml: -------------------------------------------------------------------------------- 1 | name: 'setup-env' 2 | description: 'Setup BrowserStack Test Environment' 3 | inputs: 4 | username: 5 | description: 'BrowserStack Username' 6 | required: true 7 | access-key: 8 | description: 'BrowserStack Access Key' 9 | required: true 10 | build-name: 11 | description: 'Build name for the tests' 12 | required: false 13 | project-name: 14 | description: 'Project name for the tests' 15 | required: false 16 | github-token: 17 | description: 'GitHub Token for authentication' 18 | required: false 19 | default: 'none' 20 | github-app: 21 | description: 'BrowserStack Github App' 22 | required: false 23 | default: 'browserstack-integrations[bot]' 24 | runs: 25 | using: 'node20' 26 | main: 'dist/index.js' 27 | -------------------------------------------------------------------------------- /browserstack-report-action/src/utils/TimeManager.js: -------------------------------------------------------------------------------- 1 | class TimeManager { 2 | constructor(timeoutSeconds) { 3 | this.timeoutMs = timeoutSeconds * 1000; 4 | this.startTime = Date.now(); 5 | } 6 | 7 | checkTimeout() { 8 | if (Date.now() - this.startTime >= this.timeoutMs) { 9 | return true; 10 | } 11 | return false; 12 | } 13 | 14 | setPollingInterval(seconds) { 15 | this.pollingInterval = seconds; 16 | } 17 | 18 | /** 19 | * Sleep for specified seconds 20 | * @param {number} seconds - Number of seconds to sleep 21 | * @returns {Promise} - Promise that resolves after the specified time 22 | */ 23 | async sleep() { 24 | return new Promise((resolve) => setTimeout(resolve, this.pollingInterval * 1000)); 25 | } 26 | } 27 | 28 | module.exports = TimeManager; 29 | -------------------------------------------------------------------------------- /browserstack-report-action/action.yml: -------------------------------------------------------------------------------- 1 | name: 'BrowserStack Report Action' 2 | description: 'Fetches a BrowserStack report and displays it in the GitHub Actions summary.' 3 | author: 'BrowserStack' 4 | 5 | inputs: 6 | username: 7 | description: 'Your BrowserStack username.' 8 | required: true 9 | access-key: 10 | description: 'Your BrowserStack access key.' 11 | required: true 12 | build-name: 13 | description: 'The name of the build on BrowserStack. Defaults to GitHub workflow name and run ID.' 14 | required: false 15 | report-timeout: 16 | description: 'User-defined timeout value (in seconds) to be sent to the report API.' 17 | required: false 18 | default: '300' 19 | 20 | runs: 21 | using: 'node16' 22 | main: 'dist/index.js' 23 | 24 | branding: 25 | icon: 'bar-chart-2' 26 | color: 'blue' 27 | -------------------------------------------------------------------------------- /.github/workflows/setup-env.yml: -------------------------------------------------------------------------------- 1 | name: 'setup-env' 2 | on: 3 | pull_request: 4 | paths: 5 | - 'setup-env/**' 6 | - '.github/workflows/setup-env*' 7 | push: 8 | paths: 9 | - 'setup-env/**' 10 | - '.github/workflows/setup-env*' 11 | 12 | 13 | jobs: 14 | unit-tests: 15 | runs-on: ${{ matrix.operating-system }} 16 | strategy: 17 | matrix: 18 | operating-system: [ubuntu-latest, macos-latest, windows-latest] 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set Node.js 20.x 23 | uses: actions/setup-node@master 24 | with: 25 | node-version: 20.x 26 | cache: 'npm' 27 | cache-dependency-path: 'setup-env/package-lock.json' 28 | 29 | - name: npm install 30 | working-directory: ./setup-env 31 | run: npm install 32 | 33 | - name: Lint and Unit tests 34 | working-directory: ./setup-env 35 | run: npm run test 36 | -------------------------------------------------------------------------------- /.github/workflows/setup-local.yml: -------------------------------------------------------------------------------- 1 | name: 'setup-local' 2 | on: 3 | pull_request: 4 | paths: 5 | - 'setup-local/**' 6 | - '.github/workflows/setup-local*' 7 | push: 8 | paths: 9 | - 'setup-local/**' 10 | - '.github/workflows/setup-local*' 11 | 12 | 13 | jobs: 14 | unit-tests: 15 | runs-on: ${{ matrix.operating-system }} 16 | strategy: 17 | matrix: 18 | operating-system: [ubuntu-latest, macos-latest, windows-latest] 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set Node.js 20.x 23 | uses: actions/setup-node@master 24 | with: 25 | node-version: 20.x 26 | cache: 'npm' 27 | cache-dependency-path: 'setup-local/package-lock.json' 28 | 29 | - name: npm install 30 | working-directory: ./setup-local 31 | run: npm install 32 | 33 | - name: Lint and Unit tests 34 | working-directory: ./setup-local 35 | run: npm run test 36 | -------------------------------------------------------------------------------- /setup-local/src/artifactsManager.js: -------------------------------------------------------------------------------- 1 | const { DefaultArtifactClient } = require('@actions/artifact'); 2 | const core = require('@actions/core'); 3 | 4 | class ArtifactsManager { 5 | /** 6 | * Upload artifacts to GitHub workflow 7 | * @param {String} artifactName Name by which the artifact should be available post uploading 8 | * @param {String[]} files Files to upload 9 | * @param {String} rootFolder Folder in which the files reside 10 | * @returns {Promise} 11 | * Response of the upload operation 12 | */ 13 | static async uploadArtifacts(artifactName, files, rootFolder) { 14 | const artifactClient = new DefaultArtifactClient(); 15 | const response = await artifactClient.uploadArtifact( 16 | artifactName, 17 | files, 18 | rootFolder, { 19 | continueOnError: true, 20 | }, 21 | ); 22 | core.info(`Response for upload: ${JSON.stringify(response)}`); 23 | return response; 24 | } 25 | } 26 | 27 | // eslint-disable-next-line import/prefer-default-export 28 | module.exports = ArtifactsManager; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 BrowserStack 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 | -------------------------------------------------------------------------------- /browserstack-report-action/src/utils/UploadFileForArtifact.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const core = require('@actions/core'); 4 | 5 | class UploadFileForArtifact { 6 | constructor(report, pathName, fileName, artifactName) { 7 | this.report = report; 8 | this.pathName = pathName; 9 | this.fileName = fileName; 10 | this.artifactName = artifactName; 11 | } 12 | 13 | async saveReportInFile() { 14 | if (!this.report) { 15 | core.debug('No HTML content available to save as artifact'); 16 | return ''; 17 | } 18 | 19 | try { 20 | // Create artifacts directory 21 | fs.mkdirSync(this.pathName, { recursive: true }); 22 | // save path in a env variable 23 | core.exportVariable('BROWSERSTACK_REPORT_PATH', this.pathName); 24 | core.exportVariable("BROWSERSTACK_REPORT_NAME", this.artifactName); 25 | 26 | // Write content 27 | fs.writeFileSync(path.join(this.pathName, this.fileName), this.report); 28 | } catch (error) { 29 | core.warning(`Failed to save file: ${error.message}`); 30 | return ''; 31 | } 32 | } 33 | } 34 | 35 | module.exports = UploadFileForArtifact; 36 | -------------------------------------------------------------------------------- /setup-local/src/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const ActionInput = require('./actionInput'); 3 | const BinaryControl = require('./binaryControl'); 4 | const constants = require('../config/constants'); 5 | 6 | const { 7 | ALLOWED_INPUT_VALUES: { 8 | LOCAL_TESTING, 9 | }, 10 | } = constants; 11 | 12 | /** 13 | * Entry point to initiate the Action. 14 | * 1. Triggers parsing of action input values 15 | * 2. Decides requirement of Local Binary 16 | * 3. Start/Stop Local Binary 17 | * 4. Triggers uploading of artifacts after stopping binary 18 | */ 19 | const run = async () => { 20 | try { 21 | const inputParser = new ActionInput(); 22 | const stateForBinary = inputParser.getInputStateForBinary(); 23 | const binaryControl = new BinaryControl(stateForBinary); 24 | 25 | if (stateForBinary.localTesting === LOCAL_TESTING.START) { 26 | await inputParser.setEnvVariables(); 27 | await binaryControl.downloadBinary(); 28 | await binaryControl.startBinary(); 29 | } else { 30 | await binaryControl.stopBinary(); 31 | await binaryControl.uploadLogFilesIfAny(); 32 | } 33 | } catch (e) { 34 | core.setFailed(`Action Failed: ${e}`); 35 | } 36 | }; 37 | 38 | run(); 39 | -------------------------------------------------------------------------------- /browserstack-report-action/config/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Default values 3 | DEFAULT_POLLING_INTERVAL_SECONDS: 3, 4 | DEFAULT_MAX_RETRIES: 3, 5 | DEFAULT_USER_TIMEOUT_SECONDS: 130, 6 | 7 | // API simulation constants 8 | MAX_POLLS_FOR_IN_PROGRESS: 3, 9 | 10 | // Report formats 11 | REPORT_FORMAT: { 12 | BASIC_HTML: 'basicHtml', 13 | RICH_HTML: 'richHtml', 14 | }, 15 | 16 | INPUT: { 17 | USERNAME: 'username', 18 | ACCESS_KEY: 'access-key', 19 | BUILD_NAME: 'build-name', 20 | TIMEOUT: 'report-timeout', 21 | }, 22 | 23 | // Report statuses 24 | REPORT_STATUS: { 25 | IN_PROGRESS: 'IN_PROGRESS', 26 | COMPLETED: 'COMPLETED', 27 | TEST_AVAILABLE: 'TEST_AVAILABLE', 28 | NOT_AVAILABLE: 'NOT_AVAILABLE', 29 | BUILD_NOT_FOUND: 'BUILD_NOT_FOUND', 30 | MULTIPLE_BUILD_FOUND: 'MULTIPLE_BUILD_FOUND', 31 | }, 32 | 33 | // Integration types 34 | INTEGRATION_TYPE: { 35 | SDK: 'sdk', 36 | NON_SDK: 'non-sdk', 37 | }, 38 | 39 | // CI system identifiers 40 | CI_SYSTEM: { 41 | GITHUB_ACTIONS: 'github-actions', 42 | }, 43 | 44 | // REPORT_REQUEST_STATE 45 | REPORT_REQUEST_STATE: { 46 | FIRST: 'FIRST', 47 | POLL: 'POLL', 48 | LAST: 'LAST', 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /setup-env/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setup-env", 3 | "version": "1.0.3", 4 | "description": "Setup BrowserStack Test Environment", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "eslint . --ext .js", 8 | "build": "ncc build src/index.js", 9 | "test": "npm run lint && nyc --reporter=html mocha 'test/**/*test.js'" 10 | }, 11 | "husky": { 12 | "hooks": { 13 | "pre-commit": "npm run test && npm run build && git add dist/" 14 | } 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/browserstack/github-actions.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "browserstack", 23 | "setup test environment" 24 | ], 25 | "author": "", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@actions/core": "^1.2.6", 29 | "@actions/github": "^6.0.0", 30 | "axios": "^1.8.2" 31 | }, 32 | "devDependencies": { 33 | "@vercel/ncc": "^0.38.1", 34 | "chai": "^4.2.0", 35 | "eslint": "^7.2.0", 36 | "eslint-config-airbnb-base": "^14.2.0", 37 | "eslint-plugin-import": "^2.22.0", 38 | "husky": "^4.2.5", 39 | "mocha": "^10.2.0", 40 | "nyc": "^15.1.0", 41 | "sinon": "^9.0.2" 42 | }, 43 | "nyc": { 44 | "all": true, 45 | "exclude": [ 46 | "**/*.test.js", 47 | "node_modules", 48 | ".eslintrc.js", 49 | "coverage", 50 | "dist/*", 51 | "config" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /setup-local/test/artifactsManager.test.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const sinon = require('sinon'); 3 | const { DefaultArtifactClient } = require('@actions/artifact'); 4 | const ArtifactsManager = require('../src/artifactsManager'); 5 | 6 | const { expect } = chai; 7 | 8 | describe('Artifacts Handling', () => { 9 | let artifactClientStub; 10 | 11 | beforeEach(() => { 12 | artifactClientStub = sinon.createStubInstance(DefaultArtifactClient); 13 | artifactClientStub.uploadArtifact.resolves('Response'); 14 | 15 | sinon.stub(ArtifactsManager, 'uploadArtifacts').callsFake((artifactName, files, rootFolder) => artifactClientStub.uploadArtifact(artifactName, 16 | files, 17 | rootFolder, 18 | { continueOnError: true })); 19 | }); 20 | 21 | afterEach(() => { 22 | sinon.restore(); 23 | }); 24 | 25 | context('Upload Artifacts', () => { 26 | it('by specifying the file location', async () => { 27 | const artifactName = 'RandomName'; 28 | const files = ['/some/path/file']; 29 | const rootFolder = '/some/path'; 30 | const options = { continueOnError: true }; 31 | 32 | const response = await ArtifactsManager.uploadArtifacts(artifactName, files, rootFolder); 33 | 34 | sinon.assert.calledWith(artifactClientStub.uploadArtifact, 35 | artifactName, 36 | files, 37 | rootFolder, 38 | options); 39 | 40 | expect(response).to.eql('Response'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /browserstack-report-action/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browserstack-report-action", 3 | "version": "1.0.0", 4 | "description": "Fetches BrowserStack report and displays it in GitHub Actions summary.", 5 | "main": "src/main.js", 6 | "scripts": { 7 | "lint": "eslint . --ext .js", 8 | "build": "ncc build src/main.js", 9 | "test": "npm run lint && nyc --reporter=html mocha 'test/**/*test.js'" 10 | }, 11 | "husky": { 12 | "hooks": { 13 | "pre-commit": "npm run test && npm run build && git add dist/" 14 | } 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/browserstack/github-actions.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "browserstack", 23 | "fetch report" 24 | ], 25 | "author": "", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@actions/artifact": "^2.3.2", 29 | "@actions/core": "^1.2.6", 30 | "@actions/github": "^4.0.0", 31 | "axios": "^1.7.7" 32 | }, 33 | "devDependencies": { 34 | "@vercel/ncc": "^0.38.1", 35 | "chai": "^4.2.0", 36 | "eslint": "^7.2.0", 37 | "eslint-config-airbnb-base": "^14.2.1", 38 | "eslint-plugin-import": "^2.31.0", 39 | "husky": "^4.2.5", 40 | "mocha": "^8.1.1", 41 | "nyc": "^15.1.0", 42 | "sinon": "^9.0.2" 43 | }, 44 | "nyc": { 45 | "all": true, 46 | "exclude": [ 47 | "**/*.test.js", 48 | "node_modules", 49 | ".eslintrc.js", 50 | "coverage", 51 | "dist/*", 52 | "config" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /browserstack-report-action/src/services/ReportProcessor.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const UploadFileForArtifact = require('../utils/UploadFileForArtifact'); 3 | 4 | class ReportProcessor { 5 | constructor(reportData) { 6 | this.reportData = reportData; 7 | } 8 | 9 | async processReport() { 10 | try { 11 | const { summary } = core; 12 | 13 | let addToSummaryReport = this.reportData?.report?.basicHtml; 14 | if (addToSummaryReport) { 15 | addToSummaryReport = `${addToSummaryReport}`; 16 | addToSummaryReport = addToSummaryReport.replace(/[\u201C\u201D]/g, '"'); // Replace smart quotes with regular quotes 17 | addToSummaryReport = addToSummaryReport.replace(/<\/?tbody>/gi, ''); // Remove tbody tags 18 | await summary.addRaw(addToSummaryReport, false); 19 | } else { 20 | await summary.addRaw('⚠️ No report content available', true); 21 | } 22 | summary.write(); 23 | const addToArtifactReport = this.reportData?.report?.richHtml; 24 | const addToArtifactReportCss = this.reportData?.report?.richCss; 25 | if (addToArtifactReport) { 26 | const report = ` ${addToArtifactReport}`; 27 | const artifactObj = new UploadFileForArtifact(report, 'browserstack-artifacts', 'browserstack-report.html', 'BrowserStack Test Report'); 28 | await artifactObj.saveReportInFile(); 29 | } 30 | } catch (error) { 31 | core.info(`Error processing report: ${JSON.stringify(error)}`); 32 | await core.summary 33 | .addRaw('❌ Error processing report', true) 34 | .write(); 35 | throw error; 36 | } 37 | } 38 | } 39 | 40 | module.exports = ReportProcessor; 41 | -------------------------------------------------------------------------------- /setup-local/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setup-local", 3 | "version": "1.0.3", 4 | "description": "Setup BrowserStack Local Binary", 5 | "main": "src/index.js", 6 | "overrides": { 7 | "@octokit/request": "8.4.1", 8 | "@octokit/plugin-paginate-rest": "9.2.2", 9 | "@octokit/request-error": "5.1.1" 10 | }, 11 | "scripts": { 12 | "lint": "eslint . --ext .js", 13 | "build": "ncc build src/index.js", 14 | "test": "npm run lint && nyc --reporter=html mocha 'test/**/*test.js'" 15 | }, 16 | "husky": { 17 | "hooks": { 18 | "pre-commit": "npm run test && npm run build && git add dist/" 19 | } 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/browserstack/github-actions.git" 24 | }, 25 | "keywords": [ 26 | "actions", 27 | "browserstack", 28 | "setup local", 29 | "test environment" 30 | ], 31 | "author": "", 32 | "license": "MIT", 33 | "dependencies": { 34 | "@actions/artifact": "^2.3.2", 35 | "@actions/core": "^1.9.1", 36 | "@actions/exec": "^1.0.4", 37 | "@actions/github": "^6.0.0", 38 | "@actions/io": "^1.0.2", 39 | "@actions/tool-cache": "^1.6.0", 40 | "minimist": "^1.2.6", 41 | "uuid": "^8.3.0" 42 | }, 43 | "devDependencies": { 44 | "@vercel/ncc": "^0.38.1", 45 | "chai": "^4.2.0", 46 | "eslint": "^7.2.0", 47 | "eslint-config-airbnb-base": "^14.2.0", 48 | "eslint-plugin-import": "^2.22.0", 49 | "husky": "^4.2.5", 50 | "mocha": "^10.2.0", 51 | "nyc": "^15.1.0", 52 | "sinon": "^9.0.2" 53 | }, 54 | "nyc": { 55 | "all": true, 56 | "exclude": [ 57 | "**/*.test.js", 58 | "node_modules", 59 | ".eslintrc.js", 60 | "coverage", 61 | "dist/*", 62 | "config" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/Semgrep.yml: -------------------------------------------------------------------------------- 1 | # Name of this GitHub Actions workflow. 2 | name: Semgrep 3 | 4 | on: 5 | # Scan changed files in PRs (diff-aware scanning): 6 | # The branches below must be a subset of the branches above 7 | pull_request: 8 | branches: ["master", "main"] 9 | push: 10 | branches: ["master", "main"] 11 | schedule: 12 | - cron: '0 6 * * *' 13 | 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | semgrep: 20 | # User definable name of this GitHub Actions job. 21 | permissions: 22 | contents: read # for actions/checkout to fetch code 23 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 24 | name: semgrep/ci 25 | # If you are self-hosting, change the following `runs-on` value: 26 | runs-on: ubuntu-latest 27 | 28 | container: 29 | # A Docker image with Semgrep installed. Do not change this. 30 | image: returntocorp/semgrep 31 | 32 | # Skip any PR created by dependabot to avoid permission issues: 33 | if: (github.actor != 'dependabot[bot]') 34 | 35 | steps: 36 | # Fetch project source with GitHub Actions Checkout. 37 | - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 38 | # Run the "semgrep ci" command on the command line of the docker image. 39 | - run: semgrep ci --sarif --output=semgrep.sarif 40 | env: 41 | # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. 42 | SEMGREP_RULES: p/default # more at semgrep.dev/explore 43 | 44 | - name: Upload SARIF file for GitHub Advanced Security Dashboard 45 | uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 46 | with: 47 | sarif_file: semgrep.sarif 48 | if: always() -------------------------------------------------------------------------------- /setup-local/config/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | INPUT: { 3 | LOCAL_TESING: 'local-testing', 4 | LOCAL_LOGGING_LEVEL: 'local-logging-level', 5 | LOCAL_IDENTIFIER: 'local-identifier', 6 | LOCAL_ARGS: 'local-args', 7 | }, 8 | 9 | PLATFORMS: { 10 | LINUX: 'linux', 11 | DARWIN: 'darwin', 12 | WIN32: 'win32', 13 | }, 14 | 15 | ENV_VARS: { 16 | BROWSERSTACK_ACCESS_KEY: 'BROWSERSTACK_ACCESS_KEY', 17 | BROWSERSTACK_LOCAL_IDENTIFIER: 'BROWSERSTACK_LOCAL_IDENTIFIER', 18 | BROWSERSTACK_LOCAL_LOGS_FILE: 'BROWSERSTACK_LOCAL_LOGS_FILE', 19 | }, 20 | 21 | BINARY_LINKS: { 22 | LINUX_32: 'https://www.browserstack.com/browserstack-local/BrowserStackLocal-linux-ia32.zip', 23 | LINUX_64: 'https://www.browserstack.com/browserstack-local/BrowserStackLocal-linux-x64.zip', 24 | WINDOWS: 'https://www.browserstack.com/browserstack-local/BrowserStackLocal-win32.zip', 25 | DARWIN: 'https://www.browserstack.com/browserstack-local/BrowserStackLocal-darwin-x64.zip', 26 | }, 27 | 28 | ALLOWED_INPUT_VALUES: { 29 | LOCAL_TESTING: { 30 | START: 'start', 31 | STOP: 'stop', 32 | }, 33 | LOCAL_LOG_LEVEL: { 34 | SETUP_LOGS: 'setup-logs', 35 | NETWORK_LOGS: 'network-logs', 36 | ALL_LOGS: 'all-logs', 37 | FALSE: 'false', 38 | }, 39 | LOCAL_IDENTIFIER_RANDOM: 'random', 40 | }, 41 | 42 | LOCAL_BINARY_TRIGGER: { 43 | START: { 44 | CONNECTED: 'connected', 45 | DISCONNECTED: 'disconnected', 46 | }, 47 | STOP: { 48 | SUCCESS: 'success', 49 | }, 50 | }, 51 | 52 | BINARY_MAX_TRIES: 3, 53 | RETRY_DELAY_BINARY: 5000, 54 | 55 | RESTRICTED_LOCAL_ARGS: ['k', 'key', 'local-identifier', 'daemon', 'only-automate', 'verbose', 'log-file', 'ci-plugin'], 56 | 57 | LOCAL_BINARY_FOLDER: 'LocalBinaryFolder', 58 | LOCAL_BINARY_NAME: 'BrowserStackLocal', 59 | LOCAL_BINARY_ZIP: 'binaryZip', 60 | LOCAL_LOG_FILE_PREFIX: 'BrowserStackLocal', 61 | }; 62 | -------------------------------------------------------------------------------- /browserstack-report-action/src/actionInput/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const InputValidator = require('./inputValidator'); 3 | const { INPUT } = require('../../config/constants'); 4 | 5 | /** 6 | * ActionInput manages the fetching of action input values, 7 | * performs validation, and prepares them for use in the action. 8 | */ 9 | class ActionInput { 10 | constructor() { 11 | this._fetchAllInput(); 12 | this._validateInput(); 13 | } 14 | 15 | /** 16 | * Fetches all the input values provided to the action. 17 | * Raises error if required values are missing. 18 | */ 19 | _fetchAllInput() { 20 | try { 21 | // Required inputs 22 | this.username = core.getInput(INPUT.USERNAME, { required: true }); 23 | this.accessKey = core.getInput(INPUT.ACCESS_KEY, { required: true }); 24 | 25 | // non-compulsory fields 26 | this.buildName = core.getInput(INPUT.BUILD_NAME); 27 | 28 | this.userTimeout = core.getInput(INPUT.TIMEOUT); 29 | } catch (e) { 30 | throw Error(`Action input failed for reason: ${e.message}`); 31 | } 32 | } 33 | 34 | /** 35 | * Validates and processes the input values. 36 | */ 37 | _validateInput() { 38 | // Validate and set build name 39 | this.buildName = InputValidator.validateBuildName(this.buildName); 40 | this.username = InputValidator.updateUsername(this.username); 41 | 42 | // Validate user timeout 43 | this.userTimeout = InputValidator.validateUserTimeout(this.userTimeout); 44 | 45 | // Safety check: Mask the access key in logs 46 | if (this.accessKey) { 47 | core.setSecret(this.accessKey); 48 | } 49 | } 50 | 51 | /** 52 | * Returns validated and processed inputs. 53 | * @returns {Object} The processed inputs 54 | */ 55 | getInputs() { 56 | return { 57 | username: this.username, 58 | accessKey: this.accessKey, 59 | buildName: this.buildName, 60 | userTimeout: this.userTimeout, 61 | }; 62 | } 63 | } 64 | 65 | module.exports = ActionInput; 66 | -------------------------------------------------------------------------------- /browserstack-report-action/test/actionInput/index.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const core = require('@actions/core'); 4 | const ActionInput = require('../../src/actionInput'); 5 | const InputValidator = require('../../src/actionInput/inputValidator'); 6 | const constants = require('../../config/constants'); 7 | 8 | const { 9 | INPUT, 10 | } = constants; 11 | 12 | describe('Action Input operations for fetching all inputs, triggering validation', () => { 13 | context('Fetch and Validate Input', () => { 14 | let stubbedInput; 15 | 16 | beforeEach(() => { 17 | stubbedInput = sinon.stub(core, 'getInput'); 18 | sinon.stub(InputValidator, 'updateUsername').returns('validatedUsername'); 19 | sinon.stub(InputValidator, 'validateBuildName').returns('validatedBuildName'); 20 | 21 | // Provide required inputs 22 | stubbedInput.withArgs(INPUT.USERNAME, { required: true }).returns('someUsername'); 23 | stubbedInput.withArgs(INPUT.ACCESS_KEY, { required: true }).returns('someAccessKey'); 24 | }); 25 | 26 | afterEach(() => { 27 | sinon.restore(); 28 | }); 29 | 30 | it('Takes input and validates it successfully', () => { 31 | stubbedInput.withArgs(INPUT.BUILD_NAME).returns('someBuildName'); 32 | const actionInput = new ActionInput(); 33 | expect(actionInput.username).to.eq('validatedUsername'); 34 | expect(actionInput.buildName).to.eq('validatedBuildName'); 35 | }); 36 | 37 | it('Takes input and throws error if username is not provided in input', () => { 38 | stubbedInput.withArgs(INPUT.USERNAME, { required: true }).throws(Error('Username Required')); 39 | try { 40 | // eslint-disable-next-line no-new 41 | new ActionInput(); 42 | } catch (e) { 43 | expect(e.message).to.eq('Action input failed for reason: Username Required'); 44 | } 45 | }); 46 | 47 | it('Takes input and throws error if access key is not provided in input', () => { 48 | stubbedInput.withArgs(INPUT.ACCESS_KEY, { required: true }).throws(Error('Access Key Required')); 49 | try { 50 | // eslint-disable-next-line no-new 51 | new ActionInput(); 52 | } catch (e) { 53 | expect(e.message).to.eq('Action input failed for reason: Access Key Required'); 54 | } 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /setup-local/test/utils/index.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | const core = require('@actions/core'); 4 | const tc = require('@actions/tool-cache'); 5 | const Utils = require("../../src/utils"); 6 | 7 | describe('Utils', () => { 8 | context('Clear Environment Variables', () => { 9 | it('Sets empty string as the value and deletes the entry from process.env', () => { 10 | sinon.stub(core, 'exportVariable'); 11 | process.env.someVariable = 'someValue'; 12 | Utils.clearEnvironmentVariable('someVariable'); 13 | sinon.assert.calledWith(core.exportVariable, 'someVariable', ''); 14 | expect(process.env.someVariable).to.eq(undefined); 15 | core.exportVariable.restore(); 16 | }); 17 | }); 18 | 19 | context('Check if tool exists in cache', () => { 20 | it('Returns the path if tool exists in cache', () => { 21 | sinon.stub(tc, 'find').returns('some/path'); 22 | expect(Utils.checkToolInCache('someTool', 'version')).to.eq('some/path'); 23 | sinon.assert.calledWith(tc.find, 'someTool', 'version'); 24 | tc.find.restore(); 25 | }); 26 | 27 | it("Returns empty string if tool doesn't exists in cache", () => { 28 | sinon.stub(tc, 'find').returns(''); 29 | expect(Utils.checkToolInCache('someTool', 'version')).to.eq(''); 30 | sinon.assert.calledWith(tc.find, 'someTool', 'version'); 31 | tc.find.restore(); 32 | }); 33 | }); 34 | 35 | context('SleepFor', () => { 36 | it('Sleeps for given milliseconds', (done) => { 37 | const fakeTimer = sinon.useFakeTimers(); 38 | const startTime = Date.now(); 39 | Utils.sleepFor(5000) 40 | .then(() => { 41 | expect(Date.now() - startTime).to.eq(5000); 42 | fakeTimer.restore(); 43 | done(); 44 | }); 45 | fakeTimer.tick(5000); 46 | }); 47 | 48 | it('Sleeps for 0 milliseconds if the value is NaN', (done) => { 49 | const fakeTimer = sinon.useFakeTimers(); 50 | const startTime = Date.now(); 51 | Utils.sleepFor("Not a Number") 52 | .then(() => { 53 | expect(Date.now() - startTime).to.eq(0); 54 | fakeTimer.restore(); 55 | done(); 56 | }); 57 | fakeTimer.tick(0); 58 | }); 59 | 60 | it('Sleeps for 0 milliseconds if the value is <= 0', (done) => { 61 | const fakeTimer = sinon.useFakeTimers(); 62 | const startTime = Date.now(); 63 | Utils.sleepFor(-10) 64 | .then(() => { 65 | expect(Date.now() - startTime).to.eq(0); 66 | fakeTimer.restore(); 67 | done(); 68 | }); 69 | fakeTimer.tick(0); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /browserstack-report-action/src/main.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const constants = require('../config/constants'); 3 | const ActionInput = require('./actionInput'); 4 | const ReportService = require('./services/ReportService'); 5 | const ReportProcessor = require('./services/ReportProcessor'); 6 | const TimeManager = require('./utils/TimeManager'); 7 | 8 | async function run() { 9 | try { 10 | core.info('Starting BrowserStack Report Action...'); 11 | 12 | const actionInput = new ActionInput(); 13 | const { 14 | username, accessKey, buildName, userTimeout, 15 | } = actionInput.getInputs(); 16 | 17 | let reportProcessor; 18 | 19 | if (userTimeout < 20 || userTimeout > 600) { 20 | const report = { 21 | report: { 22 | basicHtml: `
Invalid report timeout value: ${userTimeout}. It should be between 20 and 600 seconds for Browserstack reports
`, 23 | }, 24 | }; 25 | reportProcessor = new ReportProcessor(report); 26 | await reportProcessor.processReport(); 27 | return; 28 | } 29 | 30 | const authHeader = `Basic ${Buffer.from(`${username}:${accessKey}`).toString('base64')}`; 31 | 32 | const timeManager = new TimeManager(userTimeout 33 | || constants.DEFAULT_USER_TIMEOUT_SECONDS); 34 | const reportService = new ReportService(authHeader); 35 | 36 | const initialParams = { 37 | originalBuildName: buildName, 38 | requestingCi: constants.CI_SYSTEM.GITHUB_ACTIONS, 39 | reportFormat: [constants.REPORT_FORMAT.BASIC_HTML, constants.REPORT_FORMAT.RICH_HTML], 40 | requestType: constants.REPORT_REQUEST_STATE.FIRST, 41 | userTimeout, 42 | }; 43 | 44 | timeManager.checkTimeout(); 45 | let reportData = await reportService.fetchReport(initialParams); 46 | let { retryCount: maxRetries, pollingInterval } = reportData; 47 | 48 | if (!pollingInterval) { 49 | pollingInterval = constants.DEFAULT_POLLING_INTERVAL_SECONDS; 50 | } 51 | 52 | if (!maxRetries) { 53 | maxRetries = constants.DEFAULT_MAX_RETRIES; 54 | } 55 | 56 | if (reportData.reportStatus === constants.REPORT_STATUS.IN_PROGRESS) { 57 | reportData = await reportService.pollReport( 58 | initialParams, 59 | timeManager, 60 | maxRetries, 61 | pollingInterval, 62 | ); 63 | } 64 | 65 | reportProcessor = new ReportProcessor(reportData); 66 | await reportProcessor.processReport(); 67 | core.info('Report processing completed successfully'); 68 | } catch (error) { 69 | core.setFailed(`Action failed: ${error.message}`); 70 | } 71 | } 72 | 73 | module.exports = { run }; 74 | 75 | if (require.main === module) { 76 | run(); 77 | } 78 | -------------------------------------------------------------------------------- /browserstack-report-action/src/services/ReportService.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const core = require('@actions/core'); 3 | const constants = require('../../config/constants'); 4 | 5 | class ReportService { 6 | constructor(authHeader) { 7 | this.authHeader = authHeader; 8 | this.apiUrl = 'https://api-observability.browserstack.com/ext/v1/builds/buildReport'; 9 | } 10 | 11 | async fetchReport(params) { 12 | try { 13 | const response = await axios.post(this.apiUrl, params, { 14 | headers: { 15 | Authorization: this.authHeader, 16 | }, 17 | }); 18 | 19 | if (response.status < 200 || response.status > 299) { 20 | core.info(`Error fetching report: ${response.status}`); 21 | return ReportService.errorResponse(response?.data?.errorMessage || "Something Went Wrong while Fetching report"); 22 | } 23 | core.info(`Response from report API: ${response?.data?.reportStatus}`); 24 | return response.data; 25 | } catch (error) { 26 | core.info(`Error fetching report: ${error.message}`); 27 | return ReportService.errorResponse("Something Went Wrong while Fetching report"); 28 | } 29 | } 30 | 31 | static errorResponse(errorMessage) { 32 | return { 33 | report: { basicHtml: `
${errorMessage}
` }, 34 | reportStatus: 'ERROR', 35 | }; 36 | } 37 | 38 | async pollReport(params, timeManager, maxRetries, pollingInterval) { 39 | timeManager.setPollingInterval(pollingInterval); 40 | const poll = async (retries) => { 41 | if (timeManager.checkTimeout()) { 42 | return ReportService.handleErrorStatus(constants.REPORT_STATUS.IN_PROGRESS); 43 | } 44 | 45 | const reportData = await this.fetchReport({ 46 | ...params, 47 | requestType: retries === maxRetries - 1 ? constants.REPORT_REQUEST_STATE.LAST 48 | : constants.REPORT_REQUEST_STATE.POLL, 49 | }); 50 | 51 | const status = reportData.reportStatus; 52 | if (status === constants.REPORT_STATUS.COMPLETED 53 | || status === constants.REPORT_STATUS.TEST_AVAILABLE 54 | || status === constants.REPORT_STATUS.NOT_AVAILABLE) { 55 | return reportData; 56 | } 57 | 58 | if (status === constants.REPORT_STATUS.IN_PROGRESS && retries < maxRetries) { 59 | await timeManager.sleep(); 60 | return poll(retries + 1); 61 | } 62 | 63 | // Instead of throwing, return error data that can be displayed 64 | return this.handleErrorStatus(status, reportData); 65 | }; 66 | 67 | return poll(0); 68 | } 69 | 70 | static handleErrorStatus(status, reportData = {}) { 71 | const errorMessages = { 72 | ERROR: 'Unable to Fetch Report', 73 | IN_PROGRESS: 'Report is still in progress', 74 | }; 75 | 76 | return { 77 | errorMessage: errorMessages[status] || `Unexpected status: ${status}`, 78 | reportStatus: status, 79 | report: status === constants.REPORT_STATUS.IN_PROGRESS ? { basicHtml: '
Report generation not completed, please try again after increasing report timeout
' } : reportData.report, 80 | }; 81 | } 82 | } 83 | 84 | module.exports = ReportService; 85 | -------------------------------------------------------------------------------- /setup-local/src/actionInput/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const InputValidator = require('./inputValidator'); 3 | const constants = require('../../config/constants'); 4 | 5 | const { 6 | INPUT, 7 | ENV_VARS, 8 | ALLOWED_INPUT_VALUES: { 9 | LOCAL_TESTING, 10 | }, 11 | } = constants; 12 | 13 | /** 14 | * ActionInput manages the fetching of action input values and 15 | * helps in setting env variables post validation. 16 | */ 17 | class ActionInput { 18 | constructor() { 19 | this._fetchAllInput(); 20 | this._validateInput(); 21 | } 22 | 23 | /** 24 | * Fetches all the input values given to the action. 25 | * Raises error if the required values are not provided. 26 | */ 27 | _fetchAllInput() { 28 | try { 29 | // required field 30 | this.localTesting = core.getInput(INPUT.LOCAL_TESING, { required: true }); 31 | this.accessKey = process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY]; 32 | 33 | if (!this.accessKey) throw Error(`${ENV_VARS.BROWSERSTACK_ACCESS_KEY} not found. Use 'browserstack/github-actions/setup-env@master' Action to set up the environment variables before invoking this Action`); 34 | 35 | // non-compulsory fields 36 | this.localLoggingLevel = core.getInput(INPUT.LOCAL_LOGGING_LEVEL); 37 | this.localIdentifier = core.getInput(INPUT.LOCAL_IDENTIFIER); 38 | this.localArgs = core.getInput(INPUT.LOCAL_ARGS); 39 | } catch (e) { 40 | throw Error(`Action input failed for reason: ${e.message}`); 41 | } 42 | } 43 | 44 | /** 45 | * Triggers conditional validation of action input values based on the operation 46 | * to be performed, i.e. starting/stopping of local connection 47 | */ 48 | _validateInput() { 49 | this.localTesting = InputValidator.validateLocalTesting(this.localTesting); 50 | 51 | if (this.localTesting === LOCAL_TESTING.START) { 52 | this.localLoggingLevel = InputValidator.validateLocalLoggingLevel(this.localLoggingLevel); 53 | this.localIdentifier = InputValidator.validateLocalIdentifier(this.localIdentifier); 54 | this.localArgs = InputValidator.validateLocalArgs(this.localArgs); 55 | } else { 56 | this.localIdentifier = process.env[ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER]; 57 | } 58 | } 59 | 60 | /** 61 | * Sets env variables to be used in the test script for BrowserStack 62 | */ 63 | setEnvVariables() { 64 | core.startGroup('Setting Environment Variables'); 65 | 66 | if ((this.localTesting === LOCAL_TESTING.START) && this.localIdentifier) { 67 | core.exportVariable(ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER, this.localIdentifier); 68 | core.info(`${ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER} environment variable set as: ${this.localIdentifier}`); 69 | core.info(`Use ${ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER} env variable in your test script as the local identifier\n`); 70 | } 71 | 72 | core.endGroup(); 73 | } 74 | 75 | /** 76 | * Returns the information required for setting up of Local Binary 77 | * @returns {{ 78 | * accessKey: String, 79 | * localTesting: String, 80 | * localArgs: String, 81 | * localIdentifier: String, 82 | * localLoggingLevel: Number 83 | * }} 84 | */ 85 | getInputStateForBinary() { 86 | return { 87 | accessKey: this.accessKey, 88 | localTesting: this.localTesting, 89 | localArgs: this.localArgs, 90 | localIdentifier: this.localIdentifier, 91 | localLoggingLevel: this.localLoggingLevel, 92 | }; 93 | } 94 | } 95 | 96 | module.exports = ActionInput; 97 | -------------------------------------------------------------------------------- /browserstack-report-action/README.md: -------------------------------------------------------------------------------- 1 | # BrowserStack Report GitHub Action 2 | 3 | This action fetches a report from a (currently dummy) BrowserStack-like API, polls for its completion, and displays the HTML report in the GitHub Actions summary tab. 4 | The polling interval and maximum retries are determined by the initial API response. 5 | 6 | ## Inputs 7 | 8 | - `username` (**required**): Your BrowserStack username. 9 | It's recommended to store this as a GitHub secret. 10 | - `access-key` (**required**): Your BrowserStack access key. 11 | It's recommended to store this as a GitHub secret. 12 | - `build-name` (optional): The name of the build on BrowserStack. 13 | Defaults to `_`. 14 | - `report-timeout` (optional): User-defined timeout value (in seconds) to be sent to the report API. 15 | Default: `10`. 16 | 17 | ## Example Usage 18 | 19 | ```yaml 20 | name: CI with BrowserStack Report 21 | 22 | on: [push] 23 | 24 | jobs: 25 | test_and_report: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v3 30 | 31 | # ... your test steps that trigger a BrowserStack build ... 32 | 33 | - name: Fetch BrowserStack Report 34 | # If using a published version: 35 | # uses: your-org/browserstack-report-action@v1 36 | # If using a local version from the same repository: 37 | uses: ./.github/actions/browserstack-report-action 38 | with: 39 | username: ${{ secrets.BROWSERSTACK_USERNAME }} 40 | access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 41 | build-name: 'My Awesome App E2E Tests' 42 | #user-timeout can be specified if needed, e.g.: 43 | report-timeout: '10' 44 | ``` 45 | 46 | ## Development 47 | 48 | 1. Clone the repository (or create these files in your existing one). 49 | 2. Navigate to the `browserstack-report-action` directory. 50 | 3. Run `npm install` to install dependencies. 51 | 4. Make changes to the source code in the `src` directory or constants in the `config` directory. 52 | 5. Run `npm run build` to compile the action to the `dist` directory. 53 | 6. Run `npm test` to run unit tests. 54 | 7. Run `npm run all` to run linting, tests, and build. 55 | 56 | ## Project Structure 57 | 58 | ``` 59 | browserstack-report-action/ 60 | ├── .gitignore - Gitignore file 61 | ├── .eslintrc.js - ESLint configuration 62 | ├── action.yml - GitHub Action metadata 63 | ├── package.json - Node.js package file 64 | ├── README.md - Documentation 65 | ├── config/ - Configuration files 66 | │ └── constants.js - Constants used throughout the action 67 | ├── dist/ - Compiled code (generated by ncc) 68 | ├── src/ - JavaScript source code 69 | │ ├── main.js - Main action code 70 | │ └── actionInput/ - Input handling and validation 71 | │ ├── index.js - ActionInput class 72 | │ └── inputValidator.js - InputValidator class 73 | └── test/ - Test files 74 | └── main.test.js - Tests for main.js 75 | ``` 76 | 77 | ## Notes 78 | 79 | - The current API interaction is simulated within `src/main.js`. You'll need to replace `fetchDummyReportAPI` with actual API calls to BrowserStack, including proper authentication using the provided username and access key. 80 | - The initial API response is expected to provide `polling_interval` (seconds) and `retry_count` which dictate the polling behavior. If not provided, defaults are used. 81 | - The `report_status` values handled are: `in_progress`, `complete`, `tests_available`, `not_available`, `build_not_found`, `more_than_one_build_found`. 82 | - The HTML report from `report.basic_html` is added to the GitHub Actions summary. 83 | -------------------------------------------------------------------------------- /setup-env/README.md: -------------------------------------------------------------------------------- 1 | # setup-env 2 | 3 | This action sets up the following environment variables in the runner environment. These environment variables shall be used in the tests for BrowserStack: 4 | 5 | 1. `BROWSERSTACK_BUILD_NAME`: This environment variable is set on the basis of the input to `build-name` field. By default, the value will be decided based on the event, i.e. push, pull_request etc for the workflow: 6 | 1. `push` event: `[] Commit : [Workflow: ]` 7 | 2. `pull_request` event: `[] PR : [Workflow: ]` 8 | 3. `release` event: `[] Release : [Workflow: ]` 9 | 4. Other events: ` [Workflow: ]` 10 | 11 | 2. `BROWSERSTACK_PROJECT_NAME`: This environment variable is set on the basis of the input to `project-name` field. By default, i.e. if any input is not provided, the value will be set as the Repository Name. 12 | 3. `BROWSERSTACK_USERNAME`: This environment variable's value is taken from the input to `username` field. Ideal way would be to pass the GitHub Secret as the input, i.e. `username: ${{ secrets.BROWSERSTACK_USERNAME }}`. 13 | 4. `BROWSERSTACK_ACCESS_KEY`: This environment variable's value is taken from the input to `access-key` field. Ideal way would be to pass the GitHub Secret as the input, i.e. `access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}`. 14 | 15 | ## Prerequisites 16 | * This action does not have any prerequisites. 17 | 18 | ## Usage 19 | ```yaml 20 | - name: 'BrowserStack Env Setup' 21 | uses: 'browserstack/github-actions/setup-env@master' 22 | with: 23 | username: ${{ secrets.BROWSERSTACK_USERNAME }} 24 | access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 25 | build-name: BUILD_INFO 26 | project-name: REPO_NAME 27 | ``` 28 | 29 | or 30 | 31 | ```yaml 32 | - name: 'BrowserStack Env Setup' 33 | uses: 'browserstack/github-actions/setup-env@master' 34 | with: 35 | username: ${{ secrets.BROWSERSTACK_USERNAME }} 36 | access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 37 | ``` 38 | 39 | ## Inputs 40 | * `username`: (**Mandatory**) This is your BrowserStack Username. This should ideally be passed via a GitHub Secret as shown in the sample workflow above. 41 | * `access-key`: (**Mandatory**) This is your BrowserStack Access key that is required to access the BrowserStack device cloud. This should also ideally be passed via a GitHub Secret as shown in the sample workflow above. 42 | * `build-name`: (**Optional**) 43 | * You can pass any string that you want to set as the `BROWSERSTACK_BUILD_NAME`. E.g. `build-name: My Build Name Goes Here`. 44 | * You can also include your personalized string along with the keyword `BUILD_INFO` in the input: 45 | * `build-name: My String Goes Here - BUILD_INFO` 46 | * `build-name: BUILD_INFO - My String at the end` 47 | * `build-name: String at the Beginning - BUILD_INFO - String at the end` 48 | * The keyword `BUILD_INFO` will be replaced by the information based on the event of the workflow as described above for `BROWSERSTACK_BUILD_NAME` environment variable. 49 | * `project-name`: (**Optional**) 50 | * You can pass any string that you want to set as the `BROWSERSTACK_PROJECT_NAME`. E.g. `project-name: My Project Name Goes Here`. 51 | * You can also pass the keyword `REPO_NAME` as the input. This will set the Repository Name for the `BROWSERSTACK_PROJECT_NAME` environment variable. 52 | * If no input is provided, `REPO_NAME` will be considered as the default input. 53 | 54 | --- 55 | **NOTE** 56 | * This action is a prerequisite for any other BrowserStack related actions. 57 | * This action should be invoked prior to the execution of tests on BrowserStack to be able to utilise the environment variables in your tests. 58 | * You have to use the environment variables set by this action in your test script. 59 | --- -------------------------------------------------------------------------------- /browserstack-report-action/src/actionInput/inputValidator.js: -------------------------------------------------------------------------------- 1 | const github = require('@actions/github'); 2 | const constants = require('../../config/constants'); 3 | 4 | /** 5 | * InputValidator performs validation on the input fields of the action. 6 | * The fields are parsed and converted into the required format. 7 | */ 8 | class InputValidator { 9 | /** 10 | * Generates metadata of the triggered workflow based on the type of event. 11 | * Supported events: 12 | * 1. Push 13 | * 2. Pull Request 14 | * 3. Release 15 | * 4. Other events 16 | * @returns {String} Metadata 17 | */ 18 | static _getBuildInfo() { 19 | const githubEvent = github.context.eventName; 20 | switch (githubEvent) { 21 | case 'push': { 22 | const { 23 | context: { 24 | payload: { 25 | head_commit: { 26 | message: commitMessage, 27 | }, 28 | }, 29 | sha: commitSHA, 30 | runNumber: workflowNumber, 31 | ref, 32 | }, 33 | } = github; 34 | 35 | const probableBranchOrTag = ref.split('/').pop(); 36 | const slicedSHA = commitSHA.slice(0, 7); 37 | return `[${probableBranchOrTag}] Commit ${slicedSHA}: ${commitMessage} [Workflow: ${workflowNumber}]`; 38 | } 39 | case 'pull_request': { 40 | const { 41 | context: { 42 | payload: { 43 | pull_request: { 44 | head: { ref: branchName }, 45 | title: prTitle, 46 | }, 47 | number: prNumber, 48 | }, 49 | runNumber: workflowNumber, 50 | }, 51 | } = github; 52 | 53 | return `[${branchName}] PR ${prNumber}: ${prTitle} [Workflow: ${workflowNumber}]`; 54 | } 55 | case 'release': { 56 | const { 57 | context: { 58 | payload: { 59 | release: { 60 | tag_name: tagName, 61 | target_commitish: branchName, 62 | name: releaseName, 63 | }, 64 | }, 65 | runNumber: workflowNumber, 66 | }, 67 | } = github; 68 | 69 | return `[${branchName}] Release ${tagName}${releaseName === tagName ? ' ' : `: ${releaseName} `}[Workflow: ${workflowNumber}]`; 70 | } 71 | default: { 72 | return `${githubEvent} [Workflow: ${github.context.runNumber}]`; 73 | } 74 | } 75 | } 76 | 77 | static updateUsername(inputUsername) { 78 | return `${inputUsername}-GitHubAction`; 79 | } 80 | 81 | /** 82 | * Validates the build-name based on the input. 83 | * If no build name is provided, generates one using workflow name and run ID. 84 | * @param {String} buildName - Action input for 'build-name' 85 | * @returns {String} Validated/default build name 86 | */ 87 | static validateBuildName(inputBuildName) { 88 | if (!inputBuildName) return InputValidator._getBuildInfo(); 89 | 90 | const prIndex = inputBuildName.toLowerCase().indexOf('build_info'); 91 | 92 | if (prIndex === -1) return inputBuildName; 93 | 94 | const metadata = InputValidator._getBuildInfo(); 95 | return inputBuildName.replace(/build_info/i, metadata); 96 | } 97 | 98 | /** 99 | * Validates the user-timeout input. 100 | * Ensures it's a positive number or uses the default. 101 | * @param {String} userTimeout - Action input for 'user-timeout' 102 | * @returns {Number} Validated user timeout value 103 | */ 104 | static validateUserTimeout(userTimeout) { 105 | if (!userTimeout) { 106 | return constants.DEFAULT_USER_TIMEOUT_SECONDS; 107 | } 108 | 109 | const timeoutValue = parseInt(userTimeout, 10); 110 | if (Number.isNaN(timeoutValue) || timeoutValue <= 0) { 111 | return constants.DEFAULT_USER_TIMEOUT_SECONDS; 112 | } 113 | 114 | return timeoutValue; 115 | } 116 | } 117 | 118 | module.exports = InputValidator; 119 | -------------------------------------------------------------------------------- /setup-local/src/actionInput/inputValidator.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const { v4: uuidv4 } = require('uuid'); 3 | const parseArgs = require('minimist'); 4 | const constants = require('../../config/constants'); 5 | 6 | const { 7 | ALLOWED_INPUT_VALUES: { 8 | LOCAL_LOG_LEVEL, 9 | LOCAL_TESTING, 10 | LOCAL_IDENTIFIER_RANDOM, 11 | }, 12 | INPUT, 13 | RESTRICTED_LOCAL_ARGS, 14 | } = constants; 15 | 16 | /** 17 | * InputValidator performs validation on the input fields of this 18 | * action. The fields are parsed and converted into the required format. 19 | */ 20 | class InputValidator { 21 | /** 22 | * Validates the action input 'local-testing' and returns the 23 | * parsed value. 24 | * Throws error if it's not a valid value 25 | * @param {String} inputLocalTesting Action input for 'local-testing' 26 | * @returns {String} One of the values from start/stop/false 27 | */ 28 | static validateLocalTesting(inputLocalTesting) { 29 | const localTestingLowered = inputLocalTesting.toLowerCase(); 30 | // eslint-disable-next-line max-len 31 | const validValue = Object.values(LOCAL_TESTING).some((allowedValue) => allowedValue === localTestingLowered); 32 | 33 | if (!validValue) { 34 | throw Error(`Invalid input for ${INPUT.LOCAL_TESING}. The valid inputs are: ${Object.values(LOCAL_TESTING).join(', ')}. Refer the README for more details`); 35 | } 36 | 37 | return localTestingLowered; 38 | } 39 | 40 | /** 41 | * Validates the action input 'local-logging-level' and returns the 42 | * verbosity level of logging. 43 | * @param {String} inputLocalLoggingLevel Action input for 'local-logging-level' 44 | * @returns {Number} Logging Level (0 - 3) 45 | */ 46 | static validateLocalLoggingLevel(inputLocalLoggingLevel) { 47 | if (!inputLocalLoggingLevel) return 0; 48 | 49 | const loggingLevelLowered = inputLocalLoggingLevel.toLowerCase(); 50 | 51 | switch (loggingLevelLowered) { 52 | case LOCAL_LOG_LEVEL.SETUP_LOGS: { 53 | return 1; 54 | } 55 | case LOCAL_LOG_LEVEL.NETWORK_LOGS: { 56 | return 2; 57 | } 58 | case LOCAL_LOG_LEVEL.ALL_LOGS: { 59 | return 3; 60 | } 61 | case LOCAL_LOG_LEVEL.FALSE: { 62 | return 0; 63 | } 64 | default: { 65 | core.info(`[Warning] Invalid input for ${INPUT.LOCAL_LOGGING_LEVEL}. No logs will be captured. The valid inputs are: ${Object.values(LOCAL_LOG_LEVEL).join(', ')}`); 66 | return 0; 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Validates the local-identifier input. It handles the generation of random 73 | * identifier if required. 74 | * @param {String} inputLocalIdentifier Action input for 'local-identifier' 75 | * @returns {String} Parsed/Random local-identifier 76 | */ 77 | static validateLocalIdentifier(inputLocalIdentifier) { 78 | if (!inputLocalIdentifier) return ''; 79 | 80 | if (inputLocalIdentifier.toLowerCase() === LOCAL_IDENTIFIER_RANDOM) { 81 | return `GitHubAction-${uuidv4()}`; 82 | } 83 | 84 | return inputLocalIdentifier.split(/\s+/).join('-'); 85 | } 86 | 87 | /** 88 | * Validates the local-args input. Removes any args which might conflict with 89 | * the input args taken from the action input for the Local Binary. 90 | * @param {String} inputLocalArgs Action input for 'local-args' 91 | * @returns {String} Parsed args 92 | */ 93 | static validateLocalArgs(inputLocalArgs) { 94 | const parsedArgs = parseArgs(inputLocalArgs.split(/\s+/)); 95 | 96 | delete parsedArgs._; 97 | RESTRICTED_LOCAL_ARGS.forEach((arg) => { 98 | delete parsedArgs[arg]; 99 | }); 100 | 101 | let parsedArgsString = ''; 102 | for (const [key, value] of Object.entries(parsedArgs)) { 103 | const argKey = key.length === 1 ? `-${key}` : `--${key}`; 104 | const argValue = value === true ? '' : value; 105 | parsedArgsString += `${argKey} ${argValue} `; 106 | } 107 | 108 | return parsedArgsString; 109 | } 110 | } 111 | 112 | module.exports = InputValidator; 113 | -------------------------------------------------------------------------------- /setup-local/test/actionInput/inputValidator.test.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const { expect } = require('chai'); 3 | const sinon = require('sinon'); 4 | const constants = require('../../config/constants'); 5 | const InputValidator = require('../../src/actionInput/inputValidator'); 6 | 7 | const { 8 | ALLOWED_INPUT_VALUES: { 9 | LOCAL_LOG_LEVEL, 10 | LOCAL_TESTING, 11 | LOCAL_IDENTIFIER_RANDOM, 12 | }, 13 | INPUT, 14 | } = constants; 15 | 16 | describe('InputValidator class to validate individual fields of the action input', () => { 17 | context('Public Static Methods', () => { 18 | context('Validates whether Local Testing is start/stop', () => { 19 | it(`Returns 'start' if the input value is ${LOCAL_TESTING.START}`, () => { 20 | const inputLocalTesting = 'start'; 21 | expect(InputValidator.validateLocalTesting(inputLocalTesting)).to.eq('start'); 22 | }); 23 | 24 | it(`Returns 'stop' if the input value is ${LOCAL_TESTING.STOP}`, () => { 25 | const inputLocalTesting = 'stop'; 26 | expect(InputValidator.validateLocalTesting(inputLocalTesting)).to.eq('stop'); 27 | }); 28 | 29 | it(`Throws error if the input is not from: ${Object.values(LOCAL_TESTING).join(', ')}`, () => { 30 | const expectedErrorMsg = `Invalid input for ${INPUT.LOCAL_TESING}. The valid inputs are: ${Object.values(LOCAL_TESTING).join(', ')}. Refer the README for more details`; 31 | try { 32 | InputValidator.validateLocalTesting('someRandomInput'); 33 | } catch (e) { 34 | expect(e.message).to.eq(expectedErrorMsg); 35 | } 36 | }); 37 | }); 38 | 39 | context('Validates local logging level and decides the verbosity level', () => { 40 | it(`Returns 1 if the input is ${LOCAL_LOG_LEVEL.SETUP_LOGS}`, () => { 41 | expect(InputValidator.validateLocalLoggingLevel(LOCAL_LOG_LEVEL.SETUP_LOGS)).to.eq(1); 42 | }); 43 | 44 | it(`Returns 2 if the input is ${LOCAL_LOG_LEVEL.NETWORK_LOGS}`, () => { 45 | expect(InputValidator.validateLocalLoggingLevel(LOCAL_LOG_LEVEL.NETWORK_LOGS)).to.eq(2); 46 | }); 47 | 48 | it(`Returns 3 if the input is ${LOCAL_LOG_LEVEL.ALL_LOGS}`, () => { 49 | expect(InputValidator.validateLocalLoggingLevel(LOCAL_LOG_LEVEL.ALL_LOGS)).to.eq(3); 50 | }); 51 | 52 | [LOCAL_LOG_LEVEL.FALSE, undefined, '', 'someRandomValue'].forEach((value) => { 53 | it(`Returns 0 if the input is ${JSON.stringify(value)}`, () => { 54 | sinon.stub(core, 'info'); 55 | expect(InputValidator.validateLocalLoggingLevel(value)).to.eq(0); 56 | core.info.restore(); 57 | }); 58 | }); 59 | }); 60 | 61 | context('Validates local identifier', () => { 62 | it("Returns the idenfier joined by '-' if the input is not 'random'", () => { 63 | const inputLocalIdentifer = 'This is The identifier'; 64 | const expectedOutput = 'This-is-The-identifier'; 65 | expect(InputValidator.validateLocalIdentifier(inputLocalIdentifer)).to.eq(expectedOutput); 66 | }); 67 | 68 | ['', null, undefined].forEach((value) => { 69 | it(`Returns empty string if the input is :${JSON.stringify(value)}`, () => { 70 | expect(InputValidator.validateLocalIdentifier(value)).to.eq(''); 71 | }); 72 | }); 73 | 74 | it("Returns a unique identifier prefixed with 'GitHubAction-' when the input is 'random' (case insensitive)", () => { 75 | expect(InputValidator.validateLocalIdentifier(LOCAL_IDENTIFIER_RANDOM)).to.match(/GitHubAction-[a-z0-9-]{36}/); 76 | }); 77 | }); 78 | 79 | context('Validates local args', () => { 80 | it('Removes the restricted/not-alowed args from the local-args input and returns the string', () => { 81 | const inputLocalArgs = '--key someKey --proxy-host hostname --someOtherKey someValue -z --daemon start --ci-plugin someName -k anotherKey --only-automate --log-file some/path/ --verbose level --local-identifier someIdentifier'; 82 | const expectedLocalArgs = '--proxy-host hostname --someOtherKey someValue -z '; 83 | expect(InputValidator.validateLocalArgs(inputLocalArgs)).to.eq(expectedLocalArgs); 84 | }); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BrowserStack GitHub Actions 2 | 3 |

4 | BrowserStack Logo 5 |

6 | 7 |

8 | setup-env build status 9 | setup-local build status 10 |

11 | 12 | This respository contains a library of GitHub Actions to help you integrate your test suite with the [BrowserStack](https://www.browserstack.com/?utm_source=github&utm_medium=partnered) device cloud. 13 | 14 | You need a BrowserStack username and access-key to run your tests on the BrowserStack device cloud. You can [sign-up for free trial](https://www.browserstack.com/users/sign_up/?utm_source=github&utm_medium=partnered) if you do not have an existing account. 15 | 16 | If you want to test your open source project on BrowserStack, then [sign-up here](https://www.browserstack.com/open-source/?utm_source=github&utm_medium=partnered) for lifetime free access to all our products. 17 | 18 | ## Available Actions 19 | * [setup-env](./setup-env): This Action helps in setting up the required environment variables that are to be used in your test scripts. The environment variables set up here shall be used by other BrowserStack actions as well for their functioning. 20 | 21 | * [setup-local](./setup-local): This Action downloads and starts the appropriate BrowserStackLocal binary, thereby creating a secure tunnel connection from the GitHub Actions runner environment to the BrowserStack device cloud. This secure tunnel will be used by the remote browsers in BrowserStack to access your web application hosted in the GitHub Actions runner environment. **You do not need this Action if the application to be tested is accessible over the public internet.** 22 | 23 | ## Prerequisites 24 | * You should set your BrowserStack Username and Access Key as GitHub Secrets, i.e. `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` respectively. 25 | 26 | ## Usage 27 | As this is a library of Actions, invoking this Action will trigger the `setup-env` Action internally. The following usage example will **only** set up the required environment variables: 28 | 29 | ```yaml 30 | - name: BrowserStack Action 31 | uses: 'browserstack/github-actions@master' 32 | with: 33 | username: ${{ secrets.BROWSERSTACK_USERNAME }} 34 | access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 35 | build-name: BUILD_INFO 36 | project-name: REPO_NAME 37 | ``` 38 | We recommend you to invoke the Actions individually depending on the use case. A sample workflow for the same is shown below. You can additionally refer to the individual `README` ([setup-env](./setup-env), [setup-local](./setup-local)) of the Actions to know more about how they work, the inputs they support and their usage examples. 39 | 40 | ## Sample Workflow with usage of both Actions 41 | The workflow example below would be useful when the web application to be tested is hosted on the GitHub Actions runner environment, i.e. not accessible from the public Internet. 42 | 43 | ```yaml 44 | name: 'BrowserStack Test' 45 | on: [push, pull_request] 46 | 47 | jobs: 48 | ubuntu-job: 49 | name: 'BrowserStack Test on Ubuntu' 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: 'BrowserStack Env Setup' 53 | uses: 'browserstack/github-actions/setup-env@master' 54 | with: 55 | username: ${{ secrets.BROWSERSTACK_USERNAME }} 56 | access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 57 | build-name: BUILD_INFO 58 | project-name: REPO_NAME 59 | - name: 'BrowserStackLocal Setup' 60 | uses: 'browserstack/github-actions/setup-local@master' 61 | with: 62 | local-testing: start 63 | local-identifier: random 64 | ``` 65 | 66 | ### Note 67 | --- 68 | Post these steps, you will have to build and run your application web server on the same runner environment. Further, invoke your test scripts by utilizing the environment variables that have been set by actions. For more detailed steps on how to integrate your test suite with BrowserStack on GitHub Actions, visit [BrowserStack Documentation](https://www.browserstack.com/docs/automate/selenium/github-actions/?utm_medium=partnered&utm_source=github) for the same. 69 | 70 | After you are done running your tests, invoke the `setup-local` Action again with `local-testing: stop` as the input: 71 | ```yaml 72 | - name: 'BrowserStackLocal Stop' 73 | uses: 'browserstack/github-actions/setup-local@master' 74 | with: 75 | local-testing: stop 76 | ``` 77 | ## Feature requests and bug reports 78 | Please file feature requests and bug reports as [github issues](https://github.com/browserstack/github-actions/issues). 79 | -------------------------------------------------------------------------------- /setup-local/README.md: -------------------------------------------------------------------------------- 1 | # setup-local 2 | This action fulfils the following objectives in your runner environment: 3 | * It will download the appropriate type of BrowserStackLocal binary in your runner environment depending on the environment, i.e. Linux/Darwin/Win32. 4 | * It will start (or stop) the binary and establish (or end) the Local tunnel connection from the runner machine to the BrowserStack cloud as per the input for `local-testing` field. 5 | * The action provides the functionality to specify the logging level of the binary and then upload the logs as artifacts in GitHub workflow. 6 | * The action allows you to pass any combination of arguments for the invocation of the BrowserStackLocal binary as given [here](https://www.browserstack.com/local-testing/binary-params). 7 | 8 | ## Prerequisites 9 | The **browserstack/github-actions/setup-env@master** action should have been invoked prior to invoking this action as a part of the same job. 10 | 11 | ## Inputs 12 | * `local-testing`: (**Mandatory**) 13 | * Valid inputs: 14 | * `start`: This will download the BrowserStackLocal binary (if it wasn't downloaded earlier by this action in the same runner environment) and start the binary with additional inputs that you might provide. The `local-identifier` that is used to start the binary will be set in the environment variable `BROWSERSTACK_LOCAL_IDENTIFIER`. The same will be used for stopping the binary when using the `stop` input. 15 | * `stop`: This will ensure that a previously running binary will be stopped and if any log-level was set by `local-logging-level`, then the logs will be uploaded as artifacts. **If you do not stop the binary after the completion of your tests, the logs will not be uploaded as artifacts.** 16 | * `local-logging-level`: (**Optional**) 17 | * Valid inputs: 18 | * `false`: No local binary logs will be captured. 19 | * `setup-logs`: Local logs to debug issues related to setting up of connections will be saved. They will be uploaded as artifacts only if the action is again invoked with `local-testing: stop`. 20 | * `network-logs`: Local logs related to network information will be saved. They will be uploaded as artifacts only if the action is again invoked with `local-testing: stop`. 21 | * `all-logs`: Local logs related to all communication to local servers for each request and response will be saved. They will be uploaded as artifacts only if the action is again invoked with `local-testing: stop`. 22 | * Default: `false`. 23 | * `local-identifier`: (**Optional**) 24 | * Valid inputs: 25 | * `random`: This is the recommended value for this input. A randomly generated string will be used to start the local tunnel connection and the string will be saved in the environment variable `BROWSERSTACK_LOCAL_IDENTIFIER`. You must use the same environment variable in your test script to specify the tunnel identifier in capabilities. 26 | * ``: You can choose any value for the `string`. The same will be saved in the environment variable `BROWSERSTACK_LOCAL_IDENTIFIER` which you must use in your test script to specify the tunnel identifier in capabilities. 27 | * Default: If you do not provide any input, then no tunnel identifier will be used. This option is not recommended because if multiple tunnels are created without any identifier (or with same identifier) for the same access-key, then tests might behave abnormally. It is strongly advised not to choose this option. 28 | * `local-args`: (**Optional**) 29 | * Valid input: You can choose to pass any additional arguments to start the local binary through this option. All your arguments must be a part of this single string. You can find the complete list of supported local-binary arguments [here](https://www.browserstack.com/local-testing/binary-params). 30 | * E.g. `local-args: --force-local --proxy-host --proxy-port --proxy-user --proxy-pass ` 31 | * **NOTE**: Do not include the following arguments as a part of this input string (they will be ignored if passed): 32 | * `--key` or `-k` 33 | * `--local-identifier` 34 | * `--daemon` 35 | * `--only-automate` 36 | * `--verbose` 37 | * `--log-file` 38 | * The above arguments are already being included in the invocation of the local binary and hence, if you include any of the above again in the `local-args` string, they will be ignored. `local-args` is an optional argument and under normal circumstances, if the application is not hosted behind any proxy, this input would not be required. Visit [this page](https://www.browserstack.com/local-testing/binary-params) to see if any additional argument is applicable to your test scenario. 39 | 40 | ## Usage 41 | Use the code snippet below in your workflow to start the BrowserStackLocal binary and establish the tunnel connection: 42 | ```yaml 43 | - name: 'Start BrowserStackLocal Tunnel' 44 | uses: 'browserstack/github-actions/setup-local@master' 45 | with: 46 | local-testing: start 47 | local-logging-level: all-logs 48 | local-identifier: random 49 | ``` 50 | 51 | Use the code snippet below at the end of your workflow after the tests have completed. This will stop the BrowserStackLocal binary and upload the local binary logs (if any): 52 | ```yaml 53 | - name: 'Stop BrowserStackLocal' 54 | uses: 'browserstack/github-actions/setup-local@master' 55 | with: 56 | local-testing: stop 57 | ``` -------------------------------------------------------------------------------- /setup-env/src/actionInput/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const axios = require('axios'); 3 | const InputValidator = require('./inputValidator'); 4 | const constants = require('../../config/constants'); 5 | const { BROWSERSTACK_INTEGRATIONS } = require("../../config/constants"); 6 | 7 | const { 8 | INPUT, 9 | ENV_VARS, 10 | } = constants; 11 | 12 | /** 13 | * ActionInput manages the fetching of action input values and 14 | * helps in setting env variables post validation. 15 | */ 16 | class ActionInput { 17 | constructor() { 18 | this._fetchAllInput(); 19 | this._validateInput(); 20 | } 21 | 22 | /** 23 | * Fetches all the input values given to the action. 24 | * Raises error if the required values are not provided. 25 | */ 26 | _fetchAllInput() { 27 | try { 28 | // required fields 29 | this.username = core.getInput(INPUT.USERNAME, { required: true }); 30 | this.accessKey = core.getInput(INPUT.ACCESS_KEY, { required: true }); 31 | 32 | // non-compulsory fields 33 | this.buildName = core.getInput(INPUT.BUILD_NAME); 34 | this.projectName = core.getInput(INPUT.PROJECT_NAME); 35 | this.githubApp = core.getInput(INPUT.GITHUB_APP); 36 | this.githubToken = core.getInput(INPUT.GITHUB_TOKEN); 37 | this.rerunAttempt = process?.env?.GITHUB_RUN_ATTEMPT; 38 | this.runId = process?.env?.GITHUB_RUN_ID; 39 | this.repository = process?.env?.GITHUB_REPOSITORY; 40 | } catch (e) { 41 | throw Error(`Action input failed for reason: ${e.message}`); 42 | } 43 | } 44 | 45 | /** 46 | * Validates the input values 47 | */ 48 | _validateInput() { 49 | this.username = InputValidator.updateUsername(this.username); 50 | this.buildName = InputValidator.validateBuildName(this.buildName); 51 | this.projectName = InputValidator.validateProjectName(this.projectName); 52 | this.githubApp = InputValidator.validateGithubAppName(this.githubApp); 53 | this.githubToken = InputValidator.validateGithubToken(this.githubToken); 54 | } 55 | 56 | /** 57 | * Sets env variables to be used in the test script for BrowserStack 58 | */ 59 | async setEnvVariables() { 60 | core.startGroup('Setting Environment Variables'); 61 | 62 | core.exportVariable(ENV_VARS.BROWSERSTACK_USERNAME, this.username); 63 | core.info(`Use ${ENV_VARS.BROWSERSTACK_USERNAME} environment variable for your username in your tests\n`); 64 | 65 | core.exportVariable(ENV_VARS.BROWSERSTACK_ACCESS_KEY, this.accessKey); 66 | core.info(`Use ${ENV_VARS.BROWSERSTACK_ACCESS_KEY} environment variable for your access key in your tests\n`); 67 | 68 | core.exportVariable(ENV_VARS.BROWSERSTACK_PROJECT_NAME, this.projectName); 69 | core.info(`${ENV_VARS.BROWSERSTACK_PROJECT_NAME} environment variable set as: ${this.projectName}`); 70 | core.info(`Use ${ENV_VARS.BROWSERSTACK_PROJECT_NAME} environment variable for your project name capability in your tests\n`); 71 | 72 | core.exportVariable(ENV_VARS.BROWSERSTACK_BUILD_NAME, this.buildName); 73 | core.info(`${ENV_VARS.BROWSERSTACK_BUILD_NAME} environment variable set as: ${this.buildName}`); 74 | core.info(`Use ${ENV_VARS.BROWSERSTACK_BUILD_NAME} environment variable for your build name capability in your tests\n`); 75 | 76 | if (await this.checkIfBStackReRun()) { 77 | await this.setBStackRerunEnvVars(); 78 | } 79 | core.endGroup(); 80 | } 81 | 82 | async checkIfBStackReRun() { 83 | // Ensure rerunAttempt is a number and greater than 1 84 | if (!this.rerunAttempt || Number(this.rerunAttempt) <= 1) { 85 | return false; 86 | } 87 | 88 | // Ensure runId, repository, username, and accessKey are valid 89 | if (!this.runId || !this.repository || this.repository === 'none' 90 | || !this.githubToken || this.githubToken === 'none' || !this.username || !this.accessKey) { 91 | return false; 92 | } 93 | 94 | const triggeringActor = process.env.GITHUB_TRIGGERING_ACTOR; 95 | core.info(`Triggering actor is - ${triggeringActor}`); 96 | return triggeringActor === this.githubApp; 97 | } 98 | 99 | async setBStackRerunEnvVars() { 100 | try { 101 | // Check if the run was triggered by the BrowserStack rerun bot 102 | core.info('The re-run was triggered by the GitHub App from BrowserStack.'); 103 | 104 | const browserStackApiUrl = BROWSERSTACK_INTEGRATIONS.DETAILS_API_URL.replace('{runId}', this.runId); 105 | 106 | // Call BrowserStack API to get the tests to rerun 107 | const bsApiResponse = await axios.get(browserStackApiUrl, { 108 | auth: { 109 | username: this.username.replace("-GitHubAction", ""), 110 | password: this.accessKey, 111 | }, 112 | headers: { 113 | 'Content-Type': 'application/json', 114 | }, 115 | }); 116 | const variables = bsApiResponse?.data?.data?.variables; 117 | if (variables && typeof variables === 'object') { 118 | Object.keys(variables).forEach((key) => { 119 | core.exportVariable(key, variables[key]); 120 | }); 121 | } 122 | } catch (error) { 123 | core.info(`Error setting BrowserStack rerun environment variables: ${error.message}`); 124 | } 125 | } 126 | } 127 | 128 | module.exports = ActionInput; 129 | -------------------------------------------------------------------------------- /setup-env/src/actionInput/inputValidator.js: -------------------------------------------------------------------------------- 1 | const github = require('@actions/github'); 2 | 3 | /** 4 | * InputValidator performs validation on the input fields of this 5 | * action. The fields are parsed and converted into the required format. 6 | */ 7 | class InputValidator { 8 | /** 9 | * Generates metadata of the triggered workflow based on the type of event. 10 | * Supported events: 11 | * 1. Push 12 | * 2. Pull Request 13 | * 3. Release 14 | * 4. Other events 15 | * @returns {String} Metadata 16 | */ 17 | static _getBuildInfo() { 18 | const githubEvent = github.context.eventName; 19 | switch (githubEvent) { 20 | case 'push': { 21 | const { 22 | context: { 23 | payload: { 24 | head_commit: { 25 | message: commitMessage, 26 | }, 27 | }, 28 | sha: commitSHA, 29 | runNumber: workflowNumber, 30 | ref, 31 | }, 32 | } = github; 33 | 34 | const probableBranchOrTag = ref.split('/').pop(); 35 | const slicedSHA = commitSHA.slice(0, 7); 36 | return `[${probableBranchOrTag}] Commit ${slicedSHA}: ${commitMessage} [Workflow: ${workflowNumber}]`; 37 | } 38 | case 'pull_request': { 39 | const { 40 | context: { 41 | payload: { 42 | pull_request: { 43 | head: { 44 | ref: branchName, 45 | }, 46 | title: prTitle, 47 | }, 48 | number: prNumber, 49 | }, 50 | runNumber: workflowNumber, 51 | }, 52 | } = github; 53 | 54 | return `[${branchName}] PR ${prNumber}: ${prTitle} [Workflow: ${workflowNumber}]`; 55 | } 56 | case 'release': { 57 | const { 58 | context: { 59 | payload: { 60 | release: { 61 | tag_name: tagName, 62 | target_commitish: branchName, 63 | name: releaseName, 64 | }, 65 | }, 66 | runNumber: workflowNumber, 67 | }, 68 | } = github; 69 | 70 | return `[${branchName}] Release ${tagName}${releaseName === tagName ? ' ' : `: ${releaseName} `}[Workflow: ${workflowNumber}]`; 71 | } 72 | default: { 73 | return `${githubEvent} [Workflow: ${github.context.runNumber}]`; 74 | } 75 | } 76 | } 77 | 78 | /** 79 | * Appends the username with '-GitHubAction' for internal instrumentation 80 | * @param {String} inputUsername BrowserStack Username 81 | * @returns {String} Modified Username 82 | */ 83 | static updateUsername(inputUsername) { 84 | return `${inputUsername}-GitHubAction`; 85 | } 86 | 87 | /** 88 | * Validates the build-name based on the input type. It performs the following: 89 | * 1. Checks if 'build_info' (case insensitive) exists in the input 90 | * 2. Adds metadata information of the PR/Commit if required (based on the input format). 91 | * @param {String} inputBuildName Action input for 'build-name' 92 | * @returns {String} Parsed/Modified Build Name 93 | */ 94 | static validateBuildName(inputBuildName) { 95 | if (!inputBuildName) return InputValidator._getBuildInfo(); 96 | 97 | const prIndex = inputBuildName.toLowerCase().indexOf('build_info'); 98 | 99 | if (prIndex === -1) return inputBuildName; 100 | 101 | const metadata = InputValidator._getBuildInfo(); 102 | return inputBuildName.replace(/build_info/i, metadata); 103 | } 104 | 105 | /** 106 | * Validates the project-name. It performs the following: 107 | * 1. Checks if there is no input or the input is 'repo_name' (case insensitive) 108 | * 2. If input is provided for the project name other than 'repo_name' 109 | * @param {String} inputProjectName Action input for 'project-name' 110 | * @returns {String} Project name 111 | */ 112 | static validateProjectName(inputProjectName) { 113 | if (!inputProjectName || inputProjectName.toLowerCase() === 'repo_name') return github.context.repo.repo; 114 | 115 | return inputProjectName; 116 | } 117 | 118 | /** 119 | * Validates the app name input to ensure it is a valid non-empty string. 120 | * If the input is 'none' or not provided, it returns 'browserstack-integrations[bot]'. 121 | * @param {string} githubAppName Input for 'github-app' 122 | * @returns {string} Validated app name, or 'browserstack-integrations[bot]' 123 | * if input is 'none' or invalid 124 | * @throws {Error} If the input is not a valid non-empty string 125 | */ 126 | static validateGithubAppName(githubAppName) { 127 | if (typeof githubAppName !== 'string') { 128 | throw new Error("Invalid input for 'github-app'. Must be a valid string."); 129 | } 130 | 131 | if (githubAppName.toLowerCase() === 'browserstack-integrations[bot]') { 132 | return 'browserstack-integrations[bot]'; 133 | } 134 | 135 | if (githubAppName.trim().length > 0) { 136 | return githubAppName; 137 | } 138 | 139 | throw new Error("Invalid input for 'github-app'. Must be a valid string."); 140 | } 141 | 142 | /** 143 | * Validates the GitHub token input to ensure it is a valid non-empty string. 144 | * If the input is 'none' or not provided, it returns 'none'. 145 | * @param {string} githubToken Input for 'github-token' 146 | * @returns {string} The validated GitHub token, or 'none' if input is 'none' or invalid 147 | * @throws {Error} If the input is not a valid non-empty string 148 | */ 149 | static validateGithubToken(githubToken) { 150 | if (typeof githubToken !== 'string') { 151 | throw new Error("Invalid input for 'github-token'. Must be a valid non-empty string."); 152 | } 153 | 154 | if (githubToken.toLowerCase() === 'none') { 155 | return 'none'; 156 | } 157 | 158 | if (githubToken.trim().length > 0) { 159 | return githubToken; 160 | } 161 | 162 | throw new Error("Invalid input for 'github-token'. Must be a valid non-empty string."); 163 | } 164 | } 165 | 166 | module.exports = InputValidator; 167 | -------------------------------------------------------------------------------- /browserstack-report-action/test/actionInput/inputValidator.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const github = require('@actions/github'); 4 | const InputValidator = require('../../src/actionInput/inputValidator'); 5 | const constants = require('../../config/constants'); 6 | 7 | describe('InputValidator', () => { 8 | describe('validateBuildName', () => { 9 | let githubContextStub; 10 | 11 | beforeEach(() => { 12 | // Setup GitHub context stub with all possible event payloads 13 | githubContextStub = { 14 | eventName: 'push', 15 | payload: { 16 | head_commit: { 17 | message: 'test commit message', 18 | }, 19 | pull_request: { 20 | head: { ref: 'feature-branch' }, 21 | title: 'Test PR Title', 22 | number: 123, 23 | }, 24 | release: { 25 | tag_name: 'v1.0.0', 26 | target_commitish: 'main', 27 | name: 'Release v1.0.0', 28 | }, 29 | }, 30 | sha: 'abcdef1234567890', 31 | runNumber: '42', 32 | ref: 'refs/heads/main', 33 | }; 34 | 35 | // Stub github.context 36 | sinon.stub(github, 'context').value(githubContextStub); 37 | }); 38 | 39 | afterEach(() => { 40 | sinon.restore(); 41 | }); 42 | 43 | context('with push events', () => { 44 | beforeEach(() => { 45 | sinon.stub(github, 'context').value({ 46 | payload: { 47 | head_commit: { 48 | message: 'messageOfHeadCommit', 49 | }, 50 | }, 51 | sha: 'someSHA', 52 | runNumber: 123, 53 | ref: 'refs/head/branchOrTagName', 54 | eventName: 'push', 55 | }); 56 | }); 57 | 58 | it('Generate build info with commit information', () => { 59 | const expectedValue = '[branchOrTagName] Commit someSHA: messageOfHeadCommit [Workflow: 123]'; 60 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 61 | }); 62 | }); 63 | 64 | context('with pull_request events', () => { 65 | beforeEach(() => { 66 | sinon.stub(github, 'context').value({ 67 | payload: { 68 | pull_request: { 69 | head: { 70 | ref: 'branchName', 71 | }, 72 | title: 'prTitle', 73 | }, 74 | number: 'prNumber', 75 | }, 76 | runNumber: 123, 77 | eventName: 'pull_request', 78 | }); 79 | }); 80 | 81 | it('returns PR metadata for empty build name', () => { 82 | const result = InputValidator.validateBuildName(''); 83 | expect(result).to.equal('[branchName] PR prNumber: prTitle [Workflow: 123]'); 84 | }); 85 | }); 86 | 87 | context('Release event', () => { 88 | beforeEach(() => { 89 | sinon.stub(github, 'context').value({ 90 | payload: { 91 | release: { 92 | tag_name: 'tagName', 93 | target_commitish: 'branchName', 94 | name: 'releaseName', 95 | }, 96 | }, 97 | runNumber: 123, 98 | eventName: 'release', 99 | }); 100 | }); 101 | 102 | it('Generate build info with Release information where release name != tag name', () => { 103 | const expectedValue = '[branchName] Release tagName: releaseName [Workflow: 123]'; 104 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 105 | }); 106 | 107 | it('Generate build info with Release information where release name == tag name', () => { 108 | github.context.payload.release.name = 'tagName'; 109 | const expectedValue = '[branchName] Release tagName [Workflow: 123]'; 110 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 111 | }); 112 | }); 113 | 114 | it('returns original build name when no placeholder exists', () => { 115 | const buildName = 'My Custom Build'; 116 | const result = InputValidator.validateBuildName(buildName); 117 | expect(result).to.equal(buildName); 118 | }); 119 | 120 | it('handles case-insensitive build_info placeholder', () => { 121 | const result = InputValidator.validateBuildName('Test-BUILD_INFO-Suite'); 122 | expect(result).to.include('Test-'); 123 | expect(result).to.include('-Suite'); 124 | expect(result).to.include('[main]'); 125 | }); 126 | }); 127 | 128 | describe('validateUserTimeout', () => { 129 | it('accepts valid positive number string', () => { 130 | expect(InputValidator.validateUserTimeout('600')).to.equal(600); 131 | }); 132 | 133 | it('returns default for empty string', () => { 134 | expect(InputValidator.validateUserTimeout('')).to.equal(constants.DEFAULT_USER_TIMEOUT_SECONDS); 135 | }); 136 | 137 | it('returns default for undefined', () => { 138 | expect( 139 | InputValidator.validateUserTimeout(undefined), 140 | ).to.equal(constants.DEFAULT_USER_TIMEOUT_SECONDS); 141 | }); 142 | 143 | it('returns default for null', () => { 144 | expect( 145 | InputValidator.validateUserTimeout(null), 146 | ).to.equal(constants.DEFAULT_USER_TIMEOUT_SECONDS); 147 | }); 148 | 149 | it('returns default for non-numeric string', () => { 150 | expect(InputValidator.validateUserTimeout('not-a-number')).to.equal(constants.DEFAULT_USER_TIMEOUT_SECONDS); 151 | }); 152 | 153 | it('returns default for negative number', () => { 154 | expect(InputValidator.validateUserTimeout('-100')).to.equal(constants.DEFAULT_USER_TIMEOUT_SECONDS); 155 | }); 156 | 157 | it('returns default for zero', () => { 158 | expect(InputValidator.validateUserTimeout('0')).to.equal(constants.DEFAULT_USER_TIMEOUT_SECONDS); 159 | }); 160 | 161 | it('handles decimal numbers by truncating', () => { 162 | expect(InputValidator.validateUserTimeout('123.45')).to.equal(123); 163 | }); 164 | 165 | it('handles large valid numbers', () => { 166 | expect(InputValidator.validateUserTimeout('3600')).to.equal(3600); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /setup-local/test/actionInput/index.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const core = require('@actions/core'); 4 | const ActionInput = require('../../src/actionInput'); 5 | const InputValidator = require('../../src/actionInput/inputValidator'); 6 | const constants = require('../../config/constants'); 7 | 8 | const { 9 | INPUT, 10 | ENV_VARS, 11 | ALLOWED_INPUT_VALUES: { 12 | LOCAL_TESTING, 13 | }, 14 | } = constants; 15 | 16 | describe('Action Input operations for fetching all inputs, triggering validation and setting env vars', () => { 17 | context('Fetch and Validate Input', () => { 18 | let stubbedInput; 19 | let previousAccessKey; 20 | let previousLocalIdentifier; 21 | 22 | beforeEach(() => { 23 | previousAccessKey = process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY]; 24 | previousLocalIdentifier = process.env[ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER]; 25 | stubbedInput = sinon.stub(core, 'getInput'); 26 | stubbedInput.withArgs(INPUT.LOCAL_LOGGING_LEVEL).returns('someLevel'); 27 | stubbedInput.withArgs(INPUT.LOCAL_IDENTIFIER).returns('someIdentifier'); 28 | stubbedInput.withArgs(INPUT.LOCAL_ARGS).returns('someArgs'); 29 | sinon.stub(InputValidator, 'validateLocalLoggingLevel').returns('validatedLoggingLevel'); 30 | sinon.stub(InputValidator, 'validateLocalIdentifier').returns('validatedLocalIdentifier'); 31 | sinon.stub(InputValidator, 'validateLocalArgs').returns('validatedLocalArgs'); 32 | }); 33 | 34 | afterEach(() => { 35 | process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY] = previousAccessKey; 36 | process.env[ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER] = previousLocalIdentifier; 37 | core.getInput.restore(); 38 | InputValidator.validateLocalLoggingLevel.restore(); 39 | InputValidator.validateLocalIdentifier.restore(); 40 | InputValidator.validateLocalArgs.restore(); 41 | }); 42 | 43 | it('Takes input and validates it successfully for start operation', () => { 44 | sinon.stub(InputValidator, 'validateLocalTesting').returns('start'); 45 | process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY] = 'someAccessKey'; 46 | stubbedInput.withArgs(INPUT.LOCAL_TESING, { required: true }).returns('start'); 47 | const actionInput = new ActionInput(); 48 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_TESING, { required: true }); 49 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_LOGGING_LEVEL); 50 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_IDENTIFIER); 51 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_ARGS); 52 | sinon.assert.calledWith(InputValidator.validateLocalTesting, 'start'); 53 | sinon.assert.calledWith(InputValidator.validateLocalLoggingLevel, 'someLevel'); 54 | sinon.assert.calledWith(InputValidator.validateLocalIdentifier, 'someIdentifier'); 55 | sinon.assert.calledWith(InputValidator.validateLocalArgs, 'someArgs'); 56 | expect(actionInput.localTesting).to.eq('start'); 57 | expect(actionInput.localLoggingLevel).to.eq('validatedLoggingLevel'); 58 | expect(actionInput.localIdentifier).to.eq('validatedLocalIdentifier'); 59 | expect(actionInput.localArgs).to.eq('validatedLocalArgs'); 60 | InputValidator.validateLocalTesting.restore(); 61 | }); 62 | 63 | it('Takes input and validates it successfully for stop operation', () => { 64 | sinon.stub(InputValidator, 'validateLocalTesting').returns('stop'); 65 | process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY] = 'someAccessKey'; 66 | process.env[ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER] = 'alreadyExistingIdentifier'; 67 | stubbedInput.withArgs(INPUT.LOCAL_TESING, { required: true }).returns('start'); 68 | const actionInput = new ActionInput(); 69 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_TESING, { required: true }); 70 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_LOGGING_LEVEL); 71 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_IDENTIFIER); 72 | sinon.assert.calledWith(core.getInput, INPUT.LOCAL_ARGS); 73 | sinon.assert.calledWith(InputValidator.validateLocalTesting, 'start'); 74 | sinon.assert.notCalled(InputValidator.validateLocalLoggingLevel); 75 | sinon.assert.notCalled(InputValidator.validateLocalIdentifier); 76 | sinon.assert.notCalled(InputValidator.validateLocalArgs); 77 | expect(actionInput.localTesting).to.eq('stop'); 78 | expect(actionInput.localIdentifier).to.eq('alreadyExistingIdentifier'); 79 | InputValidator.validateLocalTesting.restore(); 80 | }); 81 | 82 | it('Throws error if local-testing input is not provided', () => { 83 | stubbedInput.withArgs(INPUT.LOCAL_TESING, { required: true }).throws(Error('Local Testing not provided')); 84 | try { 85 | // eslint-disable-next-line no-new 86 | new ActionInput(); 87 | } catch (e) { 88 | expect(e.message).to.eq('Action input failed for reason: Local Testing not provided'); 89 | } 90 | }); 91 | 92 | it('Throws error if access key is not found from the env vars', () => { 93 | process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY] = ''; 94 | try { 95 | // eslint-disable-next-line no-new 96 | new ActionInput(); 97 | } catch (e) { 98 | expect(e.message).to.eq(`Action input failed for reason: ${ENV_VARS.BROWSERSTACK_ACCESS_KEY} not found. Use 'browserstack/github-actions/setup-env@master' Action to set up the environment variables before invoking this Action`); 99 | } 100 | }); 101 | }); 102 | 103 | context('Set Environment Variables', () => { 104 | beforeEach(() => { 105 | sinon.stub(core, 'exportVariable'); 106 | sinon.stub(core, 'info'); 107 | sinon.stub(core, 'startGroup'); 108 | sinon.stub(core, 'endGroup'); 109 | sinon.stub(ActionInput.prototype, '_fetchAllInput'); 110 | sinon.stub(ActionInput.prototype, '_validateInput'); 111 | }); 112 | 113 | afterEach(() => { 114 | core.exportVariable.restore(); 115 | core.info.restore(); 116 | core.startGroup.restore(); 117 | core.endGroup.restore(); 118 | ActionInput.prototype._fetchAllInput.restore(); 119 | ActionInput.prototype._validateInput.restore(); 120 | }); 121 | 122 | it('sets the local identifier env variable if the operation is to start the local binary with a local-identifier', () => { 123 | const actionInput = new ActionInput(); 124 | actionInput.localTesting = LOCAL_TESTING.START; 125 | actionInput.localIdentifier = 'someIdentifier'; 126 | actionInput.setEnvVariables(); 127 | sinon.assert.calledWith(core.exportVariable, ENV_VARS.BROWSERSTACK_LOCAL_IDENTIFIER, 'someIdentifier'); 128 | }); 129 | 130 | it('no local identifier env var is set if no input is provided and the operation is to start the local binary', () => { 131 | const actionInput = new ActionInput(); 132 | actionInput.localTesting = LOCAL_TESTING.START; 133 | actionInput.localIdentifier = ''; 134 | actionInput.setEnvVariables(); 135 | sinon.assert.notCalled(core.exportVariable); 136 | }); 137 | 138 | it('no local identifier env var is set if the operation is to stop binary', () => { 139 | const actionInput = new ActionInput(); 140 | actionInput.localTesting = LOCAL_TESTING.STOP; 141 | actionInput.setEnvVariables(); 142 | sinon.assert.notCalled(core.exportVariable); 143 | }); 144 | }); 145 | 146 | context('Fetch state for input to binary control', () => { 147 | it('returns an object with the state', () => { 148 | sinon.stub(ActionInput.prototype, '_fetchAllInput'); 149 | sinon.stub(ActionInput.prototype, '_validateInput'); 150 | const actionInput = new ActionInput(); 151 | actionInput.accessKey = 'someKey'; 152 | actionInput.localTesting = 'someValue'; 153 | actionInput.localArgs = 'someArgs'; 154 | actionInput.localIdentifier = 'someIdentifier'; 155 | actionInput.localLoggingLevel = 1; // any numeric value 156 | 157 | const response = actionInput.getInputStateForBinary(); 158 | expect(response).to.eql({ 159 | accessKey: 'someKey', 160 | localTesting: 'someValue', 161 | localArgs: 'someArgs', 162 | localIdentifier: 'someIdentifier', 163 | localLoggingLevel: 1, 164 | }); 165 | 166 | ActionInput.prototype._fetchAllInput.restore(); 167 | ActionInput.prototype._validateInput.restore(); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /setup-env/test/actionInput/inputValidator.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const github = require('@actions/github'); 3 | const sinon = require('sinon'); 4 | const InputValidator = require('../../src/actionInput/inputValidator'); 5 | 6 | describe('InputValidator class to validate individual fields of the action input', () => { 7 | context('Public Static Methods', () => { 8 | context('Validates Username', () => { 9 | it("Returns the username with '-GitHubAction' suffix", () => { 10 | const inputUsername = 'someUsername'; 11 | expect(InputValidator.updateUsername(inputUsername)).to.eq(`${inputUsername}-GitHubAction`); 12 | }); 13 | }); 14 | 15 | context('Validates Build Name', () => { 16 | beforeEach(() => { 17 | sinon.stub(InputValidator, '_getBuildInfo').returns('someBuildInfo'); 18 | }); 19 | 20 | afterEach(() => { 21 | InputValidator._getBuildInfo.restore(); 22 | }); 23 | 24 | it("Returns the build name as it is if the input doesn't contain the keyword 'build_info' (case insensitive)", () => { 25 | const inputBuildName = 'this is the build name'; 26 | expect(InputValidator.validateBuildName(inputBuildName)).to.eq(inputBuildName); 27 | }); 28 | 29 | it("Returns the build info if the input is undefined", () => { 30 | expect(InputValidator.validateBuildName()).to.eq('someBuildInfo'); 31 | }); 32 | 33 | it("Returns the build info if the input is empty string", () => { 34 | expect(InputValidator.validateBuildName('')).to.eq('someBuildInfo'); 35 | }); 36 | 37 | it("Replaces the 'build_info' (case insensitive) keyword with the build info in users input", () => { 38 | expect(InputValidator.validateBuildName('some string buiLD_iNFo')).to.eq('some string someBuildInfo'); 39 | }); 40 | }); 41 | 42 | context('Validates Project Name', () => { 43 | beforeEach(() => { 44 | sinon.stub(github, 'context').value({ 45 | repo: { 46 | repo: 'someRepoName', 47 | }, 48 | }); 49 | }); 50 | 51 | it('Returns the repository name if the input is empty stirng', () => { 52 | expect(InputValidator.validateProjectName('')).to.eq('someRepoName'); 53 | }); 54 | 55 | it('Returns the repository name if the input is undefined', () => { 56 | expect(InputValidator.validateProjectName()).to.eq('someRepoName'); 57 | }); 58 | 59 | it("Returns the repo name if the input is 'repo_name' keyword (case insensitive)", () => { 60 | expect(InputValidator.validateProjectName('RePo_NaME')).to.eq('someRepoName'); 61 | }); 62 | 63 | it("Returns the string input by the user as it is if input is not 'repo_name'", () => { 64 | expect(InputValidator.validateProjectName('some project')).to.eq('some project'); 65 | }); 66 | }); 67 | }); 68 | 69 | context('Private Static Methods', () => { 70 | context('Build Info Generation based on the GitHub EventType', () => { 71 | context('Push event', () => { 72 | beforeEach(() => { 73 | sinon.stub(github, 'context').value({ 74 | payload: { 75 | head_commit: { 76 | message: 'messageOfHeadCommit', 77 | }, 78 | }, 79 | sha: 'someSHA', 80 | runNumber: 123, 81 | ref: 'refs/head/branchOrTagName', 82 | eventName: 'push', 83 | }); 84 | }); 85 | 86 | it('Generate build info with commit information', () => { 87 | const expectedValue = '[branchOrTagName] Commit someSHA: messageOfHeadCommit [Workflow: 123]'; 88 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 89 | }); 90 | }); 91 | 92 | context('Pull Request event', () => { 93 | beforeEach(() => { 94 | sinon.stub(github, 'context').value({ 95 | payload: { 96 | pull_request: { 97 | head: { 98 | ref: 'branchName', 99 | }, 100 | title: 'prTitle', 101 | }, 102 | number: 'prNumber', 103 | }, 104 | runNumber: 123, 105 | eventName: 'pull_request', 106 | }); 107 | }); 108 | 109 | it('Generate build info with PR information', () => { 110 | const expectedValue = '[branchName] PR prNumber: prTitle [Workflow: 123]'; 111 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 112 | }); 113 | }); 114 | 115 | context('Release event', () => { 116 | beforeEach(() => { 117 | sinon.stub(github, 'context').value({ 118 | payload: { 119 | release: { 120 | tag_name: 'tagName', 121 | target_commitish: 'branchName', 122 | name: 'releaseName', 123 | }, 124 | }, 125 | runNumber: 123, 126 | eventName: 'release', 127 | }); 128 | }); 129 | 130 | it('Generate build info with Release information where release name != tag name', () => { 131 | const expectedValue = '[branchName] Release tagName: releaseName [Workflow: 123]'; 132 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 133 | }); 134 | 135 | it('Generate build info with Release information where release name == tag name', () => { 136 | github.context.payload.release.name = 'tagName'; 137 | const expectedValue = '[branchName] Release tagName [Workflow: 123]'; 138 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 139 | }); 140 | }); 141 | 142 | context('Other event', () => { 143 | beforeEach(() => { 144 | sinon.stub(github, 'context').value({ 145 | runNumber: 123, 146 | eventName: 'anyOtherEvent', 147 | }); 148 | }); 149 | 150 | it('Generate build info with basic event type and workflow run number', () => { 151 | const expectedValue = 'anyOtherEvent [Workflow: 123]'; 152 | expect(InputValidator._getBuildInfo()).to.eq(expectedValue); 153 | }); 154 | }); 155 | 156 | context('Validates GitHub App Name', () => { 157 | it("Returns 'browserstack-integrations[bot]' if the app name is not provided", () => { 158 | expect(() => InputValidator.validateGithubAppName()).to.throw("Invalid input for 'github-app'. Must be a valid string."); 159 | }); 160 | 161 | it("Returns 'browserstack-integrations[bot]' if the app name is 'browserstack-integrations[bot]' (case insensitive)", () => { 162 | expect(InputValidator.validateGithubAppName('BrowserStack-integrations[BOT]')).to.eq('browserstack-integrations[bot]'); 163 | }); 164 | 165 | it('Throws an error if the app name is an empty string', () => { 166 | expect(() => InputValidator.validateGithubAppName('')).to.throw("Invalid input for 'github-app'. Must be a valid string."); 167 | }); 168 | 169 | it('Throws an error if the app name is not a valid string', () => { 170 | expect(() => InputValidator.validateGithubAppName(123)).to.throw("Invalid input for 'github-app'. Must be a valid string."); 171 | }); 172 | 173 | it('Returns the app name if it is a valid non-empty string and not "browserstack-integrations[bot]"', () => { 174 | const validAppName = 'someValidAppName'; 175 | expect(InputValidator.validateGithubAppName(validAppName)).to.eq(validAppName); 176 | }); 177 | }); 178 | 179 | context('Validates GitHub Token', () => { 180 | it("Returns 'none' if the token is not provided", () => { 181 | expect(() => InputValidator.validateGithubToken()).to.throw("Invalid input for 'github-token'. Must be a valid non-empty string."); 182 | }); 183 | 184 | it("Returns 'none' if the token is 'none' (case insensitive)", () => { 185 | expect(InputValidator.validateGithubToken('None')).to.eq('none'); 186 | }); 187 | 188 | it('Throws an error if the token is an empty string', () => { 189 | expect(() => InputValidator.validateGithubToken('')).to.throw("Invalid input for 'github-token'. Must be a valid non-empty string."); 190 | }); 191 | 192 | it('Throws an error if the token is not a valid string', () => { 193 | expect(() => InputValidator.validateGithubToken(123)).to.throw("Invalid input for 'github-token'. Must be a valid non-empty string."); 194 | }); 195 | 196 | it('Returns the token if it is a valid non-empty string and not "none"', () => { 197 | const validToken = 'someValidToken'; 198 | expect(InputValidator.validateGithubToken(validToken)).to.eq(validToken); 199 | }); 200 | }); 201 | }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /setup-local/src/binaryControl.js: -------------------------------------------------------------------------------- 1 | const tc = require('@actions/tool-cache'); 2 | const io = require('@actions/io'); 3 | const exec = require('@actions/exec'); 4 | const core = require('@actions/core'); 5 | const github = require('@actions/github'); 6 | const os = require('os'); 7 | const path = require('path'); 8 | const fs = require('fs'); 9 | const Utils = require('./utils'); 10 | const ArtifactsManager = require('./artifactsManager'); 11 | const constants = require('../config/constants'); 12 | 13 | const { 14 | BINARY_LINKS, 15 | LOCAL_BINARY_FOLDER, 16 | PLATFORMS, 17 | LOCAL_BINARY_NAME, 18 | LOCAL_BINARY_ZIP, 19 | LOCAL_LOG_FILE_PREFIX, 20 | LOCAL_BINARY_TRIGGER, 21 | RETRY_DELAY_BINARY, 22 | BINARY_MAX_TRIES, 23 | ALLOWED_INPUT_VALUES: { 24 | LOCAL_TESTING, 25 | }, 26 | ENV_VARS: { 27 | BROWSERSTACK_LOCAL_LOGS_FILE, 28 | }, 29 | } = constants; 30 | 31 | /** 32 | * BinaryControl handles the operations to be performed on the Local Binary. 33 | * It takes care of logs generation and triggering the upload as well. 34 | */ 35 | class BinaryControl { 36 | constructor(stateForBinary) { 37 | this.platform = os.platform(); 38 | this.stateForBinary = stateForBinary; 39 | 40 | this._decidePlatformAndBinary(); 41 | } 42 | 43 | /** 44 | * decides the binary link and the folder to store the binary based on the 45 | * platform and the architecture 46 | */ 47 | _decidePlatformAndBinary() { 48 | this.binaryFolder = path.resolve( 49 | process.env.GITHUB_WORKSPACE, 50 | '..', '..', '..', 51 | '_work', 52 | 'binary', 53 | LOCAL_BINARY_FOLDER, 54 | this.platform, 55 | ); 56 | switch (this.platform) { 57 | case PLATFORMS.DARWIN: 58 | this.binaryLink = BINARY_LINKS.DARWIN; 59 | break; 60 | case PLATFORMS.LINUX: 61 | this.binaryLink = os.arch() === 'x32' ? BINARY_LINKS.LINUX_32 : BINARY_LINKS.LINUX_64; 62 | break; 63 | case PLATFORMS.WIN32: 64 | this.binaryLink = BINARY_LINKS.WINDOWS; 65 | break; 66 | default: 67 | throw Error(`Unsupported Platform: ${this.platform}. No BrowserStackLocal binary found.`); 68 | } 69 | } 70 | 71 | /** 72 | * Creates directory recursively for storing Local Binary & its logs. 73 | */ 74 | async _makeDirectory() { 75 | await io.mkdirP(this.binaryFolder); 76 | } 77 | 78 | /** 79 | * Generates logging file name and its path for Local Binary 80 | */ 81 | _generateLogFileMetadata() { 82 | this.logFileName = process.env[BROWSERSTACK_LOCAL_LOGS_FILE] || `${LOCAL_LOG_FILE_PREFIX}_${github.context.job}_${Date.now()}.log`; 83 | this.logFilePath = path.resolve(this.binaryFolder, this.logFileName); 84 | core.exportVariable(BROWSERSTACK_LOCAL_LOGS_FILE, this.logFileName); 85 | } 86 | 87 | /** 88 | * Generates the args to be provided for the Local Binary based on the operation, i.e. 89 | * start/stop. 90 | * These are generated based on the input state provided to the Binary Control. 91 | */ 92 | _generateArgsForBinary() { 93 | const { 94 | accessKey: key, 95 | localArgs, 96 | localIdentifier, 97 | localLoggingLevel: verbose, 98 | localTesting: binaryAction, 99 | } = this.stateForBinary; 100 | 101 | let argsString = `--key ${key} --only-automate --ci-plugin GitHubAction `; 102 | 103 | switch (binaryAction) { 104 | case LOCAL_TESTING.START: { 105 | if (localArgs) argsString += `${localArgs} `; 106 | if (localIdentifier) argsString += `--local-identifier ${localIdentifier} `; 107 | if (verbose) { 108 | this._generateLogFileMetadata(); 109 | argsString += `--verbose ${verbose} --log-file ${this.logFilePath} `; 110 | } 111 | break; 112 | } 113 | case LOCAL_TESTING.STOP: { 114 | if (localIdentifier) argsString += `--local-identifier ${localIdentifier} `; 115 | break; 116 | } 117 | default: { 118 | throw Error('Invalid Binary Action'); 119 | } 120 | } 121 | 122 | this.binaryArgs = argsString; 123 | } 124 | 125 | /** 126 | * Triggers the Local Binary. It is used for starting/stopping. 127 | * @param {String} operation start/stop operation 128 | */ 129 | async _triggerBinary(operation) { 130 | let triggerOutput = ''; 131 | let triggerError = ''; 132 | await exec.exec( 133 | `${LOCAL_BINARY_NAME} ${this.binaryArgs} --daemon ${operation}`, 134 | [], 135 | { 136 | listeners: { 137 | stdout: (data) => { 138 | triggerOutput += data.toString(); 139 | }, 140 | stderr: (data) => { 141 | triggerError += data.toString(); 142 | }, 143 | }, 144 | }, 145 | ); 146 | 147 | return { 148 | output: triggerOutput, 149 | error: triggerError, 150 | }; 151 | } 152 | 153 | async _removeAnyStaleBinary() { 154 | const binaryZip = path.resolve(this.binaryFolder, LOCAL_BINARY_ZIP); 155 | const previousLocalBinary = path.resolve( 156 | this.binaryFolder, 157 | `${LOCAL_BINARY_NAME}${this.platform === PLATFORMS.WIN32 ? '.exe' : ''}`, 158 | ); 159 | await Promise.all([io.rmRF(binaryZip), io.rmRF(previousLocalBinary)]); 160 | } 161 | 162 | /** 163 | * Downloads the Local Binary, extracts it and adds it in the PATH variable 164 | */ 165 | async downloadBinary() { 166 | const cachedBinaryPath = Utils.checkToolInCache(LOCAL_BINARY_NAME, '1.0.0'); 167 | if (cachedBinaryPath) { 168 | core.info('BrowserStackLocal binary already exists in cache. Using that instead of downloading again...'); 169 | // A cached tool is persisted across runs. But the PATH is reset back to its original 170 | // state between each run. Thus, adding the cached tool path back to PATH again. 171 | core.addPath(cachedBinaryPath); 172 | return; 173 | } 174 | 175 | try { 176 | await this._makeDirectory(); 177 | core.debug('BrowserStackLocal binary not found in cache. Deleting any stale/existing binary before downloading...'); 178 | await this._removeAnyStaleBinary(); 179 | 180 | core.info('Downloading BrowserStackLocal binary...'); 181 | const downloadPath = await tc.downloadTool( 182 | this.binaryLink, 183 | path.resolve(this.binaryFolder, LOCAL_BINARY_ZIP), 184 | ); 185 | const extractedPath = await tc.extractZip(downloadPath, this.binaryFolder); 186 | core.info(`BrowserStackLocal binary downloaded & extracted successfuly at: ${extractedPath}`); 187 | const cachedPath = await tc.cacheDir(extractedPath, LOCAL_BINARY_NAME, '1.0.0'); 188 | core.addPath(cachedPath); 189 | } catch (e) { 190 | throw Error(`BrowserStackLocal binary could not be downloaded due to ${e.message}`); 191 | } 192 | } 193 | 194 | /** 195 | * Starts Local Binary using the args generated for this action 196 | */ 197 | async startBinary() { 198 | this._generateArgsForBinary(); 199 | let { localIdentifier } = this.stateForBinary; 200 | localIdentifier = localIdentifier ? `with local-identifier=${localIdentifier}` : ''; 201 | core.info(`Starting local tunnel ${localIdentifier} in daemon mode...`); 202 | 203 | let triesAvailable = BINARY_MAX_TRIES; 204 | 205 | while (triesAvailable--) { 206 | try { 207 | // eslint-disable-next-line no-await-in-loop 208 | const { output, error } = await this._triggerBinary(LOCAL_TESTING.START); 209 | 210 | if (!error) { 211 | const outputParsed = JSON.parse(output); 212 | if (outputParsed.state === LOCAL_BINARY_TRIGGER.START.CONNECTED) { 213 | core.info(`Local tunnel status: ${JSON.stringify(outputParsed.message)}`); 214 | return; 215 | } 216 | 217 | throw Error(JSON.stringify(outputParsed.message)); 218 | } else { 219 | throw Error(JSON.stringify(error)); 220 | } 221 | } catch (e) { 222 | if (triesAvailable) { 223 | core.info(`Error in starting local tunnel: ${e.message}. Trying again in 5 seconds...`); 224 | // eslint-disable-next-line no-await-in-loop 225 | await Utils.sleepFor(RETRY_DELAY_BINARY); 226 | } else { 227 | throw Error(`Local tunnel could not be started. Error message from binary: ${e.message}`); 228 | } 229 | } 230 | } 231 | } 232 | 233 | /** 234 | * Stops Local Binary using the args generated for this action 235 | */ 236 | async stopBinary() { 237 | this._generateArgsForBinary(); 238 | try { 239 | let { localIdentifier } = this.stateForBinary; 240 | localIdentifier = localIdentifier ? `with local-identifier=${localIdentifier}` : ''; 241 | core.info(`Stopping local tunnel ${localIdentifier} in daemon mode...`); 242 | 243 | const { output, error } = await this._triggerBinary(LOCAL_TESTING.STOP); 244 | 245 | if (!error) { 246 | const outputParsed = JSON.parse(output); 247 | if (outputParsed.status === LOCAL_BINARY_TRIGGER.STOP.SUCCESS) { 248 | core.info(`Local tunnel stopping status: ${outputParsed.message}`); 249 | } else { 250 | throw Error(JSON.stringify(outputParsed.message)); 251 | } 252 | } else { 253 | throw Error(JSON.stringify(error)); 254 | } 255 | } catch (e) { 256 | core.info(`[Warning] Error in stopping local tunnel: ${e.message}. Continuing the workflow without breaking...`); 257 | } 258 | } 259 | 260 | /** 261 | * Uploads BrowserStackLocal generated logs (if the file exists for the job) 262 | */ 263 | async uploadLogFilesIfAny() { 264 | this._generateLogFileMetadata(); 265 | if (fs.existsSync(this.logFilePath)) { 266 | await ArtifactsManager.uploadArtifacts( 267 | this.logFileName, 268 | [this.logFilePath], 269 | this.binaryFolder, 270 | ); 271 | await io.rmRF(this.logFilePath); 272 | } 273 | Utils.clearEnvironmentVariable(BROWSERSTACK_LOCAL_LOGS_FILE); 274 | } 275 | } 276 | 277 | module.exports = BinaryControl; 278 | -------------------------------------------------------------------------------- /setup-env/test/actionInput/index.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const axios = require('axios'); 4 | const core = require('@actions/core'); 5 | const ActionInput = require('../../src/actionInput'); 6 | const InputValidator = require('../../src/actionInput/inputValidator'); 7 | const constants = require('../../config/constants'); 8 | 9 | const { 10 | INPUT, 11 | ENV_VARS, 12 | } = constants; 13 | 14 | describe('Action Input operations for fetching all inputs, triggering validation and setting env vars', () => { 15 | context('Fetch and Validate Input', () => { 16 | let stubbedInput; 17 | 18 | beforeEach(() => { 19 | stubbedInput = sinon.stub(core, 'getInput'); 20 | sinon.stub(InputValidator, 'updateUsername').returns('validatedUsername'); 21 | sinon.stub(InputValidator, 'validateBuildName').returns('validatedBuildName'); 22 | sinon.stub(InputValidator, 'validateProjectName').returns('validatedProjectName'); 23 | sinon.stub(InputValidator, 'validateGithubAppName').returns('validatedAppName'); 24 | sinon.stub(InputValidator, 'validateGithubToken').returns('validatedToken'); 25 | 26 | // Provide required inputs 27 | stubbedInput.withArgs(INPUT.USERNAME, { required: true }).returns('someUsername'); 28 | stubbedInput.withArgs(INPUT.ACCESS_KEY, { required: true }).returns('someAccessKey'); 29 | 30 | process.env.GITHUB_REPOSITORY = 'browserstack/github-actions'; 31 | process.env.GITHUB_RUN_ID = '12345'; 32 | process.env.GITHUB_RUN_ATTEMPT = '2'; 33 | }); 34 | 35 | afterEach(() => { 36 | sinon.restore(); 37 | delete process.env.GITHUB_REPOSITORY; 38 | delete process.env.GITHUB_RUN_ID; 39 | delete process.env.GITHUB_RUN_ATTEMPT; 40 | }); 41 | 42 | it('Takes input and validates it successfully', () => { 43 | stubbedInput.withArgs(INPUT.BUILD_NAME).returns('someBuildName'); 44 | stubbedInput.withArgs(INPUT.PROJECT_NAME).returns('someProjectName'); 45 | const actionInput = new ActionInput(); 46 | expect(actionInput.username).to.eq('validatedUsername'); 47 | expect(actionInput.buildName).to.eq('validatedBuildName'); 48 | expect(actionInput.projectName).to.eq('validatedProjectName'); 49 | }); 50 | 51 | it('Takes input and throws error if username is not provided in input', () => { 52 | stubbedInput.withArgs(INPUT.USERNAME, { required: true }).throws(Error('Username Required')); 53 | try { 54 | // eslint-disable-next-line no-new 55 | new ActionInput(); 56 | } catch (e) { 57 | expect(e.message).to.eq('Action input failed for reason: Username Required'); 58 | } 59 | }); 60 | 61 | it('Takes input and throws error if access key is not provided in input', () => { 62 | stubbedInput.withArgs(INPUT.ACCESS_KEY, { required: true }).throws(Error('Access Key Required')); 63 | try { 64 | // eslint-disable-next-line no-new 65 | new ActionInput(); 66 | } catch (e) { 67 | expect(e.message).to.eq('Action input failed for reason: Access Key Required'); 68 | } 69 | }); 70 | 71 | it('Takes input and validates GitHub token and app name successfully', () => { 72 | stubbedInput.withArgs(INPUT.GITHUB_TOKEN).returns('someToken'); 73 | stubbedInput.withArgs(INPUT.GITHUB_APP).returns('someApp'); 74 | const actionInput = new ActionInput(); 75 | expect(actionInput.githubToken).to.eq('validatedToken'); 76 | expect(actionInput.githubApp).to.eq('validatedAppName'); 77 | }); 78 | }); 79 | 80 | context('Set Environment Variables', () => { 81 | let actionInput; 82 | 83 | beforeEach(() => { 84 | sinon.stub(core, 'exportVariable'); 85 | sinon.stub(core, 'info'); 86 | sinon.stub(core, 'startGroup'); 87 | sinon.stub(core, 'endGroup'); 88 | sinon.stub(ActionInput.prototype, '_fetchAllInput'); 89 | sinon.stub(ActionInput.prototype, '_validateInput'); 90 | 91 | // Mock required properties 92 | actionInput = new ActionInput(); 93 | actionInput.username = 'someUsername'; 94 | actionInput.accessKey = 'someAccessKey'; 95 | actionInput.buildName = 'someBuildName'; 96 | actionInput.projectName = 'someProjectName'; 97 | 98 | // Stub checkIfBStackReRun to return true 99 | sinon.stub(actionInput, 'checkIfBStackReRun').returns(Promise.resolve(true)); 100 | }); 101 | 102 | afterEach(() => { 103 | sinon.restore(); 104 | }); 105 | 106 | it('Sets the environment variables required in test scripts for BrowserStack', () => { 107 | actionInput.setEnvVariables(); 108 | sinon.assert.calledWith(core.exportVariable, ENV_VARS.BROWSERSTACK_USERNAME, 'someUsername'); 109 | sinon.assert.calledWith(core.exportVariable, ENV_VARS.BROWSERSTACK_ACCESS_KEY, 'someAccessKey'); 110 | sinon.assert.calledWith(core.exportVariable, ENV_VARS.BROWSERSTACK_PROJECT_NAME, 'someProjectName'); 111 | sinon.assert.calledWith(core.exportVariable, ENV_VARS.BROWSERSTACK_BUILD_NAME, 'someBuildName'); 112 | }); 113 | 114 | it('Calls setBStackRerunEnvVars when checkIfBStackReRun returns true', async () => { 115 | const setBStackRerunEnvVarsStub = sinon.stub(actionInput, 'setBStackRerunEnvVars').resolves(); 116 | await actionInput.setEnvVariables(); 117 | sinon.assert.calledOnce(setBStackRerunEnvVarsStub); 118 | }); 119 | }); 120 | 121 | context('Check if BrowserStack Rerun', () => { 122 | let stubbedInput; 123 | 124 | beforeEach(() => { 125 | stubbedInput = sinon.stub(core, 'getInput'); 126 | sinon.stub(InputValidator, 'updateUsername').returns('validatedUsername'); 127 | sinon.stub(InputValidator, 'validateBuildName').returns('validatedBuildName'); 128 | sinon.stub(InputValidator, 'validateProjectName').returns('validatedProjectName'); 129 | sinon.stub(InputValidator, 'validateGithubAppName').returns('validatedAppName'); 130 | sinon.stub(InputValidator, 'validateGithubToken').returns('validatedToken'); 131 | 132 | // Provide required inputs 133 | stubbedInput.withArgs(INPUT.USERNAME, { required: true }).returns('someUsername'); 134 | stubbedInput.withArgs(INPUT.ACCESS_KEY, { required: true }).returns('someAccessKey'); 135 | 136 | process.env.GITHUB_REPOSITORY = 'browserstack/github-actions'; 137 | process.env.GITHUB_RUN_ID = '12345'; 138 | process.env.GITHUB_RUN_ATTEMPT = '2'; 139 | }); 140 | 141 | afterEach(() => { 142 | sinon.restore(); 143 | }); 144 | 145 | it('Returns false if rerun attempt is less than or equal to 1', async () => { 146 | const actionInput = new ActionInput(); 147 | actionInput.rerunAttempt = '1'; // This should be a string or number 148 | const result = await actionInput.checkIfBStackReRun(); 149 | // eslint-disable-next-line no-unused-expressions 150 | expect(result).to.be.false; 151 | }); 152 | 153 | it('Returns false if runId, repository, or token are invalid', async () => { 154 | const actionInput = new ActionInput(); 155 | actionInput.runId = ''; 156 | const result = await actionInput.checkIfBStackReRun(); 157 | // eslint-disable-next-line no-unused-expressions 158 | expect(result).to.be.false; 159 | }); 160 | 161 | it('Returns true if rerun was triggered by the GitHub App', async () => { 162 | const actionInput = new ActionInput(); 163 | process.env.GITHUB_TRIGGERING_ACTOR = 'validatedAppName'; 164 | const result = await actionInput.checkIfBStackReRun(); 165 | // eslint-disable-next-line no-unused-expressions 166 | expect(result).to.be.true; 167 | delete process.env.GITHUB_TRIGGERING_ACTOR; 168 | }); 169 | 170 | it('Returns false if rerun was not triggered by the GitHub App', async () => { 171 | const actionInput = new ActionInput(); 172 | process.env.GITHUB_TRIGGERING_ACTOR = 'otherActor'; 173 | const result = await actionInput.checkIfBStackReRun(); 174 | // eslint-disable-next-line no-unused-expressions 175 | expect(result).to.be.false; 176 | delete process.env.GITHUB_TRIGGERING_ACTOR; 177 | }); 178 | }); 179 | 180 | context('Set BrowserStack Rerun Environment Variables', () => { 181 | let axiosGetStub; 182 | let stubbedInput; 183 | 184 | beforeEach(() => { 185 | stubbedInput = sinon.stub(core, 'getInput'); 186 | sinon.stub(InputValidator, 'updateUsername').returns('validatedUsername'); 187 | sinon.stub(InputValidator, 'validateBuildName').returns('validatedBuildName'); 188 | sinon.stub(InputValidator, 'validateProjectName').returns('validatedProjectName'); 189 | sinon.stub(InputValidator, 'validateGithubAppName').returns('validatedAppName'); 190 | sinon.stub(InputValidator, 'validateGithubToken').returns('validatedToken'); 191 | 192 | // Provide required inputs 193 | stubbedInput.withArgs(INPUT.USERNAME, { required: true }).returns('someUsername'); 194 | stubbedInput.withArgs(INPUT.ACCESS_KEY, { required: true }).returns('someAccessKey'); 195 | 196 | process.env.GITHUB_REPOSITORY = 'browserstack/github-actions'; 197 | process.env.GITHUB_RUN_ID = '12345'; 198 | process.env.GITHUB_RUN_ATTEMPT = '2'; 199 | 200 | // Stub the axios.get method 201 | axiosGetStub = sinon.stub(axios, 'get'); 202 | // Stub core.info to prevent it from throwing an error 203 | sinon.stub(core, 'exportVariable'); 204 | sinon.stub(core, 'info'); 205 | }); 206 | 207 | afterEach(() => { 208 | sinon.restore(); 209 | }); 210 | 211 | it('Sets environment variables from BrowserStack API response', async () => { 212 | const actionInput = new ActionInput(); 213 | const variables = { VAR1: 'value1', VAR2: 'value2' }; 214 | axiosGetStub.resolves({ 215 | data: { data: { variables } }, 216 | }); 217 | 218 | await actionInput.setBStackRerunEnvVars(); 219 | 220 | sinon.assert.calledWith(core.exportVariable, 'VAR1', 'value1'); 221 | sinon.assert.calledWith(core.exportVariable, 'VAR2', 'value2'); 222 | }); 223 | 224 | it('Handles errors when BrowserStack API fails', async () => { 225 | const actionInput = new ActionInput(); 226 | axiosGetStub.rejects(new Error('API failed')); 227 | 228 | await actionInput.setBStackRerunEnvVars(); 229 | 230 | sinon.assert.calledTwice(core.info); 231 | sinon.assert.neverCalledWith(core.exportVariable, sinon.match.any, sinon.match.any); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /setup-local/test/binaryControl.test.js: -------------------------------------------------------------------------------- 1 | const { expect, assert } = require('chai'); 2 | const sinon = require('sinon'); 3 | const tc = require('@actions/tool-cache'); 4 | const io = require('@actions/io'); 5 | const exec = require('@actions/exec'); 6 | const core = require('@actions/core'); 7 | const path = require('path'); 8 | const github = require('@actions/github'); 9 | const os = require('os'); 10 | const fs = require('fs'); 11 | const BinaryControl = require('../src/binaryControl'); 12 | const ArtifactsManager = require('../src/artifactsManager'); 13 | const constants = require('../config/constants'); 14 | const Utils = require('../src/utils'); 15 | 16 | const { 17 | BINARY_LINKS, 18 | LOCAL_BINARY_FOLDER, 19 | PLATFORMS, 20 | LOCAL_BINARY_NAME, 21 | LOCAL_BINARY_ZIP, 22 | LOCAL_LOG_FILE_PREFIX, 23 | LOCAL_BINARY_TRIGGER, 24 | RETRY_DELAY_BINARY, 25 | ALLOWED_INPUT_VALUES: { 26 | LOCAL_TESTING, 27 | }, 28 | ENV_VARS: { 29 | BROWSERSTACK_LOCAL_LOGS_FILE, 30 | }, 31 | } = constants; 32 | 33 | describe('Binary Control Operations', () => { 34 | const earlierGitHubWorkspace = process.env.GITHUB_WORKSPACE; 35 | 36 | beforeEach(() => { 37 | process.env.GITHUB_WORKSPACE = 'some_workspace'; 38 | }); 39 | 40 | afterEach(() => { 41 | process.env.GITHUB_WORKSPACE = earlierGitHubWorkspace; 42 | }); 43 | 44 | context('Private Methods Behaviour', () => { 45 | before(() => { 46 | process.env.GITHUB_WORKSPACE = '/some/work/space'; 47 | }); 48 | 49 | after(() => { 50 | process.env.GITHUB_WORKSPACE = ''; 51 | }); 52 | 53 | const platformAndBinary = [ 54 | { 55 | binary: BINARY_LINKS.DARWIN, 56 | folder: `/_work/binary/${LOCAL_BINARY_FOLDER}/darwin`, 57 | arch: 'x64', 58 | platform: PLATFORMS.DARWIN, 59 | }, { 60 | binary: BINARY_LINKS.LINUX_32, 61 | folder: `/_work/binary/${LOCAL_BINARY_FOLDER}/linux`, 62 | arch: 'x32', 63 | platform: PLATFORMS.LINUX, 64 | }, { 65 | binary: BINARY_LINKS.LINUX_64, 66 | folder: `/_work/binary/${LOCAL_BINARY_FOLDER}/linux`, 67 | arch: 'x64', 68 | platform: PLATFORMS.LINUX, 69 | }, { 70 | binary: BINARY_LINKS.WINDOWS, 71 | folder: `/_work/binary/${LOCAL_BINARY_FOLDER}/win32`, 72 | arch: 'x32', 73 | platform: PLATFORMS.WIN32, 74 | }, 75 | ]; 76 | 77 | platformAndBinary.forEach((system) => { 78 | it(`decides the binary and the folder based on the platform and architecture for ${system.platform} - ${system.arch}`, () => { 79 | sinon.stub(os, 'platform').returns(system.platform); 80 | sinon.stub(os, 'arch').returns(system.arch); 81 | sinon.stub(path, 'resolve').returns(system.folder); 82 | const binaryControl = new BinaryControl(); 83 | expect(binaryControl.binaryLink).to.eql(system.binary); 84 | expect(binaryControl.binaryFolder).to.eq(system.folder); 85 | os.platform.restore(); 86 | os.arch.restore(); 87 | path.resolve.restore(); 88 | }); 89 | }); 90 | 91 | it(`Throws error and exits the workflow if the platform is not supported`, () => { 92 | sinon.stub(os, 'platform').returns('somePlatform'); 93 | try { 94 | // eslint-disable-next-line no-new 95 | new BinaryControl(); 96 | } catch (e) { 97 | expect(e.message).to.eq('Unsupported Platform: somePlatform. No BrowserStackLocal binary found.'); 98 | } 99 | os.platform.restore(); 100 | }); 101 | 102 | it('Makes Directory for the binary folder in recursive manner', async () => { 103 | sinon.stub(io, 'mkdirP').returns(true); 104 | const binaryControl = new BinaryControl(); 105 | await binaryControl._makeDirectory(); 106 | sinon.assert.calledWith(io.mkdirP, path.resolve( 107 | process.env.GITHUB_WORKSPACE, 108 | '..', '..', '..', 109 | '_work', 110 | 'binary', 111 | LOCAL_BINARY_FOLDER, 112 | os.platform(), 113 | )); 114 | io.mkdirP.restore(); 115 | }); 116 | 117 | context('Log File metadata', () => { 118 | beforeEach(() => { 119 | sinon.stub(core, 'exportVariable'); 120 | sinon.stub(github, 'context').value({ 121 | job: 'someJobName', 122 | }); 123 | }); 124 | 125 | afterEach(() => { 126 | delete process.env[BROWSERSTACK_LOCAL_LOGS_FILE]; 127 | core.exportVariable.restore(); 128 | }); 129 | 130 | it('Generates log-file name and path for Binary', () => { 131 | sinon.stub(Date, 'now').returns('now'); 132 | const expectedLogFileName = `${LOCAL_LOG_FILE_PREFIX}_${github.context.job}_now.log`; 133 | const expectedLogFilePath = path.resolve( 134 | path.resolve( 135 | process.env.GITHUB_WORKSPACE, 136 | '..', '..', '..', 137 | '_work', 138 | 'binary', 139 | LOCAL_BINARY_FOLDER, 140 | os.platform(), 141 | ), 142 | expectedLogFileName, 143 | ); 144 | const binaryControl = new BinaryControl(); 145 | binaryControl._generateLogFileMetadata(); 146 | expect(binaryControl.logFileName).to.eq(expectedLogFileName); 147 | expect(binaryControl.logFilePath).to.eq(expectedLogFilePath); 148 | sinon.assert.calledWith( 149 | core.exportVariable, 150 | BROWSERSTACK_LOCAL_LOGS_FILE, 151 | expectedLogFileName, 152 | ); 153 | Date.now.restore(); 154 | }); 155 | 156 | it('Fetches log-file name and generates path for Binary if logs file name was already defined', () => { 157 | process.env[BROWSERSTACK_LOCAL_LOGS_FILE] = `${LOCAL_LOG_FILE_PREFIX}_${github.context.job}_now.log`; 158 | const expectedLogFileName = `${LOCAL_LOG_FILE_PREFIX}_${github.context.job}_now.log`; 159 | const expectedLogFilePath = path.resolve( 160 | path.resolve( 161 | process.env.GITHUB_WORKSPACE, 162 | '..', '..', '..', 163 | '_work', 164 | 'binary', 165 | LOCAL_BINARY_FOLDER, 166 | os.platform(), 167 | ), 168 | expectedLogFileName, 169 | ); 170 | const binaryControl = new BinaryControl(); 171 | binaryControl._generateLogFileMetadata(); 172 | expect(binaryControl.logFileName).to.eq(expectedLogFileName); 173 | expect(binaryControl.logFilePath).to.eq(expectedLogFilePath); 174 | sinon.assert.calledWith( 175 | core.exportVariable, 176 | BROWSERSTACK_LOCAL_LOGS_FILE, 177 | expectedLogFileName, 178 | ); 179 | }); 180 | }); 181 | 182 | context('Generates args string based on the input to Binary Control & the operation required, i.e. start/stop', () => { 183 | beforeEach(() => { 184 | sinon.stub(github, 'context').value({ 185 | job: 'someJobName', 186 | }); 187 | sinon.stub(Date, 'now').returns('now'); 188 | sinon.stub(core, 'exportVariable'); 189 | process.env.GITHUB_WORKSPACE = '/some/work/space'; 190 | }); 191 | 192 | afterEach(() => { 193 | Date.now.restore(); 194 | core.exportVariable.restore(); 195 | process.env.GITHUB_WORKSPACE = ''; 196 | }); 197 | 198 | context('Start Operation', () => { 199 | it('with localArgs, localIdentifier, localLoggingLevel', () => { 200 | const stateForBinary = { 201 | accessKey: 'someKey', 202 | localArgs: '--arg1 val1 --arg2 val2', 203 | localIdentifier: 'someIdentifier', 204 | localLoggingLevel: 1, 205 | localTesting: 'start', 206 | }; 207 | 208 | const expectedLogFilePath = path.resolve( 209 | process.env.GITHUB_WORKSPACE, 210 | '..', '..', '..', 211 | '_work', 212 | 'binary', 213 | 'LocalBinaryFolder', 214 | os.platform(), 215 | 'BrowserStackLocal_someJobName_now.log', 216 | ); 217 | const expectedFinalArgs = `--key someKey --only-automate --ci-plugin GitHubAction --arg1 val1 --arg2 val2 --local-identifier someIdentifier --verbose 1 --log-file ${expectedLogFilePath} `; 218 | const binaryControl = new BinaryControl(stateForBinary); 219 | binaryControl._generateArgsForBinary(); 220 | expect(binaryControl.binaryArgs).to.eq(expectedFinalArgs); 221 | }); 222 | 223 | it('with localArgs, localIdentifier', () => { 224 | const stateForBinary = { 225 | accessKey: 'someKey', 226 | localArgs: '--arg1 val1 --arg2 val2', 227 | localIdentifier: 'someIdentifier', 228 | localLoggingLevel: 0, 229 | localTesting: 'start', 230 | }; 231 | 232 | const expectedFinalArgs = `--key someKey --only-automate --ci-plugin GitHubAction --arg1 val1 --arg2 val2 --local-identifier someIdentifier `; 233 | const binaryControl = new BinaryControl(stateForBinary); 234 | binaryControl._generateArgsForBinary(); 235 | expect(binaryControl.binaryArgs).to.eq(expectedFinalArgs); 236 | }); 237 | 238 | it('with localArgs', () => { 239 | const stateForBinary = { 240 | accessKey: 'someKey', 241 | localArgs: '--arg1 val1 --arg2 val2', 242 | localIdentifier: '', 243 | localLoggingLevel: 0, 244 | localTesting: 'start', 245 | }; 246 | 247 | const expectedFinalArgs = `--key someKey --only-automate --ci-plugin GitHubAction --arg1 val1 --arg2 val2 `; 248 | const binaryControl = new BinaryControl(stateForBinary); 249 | binaryControl._generateArgsForBinary(); 250 | expect(binaryControl.binaryArgs).to.eq(expectedFinalArgs); 251 | }); 252 | 253 | it('with the default args', () => { 254 | const stateForBinary = { 255 | accessKey: 'someKey', 256 | localArgs: '', 257 | localIdentifier: '', 258 | localLoggingLevel: 0, 259 | localTesting: 'start', 260 | }; 261 | 262 | const expectedFinalArgs = `--key someKey --only-automate --ci-plugin GitHubAction `; 263 | const binaryControl = new BinaryControl(stateForBinary); 264 | binaryControl._generateArgsForBinary(); 265 | expect(binaryControl.binaryArgs).to.eq(expectedFinalArgs); 266 | }); 267 | }); 268 | 269 | context('Stop operation', () => { 270 | it('with localIdentifier', () => { 271 | const stateForBinary = { 272 | accessKey: 'someKey', 273 | localArgs: '', 274 | localIdentifier: 'someIdentifier', 275 | localLoggingLevel: 0, 276 | localTesting: 'stop', 277 | }; 278 | 279 | const expectedFinalArgs = `--key someKey --only-automate --ci-plugin GitHubAction --local-identifier someIdentifier `; 280 | const binaryControl = new BinaryControl(stateForBinary); 281 | binaryControl._generateArgsForBinary(); 282 | expect(binaryControl.binaryArgs).to.eq(expectedFinalArgs); 283 | }); 284 | 285 | it('without localIdentifier', () => { 286 | const stateForBinary = { 287 | accessKey: 'someKey', 288 | localArgs: '', 289 | localIdentifier: '', 290 | localLoggingLevel: 0, 291 | localTesting: 'stop', 292 | }; 293 | 294 | const expectedFinalArgs = `--key someKey --only-automate --ci-plugin GitHubAction `; 295 | const binaryControl = new BinaryControl(stateForBinary); 296 | binaryControl._generateArgsForBinary(); 297 | expect(binaryControl.binaryArgs).to.eq(expectedFinalArgs); 298 | }); 299 | }); 300 | 301 | context('Invalid Operation', () => { 302 | it('Throws error in case its not start/stop', () => { 303 | const stateForBinary = { 304 | accessKey: 'someKey', 305 | localArgs: '', 306 | localIdentifier: 'someIdentifier', 307 | localLoggingLevel: 0, 308 | localTesting: 'someOperation', 309 | }; 310 | 311 | const binaryControl = new BinaryControl(stateForBinary); 312 | assert.throw(() => { 313 | binaryControl._generateArgsForBinary(); 314 | }, 'Invalid Binary Action'); 315 | }); 316 | }); 317 | }); 318 | 319 | context('Triggers Binary with the required operation, i.e. start/stop', () => { 320 | let binaryControl; 321 | 322 | beforeEach(() => { 323 | sinon.stub(exec, 'exec'); 324 | binaryControl = new BinaryControl(); 325 | binaryControl.binaryArgs = 'someArgs '; 326 | }); 327 | 328 | afterEach(() => { 329 | exec.exec.restore(); 330 | }); 331 | 332 | it('Start Operation', async () => { 333 | const response = await binaryControl._triggerBinary(LOCAL_TESTING.START); 334 | sinon.assert.calledWith(exec.exec, `${LOCAL_BINARY_NAME} someArgs --daemon start`); 335 | expect(response).to.eql({ 336 | output: '', 337 | error: '', 338 | }); 339 | }); 340 | 341 | it('Stop Operation', async () => { 342 | const response = await binaryControl._triggerBinary(LOCAL_TESTING.STOP); 343 | sinon.assert.calledWith(exec.exec, `${LOCAL_BINARY_NAME} someArgs --daemon stop`); 344 | expect(response).to.eql({ 345 | output: '', 346 | error: '', 347 | }); 348 | }); 349 | }); 350 | }); 351 | 352 | context('Public Methods Behaviour', () => { 353 | context('Downloading Binary', () => { 354 | let binaryControl; 355 | 356 | beforeEach(() => { 357 | binaryControl = new BinaryControl(); 358 | sinon.stub(binaryControl, '_makeDirectory').returns(true); 359 | binaryControl.binaryLink = 'someLink'; 360 | binaryControl.binaryFolder = 'someFolder'; 361 | sinon.stub(core, 'info'); 362 | sinon.stub(core, 'debug'); 363 | sinon.stub(core, 'addPath'); 364 | sinon.stub(io, 'rmRF'); 365 | }); 366 | 367 | afterEach(() => { 368 | core.info.restore(); 369 | core.debug.restore(); 370 | core.addPath.restore(); 371 | io.rmRF.restore(); 372 | }); 373 | 374 | it('Downloads and sets the binary path without any error', async () => { 375 | sinon.stub(Utils, 'checkToolInCache').returns(false); 376 | sinon.stub(tc, 'downloadTool').returns('downloadPath'); 377 | sinon.stub(tc, 'extractZip').returns('extractedPath'); 378 | sinon.stub(tc, 'cacheDir').returns('cachedPath'); 379 | sinon.stub(binaryControl, '_removeAnyStaleBinary'); 380 | await binaryControl.downloadBinary(); 381 | sinon.assert.called(binaryControl._removeAnyStaleBinary); 382 | tc.downloadTool.restore(); 383 | tc.extractZip.restore(); 384 | tc.cacheDir.restore(); 385 | Utils.checkToolInCache.restore(); 386 | binaryControl._removeAnyStaleBinary.restore(); 387 | }); 388 | 389 | it('Delete any stale local binary (non windows)', async () => { 390 | binaryControl.platform = PLATFORMS.DARWIN; 391 | await binaryControl._removeAnyStaleBinary(); 392 | const binaryZipPath = path.resolve(binaryControl.binaryFolder, LOCAL_BINARY_ZIP); 393 | const staleBinaryPath = path.resolve( 394 | binaryControl.binaryFolder, 395 | `${LOCAL_BINARY_NAME}`, 396 | ); 397 | sinon.assert.calledWith(io.rmRF, binaryZipPath); 398 | sinon.assert.calledWith(io.rmRF, staleBinaryPath); 399 | }); 400 | 401 | it('Delete any stale local binary (windows)', async () => { 402 | binaryControl.platform = PLATFORMS.WIN32; 403 | await binaryControl._removeAnyStaleBinary(); 404 | const binaryZipPath = path.resolve(binaryControl.binaryFolder, LOCAL_BINARY_ZIP); 405 | const staleBinaryPath = path.resolve( 406 | binaryControl.binaryFolder, 407 | `${LOCAL_BINARY_NAME}.exe`, 408 | ); 409 | sinon.assert.calledWith(io.rmRF, binaryZipPath); 410 | sinon.assert.calledWith(io.rmRF, staleBinaryPath); 411 | }); 412 | 413 | it('Uses cached binary if it already exists (was already downloaded)', async () => { 414 | sinon.stub(Utils, 'checkToolInCache').returns('some/path/of/tool'); 415 | sinon.stub(tc, 'downloadTool').returns('downloadPath'); 416 | sinon.stub(tc, 'extractZip').returns('extractedPath'); 417 | sinon.stub(tc, 'cacheDir').returns('cachedPath'); 418 | await binaryControl.downloadBinary(); 419 | sinon.assert.calledWith(core.info, 'BrowserStackLocal binary already exists in cache. Using that instead of downloading again...'); 420 | sinon.assert.calledWith(core.addPath, 'some/path/of/tool'); 421 | sinon.assert.notCalled(tc.downloadTool); 422 | sinon.assert.notCalled(tc.extractZip); 423 | sinon.assert.notCalled(tc.cacheDir); 424 | sinon.assert.notCalled(binaryControl._makeDirectory); 425 | tc.downloadTool.restore(); 426 | tc.extractZip.restore(); 427 | tc.cacheDir.restore(); 428 | Utils.checkToolInCache.restore(); 429 | }); 430 | 431 | it('Throws error if download of Binary fails', async () => { 432 | sinon.stub(Utils, 'checkToolInCache').returns(false); 433 | sinon.stub(tc, 'downloadTool').throws(Error('someError')); 434 | try { 435 | await binaryControl.downloadBinary(); 436 | } catch (e) { 437 | expect(e.message).to.eq('BrowserStackLocal binary could not be downloaded due to someError'); 438 | } 439 | tc.downloadTool.restore(); 440 | Utils.checkToolInCache.restore(); 441 | }); 442 | }); 443 | 444 | context('Triggering Binary to Start/Stop Local Tunnel', () => { 445 | let binaryControl; 446 | 447 | beforeEach(() => { 448 | binaryControl = new BinaryControl(); 449 | sinon.stub(binaryControl, '_generateArgsForBinary').returns(true); 450 | sinon.stub(core, 'info'); 451 | sinon.stub(Utils, 'sleepFor'); 452 | }); 453 | 454 | afterEach(() => { 455 | core.info.restore(); 456 | Utils.sleepFor.restore(); 457 | }); 458 | 459 | context('Starting Local Tunnel', () => { 460 | it("Starts the local tunnel successfully (with local identifier) and gets connected if the response state is 'connected'", async () => { 461 | const response = { 462 | output: JSON.stringify({ 463 | state: LOCAL_BINARY_TRIGGER.START.CONNECTED, 464 | pid: 1234, 465 | message: 'some message', 466 | }), 467 | error: '', 468 | }; 469 | sinon.stub(binaryControl, 'stateForBinary').value({ 470 | localIdentifier: 'someIdentifier', 471 | }); 472 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 473 | await binaryControl.startBinary(); 474 | sinon.assert.calledWith(binaryControl._triggerBinary, LOCAL_TESTING.START); 475 | sinon.assert.calledWith(core.info, 'Starting local tunnel with local-identifier=someIdentifier in daemon mode...'); 476 | sinon.assert.calledWith(core.info, 'Local tunnel status: "some message"'); 477 | }); 478 | 479 | it("Starts the local tunnel successfully (without local identifier) and gets connected if the response state is 'connected'", async () => { 480 | const response = { 481 | output: JSON.stringify({ 482 | state: LOCAL_BINARY_TRIGGER.START.CONNECTED, 483 | pid: 1234, 484 | message: 'some message', 485 | }), 486 | error: '', 487 | }; 488 | sinon.stub(binaryControl, 'stateForBinary').value({ 489 | localIdentifier: '', 490 | }); 491 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 492 | await binaryControl.startBinary(); 493 | sinon.assert.calledWith(core.info, 'Starting local tunnel in daemon mode...'); 494 | sinon.assert.calledWith(core.info, 'Local tunnel status: "some message"'); 495 | }); 496 | 497 | it("Fails and doesn't connect the local tunnel if the response state is 'disconnected' after each available tries", async () => { 498 | const response = { 499 | output: JSON.stringify({ 500 | state: LOCAL_BINARY_TRIGGER.START.DISCONNECTED, 501 | pid: 1234, 502 | message: 'some message', 503 | }), 504 | error: '', 505 | }; 506 | sinon.stub(binaryControl, 'stateForBinary').value({ 507 | localIdentifier: 'someIdentifier', 508 | }); 509 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 510 | try { 511 | await binaryControl.startBinary(); 512 | } catch (e) { 513 | sinon.assert.calledWith(Utils.sleepFor, RETRY_DELAY_BINARY); 514 | sinon.assert.calledWith(core.info, 'Error in starting local tunnel: "some message". Trying again in 5 seconds...'); 515 | expect(e.message).to.eq('Local tunnel could not be started. Error message from binary: "some message"'); 516 | } 517 | }); 518 | 519 | it("Fails and doesn't connect if binary throws an error message after each available tries", async () => { 520 | const response = { 521 | output: '', 522 | error: JSON.stringify({ 523 | key: 'value', 524 | }), 525 | }; 526 | sinon.stub(binaryControl, 'stateForBinary').value({ 527 | localIdentifier: 'someIdentifier', 528 | }); 529 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 530 | try { 531 | await binaryControl.startBinary(); 532 | } catch (e) { 533 | sinon.assert.calledWith(Utils.sleepFor, RETRY_DELAY_BINARY); 534 | sinon.assert.calledWith(core.info, `Error in starting local tunnel: ${JSON.stringify(response.error)}. Trying again in 5 seconds...`); 535 | expect(e.message).to.eq(`Local tunnel could not be started. Error message from binary: ${JSON.stringify(response.error)}`); 536 | } 537 | }); 538 | }); 539 | 540 | context('Stopping Local Tunnel', () => { 541 | it("Stops the local tunnel successfully (with local identifier) if the response status is 'success'", async () => { 542 | const response = { 543 | output: JSON.stringify({ 544 | status: LOCAL_BINARY_TRIGGER.STOP.SUCCESS, 545 | message: 'some message', 546 | }), 547 | error: '', 548 | }; 549 | sinon.stub(binaryControl, 'stateForBinary').value({ 550 | localIdentifier: 'someIdentifier', 551 | }); 552 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 553 | await binaryControl.stopBinary(); 554 | sinon.assert.calledWith(binaryControl._triggerBinary, LOCAL_TESTING.STOP); 555 | sinon.assert.calledWith(core.info, 'Stopping local tunnel with local-identifier=someIdentifier in daemon mode...'); 556 | sinon.assert.calledWith(core.info, 'Local tunnel stopping status: some message'); 557 | }); 558 | 559 | it("Stops the local tunnel successfully (without local identifier) if the response status is 'success'", async () => { 560 | const response = { 561 | output: JSON.stringify({ 562 | status: LOCAL_BINARY_TRIGGER.STOP.SUCCESS, 563 | message: 'some message', 564 | }), 565 | error: '', 566 | }; 567 | sinon.stub(binaryControl, 'stateForBinary').value({ 568 | localIdentifier: '', 569 | }); 570 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 571 | await binaryControl.stopBinary(); 572 | sinon.assert.calledWith(binaryControl._triggerBinary, LOCAL_TESTING.STOP); 573 | sinon.assert.calledWith(core.info, 'Stopping local tunnel in daemon mode...'); 574 | sinon.assert.calledWith(core.info, 'Local tunnel stopping status: some message'); 575 | }); 576 | 577 | it("Fails while disconnecting the local tunnel if the response status is not 'success'", async () => { 578 | const response = { 579 | output: JSON.stringify({ 580 | status: 'someStatus', 581 | message: 'some message', 582 | }), 583 | error: '', 584 | }; 585 | sinon.stub(binaryControl, 'stateForBinary').value({ 586 | localIdentifier: '', 587 | }); 588 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 589 | await binaryControl.stopBinary(); 590 | sinon.assert.calledWith(binaryControl._triggerBinary, LOCAL_TESTING.STOP); 591 | sinon.assert.calledWith(core.info, 'Stopping local tunnel in daemon mode...'); 592 | sinon.assert.calledWith(core.info, '[Warning] Error in stopping local tunnel: "some message". Continuing the workflow without breaking...'); 593 | }); 594 | 595 | it("Fails while disconnecting the local tunnel if binanry thrown as error message", async () => { 596 | const response = { 597 | output: '', 598 | error: JSON.stringify({ 599 | key: 'value', 600 | }), 601 | }; 602 | sinon.stub(binaryControl, 'stateForBinary').value({ 603 | localIdentifier: '', 604 | }); 605 | sinon.stub(binaryControl, '_triggerBinary').returns(response); 606 | await binaryControl.stopBinary(); 607 | sinon.assert.calledWith(binaryControl._triggerBinary, LOCAL_TESTING.STOP); 608 | sinon.assert.calledWith(core.info, 'Stopping local tunnel in daemon mode...'); 609 | sinon.assert.calledWith(core.info, `[Warning] Error in stopping local tunnel: ${JSON.stringify(response.error)}. Continuing the workflow without breaking...`); 610 | }); 611 | }); 612 | }); 613 | 614 | context('Uploading log files if they exists', () => { 615 | let binaryControl; 616 | 617 | beforeEach(() => { 618 | binaryControl = new BinaryControl(); 619 | sinon.stub(binaryControl, '_generateLogFileMetadata'); 620 | sinon.stub(io, 'rmRF'); 621 | sinon.stub(ArtifactsManager, 'uploadArtifacts').returns(true); 622 | sinon.stub(Utils, 'clearEnvironmentVariable'); 623 | binaryControl.logFilePath = 'somePath'; 624 | binaryControl.logFileName = 'someName'; 625 | binaryControl.binaryFolder = 'someFolderPath'; 626 | }); 627 | 628 | afterEach(() => { 629 | io.rmRF.restore(); 630 | ArtifactsManager.uploadArtifacts.restore(); 631 | Utils.clearEnvironmentVariable.restore(); 632 | }); 633 | 634 | it('Uploads the log files if they exists', async () => { 635 | sinon.stub(fs, 'existsSync').returns(true); 636 | await binaryControl.uploadLogFilesIfAny(); 637 | sinon.assert.calledWith( 638 | ArtifactsManager.uploadArtifacts, 639 | 'someName', 640 | ['somePath'], 641 | 'someFolderPath', 642 | ); 643 | sinon.assert.calledWith(io.rmRF, 'somePath'); 644 | sinon.assert.calledWith(Utils.clearEnvironmentVariable, BROWSERSTACK_LOCAL_LOGS_FILE); 645 | fs.existsSync.restore(); 646 | }); 647 | 648 | it("Doesn't upload the log files if they don't exist", async () => { 649 | sinon.stub(fs, 'existsSync').returns(false); 650 | await binaryControl.uploadLogFilesIfAny(); 651 | sinon.assert.notCalled(ArtifactsManager.uploadArtifacts); 652 | sinon.assert.notCalled(io.rmRF); 653 | sinon.assert.calledWith(Utils.clearEnvironmentVariable, BROWSERSTACK_LOCAL_LOGS_FILE); 654 | fs.existsSync.restore(); 655 | }); 656 | }); 657 | }); 658 | }); 659 | --------------------------------------------------------------------------------