4a - Merging reports

Another core feature of Codecov is our processing and merging of coverage reports. That means we take in any number of reports and aggregate them together, so you can see your coverage data in one view. Let’s see what that looks like.

Write a frontend

Pull the latest from main and create a new branch step4

git checkout main
git pull
git checkout -b 'step4'

Next, we are going to build out a frontend for our calculator app. We are going to be adding a web directory with a few files. Create the files by running these commands from the root directory:

mkdir -p web/static/css
mkdir -p web/static/js
touch web/.babelrc
touch web/index.html
touch web/package.json
touch web/server.js
touch web/static/css/calculator.css
touch web/static/js/calculator.js
touch web/static/js/calculatorView.js

Copy and paste the contents below into each file:

{
  "env": {
    "test": {
      "plugins": ["@babel/plugin-transform-modules-commonjs"]
    }
  }
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Codecov Server-side Calculator App</title>
    <link rel="stylesheet" href="/static/css/calculator.css">
</head>
<body>
    <div class="calculator">
        <div class="output">
            <div data-previous-operand class="previous-operand">123 +</div>
            <div data-current-operand class="current-operand">456</div>
        </div>
        <button class="span-3 disabled"></button>
        <button data-all-clear>C</button>
        <button data-number>7</button>
        <button data-number>8</button>
        <button data-number>9</button>
        <button data-operation>÷</button>
        <button data-number>4</button>
        <button data-number>5</button>
        <button data-number>6</button>
        <button data-operation>*</button>
        <button data-number>1</button>
        <button data-number>2</button>
        <button data-number>3</button>
        <button data-operation>-</button>
        <button data-number>.</button>
        <button data-number>0</button>
        <button data-equals>=</button>
        <button data-operation>+</button>
    </div>
    <script type="module" src="/static/js/calculatorView.js"></script>
</body>
</html>
{
  "name": "web",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "test": "jest --collectCoverage",
    "build": "babel --plugins @babel/plugin-transform-modules-commonjs script.js"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "axios": "^0.25.0",
    "express": "^4.17.2"
  },
  "devDependencies": {
    "@babel/plugin-transform-modules-commonjs": "^7.16.8",
    "jest": "^27.4.7"
  }
}
const axios = require('axios')
const express = require("express");
const path = require("path");

const app = express();

const backendHost = 'http://localhost';
const backendPort = '8080';

app.use(express.json());
app.use("/static", express.static(path.resolve(__dirname, "static")));

app.post("/api/:operation", (req, res) => {
  axios.post(
    backendHost + ':' +  backendPort + '/api/' + req.params['operation'],
    req.body
  ).then(response => {
    res.json(response.data);
  }).catch(error => {
    console.log("Error: " + error);
  });
});

app.get("/", (req, res) => {
  res.sendFile(path.resolve("index.html"));
});
app.listen(process.env.PORT || 3000, () => console.log("Server running..."));

In web/static, copy and paste the contents below into each file:

*, *::before, *::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 0;
  background-color: #ccc;
}

.calculator {
  align-content: center;
  display: grid;
  grid-template-columns: repeat(4, 100px);
  grid-template-rows: minmax(120px, auto) repeat(5, 100px);
  justify-content: center;
  min-height: 100vh;
}

.calculator > button {
  background-color: rbga(255, 255, 255, 0.75);
  border: 1px solid #FFFFFF;
  cursor: pointer;
  font-size: 32px;
  outline: none;
}

.calculator > button:hover {
  background-color: #aaaaaa;
}

.span-3 {
  grid-column: span 3;
}

.disabled:hover {
  background-color: rbga(255, 255, 255, 0.75);
  cursor: not-allowed;
}

.output{
  align-items: flex-end;
  background-color: rgba(0, 0, 0, 0.75);
  display: flex;
  flex-direction: column;
  grid-column: 1 / -1;
  justify-content: space-around;
  padding: 10px;
  word-break: break-all;
  word-wrap: break-word;
}

