fix tests, add CI, fix on mac, extend docs (#25)

* tests: fix float problems and PAT problem

* extend CONTRIBUTING.md

* extend build documentation, add GH action, fix build on MacOS

* fix build on Vercel

* update .gitignore and CONTRIBUTING.md

* fix all tests

* add backend tests to CI

* make e2e tests work

* add debug log to e2e test

* ignore copied tests in CI
This commit is contained in:
martin-mfg
2026-01-19 20:52:26 +01:00
committed by GitHub
parent d08730aafd
commit a28a417e88
16 changed files with 209 additions and 117 deletions
+8 -5
View File
@@ -2,12 +2,15 @@
## Local Development
To run and test GitHub-Stats-Extended, you need to follow a few simple steps:
To set up the project GitHub-Stats-Extended locally, run the following commands:
1. create [your own deployment](../docs/deploy.md)
2. optional: add an SQL database; by using e.g. the ["Nile" integration](https://vercel.com/marketplace/nile) or by manually setting the environment variable `POSTGRES_URL`
3. optional: [create your own OAuth App](https://github.com/settings/developers) and set environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly
4. optional: in addition to the Vercel project based on the `backend` folder, create a second project based on the `frontend/frontend` folder. No environment variables needed.
```bash
./vercel-preparation.sh
(cd ./backend/ && npm install)
(cd ./frontend/frontend/ && yarn install && yarn build-trends)
```
The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md).
## Themes Contribution
+37
View File
@@ -0,0 +1,37 @@
name: Basic Build
on:
workflow_dispatch: # Allows you to run this manually from the Actions tab
push:
pull_request:
jobs:
execute:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: latest
- name: Install Yarn
run: npm install -g yarn
- name: Checkout code
uses: actions/checkout@v6
- name: Install and Build
run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
(cd ./backend/ && npm install)
(cd ./frontend/frontend/ && yarn install && yarn build-trends)
- name: Run Backend Tests
run: |
(cd ./backend/ && npm test)
+1
View File
@@ -5,6 +5,7 @@ backend/*.lock
backend/coverage
backend/benchmarks
backend/vercel_token
backend-copy
frontend/frontend/.env
frontend/frontend/src/backend
+11 -2
View File
@@ -4,10 +4,19 @@ export default {
testEnvironment: "jsdom",
coverageProvider: "v8",
setupFiles: ["<rootDir>/tests/setup.jest.js"],
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
modulePathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
testPathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/tests/e2e/",
"<rootDir>/.vercel/",
],
modulePathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/tests/e2e/",
"<rootDir>/.vercel/",
],
coveragePathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/tests/E2E/",
"<rootDir>/.vercel/",
],
};
+18 -48
View File
@@ -42,7 +42,7 @@
"prettier": "^3.7.3"
},
"engines": {
"node": ">=22"
"node": "24.x"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -3422,18 +3422,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/eslint/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -6331,6 +6319,17 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/pg": {
"version": "8.16.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz",
@@ -6429,17 +6428,6 @@
"node": ">= 10.x"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6491,12 +6479,6 @@
"node": ">=8"
}
},
"node_modules/platform": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"dev": true
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -10403,12 +10385,6 @@
"optionator": "^0.9.3"
},
"dependencies": {
"@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true
},
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -12555,6 +12531,12 @@
}
}
},
"path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"dev": true
},
"pg": {
"version": "8.16.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz",
@@ -12622,12 +12604,6 @@
}
}
},
"path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"dev": true
},
"picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12661,12 +12637,6 @@
"find-up": "^4.0.0"
}
},
"platform": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"dev": true
},
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+2 -3
View File
@@ -36,14 +36,13 @@ const retryer = async (fetcher, username, variables) => {
);
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
}
const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs.length;
if (!RETRIES) {
if (!PATs.length) {
throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS);
}
const startPAT = getRandomInt(PATs.length);
for (let retries = 0; retries < RETRIES; retries++) {
for (let retries = 0; retries < PATs.length; retries++) {
const currentPAT = PATs[(startPAT + retries) % PATs.length];
try {
+14
View File
@@ -131,3 +131,17 @@ export const runAndLogStats = async (
return stats;
};
export function approxNumber(expected, precision = 10) {
return {
asymmetricMatch(actual) {
if (typeof actual !== "number" || typeof expected !== "number")
return false;
const epsilon = Math.pow(10, -precision);
return Math.abs(actual - expected) < epsilon;
},
toAsymmetricMatcher() {
return `${expected} (precision ${precision})`;
},
};
}
+19 -6
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import { calculateRank } from "../src/calculateRank.js";
import { approxNumber } from "./bench/utils.js";
describe("Test calculateRank", () => {
it("new user gets C rank", () => {
@@ -30,7 +31,10 @@ describe("Test calculateRank", () => {
stars: 25,
followers: 5,
}),
).toStrictEqual({ level: "B-", percentile: 65.02918514848255 });
).toStrictEqual({
level: "B-",
percentile: approxNumber(65.02918514848255),
});
});
it("median user gets B+ rank", () => {
@@ -45,7 +49,7 @@ describe("Test calculateRank", () => {
stars: 50,
followers: 10,
}),
).toStrictEqual({ level: "B+", percentile: 46.09375 });
).toStrictEqual({ level: "B+", percentile: approxNumber(46.09375) });
});
it("average user gets B+ rank (include_all_commits)", () => {
@@ -60,7 +64,7 @@ describe("Test calculateRank", () => {
stars: 50,
followers: 10,
}),
).toStrictEqual({ level: "B+", percentile: 46.09375 });
).toStrictEqual({ level: "B+", percentile: approxNumber(46.09375) });
});
it("advanced user gets A rank", () => {
@@ -75,7 +79,10 @@ describe("Test calculateRank", () => {
stars: 200,
followers: 40,
}),
).toStrictEqual({ level: "A", percentile: 20.841471354166664 });
).toStrictEqual({
level: "A",
percentile: approxNumber(20.841471354166664),
});
});
it("expert user gets A+ rank", () => {
@@ -90,7 +97,10 @@ describe("Test calculateRank", () => {
stars: 800,
followers: 160,
}),
).toStrictEqual({ level: "A+", percentile: 5.575988339442828 });
).toStrictEqual({
level: "A+",
percentile: approxNumber(5.575988339442828),
});
});
it("sindresorhus gets S rank", () => {
@@ -105,6 +115,9 @@ describe("Test calculateRank", () => {
stars: 600000,
followers: 50000,
}),
).toStrictEqual({ level: "S", percentile: 0.4578556547153667 });
).toStrictEqual({
level: "S",
percentile: approxNumber(0.4578556547153667),
});
});
});
+1 -2
View File
@@ -105,11 +105,10 @@ const GIST_DATA = {
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
describe("Fetch Cards", () => {
let VERCEL_PREVIEW_URL;
let VERCEL_PREVIEW_URL = "https://github-stats-extended.vercel.app";
beforeAll(() => {
process.env.NODE_ENV = "development";
VERCEL_PREVIEW_URL = process.env.VERCEL_PREVIEW_URL;
});
test("retrieve stats card", async () => {
+17
View File
@@ -1,3 +1,10 @@
/** @jest-environment node */
/*
Jest must use node for these tests because jsdom doesn't support the structuredClone call in stats.js.
https://jestjs.io/docs/configuration#testenvironment-string
https://github.com/jsdom/jsdom/issues/3363
*/
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
@@ -519,6 +526,11 @@ describe("Test fetchStats", () => {
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
rank,
});
});
@@ -552,6 +564,11 @@ describe("Test fetchStats", () => {
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
rank,
});
});
+3 -2
View File
@@ -3,6 +3,7 @@ import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { approxNumber } from "./bench/utils.js";
const mock = new MockAdapter(axios);
@@ -70,13 +71,13 @@ describe("FetchTopLanguages", () => {
color: "#0f0",
count: 2,
name: "HTML",
size: 20.000000000000004,
size: approxNumber(20.0),
},
javascript: {
color: "#0ff",
count: 2,
name: "javascript",
size: 20.000000000000004,
size: approxNumber(20.0),
},
});
});
+57 -41
View File
@@ -22,6 +22,7 @@ import {
} from "../src/cards/top-languages.js";
import { themes } from "../themes/index.js";
import { approxNumber } from "./bench/utils.js";
const langs = {
HTML: {
@@ -120,89 +121,104 @@ describe("Test renderTopLanguages helper functions", () => {
});
it("degreesToRadians", () => {
expect(degreesToRadians(0)).toBe(0);
expect(degreesToRadians(90)).toBe(Math.PI / 2);
expect(degreesToRadians(180)).toBe(Math.PI);
expect(degreesToRadians(270)).toBe((3 * Math.PI) / 2);
expect(degreesToRadians(360)).toBe(2 * Math.PI);
expect(degreesToRadians(0)).toBeCloseTo(0);
expect(degreesToRadians(90)).toBeCloseTo(Math.PI / 2);
expect(degreesToRadians(180)).toBeCloseTo(Math.PI);
expect(degreesToRadians(270)).toBeCloseTo((3 * Math.PI) / 2);
expect(degreesToRadians(360)).toBeCloseTo(2 * Math.PI);
});
it("radiansToDegrees", () => {
expect(radiansToDegrees(0)).toBe(0);
expect(radiansToDegrees(Math.PI / 2)).toBe(90);
expect(radiansToDegrees(Math.PI)).toBe(180);
expect(radiansToDegrees((3 * Math.PI) / 2)).toBe(270);
expect(radiansToDegrees(2 * Math.PI)).toBe(360);
expect(radiansToDegrees(0)).toBeCloseTo(0);
expect(radiansToDegrees(Math.PI / 2)).toBeCloseTo(90);
expect(radiansToDegrees(Math.PI)).toBeCloseTo(180);
expect(radiansToDegrees((3 * Math.PI) / 2)).toBeCloseTo(270);
expect(radiansToDegrees(2 * Math.PI)).toBeCloseTo(360);
});
it("polarToCartesian", () => {
expect(polarToCartesian(100, 100, 60, 0)).toStrictEqual({ x: 160, y: 100 });
expect(polarToCartesian(100, 100, 60, 0)).toStrictEqual({
x: approxNumber(160),
y: approxNumber(100),
});
expect(polarToCartesian(100, 100, 60, 45)).toStrictEqual({
x: 142.42640687119285,
y: 142.42640687119285,
x: approxNumber(142.42640687119285),
y: approxNumber(142.42640687119285),
});
expect(polarToCartesian(100, 100, 60, 90)).toStrictEqual({
x: 100,
y: 160,
x: approxNumber(100),
y: approxNumber(160),
});
expect(polarToCartesian(100, 100, 60, 135)).toStrictEqual({
x: 57.573593128807154,
y: 142.42640687119285,
x: approxNumber(57.573593128807154),
y: approxNumber(142.42640687119285),
});
expect(polarToCartesian(100, 100, 60, 180)).toStrictEqual({
x: 40,
y: 100.00000000000001,
x: approxNumber(40),
y: approxNumber(100),
});
expect(polarToCartesian(100, 100, 60, 225)).toStrictEqual({
x: 57.57359312880714,
y: 57.573593128807154,
x: approxNumber(57.57359312880714),
y: approxNumber(57.573593128807154),
});
expect(polarToCartesian(100, 100, 60, 270)).toStrictEqual({
x: 99.99999999999999,
y: 40,
x: approxNumber(100),
y: approxNumber(40),
});
expect(polarToCartesian(100, 100, 60, 315)).toStrictEqual({
x: 142.42640687119285,
y: 57.57359312880714,
x: approxNumber(142.42640687119285),
y: approxNumber(57.57359312880714),
});
expect(polarToCartesian(100, 100, 60, 360)).toStrictEqual({
x: 160,
y: 99.99999999999999,
x: approxNumber(160),
y: approxNumber(100),
});
});
it("cartesianToPolar", () => {
expect(cartesianToPolar(100, 100, 160, 100)).toStrictEqual({
radius: 60,
angleInDegrees: 0,
radius: approxNumber(60),
angleInDegrees: approxNumber(0),
});
expect(
cartesianToPolar(100, 100, 142.42640687119285, 142.42640687119285),
).toStrictEqual({ radius: 60.00000000000001, angleInDegrees: 45 });
).toStrictEqual({
radius: approxNumber(60),
angleInDegrees: approxNumber(45),
});
expect(cartesianToPolar(100, 100, 100, 160)).toStrictEqual({
radius: 60,
angleInDegrees: 90,
radius: approxNumber(60),
angleInDegrees: approxNumber(90),
});
expect(
cartesianToPolar(100, 100, 57.573593128807154, 142.42640687119285),
).toStrictEqual({ radius: 60, angleInDegrees: 135 });
).toStrictEqual({
radius: approxNumber(60),
angleInDegrees: approxNumber(135),
});
expect(cartesianToPolar(100, 100, 40, 100.00000000000001)).toStrictEqual({
radius: 60,
angleInDegrees: 180,
radius: approxNumber(60),
angleInDegrees: approxNumber(180),
});
expect(
cartesianToPolar(100, 100, 57.57359312880714, 57.573593128807154),
).toStrictEqual({ radius: 60, angleInDegrees: 225 });
).toStrictEqual({
radius: approxNumber(60),
angleInDegrees: approxNumber(225),
});
expect(cartesianToPolar(100, 100, 99.99999999999999, 40)).toStrictEqual({
radius: 60,
angleInDegrees: 270,
radius: approxNumber(60),
angleInDegrees: approxNumber(270),
});
expect(
cartesianToPolar(100, 100, 142.42640687119285, 57.57359312880714),
).toStrictEqual({ radius: 60.00000000000001, angleInDegrees: 315 });
).toStrictEqual({
radius: approxNumber(60),
angleInDegrees: approxNumber(315),
});
expect(cartesianToPolar(100, 100, 160, 99.99999999999999)).toStrictEqual({
radius: 60,
angleInDegrees: 360,
radius: approxNumber(60),
angleInDegrees: approxNumber(360),
});
});
+2 -2
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, jest } from "@jest/globals";
import "@testing-library/jest-dom";
import { RETRIES, retryer } from "../src/common/retryer.js";
import { retryer } from "../src/common/retryer.js";
import { logger } from "../src/common/log.js";
const fetcher = jest.fn((variables, token) => {
@@ -75,7 +75,7 @@ describe("Test Retryer", () => {
try {
await retryer(fetcherFail, {});
} catch (err) {
expect(fetcherFail).toHaveBeenCalledTimes(RETRIES + 1);
expect(fetcherFail).toHaveBeenCalledTimes(2);
// @ts-ignore
expect(err.message).toBe("Downtime due to GitHub API rate limiting");
}
+2
View File
@@ -3,3 +3,5 @@
import { TextEncoder, TextDecoder } from "util";
Object.assign(global, { TextDecoder, TextEncoder });
process.env.PAT_1 = "dummyPAT1";
process.env.PAT_2 = "dummyPAT2";
+3
View File
@@ -62,6 +62,9 @@ Click on the deploy button to get started!
![](https://files.catbox.moe/0yclio.png)
11. As `Root directory` select the `backend` folder.
12. Click deploy, and you're good to go. See your domains to use the API!
13. optional: add an SQL database; by using e.g. the ["Nile" integration](https://vercel.com/marketplace/nile) or by manually setting the environment variable `POSTGRES_URL`
14. optional: [create your own OAuth App](https://github.com/settings/developers) and set environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly
15. optional: in addition to the Vercel project based on the `backend` folder, create a second project based on the `frontend/frontend` folder. No environment variables needed.
</details>
+14 -6
View File
@@ -1,7 +1,15 @@
# move into the folder of this script
cd "$(dirname "$0")" || exit 1
#!/bin/bash
mkdir --parents backend/.vercel/output/functions/api.func/
cp --recursive backend/* backend/.vercel/output/functions/api.func/
cp --recursive backend/.vercel/output/functions/api.func/_dot_vercel_copy/output/ backend/.vercel/
cp --recursive backend frontend/frontend/src/backend/
# defensive error handling
set -euo pipefail
# move into the folder of this script
cd "$(dirname "$0")"
mkdir -p backend/.vercel/output/functions/api.func/
# copying `backend` to `backend/.vercel/...` directly may cause problems
cp -R backend/. backend-copy/
# `shopt` includes dot-files in the `mv` operation
(shopt -s dotglob && mv backend-copy/* backend/.vercel/output/functions/api.func/)
cp -R backend/.vercel/output/functions/api.func/_dot_vercel_copy/output backend/.vercel/
cp -R backend frontend/frontend/src/backend/