Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions .github/workflows/forked_run_tests.yml

This file was deleted.

36 changes: 0 additions & 36 deletions .github/workflows/run_tests.yml

This file was deleted.

91 changes: 91 additions & 0 deletions .github/workflows/sonarqube.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: SonarQube Analysis

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Create NYC folder
run: mkdir -p .nyc_output

- name: Install dependencies
run: npm install --legacy-peer-deps

- name: Run unit tests with coverage
run: npm test
continue-on-error: true

- name: Generate LCOV and HTML coverage reports
run: npx nyc report --reporter=lcov --reporter=html --report-dir=coverage
continue-on-error: true

- name: Generate metrics JSON
run: node scripts/generate-metrics.js

- name: Post test inventory to summary
if: always()
run: |
if [ -f metrics.json ]; then
node -e "
const m = require('./metrics.json');
const cov = m.coverage?.lines?.pct != null ? m.coverage.lines.pct + '%' : 'N/A';
const brCov = m.coverage?.branches?.pct != null ? m.coverage.branches.pct + '%' : 'N/A';
const fnCov = m.coverage?.functions?.pct != null ? m.coverage.functions.pct + '%' : 'N/A';
let md = '## 📊 Test Inventory & Coverage — ' + m.repository + '\n\n';
md += '| Metric | Value |\n|---|---|\n';
md += '| **Repository** | ' + m.repository + ' |\n';
md += '| **Version** | ' + m.version + ' |\n';
md += '| **Unit Test Files** | ' + m.tests.unitFiles + ' |\n';
md += '| **Integration Test Files** | ' + m.tests.integrationFiles + ' |\n';
md += '| **Total Test Files** | ' + m.tests.totalFiles + ' |\n';
md += '| **Line Coverage** | ' + cov + ' |\n';
md += '| **Branch Coverage** | ' + brCov + ' |\n';
md += '| **Function Coverage** | ' + fnCov + ' |\n\n';
if (m.tests.unitTestFiles.length > 0) {
md += '### Unit Tests\n';
m.tests.unitTestFiles.forEach(f => md += '- \`' + f + '\`\n');
md += '\n';
}
if (m.tests.integrationTestFiles.length > 0) {
md += '### Integration Tests\n';
m.tests.integrationTestFiles.forEach(f => md += '- \`' + f + '\`\n');
}
require('fs').appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
"
fi

- name: SonarQube Cloud Scan
uses: SonarSource/sonarcloud-github-action@v3
env:
SONAR_TOKEN: ${{ secrets.SONAR }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload metrics artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: test-metrics
path: metrics.json
if-no-files-found: ignore

- name: Upload HTML coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report-html
path: coverage/
if-no-files-found: ignore
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

140 changes: 140 additions & 0 deletions scripts/generate-metrics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env node

/**
* Generates a JSON metrics file for the current repository.
* Designed to run during CI after tests + coverage have completed.
*
* Output: metrics.json in the repo root
*
* Metrics captured:
* - repository name and timestamp
* - test file counts (unit vs integration)
* - coverage summary parsed from LCOV
*/

const fs = require("fs");
const path = require("path");

const repoRoot = process.cwd();

function getPackageInfo() {
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
);
return {
name: pkg.name,
version: pkg.version,
repository: pkg.repository?.url || pkg.repository || "",
};
} catch {
return { name: path.basename(repoRoot), version: "unknown", repository: "" };
}
Comment on lines +30 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The catch block is empty. While there's a fallback mechanism, swallowing the error silently is not ideal. If package.json is unreadable or malformed, the script will proceed with default values without any indication of what went wrong. It's a good practice to log the error to stderr to aid in debugging.

  } catch (error) {
    console.error('Failed to read or parse package.json:', error);
    return { name: path.basename(repoRoot), version: "unknown", repository: "" };
  }

}

function findTestFiles(dir, pattern) {
const results = [];
if (!fs.existsSync(dir)) return results;

const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findTestFiles(fullPath, pattern));
} else if (pattern.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}

function classifyTests() {
const testDir = path.join(repoRoot, "test");
const allTestFiles = findTestFiles(testDir, /\.(test|spec)\.js$|^(?!setup)[a-zA-Z]+\.js$/);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The regular expression to find test files seems a bit fragile. The part ^(?!setup)[a-zA-Z]+\.js$ will only match files whose names consist exclusively of alphabetic characters (e.g., cards.js), and will fail to match test files with names containing numbers, hyphens, or other characters (e.g., my-test-1.js). This could lead to an inaccurate count of test files if naming conventions change in the future. Consider making the pattern less restrictive to allow for more characters in the filename.


const unit = [];
const integration = [];

for (const file of allTestFiles) {
const relative = path.relative(repoRoot, file);
if (relative.includes("setup") || relative.includes("mock") || relative.includes("fixture")) {
continue;
}
if (relative.includes("integration")) {
integration.push(relative);
} else {
unit.push(relative);
}
}

return { unit, integration };
}