.output .previous-operand{
  color: rgba(255,255, 255, 0.75);
}

.output .current-operand{
  color: white;
  font-size: 42px;
}
export class Calculator {
  constructor(previousOperandTextElement, currentOperandTextElement) {
    this.previousOperandTextElement = previousOperandTextElement
    this.currentOperandTextElement = currentOperandTextElement
    this.clear()
  }

  clear() {
    this.currentOperand = ''
    this.previousOperand = ''
    this.operation = undefined
    this.validOperand = true
  }

  delete() {
    this.currentOperand = this.currentOperand.toString().slice(0, -1)
  }

  appendNumber(number) {
    if (!this.validOperand) return
    if (number === '.' && this.currentOperand.includes('.')) return
    this.currentOperand = this.currentOperand.toString() + number.toString()
  }

  chooseOperation(operation) {
    if (!this.validOperand) return
    if (this.currentOperand === '') return
    if (this.previousOperand !== '') {
      this.compute()
    }
    this.operation = operation
    this.previousOperand = this.currentOperand
    this.currentOperand = ''
  }

  compute() {
    if (!this.validOperand) {
      return;
    }

    const prev = parseFloat(this.previousOperand)
    const current = parseFloat(this.currentOperand)
    if (isNaN(prev) || isNaN(current)) return

    let operation
    switch (this.operation) {
      case '+':
        operation = 'add'
        break
      case '-':
        operation = 'subtract'
        break
      case '*':
        operation = 'multiply'
        break
      case '÷':
        operation = 'divide'
        break
      default:
        return
    }
    this.callApi(operation)
  }

  async callApi(operation) {
    const response = await fetch("/api/" + operation, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        x: this.previousOperand,
        y: this.currentOperand
      })
    })
    if (!response.ok) {
      throw new Error("Error: " + response.status);
    }
    this.currentOperand = await response.json();
    this.operation = undefined;
    this.previousOperand = '';
    this.updateDisplay();
  }

  getDisplayNumber(number) {
    const stringNumber = number.toString()
    const integerDigits = parseFloat(stringNumber.split('.')[0])
    const decimalDigits = stringNumber.split('.')[1]
    let integerDisplay
    if (isNaN(integerDigits)) {
      integerDisplay = stringNumber;
      if (stringNumber.length > 0) {
        this.validOperand = false;
      }
    } else {
      integerDisplay = integerDigits.toLocaleString('en', { maximumFractionDigits: 0 })
    }
    if (decimalDigits != null) {
      return `${integerDisplay}.${decimalDigits}`
    } else {
      return integerDisplay
    }
  }

  updateDisplay() {
    this.currentOperandTextElement.innerText =
      this.getDisplayNumber(this.currentOperand)
    if (this.operation != null) {
      this.previousOperandTextElement.innerText =
        `${this.getDisplayNumber(this.previousOperand)} ${this.operation}`
    } else {
      this.previousOperandTextElement.innerText = ''
    }
  }
}
import { Calculator } from './calculator.js';

const numberButtons = document.querySelectorAll('[data-number]')
const operationButtons = document.querySelectorAll('[data-operation]')
const equalsButton = document.querySelector('[data-equals]')
const allClearButton = document.querySelector('[data-all-clear]')
const previousOperandTextElement = document.querySelector('[data-previous-operand]')
const currentOperandTextElement = document.querySelector('[data-current-operand]')

const calculator = new Calculator(previousOperandTextElement, currentOperandTextElement)

numberButtons.forEach(button => {
  button.addEventListener('click', () => {
    calculator.appendNumber(button.innerText)
    calculator.updateDisplay()
  })
})

operationButtons.forEach(button => {
  button.addEventListener('click', () => {
    calculator.chooseOperation(button.innerText)
    calculator.updateDisplay()
  })
})

equalsButton.addEventListener('click', button => {
  calculator.compute()
})

