├── .eslintignore ├── .mocharc.yaml ├── test ├── fixtures │ └── uncompiled.js ├── integration.test.js ├── resolvePromiseWithWorkers.test.js ├── helpers.test.js └── HoneybadgerSourceMapPlugin.test.js ├── .gitignore ├── .babelrc ├── .npmignore ├── src ├── constants.js ├── helpers.js ├── resolvePromiseWithWorkers.js └── HoneybadgerSourceMapPlugin.js ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── shipjs-trigger.yml │ ├── shipjs-schedule-prepare.yml │ ├── nodejs.yml │ └── shipjs-manual-prepare.yml ├── LICENSE ├── CHANGELOG.md ├── ship.config.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.mocharc.yaml: -------------------------------------------------------------------------------- 1 | require: '@babel/register' -------------------------------------------------------------------------------- /test/fixtures/uncompiled.js: -------------------------------------------------------------------------------- 1 | 2 | import find from 'lodash.find' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | tmp 4 | *.log 5 | .nyc_output 6 | coverage.lcov 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .*rc 2 | .eslintignore 3 | .travis.yml 4 | test 5 | examples 6 | docs 7 | tmp 8 | coverage.lcov 9 | .nyc_output 10 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const PLUGIN_NAME = 'HoneybadgerSourceMapPlugin' 2 | export const ENDPOINT = 'https://api.honeybadger.io/v1/source_maps' 3 | export const DEPLOY_ENDPOINT = 'https://api.honeybadger.io/v1/deploys' 4 | export const MAX_RETRIES = 10 5 | export const MIN_WORKER_COUNT = 1 6 | 7 | export const REQUIRED_FIELDS = [ 8 | 'apiKey', 9 | 'assetsUrl' 10 | ] 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## What are the steps to reproduce this issue? 4 | 5 | 1. … 6 | 2. … 7 | 3. … 8 | 9 | ## What happens? 10 | … 11 | 12 | ## What were you expecting to happen? 13 | … 14 | 15 | ## Any logs, error output, etc? 16 | … 17 | 18 | ## Any other comments? 19 | … 20 | 21 | ## What versions are you using? 22 | **Operating System:** … 23 | **Package Version:** … 24 | **Browser Version:** … 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Status 2 | 3 | **READY/WIP/HOLD** 4 | 5 | ## Description 6 | A few sentences describing the overall goals of the pull request's commits. 7 | 8 | ## Related PRs 9 | List related PRs against other branches: 10 | 11 | branch | PR 12 | ------ | ------ 13 | other_pr_production | [link]() 14 | other_pr_master | [link]() 15 | 16 | ## Todos 17 | - [ ] Tests 18 | - [ ] Documentation 19 | - [ ] Changelog Entry (unreleased) 20 | 21 | ## Steps to Test or Reproduce 22 | Outline the steps to test or reproduce the PR here. 23 | 24 | ```bash 25 | > git pull --prune 26 | > git checkout 27 | > npm test 28 | ``` 29 | 30 | 1. 31 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import VError from 'verror' 2 | import { REQUIRED_FIELDS } from './constants' 3 | 4 | export function handleError (err, prefix = 'HoneybadgerSourceMapPlugin') { 5 | if (!err) { 6 | return [] 7 | } 8 | 9 | const errors = [].concat(err) 10 | return errors.map(e => new VError(e, prefix)) 11 | } 12 | 13 | export function validateOptions (ref) { 14 | const errors = REQUIRED_FIELDS.reduce((result, field) => { 15 | if (ref && ref[field]) { 16 | return result 17 | } 18 | 19 | return [ 20 | ...result, 21 | new Error(`required field, '${field}', is missing.`) 22 | ] 23 | }, []) 24 | 25 | return errors.length ? errors : null 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-trigger.yml: -------------------------------------------------------------------------------- 1 | name: Ship js trigger 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | jobs: 7 | build: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/v') 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | ref: master 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 16.x 19 | registry-url: "https://registry.npmjs.org" 20 | - run: | 21 | if [ -f "yarn.lock" ]; then 22 | yarn install 23 | else 24 | npm install 25 | fi 26 | - run: npx shipjs trigger 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 30 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 31 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-schedule-prepare.yml: -------------------------------------------------------------------------------- 1 | name: Ship js Schedule Prepare 2 | on: 3 | schedule: 4 | # * is a special character in YAML so you have to quote this string 5 | - cron: "0 19 * * 2" 6 | jobs: 7 | schedule_prepare: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | ref: master 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 16.x 17 | - run: | 18 | if [ -f "yarn.lock" ]; then 19 | yarn install 20 | else 21 | npm install 22 | fi 23 | - run: | 24 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 25 | git config --global user.name "github-actions[bot]" 26 | - run: npm run release -- --yes --no-browse 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 30 | -------------------------------------------------------------------------------- /src/resolvePromiseWithWorkers.js: -------------------------------------------------------------------------------- 1 | function * generator ( 2 | promiseFactories 3 | ) { 4 | for (let i = 0; i < promiseFactories.length; ++i) { 5 | yield [promiseFactories[i](), i] 6 | } 7 | } 8 | 9 | async function worker (generator, results) { 10 | for (const [promise, index] of generator) { 11 | results[index] = await promise 12 | } 13 | } 14 | 15 | export async function resolvePromiseWithWorkers ( 16 | promiseFactories, 17 | workerCount 18 | ) { 19 | // The generator and the results are shared between workers, ensuring each promise is only resolved once 20 | const sharedGenerator = generator(promiseFactories) 21 | const results = [] 22 | 23 | // There's no need to create more workers than promises to resolve 24 | const actualWorkerCount = Math.min( 25 | workerCount, 26 | promiseFactories.length 27 | ) 28 | 29 | const workers = Array.from(new Array(actualWorkerCount)).map(() => 30 | worker(sharedGenerator, results) 31 | ) 32 | 33 | await Promise.all(workers) 34 | 35 | return results 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.x, 14.x, 16.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Get npm cache directory 21 | id: npm-cache-dir 22 | run: | 23 | echo "::set-output name=dir::$(npm config get cache)" 24 | - uses: actions/cache@v2 25 | id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' 26 | with: 27 | path: ${{ steps.npm-cache-dir.outputs.dir }} 28 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 29 | restore-keys: | 30 | ${{ runner.os }}-node- 31 | - name: npm install, build, and test 32 | run: | 33 | npm i 34 | npm run build --if-present 35 | npm run lint 36 | npm test 37 | env: 38 | CI: true 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Honeybadger Industries LLC 2 | Copyright (c) 2016 Brandon Doran 3 | 4 | Permission is hereby granted, free of charge, to any person 5 | obtaining a copy of this software and associated documentation 6 | files (the "Software"), to deal in the Software without 7 | restriction, including without limitation the rights to use, 8 | copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 20 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 21 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 23 | OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | ## [1.5.1] - 2021-11-19 8 | ### Fixed 9 | - Don't upload source maps if app is running with webpack-dev-server (#325) 10 | 11 | ## [1.5.0] - 2021-06-21 12 | ### Added 13 | - Support for sending deployment notifications 14 | 15 | ## [1.4.0] - 2021-04-20 16 | ### Added 17 | - Add worker support when uploading sourcemaps. Defaults to 5 files 18 | being uploaded in parallel. 19 | 20 | ## [1.3.0] - 2021-04-13 21 | ### Added 22 | - Add retry functionality for fetch requests via 23 | [fetch-retry](https://github.com/vercel/fetch-retry) 24 | - Add a retry option that defaults to 3, with a max number of retries 25 | of 10. 26 | - Add a warning if no assets will be uploaded. Uses console.info instead 27 | of process.stdout.write. 28 | - Add a configurable `endpoint` to the constructor, defaults to 29 | `https://api.honeybadger.io/v1/source_maps` 30 | - Add a check for auxiliary files for Webpack 5 compatibility 31 | - Add Webpack 5 compatibility 32 | - Make Webpack 4+ a peerDependency 33 | 34 | ### Fixed 35 | - fetch separates response errors from network errors. 36 | 400+ status codes are treated separately from actual network errors. 37 | - Attempt to reduce `ECONNRESET` and `SOCKETTIMEOUT` errors by 38 | using `fetch-retry` 39 | 40 | ## [1.2.0] - 2019-12-18 41 | ### Changed 42 | - [Requires Webpack 4.39] Use assetEmitted hook to mitigate `futureEmitAssets = true` -@qnighy (#122) 43 | 44 | ### Fixed 45 | - Dependency & security updates 46 | -------------------------------------------------------------------------------- /ship.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require('fs'); 3 | const semver = require('semver') 4 | const keepachangelog = require('keepachangelog') 5 | 6 | // eslint-disable-next-line no-undef 7 | module.exports = { 8 | updateChangelog: false, 9 | formatCommitMessage: ({ version, _releaseType, _baseBranch }) => `Release v${version}`, 10 | formatPullRequestTitle: ({ version, _releaseType }) => `Release v${version}`, 11 | getNextVersion: ({ _revisionRange, _commitTitles, _commitBodies, currentVersion, dir }) => { 12 | const changelogFile = `${dir}/CHANGELOG.md` 13 | const content = fs.readFileSync(changelogFile, 'utf8') 14 | const changelog = keepachangelog.parse(content) 15 | return getNextVersion(changelog, currentVersion) 16 | }, 17 | beforeCommitChanges: ({ nextVersion, _releaseType, _exec, dir }) => { 18 | const changelogFile = `${dir}/CHANGELOG.md` 19 | const content = fs.readFileSync(changelogFile, 'utf8') 20 | const changelog = keepachangelog.parse(content) 21 | changelog.addRelease(nextVersion) 22 | fs.writeFileSync(changelogFile, changelog.build(), 'utf8') 23 | }, 24 | shouldPrepare: ({ nextVersion, commitNumbersPerType }) => { 25 | return !!nextVersion 26 | } 27 | } 28 | 29 | 30 | function getNextVersion(changelog, currentVersion, releaseTag = 'latest') { 31 | const parsedVersion = semver.parse(currentVersion) 32 | const upcomingRelease = changelog.getRelease('upcoming') || { version: 'upcoming' } 33 | 34 | let releaseType 35 | if (upcomingRelease.Changed?.length > 0) { 36 | releaseType = 'major' 37 | } else if (upcomingRelease.Added?.length > 0) { 38 | releaseType = 'minor' 39 | } else if (upcomingRelease.Fixed?.length > 0) { 40 | releaseType = 'patch' 41 | } else { 42 | return null 43 | } 44 | 45 | if (releaseTag !== 'latest') { 46 | if (parsedVersion.prerelease.length) { 47 | parsedVersion.inc('prerelease', releaseTag) 48 | } else { 49 | parsedVersion.inc(releaseType) 50 | parsedVersion.prerelease = [ releaseTag, 0 ] 51 | parsedVersion.format() 52 | } 53 | } else { 54 | parsedVersion.inc(releaseType) 55 | } 56 | 57 | return parsedVersion.version 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-manual-prepare.yml: -------------------------------------------------------------------------------- 1 | name: Ship js Manual Prepare 2 | on: 3 | issue_comment: 4 | types: [created] 5 | jobs: 6 | manual_prepare: 7 | if: | 8 | github.event_name == 'issue_comment' && 9 | (github.event.comment.author_association == 'member' || github.event.comment.author_association == 'owner') && 10 | startsWith(github.event.comment.body, '@shipjs prepare') 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | ref: master 17 | - uses: actions/setup-node@v1 18 | with: 19 | node-version: 16.x 20 | - run: | 21 | if [ -f "yarn.lock" ]; then 22 | yarn install 23 | else 24 | npm install 25 | fi 26 | - run: | 27 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 28 | git config --global user.name "github-actions[bot]" 29 | - run: npm run release -- --yes --no-browse 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 33 | 34 | create_done_comment: 35 | if: success() 36 | needs: manual_prepare 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/github-script@v3 40 | with: 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | script: | 43 | github.issues.createComment({ 44 | issue_number: context.issue.number, 45 | owner: context.repo.owner, 46 | repo: context.repo.repo, 47 | body: "@${{github.actor}} `shipjs prepare` done" 48 | }) 49 | 50 | create_fail_comment: 51 | if: cancelled() || failure() 52 | needs: manual_prepare 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/github-script@v3 56 | with: 57 | github-token: ${{ secrets.GITHUB_TOKEN }} 58 | script: | 59 | github.issues.createComment({ 60 | issue_number: context.issue.number, 61 | owner: context.repo.owner, 62 | repo: context.repo.repo, 63 | body: "@${{github.actor}} `shipjs prepare` fail" 64 | }) 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@honeybadger-io/webpack", 3 | "version": "1.5.1", 4 | "description": "Webpack plugin to upload source maps to Honeybadger's API - http://docs.honeybadger.io/guides/source-maps.html", 5 | "main": "./dist/HoneybadgerSourceMapPlugin.js", 6 | "scripts": { 7 | "build": "babel src -d dist", 8 | "build:watch": "watch 'npm run build' ./src", 9 | "clean": "rimraf dist", 10 | "prebuild": "npm run -s clean", 11 | "prepublishOnly": "npm run clean && npm run build && npm run test", 12 | "test": "mocha", 13 | "test:watch": "npm test -- -w", 14 | "preversion": "npm test", 15 | "postversion": "git push && git push --tags", 16 | "lint": "standard ./src/**/*.js ./test/*.js", 17 | "fix": "standard --fix ./src/**/*.js ./test/*.js", 18 | "prepare": "cd node_modules/keepachangelog && npm install", 19 | "release": "shipjs prepare" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/honeybadger-io/honeybadger-webpack.git" 24 | }, 25 | "keywords": [ 26 | "webpack", 27 | "sourcemap", 28 | "source map", 29 | "minified js", 30 | "honeybadger", 31 | "honey badger" 32 | ], 33 | "author": "Honeybadger.io (https://www.honeybadger.io/)", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/honeybadger-io/honeybadger-webpack/issues" 37 | }, 38 | "homepage": "https://github.com/honeybadger-io/honeybadger-webpack#readme", 39 | "devDependencies": { 40 | "@babel/cli": "^7.14.5", 41 | "@babel/core": "^7.14.5", 42 | "@babel/plugin-transform-runtime": "^7.14.5", 43 | "@babel/preset-env": "^7.14.5", 44 | "@babel/register": "^7.14.5", 45 | "chai": "^4.3.4", 46 | "cross-env": "^7.0.0", 47 | "debug": "^4.1.0", 48 | "keepachangelog": "git+https://github.com/honeybadger-io/keepachangelog#release", 49 | "lodash": "^4.17.21", 50 | "mocha": "^9.0.1", 51 | "nock": "^13.1.0", 52 | "rimraf": "^3.0.2", 53 | "shipjs": "^0.24.0", 54 | "sinon": "^14.0.0", 55 | "standard": "^17.0.0", 56 | "watch": "^1.0.2", 57 | "webpack": "^5.39.0" 58 | }, 59 | "dependencies": { 60 | "@babel/runtime": "^7.14.5", 61 | "form-data": "^4.0.0", 62 | "lodash.find": "^4.3.0", 63 | "lodash.foreach": "^4.2.0", 64 | "lodash.reduce": "^4.3.0", 65 | "node-fetch-retry": "^2.0.0", 66 | "verror": "^1.6.1" 67 | }, 68 | "peerDependencies": { 69 | "webpack": ">= 4.0.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import HoneybadgerSourceMapPlugin from '../src/HoneybadgerSourceMapPlugin' 4 | import webpack from 'webpack' 5 | import chai from 'chai' 6 | import path from 'path' 7 | import nock from 'nock' 8 | import sinon from 'sinon' 9 | 10 | const expect = chai.expect 11 | 12 | const consoleInfo = sinon.stub(console, 'info') 13 | const ASSETS_URL = 'https://cdn.example.com/assets' 14 | const TEST_ENDPOINT = 'https://api.honeybadger.io' 15 | const SOURCEMAP_PATH = '/v1/source_maps' 16 | const DEPLOY_PATH = '/v1/deploys' 17 | 18 | it('only uploads source maps if no deploy config', function (done) { 19 | nock(TEST_ENDPOINT) 20 | .post(SOURCEMAP_PATH) 21 | .reply(201, JSON.stringify({ status: 'OK' })) 22 | 23 | const webpackConfig = { 24 | mode: 'development', 25 | entry: path.join(__dirname, 'fixtures/uncompiled.js'), 26 | output: { 27 | path: path.join(__dirname, '../tmp/') 28 | }, 29 | devtool: 'source-map', 30 | plugins: [new HoneybadgerSourceMapPlugin({ 31 | apiKey: 'abc123', 32 | retries: 0, 33 | assetsUrl: ASSETS_URL, 34 | revision: 'master' 35 | })] 36 | } 37 | webpack(webpackConfig, (err, stats) => { 38 | expect(err).to.eq(null) 39 | 40 | const info = stats.toJson('errors-warnings') 41 | expect(info.errors.length).to.equal(0) 42 | expect(info.warnings.length).to.equal(0) 43 | 44 | expect(consoleInfo.calledWith('Uploaded main.js.map to Honeybadger API')).to.eq(true) 45 | expect(consoleInfo.calledWith('Successfully sent deploy notification to Honeybadger API.')).to.eq(false) 46 | done() 47 | }) 48 | }) 49 | 50 | it('uploads source maps and sends deployment notification if configured', function (done) { 51 | nock(TEST_ENDPOINT) 52 | .post(SOURCEMAP_PATH) 53 | .reply(201, JSON.stringify({ status: 'OK' })) 54 | nock(TEST_ENDPOINT) 55 | .post(DEPLOY_PATH) 56 | .reply(201, JSON.stringify({ status: 'OK' })) 57 | 58 | const webpackConfig = { 59 | mode: 'development', 60 | entry: path.join(__dirname, 'fixtures/uncompiled.js'), 61 | output: { 62 | path: path.join(__dirname, '../tmp/') 63 | }, 64 | devtool: 'source-map', 65 | plugins: [new HoneybadgerSourceMapPlugin({ 66 | apiKey: 'abc123', 67 | retries: 0, 68 | assetsUrl: ASSETS_URL, 69 | revision: 'master', 70 | deploy: { 71 | environment: 'production', 72 | repository: 'https://cdn.example.com', 73 | localUsername: 'Jane' 74 | } 75 | })] 76 | } 77 | 78 | webpack(webpackConfig, (err, stats) => { 79 | expect(err).to.eq(null) 80 | 81 | const info = stats.toJson('errors-warnings') 82 | expect(info.errors.length).to.equal(0) 83 | expect(info.warnings.length).to.equal(0) 84 | 85 | expect(consoleInfo.calledWith('Uploaded main.js.map to Honeybadger API')).to.eq(true) 86 | expect(consoleInfo.calledWith('Successfully sent deploy notification to Honeybadger API.')).to.eq(true) 87 | done() 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/resolvePromiseWithWorkers.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import chai from 'chai' 4 | import sinon from 'sinon' 5 | 6 | import { resolvePromiseWithWorkers } from '../src/resolvePromiseWithWorkers' 7 | 8 | const expect = chai.expect 9 | 10 | function asyncPromiseGenerator ( 11 | name, 12 | timeout, 13 | traceCallback 14 | ) { 15 | return new Promise((resolve) => { 16 | if (typeof traceCallback === 'function') { 17 | traceCallback(`start ${name}`) 18 | } 19 | 20 | setTimeout(() => { 21 | if (typeof traceCallback === 'function') { 22 | traceCallback(`resolve ${name}`) 23 | } 24 | resolve(name) 25 | }, timeout) 26 | }) 27 | } 28 | 29 | function assertCallSequence ( 30 | spy, 31 | sequence 32 | ) { 33 | for (let i = 0; i < sequence.length; ++i) { 34 | sinon.assert.calledWith(spy.getCall(i), sequence[i]) 35 | } 36 | } 37 | 38 | const promises = [ 39 | () => asyncPromiseGenerator('First', 1), 40 | () => asyncPromiseGenerator('Second', 10), 41 | () => asyncPromiseGenerator('Third', 3) 42 | ] 43 | 44 | describe('resolvePromiseWithWorkers', function () { 45 | it('should resolve all promises', async function () { 46 | expect(await resolvePromiseWithWorkers(promises, 5)) 47 | .to.deep.eq(['First', 'Second', 'Third']) 48 | }) 49 | 50 | it('should resolve all promises if the number of workers is lower than the number of promises', async function () { 51 | expect(await resolvePromiseWithWorkers(promises, 1)) 52 | .to.deep.eq(['First', 'Second', 'Third']) 53 | }) 54 | 55 | it('should resolve the promises sequentially if the number of worker is 1', async function () { 56 | const spy = sinon.spy() 57 | const promisesWithCallback = [ 58 | () => asyncPromiseGenerator('First', 1, spy), 59 | () => asyncPromiseGenerator('Second', 3, spy), 60 | () => asyncPromiseGenerator('Third', 5, spy) 61 | ] 62 | 63 | await resolvePromiseWithWorkers(promisesWithCallback, 1) 64 | 65 | const sequence = [ 66 | 'start First', 67 | 'resolve First', 68 | 'start Second', 69 | 'resolve Second', 70 | 'start Third', 71 | 'resolve Third' 72 | ] 73 | assertCallSequence(spy, sequence) 74 | }) 75 | 76 | it('should resolve the promises by workers', async function () { 77 | const spy = sinon.spy() 78 | const promisesWithCallback = [ 79 | () => asyncPromiseGenerator('First', 1, spy), 80 | () => 81 | asyncPromiseGenerator( 82 | 'Second', 83 | 50 /* A very long promise that should keep a worker busy */, 84 | spy 85 | ), 86 | () => asyncPromiseGenerator('Third', 1, spy), 87 | () => asyncPromiseGenerator('Fourth', 1, spy) 88 | ] 89 | 90 | await resolvePromiseWithWorkers(promisesWithCallback, 2) 91 | const sequence = [ 92 | 'start First', 93 | 'start Second', 94 | 'resolve First', 95 | 'start Third', 96 | 'resolve Third', 97 | 'start Fourth', 98 | 'resolve Fourth', 99 | 'resolve Second' 100 | ] 101 | assertCallSequence(spy, sequence) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /test/helpers.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import chai from 'chai' 4 | import { REQUIRED_FIELDS } from '../src/constants' 5 | import * as helpers from '../src/helpers' 6 | 7 | const expect = chai.expect 8 | 9 | describe('helpers', function () { 10 | describe('handleError', function () { 11 | it('should return an array of length 1 given a single error', function () { 12 | const result = helpers.handleError(new Error('required field missing')) 13 | expect(result).to.be.an.instanceof(Array) 14 | expect(result.length).to.eq(1) 15 | }) 16 | 17 | it('should return an array of length 2 given an array of length 2', function () { 18 | const result = helpers.handleError([ 19 | new Error('required field missing'), 20 | new Error('request failed') 21 | ]) 22 | expect(result).to.an.instanceof(Array) 23 | expect(result.length).to.eq(2) 24 | }) 25 | 26 | it('should prefix message of single error', function () { 27 | const result = helpers.handleError(new Error('required field missing'), 'Plugin') 28 | expect(result.length).to.eq(1) 29 | expect(result[0]).to.include({ message: 'Plugin: required field missing' }) 30 | }) 31 | 32 | it('should prefix message of an array of errors', function () { 33 | const result = helpers.handleError([ 34 | new Error('required field missing'), 35 | new Error('request failed') 36 | ], 'Plugin') 37 | expect(result.length).to.eq(2) 38 | expect(result[0]).to.include({ message: 'Plugin: required field missing' }) 39 | }) 40 | 41 | it('should default prefix to "HoneybadgerSourceMapPlugin"', function () { 42 | const result = helpers.handleError(new Error('required field missing')) 43 | expect(result.length).to.eq(1) 44 | expect(result[0]).to.include({ 45 | message: 'HoneybadgerSourceMapPlugin: required field missing' 46 | }) 47 | }) 48 | 49 | it('should handle null', function () { 50 | const result = helpers.handleError(null) 51 | expect(result).to.be.an('array') 52 | expect(result.length).to.eq(0) 53 | }) 54 | 55 | it('should handle empty []', function () { 56 | const result = helpers.handleError([]) 57 | expect(result).to.be.an('array') 58 | expect(result.length).to.eq(0) 59 | }) 60 | }) 61 | 62 | describe('validateOptions', function () { 63 | it('should return null if all required options are supplied', function () { 64 | const options = { 65 | apiKey: 'abcd1234', 66 | revision: 'fab5a8727c70647dcc539318b5b3e9b0cb8ae17b', 67 | assetsUrl: 'https://cdn.example.com/assets' 68 | } 69 | const result = helpers.validateOptions(options) 70 | expect(result).to.eq(null) 71 | }) 72 | 73 | it('should return an error if apiKey is not supplied', function () { 74 | const options = { 75 | revision: 'fab5a8727c70647dcc539318b5b3e9b0cb8ae17b', 76 | assetsUrl: 'https://cdn.example.com/assets' 77 | } 78 | const result = helpers.validateOptions(options) 79 | expect(result).to.be.an.instanceof(Array) 80 | expect(result.length).to.eq(1) 81 | expect(result[0]).to.be.an.instanceof(Error) 82 | expect(result[0]).to.include({ message: 'required field, \'apiKey\', is missing.' }) 83 | }) 84 | 85 | it('should return an error if assetsUrl is not supplied', function () { 86 | const options = { 87 | apiKey: 'abcd1234', 88 | revision: 'fab5a8727c70647dcc539318b5b3e9b0cb8ae17b' 89 | } 90 | const result = helpers.validateOptions(options) 91 | expect(result).to.be.an.instanceof(Array) 92 | expect(result.length).to.eq(1) 93 | expect(result[0]).to.be.an.instanceof(Error) 94 | expect(result[0]).to.include({ message: 'required field, \'assetsUrl\', is missing.' }) 95 | }) 96 | 97 | it('should handle multiple missing required options', function () { 98 | const options = {} 99 | const result = helpers.validateOptions(options) 100 | expect(result).to.be.an.instanceof(Array) 101 | expect(result.length).to.eq(REQUIRED_FIELDS.length) 102 | }) 103 | 104 | it('should handle null for options', function () { 105 | const result = helpers.validateOptions(null) 106 | expect(result).to.be.an.instanceof(Array) 107 | expect(result.length).to.eq(REQUIRED_FIELDS.length) 108 | }) 109 | 110 | it('should handle no options passed', function () { 111 | const result = helpers.validateOptions() 112 | expect(result).to.be.an.instanceof(Array) 113 | expect(result.length).to.eq(REQUIRED_FIELDS.length) 114 | }) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Honeybadger's Webpack Source Map Plugin 2 | 3 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fhoneybadger-io%2Fhoneybadger-webpack%2Fbadge%3Fref%3Dmaster&style=flat)](https://actions-badge.atrox.dev/honeybadger-io/honeybadger-webpack/goto?ref=master) 4 | [![npm version](https://badge.fury.io/js/%40honeybadger-io%2Fwebpack.svg)](https://badge.fury.io/js/%40honeybadger-io%2Fwebpack) 5 | 6 | > **Note:** This repository has been moved to [@honeybadger-io/js](https://github.com/honeybadger-io/honeybadger-js), home to all Honeybadger's JavaScript packages. 7 | 8 | [Webpack](https://webpack.js.org/) plugin to upload JavaScript 9 | sourcemaps to [Honeybadger](https://docs.honeybadger.io/guides/source-maps.html). You can also send [deployment notifications](https://docs.honeybadger.io/api/deployments.html). 10 | 11 | Word Up! to the [thredUP](https://github.com/thredup) development team for a 12 | similar webpack plugin they have authored. 13 | 14 | ## Installation 15 | 16 | ``` 17 | npm install @honeybadger-io/webpack --save-dev 18 | ``` 19 | 20 | ## Configuration 21 | 22 | ### Plugin parameters 23 | 24 | These plugin parameters correspond to the Honeybadger [Source Map Upload API](https://docs.honeybadger.io/guides/source-maps.html) and [Deployments API](https://docs.honeybadger.io/api/deployments.html). 25 | 26 |
27 |
apiKey (required)
28 |
The API key of your Honeybadger project
29 | 30 |
assetsUrl (required)
31 |
The base URL to production assets (scheme://host/path)*wildcards are supported. The plugin combines assetsUrl with the generated minified js file name to build the API parameter minified_url
32 | 33 |
endpoint (optional — default: "https://api.honeybadger.io/v1/source_maps")
34 |
Where to upload your sourcemaps to. Perhaps you have a self hosted 35 | sourcemap server you would like to upload your sourcemaps to instead 36 | of honeybadger.
37 | 38 |
revision (optional — default: "master")
39 |
The deploy revision (i.e. commit hash) that your source map applies to. This could also be a code version. For best results, set it to something unique every time your code changes. See the Honeybadger docs for examples.
40 | 41 |
silent (optional — default: "null/false")
42 |
If true, silence log information emitted by the plugin.
43 | 44 |
ignoreErrors (optional — default: false)
45 |
If true, webpack compilation errors are treated as warnings.
46 | 47 |
retries (optional — default: 3, max: 10)
48 |
This package implements fetch retry functionality via 49 | https://github.com/vercel/fetch-retry
50 | Retrying helps fix issues like `ECONNRESET` and `SOCKETTIMEOUT` 51 | errors and retries on 429 and 500 errors as well. 52 |
53 | 54 |
workerCount (optional — default: 5, min: 1)
55 |
Sourcemaps are uploaded in parallel by a configurable number of 56 | workers. Increase or decrease this value to configure how many sourcemaps 57 | are being uploaded in parallel.
58 | Limited parallelism helps with connection issues in Docker environments.
59 | 60 |
deploy (optional — default: false)
61 |
62 | Configuration for deployment notifications. To disable deployment notifications, ignore this option. To enable deployment notifications, set this to true, or to an object containing any of these fields (see the API reference):
63 | 64 |
65 |
environment
66 |
The environment name, for example, "production"
67 |
repository
68 |
The base URL of the VCS repository (HTTPS-style), for example, "https://github.com/yourusername/yourrepo"
69 |
localUsername
70 |
The name of the user that triggered this deploy, for example, "Jane"
71 |
72 |
73 |
74 | 75 | ### Vanilla webpack.config.js 76 | 77 | ```javascript 78 | const HoneybadgerSourceMapPlugin = require('@honeybadger-io/webpack') 79 | const ASSETS_URL = 'https://cdn.example.com/assets'; 80 | const webpackConfig = { 81 | plugins: [new HoneybadgerSourceMapPlugin({ 82 | apiKey: 'abc123', 83 | assetsUrl: ASSETS_URL, 84 | revision: 'master', 85 | // You can also enable deployment notifications: 86 | deploy: { 87 | environment: process.env.NODE_ENV, 88 | repository: "https://github.com/yourusername/yourrepo" 89 | } 90 | })] 91 | } 92 | ``` 93 | 94 | ### Rails Webpacker config/webpack/environment.js 95 | 96 | ```javascript 97 | const { environment } = require('@rails/webpacker') 98 | const HoneybadgerSourceMapPlugin = require('@honeybadger-io/webpack') 99 | 100 | // Assumes Heroku / 12-factor application style ENV variables 101 | // named GIT_COMMIT, HONEYBADGER_API_KEY, ASSETS_URL 102 | const revision = process.env.GIT_COMMIT || 'master' 103 | 104 | environment.plugins.append( 105 | 'HoneybadgerSourceMap', 106 | new HoneybadgerSourceMapPlugin({ 107 | apiKey: process.env.HONEYBADGER_API_KEY, 108 | assetsUrl: process.env.ASSETS_URL, 109 | silent: false, 110 | ignoreErrors: false, 111 | revision: revision 112 | })) 113 | 114 | module.exports = environment 115 | ``` 116 | 117 | ## Changelog 118 | 119 | See https://github.com/honeybadger-io/honeybadger-webpack/blob/master/CHANGELOG.md 120 | 121 | ## Contributing 122 | 123 | 1. Fork it. 124 | 2. Create a topic branch `git checkout -b my_branch` 125 | 3. Commit your changes `git commit -am "Boom"` 126 | 3. Push to your branch `git push origin my_branch` 127 | 4. Send a [pull request](https://github.com/honeybadger-io/honeybadger-webpack/pulls) 128 | 129 | ## Development 130 | 131 | 1. Run `npm install` 132 | 2. Run the tests with `npm test` 133 | 3. Build/test on save with `npm run build:watch` and `npm run test:watch` 134 | 135 | ## Releasing 136 | 137 | 1. With a clean working tree, use `npm version [new version]` to bump the version, 138 | commit the changes, tag the release, and push to GitHub. See `npm help version` 139 | for documentation. 140 | 2. To publish the release, use `npm publish`. See `npm help publish` for 141 | documentation. 142 | 143 | ## License 144 | 145 | The Honeybadger's Webpack Source Map Plugin is MIT licensed. See the 146 | [MIT-LICENSE](https://raw.github.com/honeybadger-io/honeybadger-webpack/master/MIT-LICENSE) 147 | file in this repository for details. 148 | -------------------------------------------------------------------------------- /src/HoneybadgerSourceMapPlugin.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import { join } from 'path' 3 | import fetch from 'node-fetch-retry' 4 | import VError from 'verror' 5 | import find from 'lodash.find' 6 | import reduce from 'lodash.reduce' 7 | import FormData from 'form-data' 8 | import { handleError, validateOptions } from './helpers' 9 | import { ENDPOINT, DEPLOY_ENDPOINT, PLUGIN_NAME, MAX_RETRIES, MIN_WORKER_COUNT } from './constants' 10 | import { resolvePromiseWithWorkers } from './resolvePromiseWithWorkers' 11 | 12 | /** 13 | * @typedef {Object} DeployObject 14 | * @property {?string} environment - production, development, staging, etc 15 | * @property {?string} repository - URL for repository IE: https://github.com/foo/bar 16 | * @property {?string} localUsername - The name of the user deploying. IE: Jane 17 | */ 18 | 19 | class HoneybadgerSourceMapPlugin { 20 | constructor ({ 21 | apiKey, 22 | assetsUrl, 23 | endpoint = ENDPOINT, 24 | revision = 'master', 25 | silent = false, 26 | ignoreErrors = false, 27 | retries = 3, 28 | workerCount = 5, 29 | deploy = false 30 | }) { 31 | this.apiKey = apiKey 32 | this.assetsUrl = assetsUrl 33 | this.endpoint = endpoint 34 | this.revision = revision 35 | this.silent = silent 36 | this.ignoreErrors = ignoreErrors 37 | this.workerCount = Math.max(workerCount, MIN_WORKER_COUNT) 38 | /** @type DeployObject|boolean */ 39 | this.deploy = deploy 40 | this.retries = retries 41 | 42 | if (this.retries > MAX_RETRIES) { 43 | this.retries = MAX_RETRIES 44 | } 45 | } 46 | 47 | async afterEmit (compilation) { 48 | if (this.isDevServerRunning()) { 49 | if (!this.silent) { 50 | console.info('\nHoneybadgerSourceMapPlugin will not upload source maps because webpack-dev-server is running.') 51 | } 52 | 53 | return 54 | } 55 | 56 | const errors = validateOptions(this) 57 | 58 | if (errors) { 59 | compilation.errors.push(...handleError(errors)) 60 | return 61 | } 62 | 63 | try { 64 | await this.uploadSourceMaps(compilation) 65 | await this.sendDeployNotification() 66 | } catch (err) { 67 | if (!this.ignoreErrors) { 68 | compilation.errors.push(...handleError(err)) 69 | } else if (!this.silent) { 70 | compilation.warnings.push(...handleError(err)) 71 | } 72 | } 73 | } 74 | 75 | isDevServerRunning () { 76 | return process.env.WEBPACK_DEV_SERVER === 'true' 77 | } 78 | 79 | apply (compiler) { 80 | compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, this.afterEmit.bind(this)) 81 | } 82 | 83 | // eslint-disable-next-line class-methods-use-this 84 | getAssetPath (compilation, name) { 85 | return join( 86 | compilation.getPath(compilation.compiler.outputPath), 87 | name.split('?')[0] 88 | ) 89 | } 90 | 91 | getSource (compilation, name) { 92 | const path = this.getAssetPath(compilation, name) 93 | return fs.readFile(path, { encoding: 'utf-8' }) 94 | } 95 | 96 | getAssets (compilation) { 97 | const { chunks } = compilation.getStats().toJson() 98 | 99 | return reduce(chunks, (result, chunk) => { 100 | const sourceFile = find(chunk.files, file => /\.js$/.test(file)) 101 | 102 | // Webpack 4 using chunk.files, Webpack 5 uses chunk.auxiliaryFiles 103 | // https://webpack.js.org/blog/2020-10-10-webpack-5-release/#stats 104 | const sourceMap = (chunk.auxiliaryFiles || chunk.files).find(file => 105 | /\.js\.map$/.test(file) 106 | ) 107 | 108 | if (!sourceFile || !sourceMap) { 109 | return result 110 | } 111 | 112 | return [ 113 | ...result, 114 | { sourceFile, sourceMap } 115 | ] 116 | }, []) 117 | } 118 | 119 | getUrlToAsset (sourceFile) { 120 | if (typeof sourceFile === 'string') { 121 | const sep = this.assetsUrl.endsWith('/') ? '' : '/' 122 | return `${this.assetsUrl}${sep}${sourceFile}` 123 | } 124 | return this.assetsUrl(sourceFile) 125 | } 126 | 127 | async uploadSourceMap (compilation, { sourceFile, sourceMap }) { 128 | const errorMessage = `failed to upload ${sourceMap} to Honeybadger API` 129 | 130 | let sourceMapSource 131 | let sourceFileSource 132 | 133 | try { 134 | sourceMapSource = await this.getSource(compilation, sourceMap) 135 | sourceFileSource = await this.getSource(compilation, sourceFile) 136 | } catch (err) { 137 | throw new VError(err, err.message) 138 | } 139 | 140 | const form = new FormData() 141 | form.append('api_key', this.apiKey) 142 | form.append('minified_url', this.getUrlToAsset(sourceFile)) 143 | form.append('minified_file', sourceFileSource, { 144 | filename: sourceFile, 145 | contentType: 'application/javascript' 146 | }) 147 | form.append('source_map', sourceMapSource, { 148 | filename: sourceMap, 149 | contentType: 'application/octet-stream' 150 | }) 151 | form.append('revision', this.revision) 152 | 153 | let res 154 | try { 155 | res = await fetch(this.endpoint, { 156 | method: 'POST', 157 | body: form, 158 | redirect: 'follow', 159 | retry: this.retries, 160 | pause: 1000 161 | }) 162 | } catch (err) { 163 | // network / operational errors. Does not include 404 / 500 errors 164 | throw new VError(err, errorMessage) 165 | } 166 | 167 | // >= 400 responses 168 | if (!res.ok) { 169 | // Attempt to parse error details from response 170 | let details 171 | try { 172 | const body = await res.json() 173 | 174 | if (body && body.error) { 175 | details = body.error 176 | } else { 177 | details = `${res.status} - ${res.statusText}` 178 | } 179 | } catch (parseErr) { 180 | details = `${res.status} - ${res.statusText}` 181 | } 182 | 183 | throw new Error(`${errorMessage}: ${details}`) 184 | } 185 | 186 | // Success 187 | if (!this.silent) { 188 | // eslint-disable-next-line no-console 189 | console.info(`Uploaded ${sourceMap} to Honeybadger API`) 190 | } 191 | } 192 | 193 | uploadSourceMaps (compilation) { 194 | const assets = this.getAssets(compilation) 195 | 196 | if (assets.length <= 0) { 197 | // We should probably tell people they're not uploading assets. 198 | // this is also an open issue on Rollbar sourcemap plugin 199 | // https://github.com/thredup/rollbar-sourcemap-webpack-plugin/issues/39 200 | if (!this.silent) { 201 | console.info(this.noAssetsFoundMessage) 202 | } 203 | 204 | return 205 | } 206 | 207 | console.info('\n') 208 | 209 | // On large projects source maps should not all be uploaded at the same time, 210 | // but in parallel with a reasonable worker count in order to avoid network issues 211 | return resolvePromiseWithWorkers( 212 | assets.map(asset => () => this.uploadSourceMap(compilation, asset)), 213 | this.workerCount 214 | ) 215 | } 216 | 217 | async sendDeployNotification () { 218 | if (this.deploy === false || this.apiKey == null) { 219 | return 220 | } 221 | 222 | let body 223 | 224 | if (this.deploy === true) { 225 | body = { 226 | deploy: { 227 | revision: this.revision 228 | } 229 | } 230 | } else if (typeof this.deploy === 'object' && this.deploy !== null) { 231 | body = { 232 | deploy: { 233 | revision: this.revision, 234 | repository: this.deploy.repository, 235 | local_username: this.deploy.localUsername, 236 | environment: this.deploy.environment 237 | } 238 | } 239 | } 240 | 241 | const errorMessage = 'Unable to send deploy notification to Honeybadger API.' 242 | let res 243 | 244 | try { 245 | res = await fetch(DEPLOY_ENDPOINT, { 246 | method: 'POST', 247 | headers: { 248 | 'X-API-KEY': this.apiKey, 249 | 'Content-Type': 'application/json', 250 | Accept: 'application/json' 251 | }, 252 | body: JSON.stringify(body), 253 | redirect: 'follow', 254 | retry: this.retries, 255 | pause: 1000 256 | }) 257 | } catch (err) { 258 | // network / operational errors. Does not include 404 / 500 errors 259 | if (!this.ignoreErrors) { 260 | throw new VError(err, errorMessage) 261 | } 262 | } 263 | 264 | // >= 400 responses 265 | if (!res.ok) { 266 | // Attempt to parse error details from response 267 | let details 268 | try { 269 | const body = await res.json() 270 | 271 | if (body && body.error) { 272 | details = body.error 273 | } else { 274 | details = `${res.status} - ${res.statusText}` 275 | } 276 | } catch (parseErr) { 277 | details = `${res.status} - ${res.statusText}` 278 | } 279 | 280 | if (!this.ignoreErrors) { 281 | throw new Error(`${errorMessage}: ${details}`) 282 | } 283 | } 284 | 285 | if (!this.silent) { 286 | console.info('Successfully sent deploy notification to Honeybadger API.') 287 | } 288 | } 289 | 290 | get noAssetsFoundMessage () { 291 | return '\nHoneybadger could not find any sourcemaps. Nothing will be uploaded.' 292 | } 293 | } 294 | 295 | module.exports = HoneybadgerSourceMapPlugin 296 | -------------------------------------------------------------------------------- /test/HoneybadgerSourceMapPlugin.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import chai from 'chai' 4 | import sinon from 'sinon' 5 | import nock from 'nock' 6 | 7 | import { promises as fs } from 'fs' 8 | import HoneybadgerSourceMapPlugin from '../src/HoneybadgerSourceMapPlugin' 9 | import { ENDPOINT, MAX_RETRIES, MIN_WORKER_COUNT, PLUGIN_NAME } from '../src/constants' 10 | 11 | const expect = chai.expect 12 | 13 | const TEST_ENDPOINT = 'https://api.honeybadger.io' 14 | const SOURCEMAP_PATH = '/v1/source_maps' 15 | const DEPLOY_PATH = '/v1/deploys' 16 | 17 | describe(PLUGIN_NAME, function () { 18 | beforeEach(function () { 19 | this.compiler = { 20 | hooks: { 21 | afterEmit: { 22 | tapPromise: sinon.spy() 23 | } 24 | } 25 | } 26 | 27 | this.options = { 28 | apiKey: 'abcd1234', 29 | assetsUrl: 'https://cdn.example.com/assets' 30 | } 31 | 32 | this.plugin = new HoneybadgerSourceMapPlugin(this.options) 33 | }) 34 | 35 | describe('constructor', function () { 36 | it('should return an instance', function () { 37 | expect(this.plugin).to.be.an.instanceof(HoneybadgerSourceMapPlugin) 38 | }) 39 | 40 | it('should set options', function () { 41 | const options = Object.assign({}, this.options, { 42 | apiKey: 'other-api-key', 43 | assetsUrl: 'https://cdn.example.com/assets', 44 | endpoint: 'https://my-random-endpoint.com' 45 | }) 46 | const plugin = new HoneybadgerSourceMapPlugin(options) 47 | expect(plugin).to.include(options) 48 | }) 49 | 50 | it('should default silent to false', function () { 51 | expect(this.plugin).to.include({ silent: false }) 52 | }) 53 | 54 | it('should default revision to "master"', function () { 55 | expect(this.plugin).to.include({ revision: 'master' }) 56 | }) 57 | 58 | it('should default retries to 3', function () { 59 | expect(this.plugin).to.include({ retries: 3 }) 60 | }) 61 | 62 | it('should default workerCount to 5', function () { 63 | expect(this.plugin).to.include({ workerCount: 5 }) 64 | }) 65 | 66 | it('should default a minimum worker count', function () { 67 | const plugin = new HoneybadgerSourceMapPlugin({ workerCount: -1 }) 68 | expect(plugin).to.include({ workerCount: MIN_WORKER_COUNT }) 69 | }) 70 | 71 | it('should default endpoint to https://api.honeybadger.io/v1/source_maps', function () { 72 | expect(this.plugin).to.include({ endpoint: ENDPOINT }) 73 | }) 74 | 75 | it('should scale back any retries > 10', function () { 76 | const options = { ...this.options, retries: 40 } 77 | const plugin = new HoneybadgerSourceMapPlugin(options) 78 | expect(plugin).to.include({ retries: MAX_RETRIES }) 79 | }) 80 | 81 | it('should allow users to set retries to 0', function () { 82 | const options = { ...this.options, retries: 0 } 83 | const plugin = new HoneybadgerSourceMapPlugin(options) 84 | expect(plugin).to.include({ retries: 0 }) 85 | }) 86 | }) 87 | 88 | describe('apply', function () { 89 | it('should hook into "after-emit"', function () { 90 | this.compiler.plugin = sinon.stub() 91 | this.plugin.apply(this.compiler) 92 | 93 | const tapPromise = this.compiler.hooks.afterEmit.tapPromise 94 | expect(tapPromise.callCount).to.eq(1) 95 | 96 | const compilerArgs = tapPromise.getCall(0).args 97 | compilerArgs[1] = compilerArgs[1].toString() 98 | 99 | expect(compilerArgs).to.include.members([ 100 | PLUGIN_NAME, 101 | compilerArgs[1] 102 | ]) 103 | }) 104 | }) 105 | 106 | describe('afterEmit', function () { 107 | afterEach(function () { 108 | sinon.reset() 109 | }) 110 | 111 | it('should call uploadSourceMaps', async function () { 112 | const compilation = { 113 | errors: [], 114 | warnings: [] 115 | } 116 | 117 | sinon.stub(this.plugin, 'uploadSourceMaps') 118 | 119 | await this.plugin.afterEmit(compilation) 120 | 121 | expect(this.plugin.uploadSourceMaps.callCount).to.eq(1) 122 | expect(compilation.errors.length).to.eq(0) 123 | expect(compilation.warnings.length).to.eq(0) 124 | }) 125 | 126 | it('should add upload warnings to compilation warnings, ' + 127 | 'if ignoreErrors is true and silent is false', async function () { 128 | const compilation = { 129 | errors: [], 130 | warnings: [] 131 | } 132 | this.plugin.ignoreErrors = true 133 | this.plugin.silent = false 134 | 135 | sinon.stub(this.plugin, 'uploadSourceMaps') 136 | .callsFake(() => { throw new Error() }) 137 | 138 | await this.plugin.afterEmit(compilation) 139 | 140 | expect(this.plugin.uploadSourceMaps.callCount).to.eq(1) 141 | expect(compilation.errors.length).to.eq(0) 142 | expect(compilation.warnings.length).to.eq(1) 143 | expect(compilation.warnings[0]).to.be.an.instanceof(Error) 144 | }) 145 | 146 | it('should not add upload errors to compilation warnings if silent is true', async function () { 147 | const compilation = { 148 | errors: [], 149 | warnings: [] 150 | } 151 | this.plugin.ignoreErrors = true 152 | this.plugin.silent = true 153 | 154 | sinon.stub(this.plugin, 'uploadSourceMaps') 155 | .callsFake(() => { throw new Error() }) 156 | 157 | await this.plugin.afterEmit(compilation) 158 | 159 | expect(this.plugin.uploadSourceMaps.callCount).to.eq(1) 160 | expect(compilation.errors.length).to.eq(0) 161 | expect(compilation.warnings.length).to.eq(0) 162 | }) 163 | 164 | it('should add upload errors to compilation errors', async function () { 165 | const compilation = { 166 | errors: [], 167 | warnings: [] 168 | } 169 | this.plugin.ignoreErrors = false 170 | 171 | sinon.stub(this.plugin, 'uploadSourceMaps') 172 | .callsFake(() => { throw new Error() }) 173 | 174 | await this.plugin.afterEmit(compilation) 175 | 176 | expect(this.plugin.uploadSourceMaps.callCount).to.eq(1) 177 | expect(compilation.warnings.length).to.eq(0) 178 | expect(compilation.errors.length).to.be.eq(1) 179 | expect(compilation.errors[0]).to.be.an.instanceof(Error) 180 | }) 181 | 182 | it('should add validation errors to compilation', async function () { 183 | const compilation = { 184 | errors: [], 185 | warnings: [], 186 | getStats: () => ({ 187 | toJson: () => ({ chunks: this.chunks }) 188 | }) 189 | } 190 | 191 | this.plugin = new HoneybadgerSourceMapPlugin({ 192 | revision: 'fab5a8727c70647dcc539318b5b3e9b0cb8ae17b', 193 | assetsUrl: 'https://cdn.example.com/assets' 194 | }) 195 | 196 | sinon.stub(this.plugin, 'uploadSourceMaps') 197 | .callsFake(() => {}) 198 | 199 | await this.plugin.afterEmit(compilation) 200 | 201 | expect(this.plugin.uploadSourceMaps.callCount).to.eq(0) 202 | expect(compilation.errors.length).to.eq(1) 203 | }) 204 | }) 205 | 206 | describe('getAssets', function () { 207 | beforeEach(function () { 208 | this.chunks = [ 209 | { 210 | id: 0, 211 | names: ['app'], 212 | files: ['app.81c1.js', 'app.81c1.js.map'] 213 | } 214 | ] 215 | this.compilation = { 216 | getStats: () => ({ 217 | toJson: () => ({ chunks: this.chunks }) 218 | }) 219 | } 220 | }) 221 | 222 | it('should return an array of js, sourcemap tuples', function () { 223 | const assets = this.plugin.getAssets(this.compilation) 224 | expect(assets).to.deep.eq([ 225 | { sourceFile: 'app.81c1.js', sourceMap: 'app.81c1.js.map' } 226 | ]) 227 | }) 228 | 229 | it('should ignore chunks that do not have a sourcemap asset', function () { 230 | this.chunks = [ 231 | { 232 | id: 0, 233 | names: ['app'], 234 | files: ['app.81c1.js', 'app.81c1.js.map'] 235 | } 236 | ] 237 | const assets = this.plugin.getAssets(this.compilation) 238 | expect(assets).to.deep.eq([ 239 | { sourceFile: 'app.81c1.js', sourceMap: 'app.81c1.js.map' } 240 | ]) 241 | }) 242 | 243 | it('should get the source map files from auxiliaryFiles in Webpack 5', function () { 244 | this.chunks = [ 245 | { 246 | id: 0, 247 | names: ['vendor'], 248 | files: ['vendor.5190.js'], 249 | auxiliaryFiles: ['vendor.5190.js.map'] 250 | } 251 | ] 252 | 253 | const assets = this.plugin.getAssets(this.compilation) 254 | expect(assets).to.deep.eq([ 255 | { sourceFile: 'vendor.5190.js', sourceMap: 'vendor.5190.js.map' } 256 | ]) 257 | }) 258 | }) 259 | 260 | describe('uploadSourceMaps', function () { 261 | beforeEach(function () { 262 | this.compilation = { name: 'test', errors: [] } 263 | this.assets = [ 264 | { sourceFile: 'vendor.5190.js', sourceMap: 'vendor.5190.js.map' }, 265 | { sourceFile: 'app.81c1.js', sourceMap: 'app.81c1.js.map' } 266 | ] 267 | sinon.stub(this.plugin, 'getAssets').returns(this.assets) 268 | sinon.stub(this.plugin, 'uploadSourceMap') 269 | .callsFake(() => {}) 270 | }) 271 | 272 | afterEach(function () { 273 | sinon.restore() 274 | }) 275 | 276 | it('should call uploadSourceMap for each chunk', async function () { 277 | await this.plugin.uploadSourceMaps(this.compilation) 278 | 279 | expect(this.plugin.getAssets.callCount).to.eq(1) 280 | expect(this.compilation.errors.length).to.eq(0) 281 | expect(this.plugin.uploadSourceMap.callCount).to.eq(2) 282 | 283 | expect(this.plugin.uploadSourceMap.getCall(0).args[0]) 284 | .to.deep.eq({ name: 'test', errors: [] }) 285 | expect(this.plugin.uploadSourceMap.getCall(0).args[1]) 286 | .to.deep.eq({ sourceFile: 'vendor.5190.js', sourceMap: 'vendor.5190.js.map' }) 287 | 288 | expect(this.plugin.uploadSourceMap.getCall(1).args[0]) 289 | .to.deep.eq({ name: 'test', errors: [] }) 290 | expect(this.plugin.uploadSourceMap.getCall(1).args[1]) 291 | .to.deep.eq({ sourceFile: 'app.81c1.js', sourceMap: 'app.81c1.js.map' }) 292 | }) 293 | 294 | it('should throw an error if the uploadSourceMap function returns an error', function () { 295 | this.plugin.uploadSourceMap.restore() 296 | 297 | const error = new Error() 298 | sinon.stub(this.plugin, 'uploadSourceMap') 299 | .callsFake(() => {}) 300 | .rejects(error) 301 | 302 | // Chai doesnt properly async / await rejections, so we gotta work around it 303 | // with a...Promise ?! 304 | this.plugin.uploadSourceMaps(this.compilation) 305 | .catch((err) => expect(err).to.eq(error)) 306 | }) 307 | 308 | context('If no sourcemaps are found', function () { 309 | it('Should warn a user if silent is false', async function () { 310 | this.plugin.getAssets.restore() 311 | sinon.stub(this.plugin, 'getAssets').returns([]) 312 | const info = sinon.stub(console, 'info') 313 | 314 | nock(TEST_ENDPOINT) 315 | .filteringRequestBody(function (_body) { return '*' }) 316 | .post(SOURCEMAP_PATH, '*') 317 | .reply(200, JSON.stringify({ status: 'OK' })) 318 | 319 | const { compilation } = this 320 | this.plugin.silent = false 321 | 322 | await this.plugin.uploadSourceMaps(compilation) 323 | 324 | expect(info.calledWith(this.plugin.noAssetsFoundMessage)).to.eq(true) 325 | }) 326 | 327 | it('Should not warn a user if silent is true', async function () { 328 | this.plugin.getAssets.restore() 329 | sinon.stub(this.plugin, 'getAssets').returns([]) 330 | const info = sinon.stub(console, 'info') 331 | 332 | nock(TEST_ENDPOINT) 333 | .post(SOURCEMAP_PATH) 334 | .reply(200, JSON.stringify({ status: 'OK' })) 335 | 336 | const { compilation } = this 337 | this.plugin.silent = true 338 | 339 | await this.plugin.uploadSourceMaps(compilation) 340 | 341 | expect(info.notCalled).to.eq(true) 342 | }) 343 | }) 344 | }) 345 | 346 | describe('uploadSourceMap', function () { 347 | beforeEach(function () { 348 | this.outputPath = '/fake/output/path' 349 | this.info = sinon.stub(console, 'info') 350 | this.compilation = { 351 | assets: { 352 | 'vendor.5190.js.map': { source: () => '{"version":3,"sources":[]' }, 353 | 'app.81c1.js.map': { source: () => '{"version":3,"sources":[]' } 354 | }, 355 | compiler: { 356 | outputPath: this.outputPath 357 | }, 358 | errors: [], 359 | getPath: () => this.outputPath 360 | } 361 | 362 | this.chunk = { 363 | sourceFile: 'vendor.5190.js', 364 | sourceMap: 'vendor.5190.js.map' 365 | } 366 | 367 | this.spyReadFile = sinon 368 | .stub(fs, 'readFile') 369 | .callsFake(() => Promise.resolve('data')) 370 | }) 371 | 372 | afterEach(function () { 373 | sinon.restore() 374 | }) 375 | 376 | it('should callback without err param if upload is success', async function () { 377 | // FIXME/TODO test multipart form body ... it isn't really supported easily by nock 378 | nock(TEST_ENDPOINT) 379 | .post(SOURCEMAP_PATH) 380 | .reply(201, JSON.stringify({ status: 'OK' })) 381 | 382 | const { compilation, chunk } = this 383 | 384 | await this.plugin.uploadSourceMap(compilation, chunk) 385 | 386 | expect(console.info.calledWith('Uploaded vendor.5190.js.map to Honeybadger API')).to.eq(true) 387 | }) 388 | 389 | it('should not log upload to console if silent option is true', async function () { 390 | nock(TEST_ENDPOINT) 391 | .post(SOURCEMAP_PATH) 392 | .reply(201, JSON.stringify({ status: 'OK' })) 393 | 394 | const { compilation, chunk } = this 395 | this.plugin.silent = true 396 | 397 | await this.plugin.uploadSourceMap(compilation, chunk) 398 | 399 | expect(this.info.notCalled).to.eq(true) 400 | }) 401 | 402 | it('should log upload to console if silent option is false', async function () { 403 | nock(TEST_ENDPOINT) 404 | .post(SOURCEMAP_PATH) 405 | .reply(201, JSON.stringify({ status: 'OK' })) 406 | 407 | const { compilation, chunk } = this 408 | this.plugin.silent = false 409 | 410 | await this.plugin.uploadSourceMap(compilation, chunk) 411 | 412 | expect(this.info.calledWith('Uploaded vendor.5190.js.map to Honeybadger API')).to.eq(true) 413 | }) 414 | 415 | it('should return error message if failure response includes message', function () { 416 | nock(TEST_ENDPOINT) 417 | .post(SOURCEMAP_PATH) 418 | .reply( 419 | 422, 420 | JSON.stringify({ error: 'The "source_map" parameter is required' }) 421 | ) 422 | 423 | const { compilation, chunk } = this 424 | 425 | this.plugin.uploadSourceMap(compilation, chunk).catch((err) => { 426 | expect(err).to.deep.include({ 427 | message: 'failed to upload vendor.5190.js.map to Honeybadger API: The "source_map" parameter is required' 428 | }) 429 | }) 430 | }) 431 | 432 | it('should handle error response with empty body', function () { 433 | nock(TEST_ENDPOINT) 434 | .post(SOURCEMAP_PATH) 435 | .reply(422, null) 436 | 437 | const { compilation, chunk } = this 438 | 439 | this.plugin.uploadSourceMap(compilation, chunk).catch((err) => { 440 | expect(err.message).to.match(/failed to upload vendor\.5190.js\.map to Honeybadger API: [\w\s]+/) 441 | }) 442 | }) 443 | 444 | it('should handle HTTP request error', function () { 445 | nock(TEST_ENDPOINT) 446 | .post(SOURCEMAP_PATH) 447 | .replyWithError('something awful happened') 448 | 449 | const { compilation, chunk } = this 450 | 451 | this.plugin.uploadSourceMap(compilation, chunk).catch((err) => { 452 | expect(err).to.deep.include({ 453 | message: 'failed to upload vendor.5190.js.map to Honeybadger API: something awful happened' 454 | }) 455 | }) 456 | }) 457 | 458 | it('should make a request to a configured endpoint', async function () { 459 | const endpoint = 'https://my-special-endpoint' 460 | const plugin = new HoneybadgerSourceMapPlugin({ ...this.options, endpoint: `${endpoint}${SOURCEMAP_PATH}` }) 461 | nock(endpoint) 462 | .post(SOURCEMAP_PATH) 463 | .reply(201, JSON.stringify({ status: 'OK' })) 464 | 465 | const { compilation, chunk } = this 466 | 467 | await plugin.uploadSourceMap(compilation, chunk) 468 | expect(this.info.calledWith('Uploaded vendor.5190.js.map to Honeybadger API')).to.eq(true) 469 | }) 470 | }) 471 | 472 | describe('sendDeployNotification', function () { 473 | beforeEach(function () { 474 | this.info = sinon.stub(console, 'info') 475 | }) 476 | 477 | afterEach(function () { 478 | sinon.restore() 479 | }) 480 | 481 | it('should send a deploy notification if all keys are present', async function () { 482 | const options = { 483 | apiKey: 'abcd1234', 484 | assetsUrl: 'https://cdn.example.com/assets', 485 | deploy: { 486 | environment: 'production', 487 | repository: 'https://cdn.example.com', 488 | localUsername: 'bugs' 489 | } 490 | } 491 | const plugin = new HoneybadgerSourceMapPlugin({ ...options }) 492 | nock(TEST_ENDPOINT) 493 | .post(DEPLOY_PATH) 494 | .reply(201, JSON.stringify({ status: 'OK' })) 495 | 496 | await plugin.sendDeployNotification() 497 | expect(this.info.calledWith('Successfully sent deploy notification to Honeybadger API.')).to.eq(true) 498 | }) 499 | 500 | it('should send a deploy notification with defaults if deploy is true', async function () { 501 | const options = { 502 | apiKey: 'abcd1234', 503 | assetsUrl: 'https://cdn.example.com/assets', 504 | deploy: true, 505 | revision: 'o8y787g26574t4' 506 | } 507 | const plugin = new HoneybadgerSourceMapPlugin({ ...options }) 508 | 509 | const scope = nock(TEST_ENDPOINT) 510 | .post(DEPLOY_PATH, { deploy: { revision: options.revision } }) 511 | .reply(201, JSON.stringify({ status: 'OK' })) 512 | 513 | await plugin.sendDeployNotification() 514 | expect(scope.isDone()).to.eq(true) 515 | }) 516 | }) 517 | }) 518 | --------------------------------------------------------------------------------