├── .eslintrc.json ├── .github └── workflows │ ├── npmpublish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib ├── loader.js ├── mocks │ ├── exec.js │ └── github.js └── runner.js ├── package-lock.json ├── package.json └── test ├── fixtures ├── env-action.js ├── error-action.js ├── exec-action.js ├── fail-action.js ├── github-action.js └── repos.json ├── mocks ├── exec.test.js └── github.test.js └── runner.test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018 15 | }, 16 | "rules": { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 12 16 | - run: npm ci 17 | - run: npm test 18 | 19 | publish-npm: 20 | needs: build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v1 24 | - uses: actions/setup-node@v1 25 | with: 26 | node-version: 12 27 | registry-url: https://registry.npmjs.org/ 28 | - run: npm publish --access public 29 | env: 30 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 31 | 32 | publish-gpr: 33 | needs: build 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v1 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: 12 40 | registry-url: https://npm.pkg.github.com/ 41 | scope: '@jonabc' 42 | - run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | on: push 3 | 4 | jobs: 5 | npm_test: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node_version: [10, 12] 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: ${{ matrix.node_version }} 15 | 16 | - run: npm install 17 | - run: npm run test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jon Ruskin 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🚨 This project has been archived. It's been largely unmaintained for over 2 years, and there are better tools and methods for testing GitHub Actions 😄 2 | 3 | # actions-mocks 4 | Mocking helpers for testing GitHub Actions 5 | 6 | ## Usage 7 | 8 | The exported package contains the following GitHub Actions testing utilities. 9 | ```javascript 10 | { 11 | // used to end-to-end test an action script 12 | run: [AsyncFunction: run], 13 | 14 | // used to unit test an action 15 | mocks: { 16 | exec: { 17 | mock: [Function: mock], 18 | clear: [Function: clear], 19 | restore: [Function: restore], 20 | setLog: [Function: setLog] 21 | }, 22 | github: { 23 | mock: [Function: mock], 24 | clear: [Function: clear], 25 | restore: [Function: restore], 26 | setLog: [Function: setLog] 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | ### End to end testing 33 | 34 | Use the exported method, `run(action, { mocks, env })` 35 | - `action` (required) is the full path to the action script to run 36 | - `mocks` (optional) configurations for mocking `@actions` modules 37 | - `env` (optional) environment to set on action process 38 | 39 | All mock classes are enabled by default to provide a safe environment to run actions locally. Mock classes can be configured by setting `mocks. = []`. All mock expectations MUST be serializable using `JSON.stringify`. 40 | 41 | ```javascript 42 | mocks.exec = [{ command: 'git commit', exitCode: 1 }] 43 | mocks.github = [{ method: 'GET', uri: '/issues', code: 200 }] 44 | ``` 45 | 46 | `run` returns an object `{ out, err, status }`. 47 | - `out`: the full log of output written to `stdout` 48 | - `err`: the full log of output written to `stderr` 49 | - `status`: the exit code of the action 50 | 51 | See the [tests](./test/runner.test.js) for more examples. 52 | 53 | ### Unit testing 54 | 55 | This package can be used for unit testing with the imported `mocks` object. 56 | 57 | Each `mocks` sub-object supports the following API: 58 | 59 | `mock` - Add one or more mock(s). Accepts either a single mock configuration and an array of mock configurations. 60 | - defaults to `JSON.parse(process.env._MOCKS || '[]')` 61 | 62 | `setLog` - Set the method used to log commands and API calls during testing 63 | - defaults to `console.log` 64 | 65 | `clear` - Clears all currently configured mocks 66 | 67 | `restore` - Resets configured mocks and the logging method back to their defaults. 68 | 69 | ```javascript 70 | const { mocks } = require('actions-mocks'); 71 | const myLib = require('../lib/myLib'); 72 | const os = require('os'); 73 | 74 | // myLib.gitAdd calls `git add` 75 | let output = ''; 76 | mocks.exec.setLog(log => output += log + os.EOL); 77 | mocks.exec.mock({ command: 'git add', stdout: 'git output', exitCode: 10 }); 78 | 79 | const { exitCode, commandStdout } = await myLib.gitAdd('arg'); 80 | expect(exitCode).toEqual(10); 81 | expect(commandStdout).toMatch('git output'); 82 | expect(output).toMatch('git add'); 83 | 84 | // myLib.listIssues calls `octokit.issues.list()` 85 | output = ''; 86 | mocks.github.setLog(log => output += log + os.EOL); 87 | mocks.github.mock({ method: 'GET', uri: '/issues', response: '[]', code: 200 }); 88 | 89 | const { data, status } = await myLib.listIssues(); 90 | expect(status).toEqual(200); 91 | expect(data).toEqual([]); 92 | expect(output).toMatch('GET /issues'); 93 | ``` 94 | 95 | See the [exec](./test/mocks/exec.test.js) and [github](./test/mocks/github.test.js) unit tests for more examples. 96 | 97 | ## Mocking `@actions/exec` 98 | 99 | The `@actions/exec` mock catches all calls to `@actions/exec.exec` and 100 | 1. log the full command and it's execution options to the specified logging method (default: `console.log`) 101 | - calls are logged as ` : :` where key/value pairs are from the `options` object passed to `@actions/exec.exec` 102 | 2. output provided `stdout` using the passed in options 103 | 3. output provided `stderr` using the passed in options 104 | 4. return an exit code 105 | - if the command doesn't match a configured mock, rejects with an error for exit code 127 106 | - if the command matches a configured mock, resolves to the configured `exitCode` if 0 or rejects with an error for non-0 values 107 | 108 | The mocked call returns a promise that is resolved for a 0 exit code and rejected with an error for all other exit codes. Calls to `@actions/exec.exec` that specify `options: { ignoreReturnCode: true }` will never be rejected. 109 | 110 | ```javascript 111 | // with a resolved mocked command 112 | const exitCode = await exec.exec(...); 113 | 114 | // with a rejected mocked command 115 | await exec.exec(...).catch(exitCode => { }); 116 | 117 | // with a rejected mocked command using ignoreReturnCode 118 | const exitCode = await exec.exec(..., { ignoreReturnCode: true }); 119 | ``` 120 | 121 | To configure the mock behavior, pass an array of objects with the following format: 122 | ```javascript 123 | { 124 | // (required) pattern of command to match. 125 | // Uses String.prototype.match to perform regex evaluation 126 | command: '', 127 | // (optional) data to output to stdout on matching exec call 128 | stdout: '', 129 | // (optional) data to output to stderr on matching exec call 130 | stderr: '', 131 | // (optional) exit code to return, defaults to 0 132 | exitCode: 0, 133 | // (optional) number of times the mock should be used. defaults to a persistent mock if not set 134 | count: 1 135 | } 136 | ``` 137 | 138 | Command patterns are prioritized based on their location in the passed in array. In the following example, `git commit` will return an exit code of 1 while all other commands will return an exit code of 0. 139 | 140 | ```javascript 141 | { command: 'git commit', exitCode: 1 }, 142 | { command: '', exitCode: 0 } 143 | ``` 144 | 145 | ## Mocking `@actions/github` 146 | 147 | The `@actions/github` mock uses `nock` to catch all calls to `https://api.github.com` and 148 | 1. log all API requests to the specified logging method (default: `console.log`) 149 | - calls are logged as ` ` 150 | 2. returns a response 151 | 1. status 152 | - calls that don't match any configured mocks will return a `404` 153 | - calls that match a configured mock will return `code`, or `200` if not set 154 | 2. data 155 | - response data can be specified when configuring a mock using the `response` property 156 | - response data can be loaded from a fixture by using the `responseFixture` property 157 | 158 | ```javascript 159 | // with a successful (2xx) mocked API call 160 | const { data } = await octokit.user.repos(...); 161 | 162 | // with a failed (4xx-5xx) mocked API call 163 | await octokit.user.repos(...).catch(({data}) => { }); 164 | ``` 165 | 166 | To configure the mock behavior, pass an array of objects with the following format: 167 | ```javascript 168 | { 169 | // (required) the request method to match 170 | method: 'GET', 171 | // (required) uri pattern to match. should not include the domain (https://api.github.com) 172 | // Uses String.prototype.match to perform regex evaluation 173 | uri: '/user/repos', 174 | // (optional) http code to set on the response, default: 200 175 | code: 200, 176 | // (DEPRECATED) Please use `code` to specify a response http code 177 | // (optional) http code to set on the response, default: 200 178 | responseCode: 200, 179 | // (optional) response to send, given as a string 180 | response: '[]', 181 | // (optional) path to load response contents from 182 | file: 'path/to/file', 183 | // (DEPRECATED) Please use `file` to load response contents from a file, along with headers `content-type` = `application/json` 184 | // (optional) response to send, given as a path to a JSON fixture to load 185 | responseFixture: '', 186 | // (optional) headers to set on the mocked response 187 | headers: {}, 188 | // (optional) number of times the mock should be used. defaults to a persistent mock if not set 189 | count: 1 190 | } 191 | ``` 192 | 193 | Command patterns are prioritized based on their location in the passed in array. 194 | 195 | ```javascript 196 | // `GET /user/repos` will return a 400 197 | { method: 'GET', uri: '/user/repos', code: 400 }, 198 | // all other `GET` commands will return 500 199 | { method: 'GET', uri: '', code: 500 } 200 | ``` 201 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | run: require('./lib/runner'), 3 | mocks: { 4 | exec: require('./lib/mocks/exec'), 5 | github: require('./lib/mocks/github') 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /lib/loader.js: -------------------------------------------------------------------------------- 1 | // simple loader to include mocks in a subprocess before running the app 2 | process.argv.slice(2).forEach(file => { 3 | require(file); 4 | }); 5 | -------------------------------------------------------------------------------- /lib/mocks/exec.js: -------------------------------------------------------------------------------- 1 | const exec = require('@actions/exec'); 2 | const os = require('os'); 3 | const sinon = require('sinon').createSandbox(); 4 | 5 | const mocks = []; 6 | let logMethod; 7 | 8 | function getOutputString(value) { 9 | if (!value) { 10 | return null; 11 | } else if (Array.isArray(value)) { 12 | return value.map(arg => JSON.stringify(arg)).join(os.EOL); 13 | } else { 14 | return JSON.stringify(value); 15 | } 16 | } 17 | 18 | sinon.stub(exec, 'exec').callsFake(async (command, args = [], options = {}) => { 19 | const optionsArray = Object.keys(options || {}).map(key => `${key}:${JSON.stringify(options[key])}`); 20 | const fullCommand = [command, ...args, ...optionsArray].join(' '); 21 | logMethod(fullCommand); 22 | 23 | let exitCode = 127; 24 | 25 | const mock = mocks.find(mock => !!fullCommand.match(mock.command)); 26 | if (mock) { 27 | const stdout = getOutputString(mock.stdout); 28 | if (stdout) { 29 | // write to stdout using the passed in options 30 | await exec.exec.wrappedMethod('node', ['-e', `process.stdout.write(${stdout})`], options); 31 | } 32 | 33 | const stderr = getOutputString(mock.stderr); 34 | if (stderr) { 35 | // write to stderr using the passed in options 36 | await exec.exec.wrappedMethod('node', ['-e', `process.stderr.write(${stderr})`], options); 37 | } 38 | 39 | exitCode = mock.exitCode || 0; 40 | 41 | if (mock.count > 0) { 42 | mock.count -= 1; 43 | if (mock.count === 0) { 44 | const index = mocks.indexOf(mock); 45 | mocks.splice(index, 1); 46 | } 47 | } 48 | } 49 | 50 | if (exitCode !== 0 && !options.ignoreReturnCode) { 51 | return Promise.reject(new Error(`Failed with exit code ${exitCode}`)); 52 | } 53 | 54 | return Promise.resolve(exitCode); 55 | }); 56 | 57 | function mock(mocksToAdd) { 58 | if (Array.isArray(mocksToAdd)) { 59 | mocks.unshift(...mocksToAdd) 60 | } else { 61 | mocks.unshift(mocksToAdd); 62 | } 63 | } 64 | 65 | function clear() { 66 | mocks.length = 0; 67 | } 68 | 69 | function restore() { 70 | clear(); 71 | setLog(console.log); 72 | 73 | // by default, add all mocks from the process environment 74 | mock(JSON.parse(process.env.EXEC_MOCKS || '[]')); 75 | } 76 | 77 | function setLog(method) { 78 | logMethod = method; 79 | } 80 | 81 | restore(); 82 | 83 | module.exports = { 84 | mock, 85 | clear, 86 | restore, 87 | setLog 88 | }; 89 | -------------------------------------------------------------------------------- /lib/mocks/github.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const nock = require('nock'); 3 | 4 | const mocks = []; 5 | let logMethod = console.log; 6 | 7 | function responseFunction(uri, requestBody) { 8 | // log the request to match against in tests 9 | logMethod(`${this.req.method} ${uri} : ${JSON.stringify(requestBody)}`); 10 | 11 | const mock = mocks.find(mock => mock.method == this.req.method && uri.match(mock.uri)); 12 | if (!mock) { 13 | // if the route wasn't mocked, return 404 14 | return [404, 'Route not mocked']; 15 | } 16 | 17 | const responseCode = mock.code || mock.responseCode || 200; 18 | let response = ''; 19 | if (mock.response) { 20 | response = mock.response; 21 | } else if (mock.responseFixture) { 22 | response = require(mock.responseFixture); 23 | } else if (mock.file) { 24 | response = fs.createReadStream(mock.file); 25 | } 26 | 27 | if (mock.count > 0) { 28 | mock.count -= 1; 29 | if (mock.count === 0) { 30 | const index = mocks.indexOf(mock); 31 | mocks.splice(index, 1); 32 | } 33 | } 34 | 35 | const headers = mock.headers || {}; 36 | return [responseCode, response, headers]; 37 | } 38 | 39 | // gatekeep this thing - don't let any requests get through 40 | nock('https://api.github.com') 41 | .persist() 42 | .get(/.*/).reply(responseFunction) 43 | .put(/.*/).reply(responseFunction) 44 | .post(/.*/).reply(responseFunction) 45 | .patch(/.*/).reply(responseFunction) 46 | .delete(/.*/).reply(responseFunction); 47 | 48 | function mock(mocksToAdd) { 49 | if (Array.isArray(mocksToAdd)) { 50 | mocks.unshift(...mocksToAdd) 51 | } else { 52 | mocks.unshift(mocksToAdd); 53 | } 54 | } 55 | 56 | function clear() { 57 | mocks.length = 0; 58 | } 59 | 60 | function restore() { 61 | clear(); 62 | setLog(console.log); 63 | 64 | // by default, add all mocks from the process environment 65 | mock(JSON.parse(process.env.GITHUB_MOCKS || '[]')); 66 | } 67 | 68 | function setLog(method) { 69 | logMethod = method; 70 | } 71 | 72 | restore(); 73 | 74 | module.exports = { 75 | mock, 76 | clear, 77 | restore, 78 | setLog 79 | }; 80 | -------------------------------------------------------------------------------- /lib/runner.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('@actions/exec'); 2 | const fs = require('fs').promises; 3 | const os = require('os'); 4 | const path = require('path'); 5 | const stream = require('stream'); 6 | 7 | const mocksDir = path.join(__dirname, 'mocks'); 8 | const nodeArgs = []; 9 | async function loadMocks() { 10 | if (nodeArgs.length == 0) { 11 | nodeArgs.push( 12 | // always run the loader first 13 | path.join(__dirname, 'loader.js'), 14 | ...(await fs.readdir(mocksDir)).map(file => path.join(mocksDir, file)) 15 | ); 16 | } 17 | } 18 | 19 | async function run(action, { mocks = {}, env = {} } = {}) { 20 | const options = { 21 | env: { 22 | ...process.env, // send process.env to action under test 23 | ...env // any passed in env overwrites process.env 24 | } 25 | }; 26 | 27 | if (mocks) { 28 | // send mocks configs to the child process 29 | // mocks keys should match actions toolkit package names, i.e. `exec` or `github` 30 | for(let key in mocks) { 31 | // send the mock options to the child process through ENV 32 | options.env[`${key.toUpperCase()}_MOCKS`] = JSON.stringify(mocks[key]); 33 | } 34 | } 35 | 36 | let outString = ''; 37 | let errString = ''; 38 | options.ignoreReturnCode = true; 39 | options.listeners = { 40 | stdout: data => outString += data.toString() + os.EOL, 41 | stderr: data => errString += data.toString() + os.EOL 42 | }; 43 | options.outStream = new stream.Writable({ write: data => outString += data + os.EOL }); 44 | options.errStream = new stream.Writable({ write: data => errString += data + os.EOL }); 45 | 46 | await loadMocks(); 47 | const exitCode = await exec('node', [...nodeArgs, action], options); 48 | return { out: outString, err: errString, status: exitCode }; 49 | } 50 | 51 | module.exports = run; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jonabc/actions-mocks", 3 | "version": "1.1.2", 4 | "description": "Mocks and utilities to help when testing GitHub Actions", 5 | "main": "index.js", 6 | "directories": { 7 | "lib": "lib", 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "lint": "eslint **.js", 12 | "test": "eslint **.js && jest" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/jonabc/actions-mocks.git" 17 | }, 18 | "keywords": [ 19 | "GitHub", 20 | "Actions", 21 | "Test", 22 | "Mock" 23 | ], 24 | "author": "Jon Ruskin", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/jonabc/actions-mocks/issues" 28 | }, 29 | "homepage": "https://github.com/jonabc/actions-mocks#readme", 30 | "dependencies": { 31 | "@actions/exec": "^1.0.1", 32 | "@actions/github": "^1.1.0", 33 | "nock": "^11.3.5", 34 | "sinon": "^7.5.0" 35 | }, 36 | "devDependencies": { 37 | "eslint": "^8.0", 38 | "jest": "^27.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/fixtures/env-action.js: -------------------------------------------------------------------------------- 1 | function run() { 2 | console.log(JSON.stringify(process.env)); 3 | } 4 | 5 | run(); 6 | -------------------------------------------------------------------------------- /test/fixtures/error-action.js: -------------------------------------------------------------------------------- 1 | function run() { 2 | throw new Error('run error'); 3 | } 4 | 5 | run(); 6 | -------------------------------------------------------------------------------- /test/fixtures/exec-action.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('@actions/exec'); 2 | 3 | async function run() { 4 | let exitCode = await exec('test-fail', ['fail-arg'], { ignoreReturnCode: true }); 5 | console.log(`test-fail exited with status ${exitCode}`); 6 | 7 | exitCode = await exec('test-succeed', ['succeed-arg'], { ignoreReturnCode: true }); 8 | console.log(`test-succeed exited with status ${exitCode}`); 9 | 10 | exitCode = await exec('test-not-mocked', [], { ignoreReturnCode: true }); 11 | console.log(`test-not-mocked exited with status ${exitCode}`); 12 | 13 | await exec('test-not-ignored').catch(exitCode => { 14 | console.log(`test-not-ignored caught with status ${exitCode}`) 15 | }); 16 | } 17 | 18 | run(); 19 | -------------------------------------------------------------------------------- /test/fixtures/fail-action.js: -------------------------------------------------------------------------------- 1 | function run() { 2 | console.error('run error'); 3 | process.exit(1); 4 | } 5 | 6 | run(); 7 | -------------------------------------------------------------------------------- /test/fixtures/github-action.js: -------------------------------------------------------------------------------- 1 | const github = require('@actions/github'); 2 | const octokit = new github.GitHub('token'); 3 | 4 | async function run() { 5 | const {status, data} = await octokit.repos.list(); 6 | console.log(`repos list status: ${status}, response: ${JSON.stringify(data)}`) 7 | 8 | await octokit.orgs.update({ org: "test", company: "company" }).catch(({status}) => { 9 | console.log(`org update status: ${status}`); 10 | }); 11 | 12 | await octokit.orgs.list().catch(({status}) => { 13 | console.log(`organizations list status: ${status}`); 14 | }); 15 | } 16 | 17 | run(); 18 | -------------------------------------------------------------------------------- /test/fixtures/repos.json: -------------------------------------------------------------------------------- 1 | ["repos"] 2 | -------------------------------------------------------------------------------- /test/mocks/exec.test.js: -------------------------------------------------------------------------------- 1 | // default state of mocks when module is loaded 2 | process.env.EXEC_MOCKS = JSON.stringify([ 3 | { command: '', exitCode: 0 } 4 | ]) 5 | 6 | const exec = require('@actions/exec'); 7 | const mocks = require('../../lib/mocks/exec'); 8 | const sinon = require('sinon'); 9 | const stream = require('stream'); 10 | const os = require('os'); 11 | 12 | let outString; 13 | 14 | afterEach(() => { 15 | mocks.restore(); 16 | }); 17 | 18 | it('uses the first found mock', async () => { 19 | mocks.setLog(() => {}); 20 | mocks.mock([ 21 | { command: '', exitCode: 2 }, 22 | { command: '', exitCode: 3 } 23 | ]); 24 | 25 | const exitCode = await exec.exec('command', [], { ignoreReturnCode: true }); 26 | expect(exitCode).toEqual(2); 27 | }); 28 | 29 | it('logs the mocked command', async () => { 30 | outString = ''; 31 | mocks.setLog(data => outString += data); 32 | 33 | await exec.exec('command', ['test', 'args']); 34 | expect(outString).toMatch('command test args'); 35 | }); 36 | 37 | it('returns a rejected promise if ignoreReturnCode is not set with an error exit code', async () => { 38 | mocks.setLog(() => {}); 39 | mocks.mock({ command: 'command', exitCode: 2 }); 40 | await expect(exec.exec('command', ['test'])).rejects.toThrow( 41 | 'Failed with exit code 2' 42 | ); 43 | }); 44 | 45 | it('includes process.env.EXEC_MOCKS on load', async () => { 46 | mocks.setLog(() => {}); 47 | const exitCode = await exec.exec('command', ['test'], { ignoreReturnCode: true }); 48 | expect(exitCode).toEqual(0); 49 | }); 50 | 51 | it('returns a failure exit code if a command isn\'t mocked', async () => { 52 | mocks.setLog(() => {}); 53 | mocks.clear(); 54 | await expect(exec.exec('command', ['test'])).rejects.toThrow( 55 | 'Failed with exit code 127' 56 | ); 57 | }); 58 | 59 | describe('mock', () => { 60 | beforeEach(() => { 61 | outString = ''; 62 | mocks.setLog(data => outString += data); 63 | }); 64 | 65 | it('prepends a command to be mocked', async () => { 66 | mocks.mock({ command: 'command', exitCode: 0 }); 67 | const exitCode = await exec.exec('command', ['test']); 68 | expect(exitCode).toEqual(0); 69 | }); 70 | 71 | it('prepends an array of commands to be mocked', async () => { 72 | mocks.mock([ 73 | { command: 'command1', exitCode: 0 }, 74 | { command: 'command2', exitCode: 10 } 75 | ]); 76 | let exitCode = await exec.exec('command1', ['test']); 77 | expect(exitCode).toEqual(0); 78 | 79 | exitCode = await exec.exec('command2', [], { ignoreReturnCode: true }); 80 | expect(exitCode).toEqual(10); 81 | }); 82 | 83 | it('writes mock stdout', async () => { 84 | const options = { 85 | listeners: { 86 | stdout: data => outString += data.toString() 87 | }, 88 | outStream: new stream.Writable({ write: data => outString += data }) 89 | }; 90 | 91 | mocks.mock({ command: 'command', stdout: 'test out', exitCode: 0 }); 92 | 93 | await exec.exec('command', ['test'], options); 94 | expect(outString).toMatch('test out'); 95 | }); 96 | 97 | it('writes mock stderr', async () => { 98 | const options = { 99 | listeners: { 100 | stderr: data => outString += data.toString() 101 | }, 102 | // prevent non stderr output from being logged 103 | outStream: new stream.Writable({ write: () => {}}), 104 | errStream: new stream.Writable({ write: data => outString += data }) 105 | }; 106 | 107 | // prevent non stderr output from being logged 108 | mocks.setLog(() => {}); 109 | mocks.mock({ command: 'command', stderr: 'test out', exitCode: 0 }); 110 | 111 | await exec.exec('command', ['test'], options); 112 | expect(outString).toMatch('test out'); 113 | }); 114 | 115 | it('sets a count of times the mock should trigger', async () => { 116 | mocks.mock([ 117 | { command: 'command', exitCode: 1, count: 1}, 118 | { command: 'command', exitCode: 2 } 119 | ]); 120 | 121 | let exitCode = await exec.exec('command', [], { ignoreReturnCode: true }); 122 | expect(exitCode).toEqual(1); 123 | 124 | exitCode = await exec.exec('command', [], { ignoreReturnCode: true }); 125 | expect(exitCode).toEqual(2); 126 | }); 127 | }); 128 | 129 | describe('clear', () => { 130 | beforeEach(() => { 131 | mocks.setLog(() => {}); 132 | }); 133 | 134 | it('clears the known mocks', async () => { 135 | mocks.mock({ command: '', exitCode: 0 }); 136 | mocks.clear(); 137 | 138 | const exitCode = await exec.exec('command', [], { ignoreReturnCode: true }); 139 | expect(exitCode).not.toEqual(0); 140 | }); 141 | }); 142 | 143 | describe('restore', () => { 144 | beforeEach(() => { 145 | outString = ''; 146 | sinon.stub(console, 'log').callsFake(data => outString += data); 147 | }); 148 | 149 | afterEach(() => { 150 | sinon.restore(); 151 | }); 152 | 153 | it('resets mocks and settings to their initial states', async () => { 154 | let fakeLog = ''; 155 | const fake = sinon.fake(log => fakeLog += log); 156 | 157 | mocks.mock({ command: '', exitCode: 1 }); 158 | mocks.setLog(fake); 159 | mocks.restore(); 160 | 161 | const exitCode = await exec.exec('command'); 162 | expect(exitCode).not.toEqual(1); 163 | expect(fakeLog).toEqual(''); 164 | expect(fake.callCount).toEqual(0); 165 | expect(outString).toMatch('command'); 166 | }); 167 | }); 168 | 169 | describe('setLog', () => { 170 | let fake; 171 | beforeEach(() => { 172 | fake = sinon.fake(log => outString += log); 173 | mocks.setLog(fake); 174 | }); 175 | 176 | it('sets the method used to log output', async () => { 177 | await exec.exec('command'); 178 | expect(outString).toMatch('command'); 179 | expect(fake.callCount).toEqual(1); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/mocks/github.test.js: -------------------------------------------------------------------------------- 1 | // default state of mocks when module is loaded 2 | process.env.GITHUB_MOCKS = JSON.stringify([ 3 | { method: 'GET', uri: '', code: 200 } 4 | ]) 5 | 6 | const github = require('@actions/github'); 7 | const mocks = require('../../lib/mocks/github'); 8 | const sinon = require('sinon'); 9 | const path = require('path'); 10 | 11 | const octokit = new github.GitHub('token'); 12 | 13 | let outString; 14 | 15 | afterEach(() => { 16 | mocks.restore(); 17 | }); 18 | 19 | it('uses the first found mock', async () => { 20 | mocks.setLog(() => {}); 21 | mocks.mock([ 22 | { method: 'GET', uri: '', code: 400 }, 23 | { method: 'GET', uri: '', code: 500 } 24 | ]); 25 | 26 | await expect(octokit.issues.list()).rejects.toHaveProperty('status', 400); 27 | }); 28 | 29 | it('logs the mocked request', async () => { 30 | outString = ''; 31 | mocks.setLog(data => outString += data); 32 | 33 | await octokit.issues.list(); 34 | expect(outString).toMatch('GET /issues'); 35 | }); 36 | 37 | it('returns a rejected promise if the response code is not successful', async () => { 38 | mocks.setLog(() => {}); 39 | mocks.mock({ method: 'GET', uri: '', code: 404 }); 40 | await expect(octokit.issues.list()).rejects.toHaveProperty('status', 404); 41 | }); 42 | 43 | it('includes process.env.GITHUB_MOCKS on load', async () => { 44 | mocks.setLog(() => {}); 45 | const { status } = await octokit.issues.list(); 46 | expect(status).toEqual(200); 47 | }); 48 | 49 | it('returns 404 if a route isn\'t mocked', async () => { 50 | mocks.setLog(() => {}); 51 | mocks.clear(); 52 | await expect(octokit.issues.list()).rejects.toHaveProperty('status', 404); 53 | }); 54 | 55 | describe('mock', () => { 56 | beforeEach(() => { 57 | outString = ''; 58 | mocks.setLog(data => outString += data); 59 | }); 60 | 61 | it('prepends a command to be mocked', async () => { 62 | mocks.mock({ method: 'GET', uri: '/issues', code: 202 }); 63 | const { status } = await octokit.issues.list(); 64 | expect(status).toEqual(202); 65 | }); 66 | 67 | it('prepends an array of commands to be mocked', async () => { 68 | mocks.mock([ 69 | { method: 'GET', uri: '/issues', code: 201 }, 70 | { method: 'GET', uri: '/users', code: 202 } 71 | ]); 72 | let { status } = await octokit.issues.list(); 73 | expect(status).toEqual(201); 74 | 75 | status = (await octokit.users.list()).status; 76 | expect(status).toEqual(202); 77 | }); 78 | 79 | it('sends a mock string response', async () => { 80 | mocks.mock({ method: 'GET', uri: '', response: 'response' }); 81 | 82 | const { data } = await octokit.issues.list(); 83 | expect(data).toMatch('response'); 84 | }); 85 | 86 | it('sends a mock response loaded from a fixture', async () => { 87 | const fixture = path.normalize(path.join(__dirname, '..', 'fixtures', 'repos.json')); 88 | mocks.mock({ method: 'GET', uri: '', responseFixture: fixture }); 89 | 90 | const { data } = await octokit.issues.list(); 91 | expect(data).toEqual(require(fixture)); 92 | }); 93 | 94 | it('sends a mock response loaded from a file', async () => { 95 | const fixture = path.normalize(path.join(__dirname, '..', 'fixtures', 'repos.json')); 96 | mocks.mock({ method: 'GET', uri: '', file: fixture }); 97 | 98 | const { data } = await octokit.issues.list(); 99 | // note that because the content-type header wasn't mocked, 100 | // the data is returned as a string 101 | expect(JSON.parse(data)).toEqual(require(fixture)); 102 | }); 103 | 104 | it('sends headers with the mock response', async () => { 105 | const fixture = path.normalize(path.join(__dirname, '..', 'fixtures', 'repos.json')); 106 | mocks.mock({ method: 'GET', uri: '', file: fixture, headers: { 'content-type': 'application/json' } }); 107 | 108 | const { data, headers } = await octokit.issues.list(); 109 | expect(headers).toEqual({ 'content-type': 'application/json' }); 110 | 111 | // with the proper content type set, this is returned as a json object 112 | expect(data).toEqual(require(fixture)); 113 | }); 114 | 115 | it('sets a count of times the mock should trigger', async () => { 116 | mocks.mock([ 117 | { method: 'GET', uri: '', code: 201, count: 1 }, 118 | { method: 'GET', uri: '', code: 202 } 119 | ]); 120 | 121 | let { status } = await octokit.issues.list(); 122 | expect(status).toEqual(201); 123 | 124 | status = (await octokit.issues.list()).status; 125 | expect(status).toEqual(202); 126 | }); 127 | }); 128 | 129 | describe('clear', () => { 130 | beforeEach(() => { 131 | mocks.setLog(() => {}); 132 | }); 133 | 134 | it('clears the known mocks', async () => { 135 | mocks.mock({ method: 'GET', uri: '', code: 200 }); 136 | mocks.clear(); 137 | 138 | await expect(octokit.issues.list()).rejects.toHaveProperty('status', 404); 139 | }); 140 | }); 141 | 142 | describe('restore', () => { 143 | beforeEach(() => { 144 | outString = ''; 145 | sinon.stub(console, 'log').callsFake(data => outString += data); 146 | }); 147 | 148 | afterEach(() => { 149 | sinon.restore(); 150 | }); 151 | 152 | it('resets mocks and settings to their initial states', async () => { 153 | let fakeLog = ''; 154 | const fake = sinon.fake(log => fakeLog += log); 155 | 156 | mocks.mock({ method: 'GET', uri: '', code: 400 }); 157 | mocks.setLog(fake); 158 | mocks.restore(); 159 | 160 | const { status } = await octokit.issues.list(); 161 | expect(status).not.toEqual(400); 162 | expect(fakeLog).toEqual(''); 163 | expect(fake.callCount).toEqual(0); 164 | expect(outString).toMatch('GET /issues'); 165 | }); 166 | }); 167 | 168 | describe('setLog', () => { 169 | let fake; 170 | beforeEach(() => { 171 | fake = sinon.fake(log => outString += log); 172 | mocks.setLog(fake); 173 | }); 174 | 175 | it('sets the method used to log output', async () => { 176 | await octokit.issues.list(); 177 | expect(outString).toMatch('GET /issues'); 178 | expect(fake.callCount).toEqual(1); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /test/runner.test.js: -------------------------------------------------------------------------------- 1 | const run = require('../lib/runner'); 2 | const path = require('path'); 3 | 4 | describe('run', () => { 5 | it('mocks exec calls in an action', async () => { 6 | const action = path.join(__dirname, 'fixtures', 'exec-action'); 7 | const mocks = { 8 | exec: [ 9 | { command: 'test-fail', exitCode: 1 }, 10 | { command: 'test-succeed', exitCode: 0 }, 11 | { command: 'test-not-ignored', exitCode: 3 } 12 | ] 13 | }; 14 | const { out, err, status } = await run(action, { mocks }); 15 | expect(status).toEqual(0); 16 | expect(out).toMatch('test-fail fail-arg'); 17 | expect(out).toMatch('test-fail exited with status 1'); 18 | 19 | expect(out).toMatch('test-succeed succeed-arg'); 20 | expect(out).toMatch('test-succeed exited with status 0'); 21 | 22 | expect(out).toMatch('test-not-mocked'); 23 | expect(out).toMatch('test-not-mocked exited with status 1'); 24 | 25 | expect(out).toMatch('test-not-ignored'); 26 | expect(out).toMatch('test-not-ignored caught with status Error: Failed with exit code 3'); 27 | }); 28 | 29 | 30 | it('mocks github API calls in an action', async () => { 31 | const action = path.join(__dirname, 'fixtures', 'github-action'); 32 | const reposFixture = path.join(__dirname, 'fixtures', 'repos'); 33 | const mocks = { 34 | github: [ 35 | // can load response from fixtures 36 | { method: 'GET', uri: '/user/repos', responseFixture: reposFixture }, 37 | // can set response code and direct response text 38 | { method: 'PATCH', uri: '/orgs/test', responseCode: 500, response: "Server error"} 39 | ] 40 | }; 41 | 42 | const { out, err, status } = await run(action, { mocks }); 43 | expect(status).toEqual(0); 44 | // verify response body is sent 45 | expect(out).toMatch('GET /user/repos'); 46 | expect(out).toMatch(`repos list status: 200, response: ${JSON.stringify(require(reposFixture))}`); 47 | 48 | // verify request body is captured 49 | expect(out).toMatch('PATCH /orgs/test : {"company":"company"}'); 50 | expect(out).toMatch('org update status: 500'); 51 | 52 | // verify unmatched routes are 404-d 53 | expect(out).toMatch('GET /organizations'); 54 | expect(out).toMatch('organizations list status: 404'); 55 | }); 56 | 57 | it('passes environment to the action', async () => { 58 | process.env.FROM_ENV = "from env"; 59 | const action = path.join(__dirname, 'fixtures', 'env-action'); 60 | const env = { FROM_ARGS: "from args" }; 61 | const { out, err, status } = await run(action, { env }); 62 | expect(status).toEqual(0); 63 | expect(out).toMatch("\"FROM_ENV\":\"from env\""); 64 | expect(out).toMatch("\"FROM_ARGS\":\"from args\""); 65 | }); 66 | 67 | it('doesn\'t error if the action fails', async () => { 68 | const action = path.join(__dirname, 'fixtures', 'fail-action'); 69 | const { out, err, status } = await run(action); 70 | expect(status).toEqual(1); 71 | expect(err).toMatch('run error'); 72 | }); 73 | 74 | it('doesn\'t error if the action throws an error', async () => { 75 | const action = path.join(__dirname, 'fixtures', 'error-action'); 76 | const { out, err, status } = await run(action); 77 | expect(status).toEqual(1); 78 | expect(err).toMatch('Error: run error'); 79 | }); 80 | }); 81 | --------------------------------------------------------------------------------