Compare commits

..
12 Commits
Author SHA1 Message Date
Martin 5c4ea2a797 merge master: documentation updates (#127) 2026-03-07 20:52:27 +01:00
Martin 7d666e2510 merge master: fix dark mode colors in delete modal (#125) 2026-03-07 16:18:04 +01:00
Martin 96a18562f0 update release (#122)
Merging the latest `master` into `release` in order to create a new
release.
2026-03-07 14:36:04 +01:00
martin-mfg b64d601d47 Merge remote-tracking branch 'origin/master' into update-release 2026-03-07 14:34:01 +01:00
martin-mfg 26aec2a4af Merge remote-tracking branch 'origin/master' into release 2026-03-07 12:55:13 +01:00
martin-mfg ad23ab39ed Merge branch 'master' into release 2026-03-07 12:23:50 +01:00
martin-mfg d982bef3c5 Revert "fix problem on simultaneous login"
This reverts commit 23ba727b9a.
2026-01-08 18:57:49 +01:00
martin-mfg 2463f0900d Merge branch 'master' into release 2026-01-08 18:51:11 +01:00
martin-mfg 23ba727b9a fix problem on simultaneous login 2026-01-07 11:55:03 +01:00
martin-mfg 6c625fb76b Merge branch 'master' into release 2026-01-07 11:44:26 +01:00
martin-mfg 0aafbbbdb5 back to release rewrite 2026-01-06 20:21:14 +01:00
martin-mfg 8e6e0fac47 back to relase env vars 2026-01-06 20:00:49 +01:00
173 changed files with 6403 additions and 8615 deletions
+2 -2
View File
@@ -5,9 +5,9 @@
To set up the project GitHub-Stats-Extended locally, run the following commands:
```bash
./vercel-preparation.sh
pnpm install
pnpm run build:packages
pnpm run dev:frontend
pnpm --filter frontend run build
```
The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md).
@@ -14,11 +14,11 @@ runs:
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
uses: pnpm/action-setup@v4
- name: Setup Node.js (via input)
if: ${{ inputs.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: "pnpm"
@@ -26,7 +26,7 @@ runs:
- name: Setup Node.js (via .nvmrc)
if: ${{ !inputs.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "pnpm"
+9 -49
View File
@@ -4,8 +4,9 @@ updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 20
interval: cron
cronjob: every day at 5am
open-pull-requests-limit: 10
commit-message:
prefix: "build(deps)"
prefix-development: "build(deps-dev)"
@@ -14,56 +15,14 @@ updates:
ignore:
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
groups:
react:
patterns:
- "react"
- "react-dom"
- "react-*"
- "@types/react"
- "@types/react-dom"
redux:
patterns:
- "redux"
- "@reduxjs/*"
- "react-redux"
vite:
patterns:
- "vite"
- "@vitejs/*"
tailwind:
patterns:
- "tailwindcss"
- "@tailwindcss/*"
- "daisyui"
vitest:
patterns:
- "vitest"
- "@vitest/*"
eslint:
patterns:
- "eslint"
- "@eslint/*"
- "eslint-*"
- "typescript-eslint"
typescript:
patterns:
- "typescript"
- "@types/*"
playwright:
patterns:
- "@playwright/*"
axios:
patterns:
- "axios"
- "axios-*"
# Maintain dependencies for GitHub Actions
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 20
interval: cron
cronjob: every day at 5am
open-pull-requests-limit: 10
commit-message:
prefix: "ci(deps)"
prefix-development: "ci(deps-dev)"
@@ -74,8 +33,9 @@ updates:
- package-ecosystem: devcontainers
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 20
interval: cron
cronjob: every day at 5am
open-pull-requests-limit: 10
commit-message:
prefix: "build(deps)"
prefix-development: "build(deps-dev)"
+29 -35
View File
@@ -31,21 +31,26 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
- name: Run vercel-preparation.sh
run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
with:
node-version: ${{ matrix.node }}
- name: Build packages
run: pnpm run build:packages
- name: Build frontend
run: pnpm run build:frontend
run: pnpm --filter frontend run build
- name: Run tests
run: pnpm run test
- name: Run frontend tests
run: pnpm --filter frontend run test
- name: Run backend tests
run: pnpm --filter github-readme-stats run test
frontend-test-e2e:
name: Frontend E2E test
@@ -59,42 +64,21 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Build packages
run: pnpm run build:packages
- name: Run vercel-preparation.sh
run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run Playwright tests
run: pnpm --filter ./apps/frontend/ run test:e2e
backend-test-e2e:
name: Backend E2E test
runs-on: ubuntu-latest
permissions:
contents: read
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Build packages
run: pnpm run build:packages
- name: Run backend end-to-end tests
run: pnpm --filter ./apps/backend/ run test:e2e
run: pnpm --filter frontend run test:e2e
code-checks:
name: Code checks
@@ -106,7 +90,17 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
# Heads up!
#
# 1. Execution of this script is needed to resolve `.vercel` folder from `apps/frontend/src/components/Card/SVG.js`
# 2. This scripts removes `apps/backend/node_modules` breaking ESLints module resolution.
# Dependency installation must occur after running ./vercel-preparation.sh.
- name: Run vercel-preparation.sh
run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
+4 -4
View File
@@ -4,7 +4,7 @@ on:
branches:
- master
paths:
- "packages/core/src/themes/index.js"
- "apps/backend/themes/index.js"
workflow_dispatch:
permissions: {}
@@ -30,17 +30,17 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Generate readme
run: |
pnpm --filter ./packages/core/ run theme-readme-gen
pnpm --filter github-readme-stats run theme-readme-gen
- name: Create Pull Request if themes README has changed
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
uses: peter-evans/create-pull-request@v8
with:
commit-message: "feat(backend): update themes README"
branch: "update_themes_readme/patch"
-30
View File
@@ -1,30 +0,0 @@
name: Publish core package to npm
on:
workflow_dispatch:
release:
types: [released]
permissions: {}
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Verify core package
run: |
pnpm --filter @stats-organization/github-readme-stats-core run typecheck
pnpm test
pnpm build:packages
pnpm --filter @stats-organization/github-readme-stats-core pack --dry-run
- name: Publish core package to npm
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public --no-git-checks
+1 -5
View File
@@ -15,16 +15,12 @@ on:
jobs:
triggerRepeatRecent:
if: |
github.repository == 'anuraghazra/github-readme-stats' ||
github.repository == 'stats-organization/github-readme-stats' ||
github.repository == 'stats-organization/github-stats-extended'
name: Trigger server to update cached data.
runs-on: ubuntu-latest
steps:
- name: Make Request
id: myRequest
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
uses: fjogeleit/http-request-action@v2
with:
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
method: "POST"
+3 -3
View File
@@ -38,16 +38,16 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Run update-languages-json.js script
run: pnpm --filter ./packages/core/ run generate-langs-json
run: pnpm --filter github-readme-stats run generate-langs-json
- name: Create Pull Request if upstream language file is changed
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
uses: peter-evans/create-pull-request@v8
with:
commit-message: "feat(backend): update languages JSON"
branch: "update_langs/patch"
-18
View File
@@ -1,18 +0,0 @@
name: Update version tags
on:
push:
branches-ignore:
- "**"
tags:
- "v*.*.*"
permissions: {}
jobs:
update-semver:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
+13 -4
View File
@@ -1,8 +1,18 @@
node_modules
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS
.DS_Store
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Project
coverage
@@ -16,12 +26,11 @@ apps/backend/vercel_token
apps/backend-copy
apps/frontend/.env
apps/frontend/src/backend
apps/frontend/build
.turbo
build
build-ts
*.tsbuildinfo
tsconfig.tsbuildinfo
# IDE
.idea/
+2 -1
View File
@@ -1,3 +1,4 @@
pnpm lint-staged
pnpm run lint
pnpm test
# TODO enable
# npm test
+8 -19
View File
@@ -5,14 +5,13 @@
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a>
</div>
This project is the [extended, actively maintained successor](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
This project is an [extended version](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
# Table of Contents
- [Quick Start](#quick-start)
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
- [Card Types](#card-types)
- [Advanced Customization](#advanced-customization)
- [Run It Yourself](#run-it-yourself)
- [Self-Hosting](#self-hosting)
- [Acknowledgements](#acknowledgements)
- [Contributing](#contributing)
@@ -28,16 +27,6 @@ This project is the [extended, actively maintained successor](docs/fork.md) of [
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
# Migration from github-readme-stats
To migrate from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) you only need to change the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`:
```diff
- https://github-readme-stats.vercel.app/api?username=octocat&theme=radical
+ https://github-stats-extended.vercel.app/api?username=octocat&theme=radical
```
GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For more details see [Compatibility Notes](docs/fork.md#compatibility-notes).
# Card Types
- Show your GitHub statistics:
@@ -66,13 +55,13 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For
# Advanced Customization
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
# Run It Yourself
If you want to run GitHub-Stats-Extended on your own, there are two main deployment options: you can use [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) to generate cards in your own GitHub Actions workflow. Or you can self-host GitHub-Stats-Extended on Vercel.
See [Run It Yourself](docs/deploy.md) for detailed instructions.
# Acknowledgements
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of their functionality I added several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend I added to the project is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
# Self-Hosting
Since the GitHub API only allows a limited number of requests per hour, the public instance of GitHub-Stats-Extended at https://github-stats-extended.vercel.app/api could possibly hit the rate limiter. If you host your own instance you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
See [Deploy on your own](docs/deploy.md) for various deployment options.
# Contributing
Contributions are welcome!
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "GitHub Stats Extended Dev",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "24" }
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
},
"forwardPorts": [3000],
"portsAttributes": {
@@ -1,5 +1,5 @@
{
"runtime": "nodejs24.x",
"runtime": "nodejs22.x",
"handler": "router.js",
"launcherType": "Nodejs"
}
@@ -0,0 +1,76 @@
/* eslint-disable import-x/no-unresolved */
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as gist } from "./api-renamed/gist.js";
import { default as api } from "./api-renamed/index.js";
import { default as pin } from "./api-renamed/pin.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as topLangs } from "./api-renamed/top-langs.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { default as wakatime } from "./api-renamed/wakatime.js";
export default async (req, res) => {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
const url = new URL(req.url, "https://localhost");
req.query = Object.fromEntries(url.searchParams.entries());
switch (url.pathname) {
case "/api":
await api(req, res);
break;
case "/api/gist":
await gist(req, res);
break;
case "/api/pin":
await pin(req, res);
break;
case "/api/top-langs":
await topLangs(req, res);
break;
case "/api/wakatime":
await wakatime(req, res);
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
+1 -2
View File
@@ -1,5 +1,4 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { logger } from "../src/common/log.js";
import { authenticate } from "../src/users.js";
/**
+1 -2
View File
@@ -1,6 +1,5 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { deleteUser } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
/**
* @param {any} req The request.
+1 -1
View File
@@ -1,7 +1,7 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import axios from "axios";
import { deleteUser, getUserAccessByKey } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
export default async (req, res) => {
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
+129
View File
@@ -0,0 +1,129 @@
// @ts-check
import { renderGistCard } from "../src/cards/gist.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchGist } from "../src/fetchers/gist.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
id,
title_color,
icon_color,
text_color,
bg_color,
theme,
cache_seconds,
locale,
border_radius,
border_color,
show_owner,
hide_border,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id,
type: "gist",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const gistData = await fetchGist(id);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
renderGistCard(gistData, {
title_color,
icon_color,
text_color,
bg_color,
theme,
border_radius,
border_color,
locale: locale ? locale.toLowerCase() : null,
show_owner: parseBoolean(show_owner),
hide_border: parseBoolean(hide_border),
}),
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
@@ -1,18 +1,26 @@
// @ts-check
import { renderStatsCard } from "../cards/stats.js";
import { renderStatsCard } from "../src/cards/stats.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../common/error.js";
import { parseArray, parseBoolean } from "../common/ops.js";
import { renderError } from "../common/render.js";
import { fetchStats } from "../fetchers/stats.js";
import { isLocaleAvailable } from "../translations.js";
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchStats } from "../src/fetchers/stats.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (
{
export default async (req, res) => {
const {
username,
repo,
owner,
@@ -32,6 +40,7 @@ export default async (
text_bold,
bg_color,
theme,
cache_seconds,
exclude_repo,
custom_title,
locale,
@@ -43,13 +52,28 @@ export default async (
border_color,
rank_icon,
show,
},
pat = null,
) => {
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
@@ -60,7 +84,7 @@ export default async (
theme,
},
}),
};
);
}
const safePattern = /^[-\w/.,]+$/;
@@ -69,9 +93,8 @@ export default async (
(repo && !safePattern.test(repo)) ||
(owner && !safePattern.test(owner))
) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage:
"Username, repository or owner contains unsafe characters",
@@ -83,10 +106,11 @@ export default async (
theme,
},
}),
};
);
}
try {
await storeRequest(req);
const showStats = parseArray(show);
const repoOwner = parseArray(owner);
let repository = parseArray(repo);
@@ -111,12 +135,18 @@ export default async (
showStats.includes("issues_authored"),
showStats.includes("issues_commented"),
parseArray(role),
pat,
);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
return {
status: "success",
content: renderStatsCard(
setCacheHeaders(res, cacheSeconds);
return res.send(
renderStatsCard(
stats,
{
hide: parseArray(hide),
@@ -149,12 +179,12 @@ export default async (
repository,
repoOwner,
),
};
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return {
status: "error - temporary",
content: renderError({
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
@@ -166,11 +196,10 @@ export default async (
show_repo_link: !(err instanceof MissingParamError),
},
}),
};
);
}
return {
status: "error - temporary",
content: renderError({
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
@@ -180,6 +209,6 @@ export default async (
theme,
},
}),
};
);
}
};
@@ -1,18 +1,26 @@
// @ts-check
import { renderRepoCard } from "../cards/repo.js";
import { renderRepoCard } from "../src/cards/repo.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../common/error.js";
import { parseArray, parseBoolean } from "../common/ops.js";
import { renderError } from "../common/render.js";
import { fetchRepo } from "../fetchers/repo.js";
import { isLocaleAvailable } from "../translations.js";
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchRepo } from "../src/fetchers/repo.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (
{
export default async (req, res) => {
const {
username,
repo,
hide_border,
@@ -23,23 +31,39 @@ export default async (
card_width,
theme,
show_owner,
browser_rendering,
show,
show_icons,
number_format,
text_bold,
line_height,
cache_seconds,
locale,
border_radius,
border_color,
description_lines_count,
},
pat = null,
) => {
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
@@ -50,7 +74,7 @@ export default async (
theme,
},
}),
};
);
}
const safePattern = /^[-\w/.,]+$/;
@@ -58,9 +82,8 @@ export default async (
(username && !safePattern.test(username)) ||
(repo && !safePattern.test(repo))
) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Username or repository contains unsafe characters",
renderOptions: {
@@ -71,10 +94,11 @@ export default async (
theme,
},
}),
};
);
}
try {
await storeRequest(req);
const showStats = parseArray(show);
const repoData = await fetchRepo(
username,
@@ -84,12 +108,19 @@ export default async (
showStats.includes("prs_reviewed"),
showStats.includes("issues_authored"),
showStats.includes("issues_commented"),
pat,
);
return {
status: "success",
content: renderRepoCard(repoData, {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
renderRepoCard(repoData, {
hide_border: parseBoolean(hide_border),
title_color,
icon_color,
@@ -100,7 +131,6 @@ export default async (
border_color,
card_width_input: parseInt(card_width, 10),
show_owner: parseBoolean(show_owner),
browser_rendering: parseBoolean(browser_rendering),
show: showStats,
show_icons: parseBoolean(show_icons),
number_format,
@@ -110,12 +140,12 @@ export default async (
locale: locale ? locale.toLowerCase() : null,
description_lines_count,
}),
};
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return {
status: "error - temporary",
content: renderError({
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
@@ -127,11 +157,10 @@ export default async (
show_repo_link: !(err instanceof MissingParamError),
},
}),
};
);
}
return {
status: "error - temporary",
content: renderError({
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
@@ -141,6 +170,6 @@ export default async (
theme,
},
}),
};
);
}
};
+11 -13
View File
@@ -6,12 +6,10 @@
*
* @description This function is currently rate limited to 1 request per 3 minutes.
*/
import {
dateDiff,
getConfig,
logger,
request,
} from "@stats-organization/github-readme-stats-core";
import { request } from "../../src/common/http.js";
import { logger } from "../../src/common/log.js";
import { dateDiff } from "../../src/common/ops.js";
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
@@ -41,7 +39,7 @@ const uptimeFetcher = (variables, token) => {
};
const getAllPATs = () => {
return getConfig().pats;
return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
};
/**
@@ -63,7 +61,7 @@ const getPATInfo = async (fetcher, variables) => {
for (const pat of PATs) {
try {
const response = await fetcher(variables, pat.value);
const response = await fetcher(variables, process.env[pat]);
const errors = response.data.errors;
const hasErrors = Boolean(errors);
const errorType = errors?.[0]?.type;
@@ -73,7 +71,7 @@ const getPATInfo = async (fetcher, variables) => {
// Store PATs with errors.
if (hasErrors && errorType !== "RATE_LIMITED") {
details[pat.name] = {
details[pat] = {
status: "error",
error: {
type: errors[0].type,
@@ -84,13 +82,13 @@ const getPATInfo = async (fetcher, variables) => {
} else if (isRateLimited) {
const date1 = new Date();
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
details[pat.name] = {
details[pat] = {
status: "exhausted",
remaining: 0,
resetIn: dateDiff(date2, date1) + " minutes",
};
} else {
details[pat.name] = {
details[pat] = {
status: "valid",
remaining: response.data.data.rateLimit.remaining,
};
@@ -99,11 +97,11 @@ const getPATInfo = async (fetcher, variables) => {
// Store the PAT if it is expired.
const errorMessage = err.response?.data?.message?.toLowerCase();
if (errorMessage === "bad credentials") {
details[pat.name] = {
details[pat] = {
status: "expired",
};
} else if (errorMessage === "sorry. your account was suspended.") {
details[pat.name] = {
details[pat] = {
status: "suspended",
};
} else {
+4 -6
View File
@@ -7,11 +7,9 @@
* @description This function is currently rate limited to 1 request per 3 minutes.
*/
import {
logger,
request,
retryer,
} from "@stats-organization/github-readme-stats-core";
import { request } from "../../src/common/http.js";
import { logger } from "../../src/common/log.js";
import { default as retryer } from "../../src/common/retryer.js";
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
@@ -89,7 +87,7 @@ export default async (req, res) => {
try {
let PATsValid = true;
try {
await retryer(uptimeFetcher, {});
await retryer(uptimeFetcher, null, {});
} catch (err) {
// Resolve eslint no-unused-vars
err;
@@ -1,18 +1,26 @@
// @ts-check
import { renderTopLanguages } from "../cards/top-languages.js";
import { renderTopLanguages } from "../src/cards/top-languages.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../common/error.js";
import { parseArray, parseBoolean } from "../common/ops.js";
import { renderError } from "../common/render.js";
import { fetchTopLanguages } from "../fetchers/top-languages.js";
import { isLocaleAvailable } from "../translations.js";
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (
{
export default async (req, res) => {
const {
username,
hide,
hide_title,
@@ -23,6 +31,7 @@ export default async (
bg_color,
prog_bar_bg_color,
theme,
cache_seconds,
layout,
langs_count,
exclude_repo,
@@ -35,15 +44,29 @@ export default async (
role,
disable_animations,
hide_progress,
hide_values,
stats_format,
},
pat = null,
) => {
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Locale not found",
renderOptions: {
@@ -54,7 +77,7 @@ export default async (
theme,
},
}),
};
);
}
if (
@@ -62,9 +85,8 @@ export default async (
(typeof layout !== "string" ||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Incorrect layout input",
renderOptions: {
@@ -75,7 +97,7 @@ export default async (
theme,
},
}),
};
);
}
if (
@@ -83,9 +105,8 @@ export default async (
(typeof stats_format !== "string" ||
!["bytes", "percentages"].includes(stats_format))
) {
return {
status: "error - permanent",
content: renderError({
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Incorrect stats_format input",
renderOptions: {
@@ -96,22 +117,29 @@ export default async (
theme,
},
}),
};
);
}
try {
await storeRequest(req);
const topLangs = await fetchTopLanguages(
username,
parseArray(exclude_repo),
size_weight,
count_weight,
parseArray(role),
pat,
);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
return {
status: "success",
content: renderTopLanguages(topLangs, {
setCacheHeaders(res, cacheSeconds);
return res.send(
renderTopLanguages(topLangs, {
custom_title,
hide_title: parseBoolean(hide_title),
hide_border: parseBoolean(hide_border),
@@ -129,15 +157,14 @@ export default async (
locale: locale ? locale.toLowerCase() : null,
disable_animations: parseBoolean(disable_animations),
hide_progress: parseBoolean(hide_progress),
hide_values: parseBoolean(hide_values),
stats_format,
}),
};
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return {
status: "error - temporary",
content: renderError({
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
@@ -149,11 +176,10 @@ export default async (
show_repo_link: !(err instanceof MissingParamError),
},
}),
};
);
}
return {
status: "error - temporary",
content: renderError({
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
@@ -163,6 +189,6 @@ export default async (
theme,
},
}),
};
);
}
};
+1 -2
View File
@@ -1,6 +1,5 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { getUserAccessByKey } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
/**
* @param {any} req The request.
+2 -4
View File
@@ -1,7 +1,5 @@
import {
fetchWakatimeStats,
logger,
} from "@stats-organization/github-readme-stats-core";
import { logger } from "../src/common/log.js";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
/**
* @param {any} req The request.
+148
View File
@@ -0,0 +1,148 @@
// @ts-check
import { renderWakatimeCard } from "../src/cards/wakatime.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
title_color,
icon_color,
hide_border,
card_width,
line_height,
text_color,
bg_color,
theme,
cache_seconds,
hide_title,
hide_progress,
custom_title,
locale,
layout,
langs_count,
hide,
api_domain,
border_radius,
border_color,
display_format,
disable_animations,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "wakatime",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const stats = await fetchWakatimeStats({ username, api_domain });
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
renderWakatimeCard(stats, {
custom_title,
hide_title: parseBoolean(hide_title),
hide_border: parseBoolean(hide_border),
card_width: parseInt(card_width, 10),
hide: parseArray(hide),
line_height,
title_color,
icon_color,
text_color,
bg_color,
theme,
hide_progress,
border_radius,
border_color,
locale: locale ? locale.toLowerCase() : null,
layout,
langs_count,
display_format,
disable_animations: parseBoolean(disable_animations),
}),
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
+13 -2
View File
@@ -1,10 +1,21 @@
import express from "express";
import router from "./router.js";
import gistCard from "./api-renamed/gist.js";
import statsCard from "./api-renamed/index.js";
import repoCard from "./api-renamed/pin.js";
import langCard from "./api-renamed/top-langs.js";
import wakatimeCard from "./api-renamed/wakatime.js";
const app = express();
const router = express.Router();
app.use(router);
router.get("/", statsCard);
router.get("/pin", repoCard);
router.get("/top-langs", langCard);
router.get("/wakatime", wakatimeCard);
router.get("/gist", gistCard);
app.use("/api", router);
const port = process.env.PORT || process.env.port || 9000;
app.listen(port, "0.0.0.0", () => {
-1
View File
@@ -1 +0,0 @@
export { default as router } from "./router.js";
+38 -15
View File
@@ -1,29 +1,52 @@
{
"name": "@stats-organization/github-readme-stats-backend",
"name": "github-readme-stats",
"version": "1.0.0",
"description": "Dynamically generate stats for your GitHub readme",
"keywords": [
"github-readme-stats",
"readme-stats",
"cards",
"card-generator"
],
"main": "src/index.js",
"type": "module",
"license": "MIT",
"engines": {
"node": "24.x"
"homepage": "https://github-stats-extended.vercel.app/frontend",
"bugs": {
"url": "https://github.com/stats-organization/github-stats-extended/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/stats-organization/github-stats-extended.git"
},
"private": true,
"main": "index.js",
"scripts": {
"test": "vitest",
"test:update:snapshot": "vitest -u",
"test:e2e": "vitest --config vitest.config.e2e.ts",
"bench": "vitest bench --run --config vitest.config.bench.ts",
"lint": "eslint",
"typecheck": "tsc -p tsconfig.typecheck.json"
"theme-readme-gen": "node scripts/generate-theme-doc",
"generate-langs-json": "node scripts/generate-langs-json",
"bench": "vitest bench --run --config vitest.config.bench.ts"
},
"author": "Anurag Hazra",
"license": "MIT",
"devDependencies": {
"axios-mock-adapter": "2.1.0",
"express": "5.2.1",
"jsdom": "catalog:default",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@uppercod/css-to-object": "^1.1.1",
"@vitest/coverage-v8": "catalog:default",
"axios-mock-adapter": "^2.1.0",
"express": "^5.2.1",
"js-yaml": "^4.1.1",
"jsdom": "28.1.0",
"vitest": "catalog:default"
},
"dependencies": {
"axios": "catalog:default",
"@stats-organization/github-readme-stats-core": "workspace:^",
"pg": "^8.21.0"
"axios": "^1.13.5",
"emoji-name-map": "^2.0.3",
"github-username-regex": "^1.0.0",
"pg": "^8.18.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": "24.x"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

-225
View File
@@ -1,225 +0,0 @@
import {
api,
gist,
pin,
topLangs,
wakatime,
} from "@stats-organization/github-readme-stats-core";
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { guardAccess } from "./src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "./src/common/cache.js";
import { getUserAccessByName, storeRequest } from "./src/common/database.js";
const getGuardResult = (query, type, id) => {
const access = guardAccess({
id,
type,
colors: {
title_color: query.title_color,
text_color: query.text_color,
bg_color: query.bg_color,
border_color: query.border_color,
theme: query.theme,
},
});
if (access.isPassed) {
return null;
}
return {
status: "error - permanent",
content: access.result,
};
};
const getUserPat = async (username) => {
if (!username) {
return null;
}
const userAccess = await getUserAccessByName(username);
if (!userAccess?.token) {
return null;
}
return userAccess.token;
};
export default async (req, res) => {
const url = new URL(req.url, "https://localhost");
if (res.send === undefined) {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
req.query = Object.fromEntries(url.searchParams.entries());
}
let result;
switch (url.pathname) {
case "/api": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await api(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/gist":
result =
getGuardResult(req.query, "gist", req.query.id) ??
(await gist(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/pin": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await pin(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/top-langs": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await topLangs(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/wakatime":
result =
getGuardResult(req.query, "wakatime", req.query.username) ??
(await wakatime(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
@@ -1,8 +1,8 @@
import fs from "fs";
import { themes } from "../src/themes/index.js";
import { themes } from "../themes/index.js";
const TARGET_FILE = "./src/themes/README.md";
const TARGET_FILE = "./themes/README.md";
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->";
@@ -8,23 +8,15 @@ import { icons } from "../common/icons.js";
import languageColors from "../common/languageColors.json" with { type: "json" };
import { parseEmojis } from "../common/ops.js";
import {
countWrappedLines,
createLanguageNode,
flexLayout,
iconWithLabel,
measureText,
wrappedTextNode,
wrappedTextStyles,
} from "../common/render.js";
const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400;
const X_OFFSET = 25;
const HEADER_MAX_LENGTH = 35;
const DESCRIPTION_BOX_WIDTH = CARD_DEFAULT_WIDTH - 2 * X_OFFSET;
const DESCRIPTION_FONT_SIZE = 13;
const DESCRIPTION_LINE_HEIGHT_PX = 16;
const DESCRIPTION_MAX_LINES = 10;
/**
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
@@ -50,7 +42,6 @@ const renderGistCard = (gistData, options = {}) => {
border_radius,
border_color,
show_owner = false,
browser_rendering = false,
hide_border = false,
} = options;
@@ -65,50 +56,14 @@ const renderGistCard = (gistData, options = {}) => {
theme,
});
const lineWidth = 59;
const linesLimit = 10;
const desc = parseEmojis(description || "No description provided");
let descriptionLines, descriptionSvg;
if (browser_rendering) {
// The browser performs the actual text wrapping inside the foreignObject;
// we only estimate the line count server-side so the SVG can reserve enough
// height. The estimate uses measureText for font-aware widths instead of a
// fixed character count.
descriptionLines = countWrappedLines(
desc,
DESCRIPTION_FONT_SIZE,
DESCRIPTION_BOX_WIDTH,
DESCRIPTION_MAX_LINES,
);
descriptionSvg = wrappedTextNode({
text: desc,
x: X_OFFSET,
y: -3,
width: DESCRIPTION_BOX_WIDTH,
height: descriptionLines * DESCRIPTION_LINE_HEIGHT_PX + 10, // 10px extra for "descenders" like g, j, q, p, y
lineCount: descriptionLines,
className: "description",
testId: "description-text",
});
} else {
const linesLimit = 10;
const multiLineDescription = wrapTextMultiline(
desc,
DESCRIPTION_BOX_WIDTH,
DESCRIPTION_FONT_SIZE,
linesLimit,
);
descriptionLines = multiLineDescription.length;
descriptionSvg = multiLineDescription
.map(
(line) =>
`<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
)
.join("");
descriptionSvg = `<text class="description" x="${X_OFFSET}" y="-5">
${descriptionSvg}
</text>`;
}
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
const descriptionLines = multiLineDescription.length;
const descriptionSvg = multiLineDescription
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
.join("");
const lineHeight = descriptionLines > 3 ? 12 : 10;
const height =
@@ -166,17 +121,16 @@ const renderGistCard = (gistData, options = {}) => {
});
card.setCSS(`
.description {
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor};
${browser_rendering ? wrappedTextStyles(textColor) : ""}
}
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.icon { fill: ${iconColor} }
`);
card.setHideBorder(hide_border);
return card.render(`
${descriptionSvg}
<text class="description" x="25" y="-5">
${descriptionSvg}
</text>
<g transform="translate(30, ${height - 75})">
${starAndForkCount}
+4
View File
@@ -0,0 +1,4 @@
export { renderRepoCard } from "./repo.js";
export { renderStatsCard } from "./stats.js";
export { renderTopLanguages } from "./top-languages.js";
export { renderWakatimeCard } from "./wakatime.js";
@@ -8,23 +8,17 @@ import { encodeHTML } from "../common/html.js";
import { icons } from "../common/icons.js";
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
import {
countWrappedLines,
createLanguageNode,
flexLayout,
iconWithLabel,
measureText,
wrappedTextNode,
wrappedTextStyles,
} from "../common/render.js";
import { repoCardLocales } from "../translations.js";
import { createTextNode } from "./stats.js";
const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400;
const X_OFFSET = 25;
const DESCRIPTION_FONT_SIZE = 13;
const DESCRIPTION_LINE_HEIGHT_PX = 16;
const DESCRIPTION_LINE_WIDTH = 59;
const DESCRIPTION_MAX_LINES = 3;
/**
@@ -85,7 +79,6 @@ const renderRepoCard = (repo, options = {}) => {
bg_color,
card_width_input,
show_owner = false,
browser_rendering = false,
show = [],
show_icons = true,
number_format = "short",
@@ -103,8 +96,8 @@ const renderRepoCard = (repo, options = {}) => {
card_width_input && !isNaN(card_width_input)
? card_width_input
: show.length >= 2
? CARD_DEFAULT_WIDTH + 30
: CARD_DEFAULT_WIDTH;
? 430
: 400;
const i18n = new I18n({
locale,
@@ -182,56 +175,23 @@ const renderRepoCard = (repo, options = {}) => {
const header = show_owner ? nameWithOwner : name;
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
const desc = parseEmojis(description || "No description provided");
const descriptionBoxWidth = card_width - 2 * X_OFFSET;
const descriptionMaxLines = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: DESCRIPTION_MAX_LINES;
let descriptionLinesCount, descriptionSvg;
if (browser_rendering) {
// The browser performs the actual text wrapping inside the foreignObject;
// we only estimate the line count server-side so the SVG can reserve enough
// height. The estimate uses measureText for font-aware widths instead of a
// fixed character count.
descriptionLinesCount = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: countWrappedLines(
desc,
DESCRIPTION_FONT_SIZE,
descriptionBoxWidth,
DESCRIPTION_MAX_LINES,
);
descriptionSvg = wrappedTextNode({
text: desc,
x: X_OFFSET,
y: -3,
width: descriptionBoxWidth,
height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX + 10, // 10px extra for "descenders" like g, j, q, p, y
lineCount: descriptionLinesCount,
className: "description",
testId: "description-text",
});
} else {
const descriptionMaxLines = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: DESCRIPTION_MAX_LINES;
const multiLineDescription = wrapTextMultiline(
desc,
descriptionBoxWidth,
DESCRIPTION_FONT_SIZE,
descriptionMaxLines,
);
descriptionLinesCount = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: multiLineDescription.length;
descriptionSvg = multiLineDescription
.map(
(line) =>
`<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
)
.join("");
descriptionSvg = `<text class="description" x="${X_OFFSET}" y="-5">
${descriptionSvg}
</text>`;
}
const desc = parseEmojis(description || "No description provided");
const multiLineDescription = wrapTextMultiline(
desc,
Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
descriptionMaxLines,
);
const descriptionLinesCount = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: multiLineDescription.length;
const descriptionSvg = multiLineDescription
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
.join("");
const extraHeight = Object.keys(STATS).length
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
@@ -313,14 +273,11 @@ const renderRepoCard = (repo, options = {}) => {
card.setHideBorder(hide_border);
card.setHideTitle(false);
card.setCSS(`
.description {
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor};
${browser_rendering ? wrappedTextStyles(colors.textColor) : ""}
}
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
.badge rect { opacity: 0.2 }
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
.stagger {
opacity: 0;
@@ -341,19 +298,21 @@ const renderRepoCard = (repo, options = {}) => {
getBadgeSVG(
i18n.t("repocard.template"),
colors.textColor,
card_width - CARD_DEFAULT_WIDTH,
card_width - 400,
)
: isArchived
? // @ts-ignore
getBadgeSVG(
i18n.t("repocard.archived"),
colors.textColor,
card_width - CARD_DEFAULT_WIDTH,
card_width - 400,
)
: ""
}
${descriptionSvg}
<text class="description" x="25" y="-5">
${descriptionSvg}
</text>
<g transform="translate(30, ${height - 75 - extraHeight})">
${starAndForkCount}
@@ -233,7 +233,7 @@ const getStyles = ({
transform: rotate(-90deg);
animation: rankAnimation 1s forwards ease-in-out;
}
${getProgressAnimation({ progress })}
${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
`;
};
@@ -221,7 +221,6 @@ const getDisplayValue = (size, percentages, format) => {
* @param {number} props.size Size of the programming language.
* @param {number} props.totalSize Total size of all languages.
* @param {string} props.statsFormat Stats format.
* @param {boolean=} props.hideValues Whether to hide stats values.
* @param {number} props.index Index of the programming language.
* @returns {string} Programming language SVG node.
*/
@@ -233,11 +232,10 @@ const createProgressTextNode = ({
size,
totalSize,
statsFormat,
hideValues,
index,
}) => {
const staggerDelay = (index + 3) * 150;
const paddingRight = hideValues ? CARD_PADDING * 2 : 95;
const paddingRight = 95;
const progressTextX = width - paddingRight + 10;
const progressWidth = width - paddingRight;
@@ -247,7 +245,7 @@ const createProgressTextNode = ({
return `
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>`}
<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>
${createProgressNode({
x: 0,
y: 25,
@@ -268,7 +266,6 @@ const createProgressTextNode = ({
* @param {Lang} props.lang Programming language object.
* @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideProgress Whether to hide percentage.
* @param {boolean=} props.hideValues Whether to hide stats values (percentages/bytes).
* @param {string=} props.statsFormat Stats format
* @param {number} props.index Index of the programming language.
* @returns {string} Compact layout programming language SVG node.
@@ -277,7 +274,6 @@ const createCompactLangNode = ({
lang,
totalSize,
hideProgress,
hideValues,
statsFormat = "percentages",
index,
}) => {
@@ -291,7 +287,7 @@ const createCompactLangNode = ({
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
<circle cx="5" cy="6" r="5" fill="${color}" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
${lang.name} ${hideProgress || hideValues ? "" : displayValue}
${lang.name} ${hideProgress ? "" : displayValue}
</text>
</g>
`;
@@ -304,7 +300,6 @@ const createCompactLangNode = ({
* @param {Lang[]} props.langs Array of programming languages.
* @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideProgress Whether to hide percentage.
* @param {boolean=} props.hideValues Whether to hide stats values.
* @param {string=} props.statsFormat Stats format
* @returns {string} Programming languages SVG node.
*/
@@ -312,7 +307,6 @@ const createLanguageTextNode = ({
langs,
totalSize,
hideProgress,
hideValues,
statsFormat,
}) => {
const longestLang = getLongestLang(langs);
@@ -324,7 +318,6 @@ const createLanguageTextNode = ({
lang,
totalSize,
hideProgress,
hideValues,
statsFormat,
index,
}),
@@ -351,23 +344,16 @@ const createLanguageTextNode = ({
* @param {object} props Function properties.
* @param {Lang[]} props.langs Array of programming languages.
* @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideValues Whether to hide stats values.
* @param {string} props.statsFormat Stats format
* @returns {string} Donut layout programming language SVG node.
*/
const createDonutLanguagesNode = ({
langs,
totalSize,
hideValues,
statsFormat,
}) => {
const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
return flexLayout({
items: langs.map((lang, index) => {
return createCompactLangNode({
lang,
totalSize,
hideProgress: false,
hideValues,
statsFormat,
index,
});
@@ -385,7 +371,6 @@ const createDonutLanguagesNode = ({
* @param {number} totalLanguageSize Total size of all languages.
* @param progBarBgColor Color of the background of progress bar.
* @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Normal layout card SVG object.
*/
const renderNormalLayout = (
@@ -394,7 +379,6 @@ const renderNormalLayout = (
totalLanguageSize,
progBarBgColor,
statsFormat,
hideValues,
) => {
return flexLayout({
items: langs.map((lang, index) => {
@@ -405,7 +389,6 @@ const renderNormalLayout = (
size: lang.size,
totalSize: totalLanguageSize,
statsFormat,
hideValues,
index,
progBarBgColor,
});
@@ -423,7 +406,6 @@ const renderNormalLayout = (
* @param {number} totalLanguageSize Total size of all languages.
* @param {boolean=} hideProgress Whether to hide progress bar.
* @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Compact layout card SVG object.
*/
const renderCompactLayout = (
@@ -432,7 +414,6 @@ const renderCompactLayout = (
totalLanguageSize,
hideProgress,
statsFormat = "percentages",
hideValues,
) => {
const paddingRight = 50;
const offsetWidth = width - paddingRight;
@@ -480,7 +461,6 @@ const renderCompactLayout = (
totalSize: totalLanguageSize,
hideProgress,
statsFormat,
hideValues,
})}
</g>
`;
@@ -492,15 +472,9 @@ const renderCompactLayout = (
* @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages.
* @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Compact layout card SVG object.
*/
const renderDonutVerticalLayout = (
langs,
totalLanguageSize,
statsFormat,
hideValues,
) => {
const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
// Donut vertical chart radius and total length
const radius = 80;
const totalCircleLength = getCircleLength(radius);
@@ -557,7 +531,6 @@ const renderDonutVerticalLayout = (
totalSize: totalLanguageSize,
hideProgress: false,
statsFormat,
hideValues,
})}
</svg>
</g>
@@ -571,10 +544,9 @@ const renderDonutVerticalLayout = (
* @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages.
* @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Compact layout card SVG object.
*/
const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
// Pie chart radius and center coordinates
const radius = 90;
const centerX = 150;
@@ -656,7 +628,6 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
totalSize: totalLanguageSize,
hideProgress: false,
statsFormat,
hideValues,
})}
</svg>
</g>
@@ -676,7 +647,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
const createDonutPaths = (cx, cy, radius, percentages) => {
const paths = [];
let startAngle = 0;
let endAngle;
let endAngle = 0;
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
for (let i = 0; i < percentages.length; i++) {
@@ -708,16 +679,9 @@ const createDonutPaths = (cx, cy, radius, percentages) => {
* @param {number} width Card width.
* @param {number} totalLanguageSize Total size of all languages.
* @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Donut layout card SVG object.
*/
const renderDonutLayout = (
langs,
width,
totalLanguageSize,
statsFormat,
hideValues,
) => {
const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
const centerX = width / 3;
const centerY = width / 3;
const radius = centerX - 60;
@@ -760,7 +724,7 @@ const renderDonutLayout = (
return `
<g transform="translate(0, 0)">
<g transform="translate(0, 0)">
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, hideValues, statsFormat })}
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
</g>
<g transform="translate(125, ${donutCenterTranslation(langs.length)})">
@@ -836,7 +800,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
prog_bar_bg_color,
hide,
hide_progress,
hide_values,
theme,
layout,
custom_title,
@@ -877,7 +840,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
theme,
});
let finalLayout;
let finalLayout = "";
if (langs.length === 0) {
height = COMPACT_LAYOUT_BASE_HEIGHT;
finalLayout = noLanguagesDataNode({
@@ -887,19 +850,13 @@ const renderTopLanguages = (topLangs, options = {}) => {
});
} else if (layout === "pie") {
height = calculatePieLayoutHeight(langs.length);
finalLayout = renderPieLayout(
langs,
totalLanguageSize,
stats_format,
hide_values,
);
finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
} else if (layout === "donut-vertical") {
height = calculateDonutVerticalLayoutHeight(langs.length);
finalLayout = renderDonutVerticalLayout(
langs,
totalLanguageSize,
stats_format,
hide_values,
);
} else if (layout === "compact" || hide_progress == true) {
height =
@@ -911,7 +868,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
totalLanguageSize,
hide_progress,
stats_format,
hide_values,
);
} else if (layout === "donut") {
height = calculateDonutLayoutHeight(langs.length);
@@ -921,7 +877,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
width,
totalLanguageSize,
stats_format,
hide_values,
);
} else {
finalLayout = renderNormalLayout(
@@ -930,7 +885,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
totalLanguageSize,
fallbackColor(prog_bar_bg_color, "#ddd"),
stats_format,
hide_values,
);
}
@@ -1,7 +1,7 @@
type ThemeNames = keyof typeof import("../themes/index.ts");
type ThemeNames = keyof typeof import("../../themes/index.js");
type RankIcon = "default" | "github" | "percentile";
interface CommonOptions {
type CommonOptions = {
title_color: string;
icon_color: string;
text_color: string;
@@ -11,10 +11,10 @@ interface CommonOptions {
border_color: string;
locale: string;
hide_border: boolean;
}
};
export type StatCardOptions = CommonOptions & {
hide: Array<string>;
hide: string[];
show_icons: boolean;
hide_title: boolean;
card_width: number;
@@ -29,15 +29,14 @@ export type StatCardOptions = CommonOptions & {
ring_color: string;
text_bold: boolean;
rank_icon: RankIcon;
show: Array<string>;
show: string[];
};
export type RepoCardOptions = CommonOptions & {
show_owner: boolean;
browser_rendering: boolean;
description_lines_count: number;
card_width_input;
show: Array<string>;
show: string[];
show_icons: boolean;
number_format: string;
text_bold: boolean;
@@ -48,20 +47,19 @@ export type RepoCardOptions = CommonOptions & {
export type TopLangOptions = CommonOptions & {
hide_title: boolean;
card_width: number;
hide: Array<string>;
hide: string[];
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
custom_title: string;
langs_count: number;
disable_animations: boolean;
hide_progress: boolean;
hide_values: boolean;
prog_bar_bg_color: string;
stats_format: "percentages" | "bytes";
};
export type WakaTimeOptions = CommonOptions & {
hide_title: boolean;
hide: Array<string>;
hide: string[];
card_width: number;
line_height: string;
hide_progress: boolean;
@@ -74,5 +72,4 @@ export type WakaTimeOptions = CommonOptions & {
export type GistCardOptions = CommonOptions & {
show_owner: boolean;
browser_rendering: boolean;
};
@@ -305,7 +305,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
textColor,
});
let finalLayout;
let finalLayout = "";
// RENDER COMPACT LAYOUT
if (layout === "compact") {
@@ -230,7 +230,7 @@ class Card {
}
${this.css}
${this.getAnimations()}
${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
${
this.animations === false
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
+25 -25
View File
@@ -1,11 +1,8 @@
// @ts-check
import {
getConfig,
renderError,
} from "@stats-organization/github-readme-stats-core";
import { blacklist } from "./blacklist.js";
import { gistWhitelist, whitelist } from "./envs.js";
import { renderError } from "./render.js";
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
@@ -15,35 +12,36 @@ const BLACKLISTED_MESSAGE = "This username is blacklisted";
* Guards access using whitelist/blacklist.
*
* @param {Object} args The parameters object.
* @param {any} args.res The response object.
* @param {string} args.id Resource identifier (username or gist id).
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
*/
const guardAccess = ({ id, type, colors }) => {
const guardAccess = ({ res, id, type, colors }) => {
if (!["username", "gist", "wakatime"].includes(type)) {
throw new Error(
'Invalid type. Expected "username", "gist", or "wakatime".',
);
}
const config = getConfig();
const currentWhitelist =
type === "gist" ? config.gistWhitelist : config.whitelist;
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
const notWhitelistedMsg =
type === "gist"
? NOT_WHITELISTED_GIST_MESSAGE
: NOT_WHITELISTED_USERNAME_MESSAGE;
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
const result = renderError({
message: notWhitelistedMsg,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
});
const result = res.send(
renderError({
message: notWhitelistedMsg,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
}),
);
return { isPassed: false, result };
}
@@ -52,14 +50,16 @@ const guardAccess = ({ id, type, colors }) => {
currentWhitelist === undefined &&
blacklist.includes(id)
) {
const result = renderError({
message: BLACKLISTED_MESSAGE,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
});
const result = res.send(
renderError({
message: BLACKLISTED_MESSAGE,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
}),
);
return { isPassed: false, result };
}
+6 -3
View File
@@ -1,6 +1,6 @@
// @ts-check
import { clampValue } from "@stats-organization/github-readme-stats-core";
import { clampValue } from "./ops.js";
const MIN = 60;
const HOUR = 60 * MIN;
@@ -106,7 +106,7 @@ const disableCaching = (res) => {
* @param {number} cacheSeconds The cache seconds to set in the headers.
*/
const setCacheHeaders = (res, cacheSeconds) => {
if (cacheSeconds < 1) {
if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
disableCaching(res);
return;
}
@@ -128,7 +128,10 @@ const setErrorCacheHeaders = (res) => {
const envCacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10)
: NaN;
if (!isNaN(envCacheSeconds) && envCacheSeconds < 1) {
if (
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
process.env.NODE_ENV === "development"
) {
disableCaching(res);
return;
}
@@ -1,6 +1,6 @@
// @ts-check
import { themes } from "../themes/index.js";
import { themes } from "../../themes/index.js";
/**
* Checks if a string is a valid hex color.
+11 -7
View File
@@ -1,10 +1,14 @@
export let pool = null;
if (process.env.POSTGRES_URL) {
const { Pool } = await import("pg");
pool = new Pool({
connectionString: process.env.POSTGRES_URL,
});
}
/**
* In the browser this has to be mocked to avoid runtime errors
* @see apps/frontend/vite.config.ts
*/
import { Pool } from "pg";
export const pool = process.env.POSTGRES_URL
? new Pool({
connectionString: process.env.POSTGRES_URL,
})
: null;
/**
* Creates all required tables if they do not exist.
+15
View File
@@ -0,0 +1,15 @@
// @ts-check
const whitelist = process.env.WHITELIST
? process.env.WHITELIST.split(",")
: undefined;
const gistWhitelist = process.env.GIST_WHITELIST
? process.env.GIST_WHITELIST.split(",")
: undefined;
const excludeRepositories = process.env.EXCLUDE_REPO
? process.env.EXCLUDE_REPO.split(",")
: [];
export { whitelist, gistWhitelist, excludeRepositories };
@@ -1,7 +1,8 @@
// @ts-check
import wrap from "word-wrap";
import { encodeHTML } from "./html.js";
import { splitWrappedText } from "./render.js";
/**
* Retrieves num with suffix k(thousands) precise to given decimal places.
@@ -56,16 +57,26 @@ const formatBytes = (bytes) => {
* Split text over multiple lines based on the card width.
*
* @param {string} text Text to split.
* @param {number} width Available wrap width in px.
* @param {number} fontSize Font size in px.
* @param {number} width Line width in number of characters.
* @param {number} maxLines Maximum number of lines.
* @returns {string[]} Array of lines.
*/
const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
const wrapped = splitWrappedText(text, fontSize, width);
const lines = wrapped
.map((line) => encodeHTML(line.trim()))
.slice(0, maxLines); // Only consider maxLines lines
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
const fullWidthComma = "";
const encoded = encodeHTML(text);
const isChinese = encoded.includes(fullWidthComma);
let wrapped = [];
if (isChinese) {
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
} else {
wrapped = wrap(encoded, {
width,
}).split("\n"); // Split wrapped lines to get an array of lines
}
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
// Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) {
@@ -1,31 +1,5 @@
// @ts-check
/*
The icons in this file are based on https://github.com/primer/octicons which is released under the MIT license:
MIT License
Copyright (c) 2026 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
const icons = {
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
export { blacklist } from "./blacklist.js";
export { Card } from "./Card.js";
export { I18n } from "./I18n.js";
export { icons } from "./icons.js";
export { retryer } from "./retryer.js";
export {
ERROR_CARD_LENGTH,
renderError,
flexLayout,
measureText,
} from "./render.js";
@@ -171,7 +171,6 @@
"Faust": "#c37240",
"Fennel": "#fff3d7",
"Filebench WML": "#F6B900",
"FlatBuffers": "#ed284a",
"Flix": "#d44a45",
"Fluent": "#ffcc33",
"Forth": "#341708",
@@ -254,7 +253,6 @@
"Hy": "#7790B2",
"IDL": "#a3522f",
"IGOR Pro": "#0000cc",
"IL Assembly": "#512BD4",
"INI": "#d1dbe0",
"ISPC": "#2D68B1",
"Idris": "#b30000",
@@ -325,7 +323,6 @@
"LigoLANG": "#0e74ff",
"LilyPond": "#9ccc7c",
"Liquid": "#67b8de",
"Liquidsoap": "#990066",
"Literate Agda": "#315665",
"Literate CoffeeScript": "#244776",
"Literate Haskell": "#5e5086",
@@ -351,7 +348,6 @@
"Mask": "#f97732",
"Mathematical Programming System": "#0530ad",
"Max": "#c4a79c",
"MeTTa": "#6a5acd",
"Mercury": "#ff2b2b",
"Mermaid": "#ff3670",
"Meson": "#007800",
@@ -468,7 +464,6 @@
"Quake": "#882233",
"QuakeC": "#975777",
"QuickBASIC": "#008080",
"Quint": "#9d6ce5",
"R": "#198CE7",
"RAML": "#77d9fb",
"RAScript": "#2C97FA",
@@ -540,7 +535,6 @@
"Snakemake": "#419179",
"Solidity": "#AA6746",
"SourcePawn": "#f69e1d",
"SpiceDB Schema": "#a5318a",
"Squirrel": "#800000",
"Stan": "#b2011d",
"Standard ML": "#dc566d",
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
const noop = () => {};
/**
* Return console instance based on the environment.
*
* @type {Console | {log: () => void, error: () => void}}
*/
const logger =
process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console;
export { logger };
+239
View File
@@ -0,0 +1,239 @@
// @ts-check
import { getCardColors } from "./color.js";
import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
import { encodeHTML } from "./html.js";
import { clampValue } from "./ops.js";
/**
* Auto layout utility, allows us to layout things vertically or horizontally with
* proper gaping.
*
* @param {object} props Function properties.
* @param {string[]} props.items Array of items to layout.
* @param {number} props.gap Gap between items.
* @param {"column" | "row"=} props.direction Direction to layout items.
* @param {number[]=} props.sizes Array of sizes for each item.
* @returns {string[]} Array of items with proper layout.
*/
const flexLayout = ({ items, gap, direction, sizes = [] }) => {
let lastSize = 0;
// filter() for filtering out empty strings
return items.filter(Boolean).map((item, i) => {
const size = sizes[i] || 0;
let transform = `translate(${lastSize}, 0)`;
if (direction === "column") {
transform = `translate(0, ${lastSize})`;
}
lastSize += size + gap;
return `<g transform="${transform}">${item}</g>`;
});
};
/**
* Creates a node to display the primary programming language of the repository/gist.
*
* @param {string} langName Language name.
* @param {string} langColor Language color.
* @returns {string} Language display SVG object.
*/
const createLanguageNode = (langName, langColor) => {
return `
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
<text data-testid="lang-name" class="gray" x="15">${langName}</text>
</g>
`;
};
/**
* Create a node to indicate progress in percentage along a horizontal line.
*
* @param {Object} params Object that contains the createProgressNode parameters.
* @param {number} params.x X-axis position.
* @param {number} params.y Y-axis position.
* @param {number} params.width Width of progress bar.
* @param {string} params.color Progress color.
* @param {number} params.progress Progress value.
* @param {string} params.progressBarBackgroundColor Progress bar bg color.
* @param {number} params.delay Delay before animation starts.
* @returns {string} Progress node.
*/
const createProgressNode = ({
x,
y,
width,
color,
progress,
progressBarBackgroundColor,
delay,
}) => {
const progressPercentage = clampValue(progress, 2, 100);
return `
<svg width="${width}" x="${x}" y="${y}">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
<svg data-testid="lang-progress" width="${progressPercentage}%">
<rect
height="8"
fill="${color}"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: ${delay}ms;"
/>
</svg>
</svg>
`;
};
/**
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
*
* @param {string} icon The icon to display.
* @param {number|string} label The label to display.
* @param {string} testid The testid to assign to the label.
* @param {number} iconSize The size of the icon.
* @returns {string} Icon with label SVG object.
*/
const iconWithLabel = (icon, label, testid, iconSize) => {
if (typeof label === "number" && label <= 0) {
return "";
}
const iconSvg = `
<svg
class="icon"
y="-12"
viewBox="0 0 16 16"
version="1.1"
width="${iconSize}"
height="${iconSize}"
>
${icon}
</svg>
`;
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
};
// Script parameters.
const ERROR_CARD_LENGTH = 576.5;
const UPSTREAM_API_ERRORS = [
TRY_AGAIN_LATER,
SECONDARY_ERROR_MESSAGES.MAX_RETRY,
];
/**
* Renders error message on the card.
*
* @param {object} args Function arguments.
* @param {string} args.message Main error message.
* @param {string} [args.secondaryMessage=""] The secondary error message.
* @param {object} [args.renderOptions={}] Render options.
* @param {string=} args.renderOptions.title_color Card title color.
* @param {string=} args.renderOptions.text_color Card text color.
* @param {string=} args.renderOptions.bg_color Card background color.
* @param {string=} args.renderOptions.border_color Card border color.
* @param {Parameters<typeof getCardColors>[0]["theme"]=} args.renderOptions.theme Card theme.
* @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
* @returns {string} The SVG markup.
*/
const renderError = ({
message,
secondaryMessage = "",
renderOptions = {},
}) => {
const {
title_color,
text_color,
bg_color,
border_color,
theme = "default",
show_repo_link = true,
} = renderOptions;
// returns theme based colors with proper overrides and defaults
const { titleColor, textColor, bgColor, borderColor } = getCardColors({
title_color,
text_color,
icon_color: "",
bg_color,
border_color,
ring_color: "",
theme,
});
return `
<svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${bgColor}" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="${
ERROR_CARD_LENGTH - 1
}" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/>
<text x="25" y="45" class="text">Something went wrong!${
UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
? ""
: " file an issue at https://tinyurl.com/github-stats"
}</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">${encodeHTML(message)}</tspan>
<tspan x="25" dy="18" class="gray">${secondaryMessage}</tspan>
</text>
</svg>
`;
};
/**
* Retrieve text length.
*
* @see https://stackoverflow.com/a/48172630/10629172
* @param {string} str String to measure.
* @param {number} fontSize Font size.
* @returns {number} Text length.
*/
const measureText = (str, fontSize = 10) => {
// prettier-ignore
const widths = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0.2796875, 0.2765625,
0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625,
0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125,
0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875,
1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625,
0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625,
0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625,
0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375,
0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625,
0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5,
0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875,
0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875,
0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875,
0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625,
];
const avg = 0.5279276315789471;
return (
str
.split("")
.map((c) =>
c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg,
)
.reduce((cur, acc) => acc + cur) * fontSize
);
};
export {
ERROR_CARD_LENGTH,
renderError,
createLanguageNode,
createProgressNode,
iconWithLabel,
flexLayout,
measureText,
};
@@ -1,6 +1,6 @@
// @ts-check
import { getConfig } from "./config.js";
import { getUserAccessByName } from "./database.js";
import { CustomError } from "./error.js";
import { logger } from "./log.js";
@@ -27,16 +27,24 @@ function getRandomInt(max) {
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
*
* @param {FetcherFunction} fetcher The fetcher function.
* @param {string?} username GitHub username of the user whose PAT to use, if available
* @param {any} variables Object with arguments to pass to the fetcher function.
* @param {string | null} pat Optional PAT override.
* @returns {Promise<any>} The response from the fetcher function.
*/
const retryer = async (fetcher, variables, pat = null) => {
const retryer = async (fetcher, username, variables) => {
let userPAT;
if (username) {
userPAT = await getUserAccessByName(username);
}
let PATs;
if (pat) {
PATs = [{ name: "user PAT from database", value: pat }];
if (userPAT?.token) {
PATs = [{ name: `USER_${username}`, value: userPAT.token }];
} else {
PATs = getConfig().pats;
const patNames = Object.keys(process.env).filter((key) =>
/PAT_\d*$/.exec(key),
);
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
}
if (!PATs.length) {
@@ -102,3 +110,4 @@ const retryer = async (fetcher, variables, pat = null) => {
};
export { retryer };
export default retryer;
@@ -84,14 +84,13 @@ const calculatePrimaryLanguage = (files) => {
* Fetch GitHub gist information by given username and ID.
*
* @param {string} id GitHub gist ID.
* @param {string | null} pat Optional PAT override.
* @returns {Promise<GistData>} Gist data.
*/
const fetchGist = async (id, pat = null) => {
const fetchGist = async (id) => {
if (!id) {
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
}
const res = await retryer(fetcher, { gistName: id }, pat);
const res = await retryer(fetcher, null, { gistName: id });
if (res.data.errors) {
throw new Error(res.data.errors[0].message);
}
@@ -76,7 +76,6 @@ const fetchRepo = async (
include_prs_reviewed = false,
include_issues_authored = false,
include_issues_commented = false,
pat = null,
) => {
let owner = username;
if (reponame && reponame.includes("/")) {
@@ -101,7 +100,7 @@ const fetchRepo = async (
throw new MissingParamError(["repo"], urlExample);
}
let res = await retryer(fetcher, { login: owner, repo: reponame }, pat);
let res = await retryer(fetcher, username, { login: owner, repo: reponame });
const data = res.data.data;
@@ -125,7 +124,6 @@ const fetchRepo = async (
include_prs_reviewed,
include_issues_authored,
include_issues_commented,
pat,
);
return {
...repoUserStats,
@@ -150,7 +148,6 @@ const fetchRepo = async (
include_prs_reviewed,
include_issues_authored,
include_issues_commented,
pat,
);
return {
...repoUserStats,
@@ -4,7 +4,7 @@ import axios from "axios";
import githubUsernameRegex from "github-username-regex";
import { calculateRank } from "../calculateRank.js";
import { getConfig } from "../common/config.js";
import { excludeRepositories } from "../common/envs.js";
import { CustomError, MissingParamError } from "../common/error.js";
import { wrapTextMultiline } from "../common/fmt.js";
import { request } from "../common/http.js";
@@ -106,8 +106,7 @@ const fetcher = (variables, token) => {
* @param {boolean} variables.includeDiscussions Include discussions.
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
* @param {string|undefined} variables.startTime Time to start the count of total commits.
* @param {string[]} variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @param {string | null} variables.pat PAT override or null.
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
*
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
@@ -119,7 +118,6 @@ const statsFetcher = async ({
includeDiscussionsAnswers,
startTime,
ownerAffiliations,
pat,
}) => {
let stats;
let hasNextPage = true;
@@ -136,7 +134,7 @@ const statsFetcher = async ({
startTime,
ownerAffiliations,
};
let res = await retryer(fetcher, variables, pat);
let res = await retryer(fetcher, username, variables);
if (res.data.errors) {
return res;
}
@@ -163,8 +161,8 @@ const statsFetcher = async ({
);
hasNextPage =
(getConfig().fetchMultiPageStars === "true" ||
getConfig().fetchMultiPageStars > fetchedPages) &&
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
repoNodes.length === repoNodesWithStars.length &&
res.data.data.user.repositories.pageInfo.hasNextPage;
@@ -209,7 +207,7 @@ const fetchTotalItems = (variables, token) => {
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
* #92#issuecomment-661026467 and #211 for more information.
*/
const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
const totalItemsFetcher = async (username, repo, owner, type, filter) => {
if (!githubUsernameRegex.test(username)) {
logger.log("Invalid username provided.");
throw new Error("Invalid username provided.");
@@ -217,20 +215,16 @@ const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
let res;
try {
res = await retryer(
fetchTotalItems,
{
login: username,
repo,
owner,
type,
filter,
},
pat,
);
res = await retryer(fetchTotalItems, username, {
login: username,
repo,
owner,
type,
filter,
});
} catch (err) {
logger.log(err);
throw err;
throw new Error(err);
}
const totalCount = res.data.total_count;
@@ -253,7 +247,6 @@ const fetchRepoUserStats = async (
include_prs_reviewed,
include_issues_authored,
include_issues_commented,
pat,
) => {
let stats = {};
if (include_prs_authored) {
@@ -263,7 +256,6 @@ const fetchRepoUserStats = async (
owner,
"issues",
`author:${username}+type:pr`,
pat,
);
}
if (include_prs_commented) {
@@ -273,7 +265,6 @@ const fetchRepoUserStats = async (
owner,
"issues",
`commenter:${username}+-author:${username}+type:pr`,
pat,
);
}
if (include_prs_reviewed) {
@@ -283,7 +274,6 @@ const fetchRepoUserStats = async (
owner,
"issues",
`reviewed-by:${username}+-author:${username}+type:pr`,
pat,
);
}
if (include_issues_authored) {
@@ -293,7 +283,6 @@ const fetchRepoUserStats = async (
owner,
"issues",
`author:${username}+type:issue`,
pat,
);
}
if (include_issues_commented) {
@@ -303,7 +292,6 @@ const fetchRepoUserStats = async (
owner,
"issues",
`commenter:${username}+-author:${username}+type:issue`,
pat,
);
}
return stats;
@@ -338,7 +326,6 @@ const fetchStats = async (
include_issues_authored = false,
include_issues_commented = false,
ownerAffiliations = [],
pat = null,
) => {
if (!username) {
throw new MissingParamError(["username"]);
@@ -365,17 +352,14 @@ const fetchStats = async (
};
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
let res = await statsFetcher(
{
username,
includeMergedPullRequests: include_merged_pull_requests,
includeDiscussions: include_discussions,
includeDiscussionsAnswers: include_discussions_answers,
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
ownerAffiliations,
},
pat,
);
let res = await statsFetcher({
username,
includeMergedPullRequests: include_merged_pull_requests,
includeDiscussions: include_discussions,
includeDiscussionsAnswers: include_discussions_answers,
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
ownerAffiliations,
});
// Catch GraphQL errors.
if (res.data.errors) {
@@ -388,7 +372,7 @@ const fetchStats = async (
}
if (res.data.errors[0].message) {
throw new CustomError(
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0],
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
res.statusText,
);
}
@@ -410,7 +394,6 @@ const fetchStats = async (
owner,
"commits",
`author:${username}`,
pat,
);
} else {
stats.totalCommits = user.commits.totalCommitContributions;
@@ -424,7 +407,6 @@ const fetchStats = async (
include_prs_reviewed,
include_issues_authored,
include_issues_commented,
pat,
);
Object.assign(stats, repoUserStats);
@@ -447,10 +429,7 @@ const fetchStats = async (
stats.contributedTo = user.repositoriesContributedTo.totalCount;
// Retrieve stars while filtering out repositories to be hidden.
const allExcludedRepos = [
...exclude_repo,
...getConfig().excludeRepositories,
];
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
let repoToHide = new Set(allExcludedRepos);
stats.totalStars = user.repositories.nodes
@@ -1,6 +1,6 @@
// @ts-check
import { getConfig } from "../common/config.js";
import { excludeRepositories } from "../common/envs.js";
import { CustomError, MissingParamError } from "../common/error.js";
import { wrapTextMultiline } from "../common/fmt.js";
import { request } from "../common/http.js";
@@ -59,7 +59,6 @@ const fetcher = (variables, token) => {
* @param {number} size_weight Weightage to be given to size.
* @param {number} count_weight Weightage to be given to count.
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @param {string|null} pat Optional PAT override.
* @returns {Promise<TopLangData>} Top languages data.
*/
const fetchTopLanguages = async (
@@ -68,21 +67,16 @@ const fetchTopLanguages = async (
size_weight = 1,
count_weight = 0,
ownerAffiliations = [],
pat = null,
) => {
if (!username) {
throw new MissingParamError(["username"]);
}
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
const res = await retryer(
fetcher,
{
login: username,
ownerAffiliations,
},
pat,
);
const res = await retryer(fetcher, username, {
login: username,
ownerAffiliations,
});
if (res.data.errors) {
logger.error(res.data.errors);
@@ -94,7 +88,7 @@ const fetchTopLanguages = async (
}
if (res.data.errors[0].message) {
throw new CustomError(
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0],
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
res.statusText,
);
}
@@ -107,10 +101,7 @@ const fetchTopLanguages = async (
let repoNodes = res.data.data.user.repositories.nodes;
/** @type {Record<string, boolean>} */
let repoToHide = {};
const allExcludedRepos = [
...exclude_repo,
...getConfig().excludeRepositories,
];
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
// populate repoToHide map for quick lookup
// while filtering out
@@ -1,13 +1,13 @@
export interface GistData {
export type GistData = {
name: string;
nameWithOwner: string;
description: string | null;
language: string | null;
starsCount: number;
forksCount: number;
}
};
export interface RepositoryData {
export type RepositoryData = {
name: string;
nameWithOwner: string;
isPrivate: boolean;
@@ -27,9 +27,9 @@ export interface RepositoryData {
totalPRsReviewed: number;
totalIssuesAuthored: number;
totalIssuesCommented: number;
}
};
export interface StatsData {
export type StatsData = {
name: string;
totalPRs: number;
totalPRsMerged: number;
@@ -47,18 +47,18 @@ export interface StatsData {
totalIssuesAuthored: number;
totalIssuesCommented: number;
rank: { level: string; percentile: number };
}
};
export interface Lang {
export type Lang = {
name: string;
color: string;
size: number;
}
};
export type TopLangData = Record<string, Lang>;
export interface WakaTimeData {
categories: Array<{
export type WakaTimeData = {
categories: {
digital: string;
hours: number;
minutes: number;
@@ -66,12 +66,12 @@ export interface WakaTimeData {
percent: number;
text: string;
total_seconds: number;
}>;
}[];
daily_average: number;
daily_average_including_other_language: number;
days_including_holidays: number;
days_minus_holidays: number;
editors: Array<{
editors: {
digital: string;
hours: number;
minutes: number;
@@ -79,7 +79,7 @@ export interface WakaTimeData {
percent: number;
text: string;
total_seconds: number;
}>;
}[];
holidays: number;
human_readable_daily_average: string;
human_readable_daily_average_including_other_language: string;
@@ -92,7 +92,7 @@ export interface WakaTimeData {
is_other_usage_visible: boolean;
is_stuck: boolean;
is_up_to_date: boolean;
languages: Array<{
languages: {
digital: string;
hours: number;
minutes: number;
@@ -100,8 +100,8 @@ export interface WakaTimeData {
percent: number;
text: string;
total_seconds: number;
}>;
operating_systems: Array<{
}[];
operating_systems: {
digital: string;
hours: number;
minutes: number;
@@ -109,7 +109,7 @@ export interface WakaTimeData {
percent: number;
text: string;
total_seconds: number;
}>;
}[];
percent_calculated: number;
range: string;
status: string;
@@ -119,10 +119,10 @@ export interface WakaTimeData {
user_id: string;
username: string;
writes_only: boolean;
}
};
export interface WakaTimeLang {
export type WakaTimeLang = {
name: string;
text: string;
percent: number;
}
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./common/index.js";
export * from "./cards/index.js";
-1
View File
@@ -80,7 +80,6 @@ async function githubAuthenticate(code, privateAccess) {
return { userId, accessToken, needDowngrade };
} catch (err) {
if (err.response) {
// eslint-disable-next-line preserve-caught-error
throw new Error(`OAuth Error: ${err.response.status}`);
}
throw err;
@@ -65,24 +65,6 @@ exports[`Test Render WakaTime Card > should render correctly 1`] = `
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -241,24 +223,6 @@ exports[`Test Render WakaTime Card > should render correctly with compact layout
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -411,24 +375,6 @@ exports[`Test Render WakaTime Card > should render correctly with compact layout
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -581,24 +527,6 @@ exports[`Test Render WakaTime Card > should render correctly with percent displa
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -0,0 +1,115 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/wakatime > should render error if user data is not accessible 1`] = `
"
<svg
width="495"
height="150"
viewBox="0 0 495 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.stat { font-size:12px; }
}
.stagger {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
.not_bold { font-weight: 400 }
.bold { font-weight: 700 }
@keyframes slideInAnimation {
from {
width: 0;
}
to {
width: calc(100%-100px);
}
}
@keyframes growWidthAnimation {
from {
width: 0;
}
to {
width: 100%;
}
}
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
</g>
</svg>
</g>
</svg>
"
`;
@@ -0,0 +1,2 @@
process.env.GIST_WHITELIST = "bbfce31e0217a3689c8d961a356cb10d";
process.env.WHITELIST = "anuraghazra";
+299 -135
View File
@@ -1,181 +1,345 @@
// @ts-check
import { api, getConfig } from "@stats-organization/github-readme-stats-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import router from "../router.js";
import api from "../api-renamed/index.js";
import { calculateRank } from "../src/calculateRank.js";
import { renderStatsCard } from "../src/cards/stats.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
import { renderError } from "../src/common/render.js";
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
const { mockCore } = await import("./utils.js");
return mockCore();
import { data_stats, stats } from "./test-data/api-data.js";
stats.rank = calculateRank({
all_commits: false,
commits: stats.totalCommits,
prs: stats.totalPRs,
reviews: stats.totalReviews,
issues: stats.totalIssues,
repos: 1,
stars: stats.totalStars,
followers: 0,
});
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
...(await importOriginal()),
storeRequest: vi.fn(),
getUserAccessByName: vi.fn(),
}));
const error = {
errors: [
{
type: "NOT_FOUND",
path: ["user"],
locations: [],
message: "Could not fetch user",
},
],
};
const apiMock = vi.mocked(api);
const getConfigMock = vi.mocked(getConfig);
const storeRequestMock = vi.mocked(storeRequest);
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
const mock = new MockAdapter(axios);
const createRequest = (search = "") => ({
headers: {},
url: `/api?${search}`,
});
// @ts-ignore
const faker = (query, data) => {
const req = {
query: {
username: "anuraghazra",
...query,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
const errorCacheHeader =
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
return { req, res };
};
beforeEach(() => {
apiMock.mockReset();
getConfigMock.mockReset().mockReturnValue({});
storeRequestMock.mockReset().mockResolvedValue(undefined);
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
process.env.CACHE_SECONDS = undefined;
});
describe("Test /api backend routing", () => {
it("happy path should pass query params and user PAT, respond with stats content and persist request", async () => {
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
apiMock.mockResolvedValue({
status: "success",
content: "mock-stats-svg",
});
afterEach(() => {
mock.reset();
});
const req = createRequest(
"username=anuraghazra&theme=dark&hide=issues,prs,contribs",
describe("Test /api/", () => {
it("should test the request", async () => {
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, { ...req.query }),
);
const res = createResponse();
});
await router(req, res);
it("should render error card on error", async () => {
const { req, res } = faker({}, error);
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
expect(apiMock).toHaveBeenCalledWith(
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: error.errors[0].message,
secondaryMessage:
"Make sure the provided username is not an organization",
}),
);
});
it("should render error card in same theme as requested card", async () => {
const { req, res } = faker({ theme: "merko" }, error);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: error.errors[0].message,
secondaryMessage:
"Make sure the provided username is not an organization",
renderOptions: { theme: "merko" },
}),
);
});
it("should get the query options", async () => {
const { req, res } = faker(
{
username: "anuraghazra",
theme: "dark",
hide: "issues,prs,contribs",
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
},
"user-pat",
data_stats,
);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}),
);
expect(req.query).toEqual({
username: "anuraghazra",
theme: "dark",
hide: "issues,prs,contribs",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
});
it("should use the shorter error cache for temporary stats errors", async () => {
apiMock.mockResolvedValue({
status: "error - temporary",
content: "temporary-error-svg",
});
it("should have proper cache", async () => {
const { req, res } = faker({}, data_stats);
const req = createRequest("username=anuraghazra");
const res = createResponse();
await api(req, res);
await router(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
expect(apiMock).toHaveBeenCalledWith(
it("should set proper cache", async () => {
const cache_seconds = DURATIONS.TWELVE_HOURS;
const { req, res } = faker({ cache_seconds }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cache_seconds}, ` +
`s-maxage=${cache_seconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should set shorter cache when error", async () => {
const { req, res } = faker({}, error);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should properly set cache using CACHE_SECONDS env variable", async () => {
const cacheSeconds = "10000";
process.env.CACHE_SECONDS = cacheSeconds;
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cacheSeconds}, ` +
`s-maxage=${cacheSeconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should disable cache when CACHE_SECONDS is set to 0", async () => {
process.env.CACHE_SECONDS = "0";
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
],
["Pragma", "no-cache"],
["Expires", "0"],
]);
});
it("should set proper cache with clamped values", async () => {
{
let { req, res } = faker({ cache_seconds: 200_000 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MAX}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MAX}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
// note i'm using block scoped vars
{
let { req, res } = faker({ cache_seconds: 0 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
{
let { req, res } = faker({ cache_seconds: -10_000 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
});
it("should allow changing ring_color", async () => {
const { req, res } = faker(
{
username: "anuraghazra",
hide: "issues,prs,contribs",
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
ring_color: "0000ff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
},
null,
data_stats,
);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
ring_color: "0000ff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}),
);
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", errorCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
});
it("should not persist permanent stats errors returned by core", async () => {
apiMock.mockResolvedValue({
status: "error - permanent",
content: "permanent-error-svg",
});
it("should render error card when wrong locale is provided", async () => {
const { req, res } = faker({ locale: "asdf" }, data_stats);
const req = createRequest("username=anuraghazra");
const res = createResponse();
await api(req, res);
await router(req, res);
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
expect(apiMock).toHaveBeenCalledWith(
{
username: "anuraghazra",
},
null,
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
expect(storeRequestMock).not.toHaveBeenCalled();
});
it("should reject blacklisted usernames before calling core logic", async () => {
const req = createRequest("username=renovate-bot");
const res = createResponse();
it("should render error card when include_all_commits true and upstream API fails", async () => {
mock
.onGet(
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
)
.reply(200, { error: "Some test error message" });
await router(req, res);
expect(apiMock).not.toHaveBeenCalled();
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This username is blacklisted",
const { req, res } = faker(
{ username: "anuraghazra", include_all_commits: true },
data_stats,
);
expect(storeRequestMock).not.toHaveBeenCalled();
});
it("should reject non-whitelisted usernames before calling core logic", async () => {
getConfigMock.mockReturnValue({ whitelist: ["allowed-user"] });
await api(req, res);
const req = createRequest("username=blocked-user");
const res = createResponse();
await router(req, res);
expect(apiMock).not.toHaveBeenCalled();
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This username is not whitelisted",
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Could not fetch data from GitHub REST API.",
secondaryMessage: "Please try again later",
}),
);
// Received SVG output should not contain string "https://tiny.one/readme-stats"
expect(res.send.mock.calls[0][0]).not.toContain(
"https://tiny.one/readme-stats",
);
expect(storeRequestMock).not.toHaveBeenCalled();
});
});
+66 -25
View File
@@ -1,40 +1,81 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { beforeAll, bench, describe, vi } from "vitest";
import { bench, describe, vi } from "vitest";
import { data_stats } from "../utils.js";
import api from "../../api-renamed/index.js";
const stats = {
name: "Anurag Hazra",
totalStars: 100,
totalCommits: 200,
totalIssues: 300,
totalPRs: 400,
totalPRsMerged: 320,
mergedPRsPercentage: 80,
totalReviews: 50,
totalDiscussionsStarted: 10,
totalDiscussionsAnswered: 40,
contributedTo: 50,
rank: null,
};
const data_stats = {
data: {
user: {
name: stats.name,
repositoriesContributedTo: { totalCount: stats.contributedTo },
commits: {
totalCommitContributions: stats.totalCommits,
},
reviews: {
totalPullRequestReviewContributions: stats.totalReviews,
},
pullRequests: { totalCount: stats.totalPRs },
mergedPullRequests: { totalCount: stats.totalPRsMerged },
openIssues: { totalCount: stats.totalIssues },
closedIssues: { totalCount: 0 },
followers: { totalCount: 0 },
repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted },
repositoryDiscussionComments: {
totalCount: stats.totalDiscussionsAnswered,
},
repositories: {
totalCount: 1,
nodes: [{ stargazers: { totalCount: 100 } }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
},
};
const mock = new MockAdapter(axios);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const faker = (query, data) => {
const req = {
query: {
username: "anuraghazra",
...query,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
let router;
return { req, res };
};
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
});
describe("bench /api", () => {
describe("/api", () => {
bench(
"base",
async () => {
const req = {
headers: {},
url: "/api?username=anuraghazra",
};
const res = createResponse();
const { req, res } = faker({}, data_stats);
await router(req, res);
await api(req, res);
},
{ warmupIterations: 50 },
);
@@ -0,0 +1,22 @@
import { bench, describe } from "vitest";
import { calculateRank } from "../../src/calculateRank.js";
describe("calculateRank", () => {
bench(
"base",
async () => {
calculateRank({
all_commits: false,
commits: 1300,
prs: 1500,
issues: 4500,
reviews: 1000,
repos: 0,
stars: 600000,
followers: 50000,
});
},
{ warmupIterations: 50 },
);
});
+39 -27
View File
@@ -1,42 +1,54 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { beforeAll, bench, describe, vi } from "vitest";
import { bench, describe, vi } from "vitest";
import { happy_path_gist_data } from "../utils.js";
import gist from "../../api-renamed/gist.js";
const gist_data = {
data: {
viewer: {
gist: {
description:
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
owner: {
login: "Yizack",
},
stargazerCount: 33,
forks: {
totalCount: 11,
},
files: [
{
name: "countries.json",
language: {
name: "JSON",
},
size: 85858,
},
],
},
},
},
};
const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock
.onPost("https://api.github.com/graphql")
.reply(200, happy_path_gist_data);
});
describe("bench /api/gist", () => {
describe("test /api/gist", () => {
bench(
"base",
async () => {
const req = {
headers: {},
url: "/api/gist?id=happy-gist-id",
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
const res = createResponse();
await router(req, res);
await gist(req, res);
},
{ warmupIterations: 50 },
);
+38 -25
View File
@@ -1,40 +1,53 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { beforeAll, bench, describe, vi } from "vitest";
import { bench, describe, vi } from "vitest";
import { data_user } from "../utils.js";
import pin from "../../api-renamed/pin.js";
const data_repo = {
repository: {
username: "anuraghazra",
name: "convoychat",
stargazers: {
totalCount: 38000,
},
description: "Help us take over the world! React + TS + GraphQL Chat App",
primaryLanguage: {
color: "#2b7489",
id: "MDg6TGFuZ3VhZ2UyODc=",
name: "TypeScript",
},
forkCount: 100,
isTemplate: false,
},
};
const data_user = {
data: {
user: { repository: data_repo.repository },
organization: null,
},
};
const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
});
describe("bench /api/pin", () => {
describe("/api/pin", () => {
bench(
"base",
async () => {
const req = {
headers: {},
url: "/api/pin?username=anuraghazra&repo=convoychat",
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
const res = createResponse();
await router(req, res);
await pin(req, res);
},
{ warmupIterations: 50 },
);
@@ -4,6 +4,8 @@ import { calculateRank } from "../src/calculateRank.js";
import { approxNumber } from "./utils.js";
import "@testing-library/jest-dom/vitest";
describe("Test calculateRank", () => {
it("new user gets C rank", () => {
expect(
@@ -6,6 +6,8 @@ import { Card } from "../src/common/Card.js";
import { getCardColors } from "../src/common/color.js";
import { icons } from "../src/common/icons.js";
import "@testing-library/jest-dom/vitest";
describe("Card", () => {
it("should hide border", () => {
const card = new Card({});
+123 -245
View File
@@ -3,343 +3,221 @@
*/
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { beforeAll, describe, expect, test } from "vitest";
import { renderGistCard } from "../../src/cards/gist.js";
import { renderRepoCard } from "../../src/cards/repo.js";
import { renderStatsCard } from "../../src/cards/stats.js";
import { renderTopLanguages } from "../../src/cards/top-languages.js";
import { renderWakatimeCard } from "../../src/cards/wakatime.js";
const REPO = "curly-fiesta";
const USER = "catelinemnemosyne";
const STATS_CARD_USER = "e2eninja";
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
const STATS_MOCK_RESPONSE = {
data: {
user: {
name: "CodeNinja",
login: STATS_CARD_USER,
repositoriesContributedTo: { totalCount: 0 },
commits: {
totalCommitContributions: 3,
},
reviews: {
totalPullRequestReviewContributions: 0,
},
pullRequests: { totalCount: 1 },
openIssues: { totalCount: 1 },
closedIssues: { totalCount: 0 },
followers: { totalCount: 0 },
repositories: {
totalCount: 1,
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
const STATS_DATA = {
name: "CodeNinja",
totalPRs: 1,
totalReviews: 0,
totalCommits: 3,
totalIssues: 1,
totalStars: 1,
contributedTo: 0,
rank: {
level: "C",
percentile: 98.73972605284538,
},
};
const COMMITS_SEARCH_MOCK_RESPONSE = {
total_count: 3,
};
const TOP_LANGS_MOCK_RESPONSE = {
data: {
user: {
repositories: {
nodes: [
{
name: REPO,
languages: {
edges: [
{
size: 1721,
node: {
color: "#e34c26",
name: "HTML",
},
},
{
size: 930,
node: {
color: "#663399",
name: "CSS",
},
},
{
size: 1912,
node: {
color: "#f1e05a",
name: "JavaScript",
},
},
],
},
},
],
},
},
const LANGS_DATA = {
HTML: {
color: "#e34c26",
name: "HTML",
size: 1721,
},
CSS: {
color: "#663399",
name: "CSS",
size: 930,
},
JavaScript: {
color: "#f1e05a",
name: "JavaScript",
size: 1912,
},
};
const WAKATIME_MOCK_RESPONSE = {
data: {
human_readable_range: "last week",
is_already_updating: false,
is_coding_activity_visible: true,
is_including_today: false,
is_other_usage_visible: false,
is_stuck: false,
is_up_to_date: false,
is_up_to_date_pending_future: false,
percent_calculated: 0,
range: "all_time",
status: "pending_update",
timeout: 15,
username: USER,
writes_only: false,
},
const WAKATIME_DATA = {
human_readable_range: "last week",
is_already_updating: false,
is_coding_activity_visible: true,
is_including_today: false,
is_other_usage_visible: false,
is_stuck: false,
is_up_to_date: false,
is_up_to_date_pending_future: false,
percent_calculated: 0,
range: "all_time",
status: "pending_update",
timeout: 15,
username: USER,
writes_only: false,
};
const REPO_MOCK_RESPONSE = {
data: {
user: {
repository: {
name: REPO,
nameWithOwner: `${USER}/cra-test`,
isPrivate: false,
isArchived: false,
isTemplate: false,
stargazers: {
totalCount: 1,
},
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
id: "MDg6TGFuZ3VhZ2UxNDA=",
name: "JavaScript",
},
forkCount: 0,
},
},
organization: null,
const REPOSITORY_DATA = {
name: REPO,
nameWithOwner: `${USER}/cra-test`,
isPrivate: false,
isArchived: false,
isTemplate: false,
stargazers: {
totalCount: 1,
},
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
id: "MDg6TGFuZ3VhZ2UxNDA=",
name: "JavaScript",
},
forkCount: 0,
starCount: 1,
};
const GIST_MOCK_RESPONSE = {
data: {
viewer: {
gist: {
description:
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
owner: {
login: "qwerty541",
},
stargazerCount: 1,
forks: {
totalCount: 0,
},
files: [
{
name: "link.txt",
language: {
name: "Text",
},
size: 1,
},
],
},
},
},
/**
* @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
*/
/**
* @type {GistData}
*/
const GIST_DATA = {
name: "link.txt",
nameWithOwner: "qwerty541/link.txt",
description:
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
language: "Text",
starsCount: 1,
forksCount: 0,
};
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
/**
* Renders a card locally through the backend router.
* @param {string} url Card URL to render through the router.
* @returns {Promise<string>} Rendered SVG markup.
*/
async function getLocalSvg(url) {
const req = {
headers: {},
url,
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
return res.end.mock.calls[0][0];
}
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
vi.stubEnv("PAT_1", "dummyPAT1");
vi.stubEnv("PAT_2", "dummyPAT2");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply((config) => {
const { query, variables } = JSON.parse(config.data);
if (
query.includes("query userInfo") &&
variables?.login === STATS_CARD_USER
) {
return [200, STATS_MOCK_RESPONSE];
}
if (query.includes("query userInfo") && variables?.login === USER) {
return [200, TOP_LANGS_MOCK_RESPONSE];
}
if (query.includes("query getRepo")) {
return [200, REPO_MOCK_RESPONSE];
}
if (query.includes("query gistInfo")) {
return [200, GIST_MOCK_RESPONSE];
}
return [500, { error: "Unhandled GraphQL request in e2e test" }];
});
mock
.onGet(
`https://api.github.com/search/commits?per_page=1&q=author:${STATS_CARD_USER}`,
)
.reply(200, COMMITS_SEARCH_MOCK_RESPONSE);
mock
.onGet(
`https://wakatime.com/api/v1/users/${USER}/stats?is_including_today=true`,
)
.reply(200, WAKATIME_MOCK_RESPONSE);
});
afterAll(() => {
mock.restore();
vi.unstubAllEnvs();
});
describe("Fetch Cards", () => {
const VERCEL_PREVIEW_URL = "https://github-stats-extended-preview.vercel.app";
let VERCEL_PREVIEW_URL = "https://github-stats-extended.vercel.app";
beforeAll(() => {
process.env.NODE_ENV = "development";
});
test("retrieve stats card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance stats card function is up and running.
await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
axios.get(`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}`),
).resolves.not.toThrow();
// Get local stats card.
const localStatsCardSVG = await getLocalSvg(cardPath);
const localStatsCardSVG = renderStatsCard(STATS_DATA, {
include_all_commits: true,
});
// Get the Vercel preview stats card response.
const serverStatsSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
const serverStatsSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`,
);
// Check if stats card from deployment matches the stats card from local.
expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
}, 20000);
}, 15000);
test("retrieve language card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/top-langs?username=${USER}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance language card function is up and running.
console.log(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
);
await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
axios.get(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow();
// Get local language card.
const localLanguageCardSVG = await getLocalSvg(cardPath);
const localLanguageCardSVG = renderTopLanguages(LANGS_DATA);
// Get the Vercel preview language card response.
const serverLanguageSVG = await axios.get(
`${VERCEL_PREVIEW_URL}${cardPath}`,
const severLanguageSVG = await axios.get(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
);
// Check if language card from deployment matches the local language card.
expect(serverLanguageSVG.data).toEqual(localLanguageCardSVG);
}, 20000);
expect(severLanguageSVG.data).toEqual(localLanguageCardSVG);
}, 15000);
test("retrieve WakaTime card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance WakaTime function is up and running.
await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`),
).resolves.not.toThrow();
// Get local WakaTime card.
const localWakaCardSVG = await getLocalSvg(cardPath);
const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA);
// Get the Vercel preview WakaTime card response.
const serverWakaTimeSvg = await axios.get(
`${VERCEL_PREVIEW_URL}${cardPath}`,
`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`,
);
// Check if WakaTime card from deployment matches the local WakaTime card.
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
}, 20000);
}, 15000);
test("retrieve repo card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/pin?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance Repo function is up and running.
await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
axios.get(
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow();
// Get local repo card.
const localRepoCardSVG = await getLocalSvg(cardPath);
const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA);
// Get the Vercel preview repo card response.
const serverRepoSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
const serverRepoSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
);
// Check if Repo card from deployment matches the local Repo card.
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
}, 20000);
}, 15000);
test("retrieve gist card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance Gist function is up and running.
await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow();
// Get local gist card.
const localGistCardSVG = await getLocalSvg(cardPath);
const localGistCardSVG = renderGistCard(GIST_DATA);
// Get the Vercel preview gist card response.
const serverGistSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
const serverGistSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
);
// Check if Gist card from deployment matches the local Gist card.
expect(serverGistSvg.data).toEqual(localGistCardSVG);
}, 20000);
}, 15000);
});
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { fetchGist } from "../src/fetchers/gist.js";
import "@testing-library/jest-dom/vitest";
const gist_data = {
data: {
viewer: {
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { fetchRepo } from "../src/fetchers/repo.js";
import "@testing-library/jest-dom/vitest";
const data_repo = {
repository: {
name: "convoychat",
@@ -1,15 +1,11 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { calculateRank } from "../src/calculateRank.js";
import { loadConfigFromEnv } from "../src/common/config.js";
import { fetchStats } from "../src/fetchers/stats.js";
vi.mock(import("../src/common/log.js"), async () => {
const { createLoggerMock } = await import("./utils.js");
return createLoggerMock();
});
import "@testing-library/jest-dom/vitest";
// Test parameters.
const data_stats = {
@@ -111,7 +107,6 @@ const mock = new MockAdapter(axios);
beforeEach(() => {
process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars.
loadConfigFromEnv();
mock.onPost("https://api.github.com/graphql").reply((cfg) => {
let req = JSON.parse(cfg.data);
@@ -318,7 +313,6 @@ describe("Test fetchStats", () => {
it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => {
process.env.FETCH_MULTI_PAGE_STARS = true;
loadConfigFromEnv();
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
@@ -355,7 +349,6 @@ describe("Test fetchStats", () => {
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => {
process.env.FETCH_MULTI_PAGE_STARS = "false";
loadConfigFromEnv();
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
@@ -392,7 +385,6 @@ describe("Test fetchStats", () => {
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => {
process.env.FETCH_MULTI_PAGE_STARS = undefined;
loadConfigFromEnv();
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
@@ -539,6 +531,7 @@ describe("Test fetchStats", () => {
});
it("should return correct data when user don't have any pull requests", async () => {
mock.reset();
mock
.onPost("https://api.github.com/graphql")
.reply(200, data_without_pull_requests);
@@ -1,25 +1,17 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { approxNumber } from "./utils.js";
vi.mock(import("../src/common/log.js"), async () => {
const { createLoggerMock } = await import("./utils.js");
return createLoggerMock();
});
const { logger } = await import("../src/index.js");
const loggerErrorSpy = vi.mocked(logger.error);
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
loggerErrorSpy.mockClear();
});
const data_langs = {
@@ -159,8 +151,6 @@ describe("FetchTopLanguages", () => {
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Could not resolve to a User with the login of 'noname'.",
);
expect(loggerErrorSpy).toHaveBeenCalledOnce();
});
it("should throw other errors with their message", async () => {
@@ -171,8 +161,6 @@ describe("FetchTopLanguages", () => {
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Some test GraphQL error",
);
expect(loggerErrorSpy).toHaveBeenCalledOnce();
});
it("should throw error with specific message when error does not contain message property", async () => {
@@ -183,7 +171,5 @@ describe("FetchTopLanguages", () => {
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
);
expect(loggerErrorSpy).toHaveBeenCalledOnce();
});
});
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
@@ -72,12 +72,7 @@ describe("Test fmt.js", () => {
it("wrapTextMultiline: should not wrap small texts", () => {
{
let multiLineText = wrapTextMultiline(
"Small text should not wrap",
130,
11,
3,
);
let multiLineText = wrapTextMultiline("Small text should not wrap");
expect(multiLineText).toEqual(["Small text should not wrap"]);
}
});
@@ -85,31 +80,26 @@ describe("Test fmt.js", () => {
it("wrapTextMultiline: should wrap large texts", () => {
let multiLineText = wrapTextMultiline(
"Hello world long long long text",
130,
11,
20,
3,
);
expect(multiLineText).toEqual(["Hello world long long", "long text"]);
expect(multiLineText).toEqual(["Hello world long", "long long text"]);
});
it("wrapTextMultiline: should wrap large texts and limit max lines", () => {
let multiLineText = wrapTextMultiline(
"Hello world long long long text",
53,
11,
10,
2,
);
expect(multiLineText).toEqual(["Hello", "world long..."]);
});
it("wrapTextMultiline: should handle chinese characters", () => {
it("wrapTextMultiline: should wrap chinese by punctuation", () => {
let multiLineText = wrapTextMultiline(
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
130,
11,
3,
);
expect(multiLineText.length).toEqual(3);
expect(multiLineText[0].length).toEqual(11 * 8); // &#xxxxx; x 8
expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8
});
});
+142 -130
View File
@@ -1,149 +1,161 @@
// @ts-check
import { getConfig, gist } from "@stats-organization/github-readme-stats-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import router from "../router.js";
import gist from "../api-renamed/gist.js";
import { renderGistCard } from "../src/cards/gist.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
import { renderError } from "../src/common/render.js";
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
const { mockCore } = await import("./utils.js");
return mockCore();
import { gist_data } from "./test-data/gist-data.js";
import "@testing-library/jest-dom/vitest";
const gist_not_found_data = {
data: {
viewer: {
gist: null,
},
},
};
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
...(await importOriginal()),
storeRequest: vi.fn(),
getUserAccessByName: vi.fn(),
}));
describe("Test /api/gist", () => {
it("should test the request", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
const gistMock = vi.mocked(gist);
const getConfigMock = vi.mocked(getConfig);
const storeRequestMock = vi.mocked(storeRequest);
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
await gist(req, res);
const createRequest = (search) => ({
headers: {},
url: `/api/gist?${search}`,
});
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
const errorCacheHeader =
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
gistMock.mockReset();
getConfigMock.mockReset().mockReturnValue({});
storeRequestMock.mockReset().mockResolvedValue(undefined);
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderGistCard({
name: gist_data.data.viewer.gist.files[0].name,
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
description: gist_data.data.viewer.gist.description,
language: gist_data.data.viewer.gist.files[0].language.name,
starsCount: gist_data.data.viewer.gist.stargazerCount,
forksCount: gist_data.data.viewer.gist.forks.totalCount,
}),
);
});
describe("Test /api/gist backend routing", () => {
it("happy path should pass query params, respond with gist content and persist request", async () => {
gistMock.mockResolvedValue({
status: "success",
content: "mock-gist-svg",
});
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d&theme=dark");
const res = createResponse();
await router(req, res);
expect(gistMock).toHaveBeenCalledWith({
id: "bbfce31e0217a3689c8d961a356cb10d",
theme: "dark",
});
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(req.query).toEqual({
id: "bbfce31e0217a3689c8d961a356cb10d",
theme: "dark",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-gist-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
it("should get the query options", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
show_owner: true,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderGistCard(
{
name: gist_data.data.viewer.gist.files[0].name,
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
description: gist_data.data.viewer.gist.description,
language: gist_data.data.viewer.gist.files[0].language.name,
starsCount: gist_data.data.viewer.gist.stargazerCount,
forksCount: gist_data.data.viewer.gist.forks.totalCount,
},
{ ...req.query },
),
);
});
it("should use the shorter error cache for temporary gist errors", async () => {
gistMock.mockResolvedValue({
status: "error - temporary",
content: "temporary-error-svg",
});
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
const res = createResponse();
await router(req, res);
expect(gistMock).toHaveBeenCalledWith({
id: "bbfce31e0217a3689c8d961a356cb10d",
});
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", errorCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
it("should render error if gist is not found", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, gist_not_found_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Gist not found" }),
);
});
it("should not persist permanent gist errors returned by core", async () => {
gistMock.mockResolvedValue({
status: "error - permanent",
content: "permanent-error-svg",
});
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
const res = createResponse();
await router(req, res);
expect(gistMock).toHaveBeenCalledWith({
id: "bbfce31e0217a3689c8d961a356cb10d",
});
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
expect(storeRequestMock).not.toHaveBeenCalled();
it("should render error if wrong locale is provided", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
locale: "asdf",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
});
it("should reject non-whitelisted gist ids before calling core logic", async () => {
getConfigMock.mockReturnValue({ gistWhitelist: ["allowed-gist-id"] });
const req = createRequest("id=blocked-gist-id");
const res = createResponse();
await router(req, res);
expect(gistMock).not.toHaveBeenCalled();
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This gist ID is not whitelisted",
it("should have proper cache", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
);
expect(storeRequestMock).not.toHaveBeenCalled();
});
});
+13 -33
View File
@@ -4,15 +4,9 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
const mock = new MockAdapter(axios);
@@ -63,35 +57,20 @@ const bad_credentials_error = {
message: "Bad credentials",
};
let RATE_LIMIT_SECONDS, patInfo;
beforeAll(async () => {
vi.stubEnv("PAT_1", "testPAT1");
vi.stubEnv("PAT_2", "testPAT2");
vi.stubEnv("PAT_3", "testPAT3");
vi.stubEnv("PAT_4", "testPAT4");
const { logger } =
await import("@stats-organization/github-readme-stats-core");
vi.spyOn(logger, "log").mockImplementation(() => {});
vi.spyOn(logger, "error").mockImplementation(() => {});
({ RATE_LIMIT_SECONDS, default: patInfo } =
await import("../api-renamed/status/pat-info.js"));
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
afterAll(() => {
vi.restoreAllMocks();
});
describe("Test /api/status/pat-info", () => {
beforeAll(() => {
// reset patenv first so that they are not populated with local envs
process.env = {};
process.env.PAT_1 = "testPAT1";
process.env.PAT_2 = "testPAT2";
process.env.PAT_3 = "testPAT3";
process.env.PAT_4 = "testPAT4";
});
it("should return only 'validPATs' if all PATs are valid", async () => {
mock
.onPost("https://api.github.com/graphql")
@@ -264,6 +243,7 @@ describe("Test /api/status/pat-info", () => {
});
it("should have proper cache when error is thrown", async () => {
mock.reset();
mock.onPost("https://api.github.com/graphql").networkError();
const { req, res } = faker({}, {});
+162 -71
View File
@@ -1,84 +1,175 @@
// @ts-check
import { pin } from "@stats-organization/github-readme-stats-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import router from "../router.js";
import pin from "../api-renamed/pin.js";
import { renderRepoCard } from "../src/cards/repo.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
import { renderError } from "../src/common/render.js";
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
const { mockCore } = await import("./utils.js");
return mockCore();
import { data_repo, data_user } from "./test-data/pin-data.js";
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
...(await importOriginal()),
storeRequest: vi.fn(),
getUserAccessByName: vi.fn(),
}));
const pinMock = vi.mocked(pin);
const storeRequestMock = vi.mocked(storeRequest);
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
const createRequest = (search = "") => ({
headers: {},
url: `/api/pin?${search}`,
});
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
pinMock.mockReset();
storeRequestMock.mockReset().mockResolvedValue(undefined);
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
describe("Test /api/pin backend routing", () => {
it("happy path should pass query params and user PAT, respond with pin content and persist request", async () => {
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
pinMock.mockResolvedValue({
status: "success",
content: "mock-pin-svg",
});
const req = createRequest(
"username=anuraghazra&repo=convoychat&theme=dark",
);
const res = createResponse();
await router(req, res);
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
expect(pinMock).toHaveBeenCalledWith(
{
describe("Test /api/pin", () => {
it("should test the request", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
theme: "dark",
},
"user-pat",
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
// @ts-ignore
renderRepoCard({
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
}),
);
});
it("should get the query options", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
full_name: "1",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderRepoCard(
// @ts-ignore
{
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
},
{ ...req.query },
),
);
});
it("should render error card if user repo not found", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: { repository: null }, organization: null } });
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "User Repository Not found" }),
);
});
it("should render error card if org repo not found", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: null, organization: { repository: null } } });
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Organization Repository Not found" }),
);
});
it("should render error card if wrong locale provided", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
locale: "asdf",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
});
it("should have proper cache", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
);
expect(req.query).toEqual({
username: "anuraghazra",
repo: "convoychat",
theme: "dark",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-pin-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
});
});
@@ -0,0 +1,41 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import api from "../../api-renamed/index.js";
import { renderError } from "../../src/common/render.js";
import { data_stats } from "../test-data/api-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,41 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import gist from "../../api-renamed/gist.js";
import { renderError } from "../../src/common/render.js";
import { gist_data } from "../test-data/gist-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/gist with gist whitelist", () => {
it("should render error card if id not in whitelist", async () => {
const req = {
query: {
id: "9bae0392ee3a26bac5cc388a6c8b1469",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This gist ID is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,64 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import pin from "../../api-renamed/pin.js";
import { renderError } from "../../src/common/render.js";
import { data_user } from "../test-data/pin-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/pin", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
it("should render error card if missing required parameters", async () => {
const req = {
query: {},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,41 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import topLangs from "../../api-renamed/top-langs.js";
import { renderError } from "../../src/common/render.js";
import { data_langs } from "../test-data/langs-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/top-langs", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
await topLangs(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});

Some files were not shown because too many files have changed in this diff Show More