allClearButton.addEventListener('click', button => {
  calculator.clear()
  calculator.updateDisplay()
})

document.addEventListener('keydown', function (event) {
  let patternForNumbers = /[0-9]/g;
  let patternForOperators = /[+\-*\/]/g
  if (event.key.match(patternForNumbers)) {
    event.preventDefault();
    calculator.appendNumber(event.key)
    calculator.updateDisplay()
  }
  if (event.key === '.') {
    event.preventDefault();
    calculator.appendNumber(event.key)
    calculator.updateDisplay()
  }
  if (event.key.match(patternForOperators)) {
    event.preventDefault();
    calculator.chooseOperation(event.key)
    calculator.updateDisplay()
  }
  if (event.key === 'Enter' || event.key === '=') {
    event.preventDefault();
    calculator.compute()
  }
});

We can test out our frontend by running

cd web
npm install
node server.js

Be sure to run the backend server as well - in a different terminal:

cd api
flask run --port 8080 --host=0.0.0.0

Go to http://localhost:3000 to view the calculator

Feel free to play around with the interface.

Writing frontend tests

Let’s add tests to our calculator.js file

touch web/static/js/calculator.test.js
const calc = require('./calculator');

describe('Calculator test suite', () => {
  test('calculator clears', () => {
    const calculator = new calc.Calculator();
    calculator.clear();

    expect(calculator.currentOperand).toBe('');
    expect(calculator.previousOperand).toBe('');
    expect(calculator.operation).toBe(undefined);
    expect(calculator.validOperand).toBe(true);
  })

  test('calculator can input numbers', () => {
    const calculator = new calc.Calculator();
    calculator.appendNumber(1);
    expect(calculator.currentOperand).toBe('1');

    calculator.appendNumber(2);
    expect(calculator.currentOperand).toBe('12');
  })

  test('calculator can delete', () => {
    const calculator = new calc.Calculator();
    calculator.appendNumber(1);
    expect(calculator.currentOperand).toBe('1');

    calculator.appendNumber(2);
    expect(calculator.currentOperand).toBe('12');

    calculator.delete();
    expect(calculator.currentOperand).toBe('1');
  })
})

Run the tests

We can run these tests from the web directory with the following command

npm run test

and we should see the following output

PASS  static/js/calculator.test.js
  Calculator test suite
    ✓ calculator clears (3 ms)
    ✓ calculator can input numbers
    ✓ calculator can deletes

---------------|---------|----------|---------|---------|-------------------
File           | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files      |   18.03 |     9.09 |   44.44 |   19.64 |
 calculator.js |   18.03 |     9.09 |   44.44 |   19.64 | 26-113
---------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.268 s

Add a frontend CI workflow

Let’s add a workflow to run our frontend tests. This workflow does a similar set of steps as our API workflow.

GitLab CI

Go back to the .gitlab-ci.yml file at the root of your project, add this below the code we added previously for api

frontend:
  image: node:latest
  script:
    - cd web && npm install
    - npm run test
    - curl -Os https://uploader.codecov.io/latest/linux/codecov
    - chmod +x codecov
    - ./codecov -t $CODECOV_TOKEN

If we commit this code,

cd ..  # back to the root directory
git add .
git commit -m 'step4: add frontend and tests'
git push origin step4

and open a merge request, we see that our code coverage has greatly decreased! With the addition of the new, but poorly tested frontend, our status checks fail.

We also see that in the Codecov comment.

We see this type of situation happen all the time. For example, some projects in a monorepository might be starting code coverage for the first time, while other parts have been using Codecov for months or years. Adding code coverage like this can greatly decrease the percentage for the whole project.

However, what you may not have noticed is that Codecov automatically merges coverage reports together, regardless of the language or CI workflow. This happens under the hood and requires no additional configuration from you.

In the next section, we'll show you how to use Codecov Flags to help group coverage reports and to set different coverage standards for different parts of your project.