function parseLcov() {
const lcovPaths = [
path.join(repoRoot, "coverage", "lcov.info"),
path.join(repoRoot, "coverage", "lcov-report", "lcov.info"),
];

let lcovContent = null;
for (const p of lcovPaths) {
if (fs.existsSync(p)) {
lcovContent = fs.readFileSync(p, "utf8");
break;
}
}

if (!lcovContent) {
return { lines: null, branches: null, functions: null, statements: null };
}

let linesHit = 0, linesTotal = 0;
let branchesHit = 0, branchesTotal = 0;
let functionsHit = 0, functionsTotal = 0;

for (const line of lcovContent.split("\n")) {
if (line.startsWith("LH:")) linesHit += parseInt(line.slice(3), 10);
if (line.startsWith("LF:")) linesTotal += parseInt(line.slice(3), 10);
if (line.startsWith("BRH:")) branchesHit += parseInt(line.slice(4), 10);
if (line.startsWith("BRF:")) branchesTotal += parseInt(line.slice(4), 10);
if (line.startsWith("FNH:")) functionsHit += parseInt(line.slice(4), 10);
if (line.startsWith("FNF:")) functionsTotal += parseInt(line.slice(4), 10);
}

const pct = (hit, total) =>
total > 0 ? Math.round((hit / total) * 10000) / 100 : null;

return {
lines: { hit: linesHit, total: linesTotal, pct: pct(linesHit, linesTotal) },
branches: { hit: branchesHit, total: branchesTotal, pct: pct(branchesHit, branchesTotal) },
functions: { hit: functionsHit, total: functionsTotal, pct: pct(functionsHit, functionsTotal) },
};
}

function main() {
const pkg = getPackageInfo();
const tests = classifyTests();
const coverage = parseLcov();

const metrics = {
repository: pkg.name,
version: pkg.version,
repositoryUrl: pkg.repository,
generatedAt: new Date().toISOString(),
tests: {
unitFiles: tests.unit.length,
integrationFiles: tests.integration.length,
totalFiles: tests.unit.length + tests.integration.length,
unitTestFiles: tests.unit,
integrationTestFiles: tests.integration,
},
coverage,
};

const outPath = path.join(repoRoot, "metrics.json");
fs.writeFileSync(outPath, JSON.stringify(metrics, null, 2));
console.log(`Metrics written to ${outPath}`);
console.log(JSON.stringify(metrics, null, 2));
}

main();
14 changes: 14 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
sonar.projectKey=vikita91_lob-node
sonar.organization=lob-qa

sonar.projectName=lob-node
sonar.projectVersion=7.1.0

sonar.sources=lib
sonar.tests=test
sonar.test.inclusions=test/**/*.js,test/integration/**/*.test.js
sonar.exclusions=test/**
Comment on lines +9 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are a couple of potential issues with the SonarQube configuration:

  1. sonar.exclusions=test/** on line 10 might prevent test files from being analyzed for coverage. Since you've already separated source and test files with sonar.sources=lib and sonar.tests=test, this exclusion is redundant and could be harmful. It's generally recommended to remove it and rely on the sonar.sources and sonar.tests properties for separation.
  2. The pattern test/integration/**/*.test.js in sonar.test.inclusions on line 9 is redundant, as it's already covered by test/**/*.js. You can simplify this to just sonar.test.inclusions=test/**/*.js.
sonar.test.inclusions=test/**/*.js


sonar.javascript.lcov.reportPaths=coverage/lcov.info

sonar.sourceEncoding=UTF-8
34 changes: 34 additions & 0 deletions test/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,40 @@ describe('cards', () => {
});
});

it('creates a card with merge_variables', (done) => {
mockLob()
.post('/v1/cards')
.reply(200, fixtures.CARD);

Lob.cards.create({
description: 'Test Card',
front: 'https://example.com/card.pdf',
back: 'https://example.com/card.pdf',
size: '2.125x3.375',
merge_variables: { name: 'Harry', company: 'Lob' }
}, (err, res) => {
expect(res.object).to.eql('card');
done();
});
});

it('creates a card with nested object params', (done) => {
mockLob()
.post('/v1/cards')
.reply(200, fixtures.CARD);

Lob.cards.create({
description: 'Test Card',
front: 'https://example.com/card.pdf',
back: 'https://example.com/card.pdf',
size: '2.125x3.375',
metadata: { key1: 'value1', key2: 'value2' }
}, (err, res) => {
expect(res.object).to.eql('card');
done();
});
});

it('errors with missing front', (done) => {
mockLob()
.post('/v1/cards')
Expand Down
Loading
Loading