Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f4ad921aa | ||
|
|
5557e9c772 | ||
|
|
570fc4aaed | ||
|
|
b6f4183ddd | ||
|
|
2ac454a177 | ||
|
|
b837c50885 | ||
|
|
dfb07a69b9 | ||
|
|
41b51dcfd9 | ||
|
|
7687c47d85 | ||
|
|
7917d54fdf | ||
|
|
7771a34790 | ||
|
|
1c266ad51e | ||
|
|
6258d90604 | ||
|
|
6d857d0f34 | ||
|
|
e15deeac76 | ||
|
|
b5d50fc28f | ||
|
|
1d394c7fad | ||
|
|
76e09ce3b0 | ||
|
|
96d141e8c7 | ||
|
|
6310073428 | ||
|
|
1d8aaf0fc8 | ||
|
|
4a2785e286 | ||
|
|
73189400ee | ||
|
|
16ea9afebe | ||
|
|
8a239313d3 | ||
|
|
c707e6b7b1 | ||
|
|
a3dbb37dd2 | ||
|
|
9c4e631bc7 | ||
|
|
9dc4910d4b | ||
|
|
5d1978011e | ||
|
|
6ebd9a082f | ||
|
|
d84cc27b9c | ||
|
|
e43e55c2b7 | ||
|
|
f884df6312 | ||
|
|
9236acd99d | ||
|
|
12b4e979a8 | ||
|
|
f67dd0f381 | ||
|
|
1da425c0bb | ||
|
|
46df37adb7 | ||
|
|
f766c3b789 | ||
|
|
8305746754 | ||
|
|
b863992994 | ||
|
|
fe5ef06049 | ||
|
|
134b738151 | ||
|
|
3263dd9c78 | ||
|
|
b4ae648153 | ||
|
|
c401be4d6c | ||
|
|
f0c250a954 | ||
|
|
a963caaf5b | ||
|
|
eab14c8932 | ||
|
|
457c457cab | ||
|
|
609578da0c | ||
|
|
c3f338780b | ||
|
|
c4a4220448 | ||
|
|
9d8a7b7f0c | ||
|
|
0898dbfb00 | ||
|
|
6d3145c3d8 | ||
|
|
5156a811cb | ||
|
|
e263e5dfc4 | ||
|
|
f8094ab5d3 | ||
|
|
9c0f4a8390 | ||
|
|
1779475e1b | ||
|
|
b474572598 | ||
|
|
4cabac4bc1 | ||
|
|
d2a2274eeb | ||
|
|
30e2233ae4 | ||
|
|
4c2628fa67 | ||
|
|
9dcfce415d | ||
|
|
ae341596f3 | ||
|
|
c63bf09478 | ||
|
|
c772f6ae56 | ||
|
|
d5e7217b4f | ||
|
|
672777a7e1 | ||
|
|
3380ee97cc | ||
|
|
baf45b7e21 | ||
|
|
fc4dc9d010 | ||
|
|
805e668715 | ||
|
|
0e45db3041 | ||
|
|
b29d7989eb | ||
|
|
b2360c535a | ||
|
|
a552a04942 | ||
|
|
775abe704c | ||
|
|
fca39d25a8 | ||
|
|
dbe6aa0033 | ||
|
|
b40c4b7915 | ||
|
|
f7b1b9f382 | ||
|
|
5ccb799198 | ||
|
|
00f5ac900e | ||
|
|
3b67bd6bcb | ||
|
|
e58bee0bfd | ||
|
|
f346dcaa73 | ||
|
|
fc4ee7507b | ||
|
|
d476f8cbe1 | ||
|
|
19c39f02af |
@@ -5,9 +5,9 @@
|
||||
To set up the project GitHub-Stats-Extended locally, run the following commands:
|
||||
|
||||
```bash
|
||||
./vercel-preparation.sh
|
||||
pnpm install
|
||||
pnpm --filter frontend run build
|
||||
pnpm run build:packages
|
||||
pnpm run dev:frontend
|
||||
```
|
||||
|
||||
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@v4
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js (via input)
|
||||
if: ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
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@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
+49
-9
@@ -4,9 +4,8 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: cron
|
||||
cronjob: every day at 5am
|
||||
open-pull-requests-limit: 10
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
commit-message:
|
||||
prefix: "build(deps)"
|
||||
prefix-development: "build(deps-dev)"
|
||||
@@ -15,14 +14,56 @@ 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: cron
|
||||
cronjob: every day at 5am
|
||||
open-pull-requests-limit: 10
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
prefix-development: "ci(deps-dev)"
|
||||
@@ -33,9 +74,8 @@ updates:
|
||||
- package-ecosystem: devcontainers
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: cron
|
||||
cronjob: every day at 5am
|
||||
open-pull-requests-limit: 10
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
commit-message:
|
||||
prefix: "build(deps)"
|
||||
prefix-development: "build(deps-dev)"
|
||||
|
||||
+35
-29
@@ -31,26 +31,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run vercel-preparation.sh
|
||||
run: |
|
||||
chmod +x ./vercel-preparation.sh
|
||||
./vercel-preparation.sh
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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 --filter frontend run build
|
||||
run: pnpm run build:frontend
|
||||
|
||||
- name: Run frontend tests
|
||||
run: pnpm --filter frontend run test
|
||||
|
||||
- name: Run backend tests
|
||||
run: pnpm --filter github-readme-stats run test
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
frontend-test-e2e:
|
||||
name: Frontend E2E test
|
||||
@@ -64,21 +59,42 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Run vercel-preparation.sh
|
||||
run: |
|
||||
chmod +x ./vercel-preparation.sh
|
||||
./vercel-preparation.sh
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm --filter frontend run test:e2e
|
||||
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
|
||||
|
||||
code-checks:
|
||||
name: Code checks
|
||||
@@ -90,17 +106,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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 ESLint’s 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
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "apps/backend/themes/index.js"
|
||||
- "packages/core/src/themes/index.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
@@ -30,17 +30,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
run: |
|
||||
pnpm --filter github-readme-stats run theme-readme-gen
|
||||
pnpm --filter ./packages/core/ run theme-readme-gen
|
||||
|
||||
- name: Create Pull Request if themes README has changed
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
commit-message: "feat(backend): update themes README"
|
||||
branch: "update_themes_readme/patch"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -15,12 +15,16 @@ 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@v2
|
||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
||||
with:
|
||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||
method: "POST"
|
||||
|
||||
@@ -38,16 +38,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Run update-languages-json.js script
|
||||
run: pnpm --filter github-readme-stats run generate-langs-json
|
||||
run: pnpm --filter ./packages/core/ run generate-langs-json
|
||||
|
||||
- name: Create Pull Request if upstream language file is changed
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
commit-message: "feat(backend): update languages JSON"
|
||||
branch: "update_langs/patch"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
+4
-13
@@ -1,18 +1,8 @@
|
||||
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
|
||||
|
||||
@@ -26,11 +16,12 @@ apps/backend/vercel_token
|
||||
apps/backend-copy
|
||||
|
||||
apps/frontend/.env
|
||||
apps/frontend/src/backend
|
||||
apps/frontend/build
|
||||
|
||||
|
||||
.turbo
|
||||
build
|
||||
build-ts
|
||||
tsconfig.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
pnpm lint-staged
|
||||
pnpm run lint
|
||||
# TODO enable
|
||||
# npm test
|
||||
pnpm test
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
<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 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.
|
||||
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.
|
||||
|
||||
# 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)
|
||||
- [Self-Hosting](#self-hosting)
|
||||
- [Run It Yourself](#run-it-yourself)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
@@ -27,6 +28,16 @@ This project is an [extended version](docs/fork.md) of [github-readme-stats](htt
|
||||
|
||||
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:
|
||||
|
||||
@@ -55,13 +66,13 @@ As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://
|
||||
# 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 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.
|
||||
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! ❤️
|
||||
|
||||
# Contributing
|
||||
Contributions are welcome!
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "GitHub Stats Extended Dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "24" }
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"portsAttributes": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"runtime": "nodejs22.x",
|
||||
"runtime": "nodejs24.x",
|
||||
"handler": "router.js",
|
||||
"launcherType": "Nodejs"
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/* 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,4 +1,5 @@
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
import { authenticate } from "../src/users.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,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 ..."
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// @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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -6,10 +6,12 @@
|
||||
*
|
||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||
*/
|
||||
|
||||
import { request } from "../../src/common/http.js";
|
||||
import { logger } from "../../src/common/log.js";
|
||||
import { dateDiff } from "../../src/common/ops.js";
|
||||
import {
|
||||
dateDiff,
|
||||
getConfig,
|
||||
logger,
|
||||
request,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||
|
||||
@@ -39,7 +41,7 @@ const uptimeFetcher = (variables, token) => {
|
||||
};
|
||||
|
||||
const getAllPATs = () => {
|
||||
return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
|
||||
return getConfig().pats;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,7 +63,7 @@ const getPATInfo = async (fetcher, variables) => {
|
||||
|
||||
for (const pat of PATs) {
|
||||
try {
|
||||
const response = await fetcher(variables, process.env[pat]);
|
||||
const response = await fetcher(variables, pat.value);
|
||||
const errors = response.data.errors;
|
||||
const hasErrors = Boolean(errors);
|
||||
const errorType = errors?.[0]?.type;
|
||||
@@ -71,7 +73,7 @@ const getPATInfo = async (fetcher, variables) => {
|
||||
|
||||
// Store PATs with errors.
|
||||
if (hasErrors && errorType !== "RATE_LIMITED") {
|
||||
details[pat] = {
|
||||
details[pat.name] = {
|
||||
status: "error",
|
||||
error: {
|
||||
type: errors[0].type,
|
||||
@@ -82,13 +84,13 @@ const getPATInfo = async (fetcher, variables) => {
|
||||
} else if (isRateLimited) {
|
||||
const date1 = new Date();
|
||||
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
|
||||
details[pat] = {
|
||||
details[pat.name] = {
|
||||
status: "exhausted",
|
||||
remaining: 0,
|
||||
resetIn: dateDiff(date2, date1) + " minutes",
|
||||
};
|
||||
} else {
|
||||
details[pat] = {
|
||||
details[pat.name] = {
|
||||
status: "valid",
|
||||
remaining: response.data.data.rateLimit.remaining,
|
||||
};
|
||||
@@ -97,11 +99,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] = {
|
||||
details[pat.name] = {
|
||||
status: "expired",
|
||||
};
|
||||
} else if (errorMessage === "sorry. your account was suspended.") {
|
||||
details[pat] = {
|
||||
details[pat.name] = {
|
||||
status: "suspended",
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||
*/
|
||||
|
||||
import { request } from "../../src/common/http.js";
|
||||
import { logger } from "../../src/common/log.js";
|
||||
import { default as retryer } from "../../src/common/retryer.js";
|
||||
import {
|
||||
logger,
|
||||
request,
|
||||
retryer,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||
|
||||
@@ -87,7 +89,7 @@ export default async (req, res) => {
|
||||
try {
|
||||
let PATsValid = true;
|
||||
try {
|
||||
await retryer(uptimeFetcher, null, {});
|
||||
await retryer(uptimeFetcher, {});
|
||||
} catch (err) {
|
||||
// Resolve eslint no-unused-vars
|
||||
err;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
||||
import {
|
||||
fetchWakatimeStats,
|
||||
logger,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// @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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
+2
-13
@@ -1,21 +1,10 @@
|
||||
import express from "express";
|
||||
|
||||
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";
|
||||
import router from "./router.js";
|
||||
|
||||
const app = express();
|
||||
const router = express.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);
|
||||
app.use(router);
|
||||
|
||||
const port = process.env.PORT || process.env.port || 9000;
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as router } from "./router.js";
|
||||
+15
-38
@@ -1,52 +1,29 @@
|
||||
{
|
||||
"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",
|
||||
"name": "@stats-organization/github-readme-stats-backend",
|
||||
"type": "module",
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
},
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:update:snapshot": "vitest -u",
|
||||
"test:e2e": "vitest --config vitest.config.e2e.ts",
|
||||
"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"
|
||||
"bench": "vitest bench --run --config vitest.config.bench.ts",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
||||
},
|
||||
"author": "Anurag Hazra",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"express": "5.2.1",
|
||||
"jsdom": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"dependencies": {
|
||||
"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"
|
||||
"axios": "catalog:default",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"pg": "^8.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,225 @@
|
||||
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,4 +0,0 @@
|
||||
export { renderRepoCard } from "./repo.js";
|
||||
export { renderStatsCard } from "./stats.js";
|
||||
export { renderTopLanguages } from "./top-languages.js";
|
||||
export { renderWakatimeCard } from "./wakatime.js";
|
||||
@@ -1,8 +1,11 @@
|
||||
// @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";
|
||||
@@ -12,36 +15,35 @@ 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 = ({ res, id, type, colors }) => {
|
||||
const guardAccess = ({ id, type, colors }) => {
|
||||
if (!["username", "gist", "wakatime"].includes(type)) {
|
||||
throw new Error(
|
||||
'Invalid type. Expected "username", "gist", or "wakatime".',
|
||||
);
|
||||
}
|
||||
|
||||
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
|
||||
const config = getConfig();
|
||||
const currentWhitelist =
|
||||
type === "gist" ? config.gistWhitelist : config.whitelist;
|
||||
const notWhitelistedMsg =
|
||||
type === "gist"
|
||||
? NOT_WHITELISTED_GIST_MESSAGE
|
||||
: NOT_WHITELISTED_USERNAME_MESSAGE;
|
||||
|
||||
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
|
||||
const result = res.send(
|
||||
renderError({
|
||||
message: notWhitelistedMsg,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = renderError({
|
||||
message: notWhitelistedMsg,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
});
|
||||
return { isPassed: false, result };
|
||||
}
|
||||
|
||||
@@ -50,16 +52,14 @@ const guardAccess = ({ res, id, type, colors }) => {
|
||||
currentWhitelist === undefined &&
|
||||
blacklist.includes(id)
|
||||
) {
|
||||
const result = res.send(
|
||||
renderError({
|
||||
message: BLACKLISTED_MESSAGE,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = renderError({
|
||||
message: BLACKLISTED_MESSAGE,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
});
|
||||
return { isPassed: false, result };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import { clampValue } from "./ops.js";
|
||||
import { clampValue } from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
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 || process.env.NODE_ENV === "development") {
|
||||
if (cacheSeconds < 1) {
|
||||
disableCaching(res);
|
||||
return;
|
||||
}
|
||||
@@ -128,10 +128,7 @@ const setErrorCacheHeaders = (res) => {
|
||||
const envCacheSeconds = process.env.CACHE_SECONDS
|
||||
? parseInt(process.env.CACHE_SECONDS, 10)
|
||||
: NaN;
|
||||
if (
|
||||
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
|
||||
process.env.NODE_ENV === "development"
|
||||
) {
|
||||
if (!isNaN(envCacheSeconds) && envCacheSeconds < 1) {
|
||||
disableCaching(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* 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;
|
||||
export let pool = null;
|
||||
if (process.env.POSTGRES_URL) {
|
||||
const { Pool } = await import("pg");
|
||||
pool = new Pool({
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all required tables if they do not exist.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// @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,13 +0,0 @@
|
||||
// @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";
|
||||
@@ -1,13 +0,0 @@
|
||||
// @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 };
|
||||
@@ -1,239 +0,0 @@
|
||||
// @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,2 +0,0 @@
|
||||
export * from "./common/index.js";
|
||||
export * from "./cards/index.js";
|
||||
@@ -80,6 +80,7 @@ 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;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// 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>
|
||||
"
|
||||
`;
|
||||
@@ -1,2 +0,0 @@
|
||||
process.env.GIST_WHITELIST = "bbfce31e0217a3689c8d961a356cb10d";
|
||||
process.env.WHITELIST = "anuraghazra";
|
||||
+133
-297
@@ -1,345 +1,181 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, getConfig } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import api from "../api-renamed/index.js";
|
||||
import { calculateRank } from "../src/calculateRank.js";
|
||||
import { renderStatsCard } from "../src/cards/stats.js";
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
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("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
const error = {
|
||||
errors: [
|
||||
{
|
||||
type: "NOT_FOUND",
|
||||
path: ["user"],
|
||||
locations: [],
|
||||
message: "Could not fetch user",
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
const apiMock = vi.mocked(api);
|
||||
const getConfigMock = vi.mocked(getConfig);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
// @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 createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api?${search}`,
|
||||
});
|
||||
|
||||
return { req, res };
|
||||
};
|
||||
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}`;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CACHE_SECONDS = undefined;
|
||||
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;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
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",
|
||||
});
|
||||
|
||||
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 req = createRequest(
|
||||
"username=anuraghazra&theme=dark&hide=issues,prs,contribs",
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
it("should render error card on error", async () => {
|
||||
const { req, res } = faker({}, error);
|
||||
await router(req, res);
|
||||
|
||||
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(
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
{
|
||||
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",
|
||||
},
|
||||
data_stats,
|
||||
"user-pat",
|
||||
);
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should have proper cache", async () => {
|
||||
const { req, res } = faker({}, data_stats);
|
||||
|
||||
await api(req, res);
|
||||
|
||||
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"],
|
||||
[
|
||||
"Cache-Control",
|
||||
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||
],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
|
||||
it("should set proper cache", async () => {
|
||||
const cache_seconds = DURATIONS.TWELVE_HOURS;
|
||||
const { req, res } = faker({ cache_seconds }, data_stats);
|
||||
await api(req, res);
|
||||
it("should use the shorter error cache for temporary stats errors", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
status: "error - temporary",
|
||||
content: "temporary-error-svg",
|
||||
});
|
||||
|
||||
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}`,
|
||||
],
|
||||
]);
|
||||
});
|
||||
const req = createRequest("username=anuraghazra");
|
||||
const res = createResponse();
|
||||
|
||||
it("should set shorter cache when error", async () => {
|
||||
const { req, res } = faker({}, error);
|
||||
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.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(
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
{
|
||||
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",
|
||||
},
|
||||
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",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
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 card when wrong locale is provided", async () => {
|
||||
const { req, res } = faker({ locale: "asdf" }, data_stats);
|
||||
it("should not persist permanent stats errors returned by core", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
status: "error - permanent",
|
||||
content: "permanent-error-svg",
|
||||
});
|
||||
|
||||
await api(req, res);
|
||||
const req = createRequest("username=anuraghazra");
|
||||
const res = createResponse();
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
}),
|
||||
await router(req, res);
|
||||
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
},
|
||||
null,
|
||||
);
|
||||
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 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" });
|
||||
it("should reject blacklisted usernames before calling core logic", async () => {
|
||||
const req = createRequest("username=renovate-bot");
|
||||
const res = createResponse();
|
||||
|
||||
const { req, res } = faker(
|
||||
{ username: "anuraghazra", include_all_commits: true },
|
||||
data_stats,
|
||||
);
|
||||
await router(req, res);
|
||||
|
||||
await api(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",
|
||||
);
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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",
|
||||
it("should reject non-whitelisted usernames before calling core logic", async () => {
|
||||
getConfigMock.mockReturnValue({ whitelist: ["allowed-user"] });
|
||||
|
||||
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(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +1,40 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { bench, describe, vi } from "vitest";
|
||||
import { beforeAll, bench, describe, vi } from "vitest";
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
import { data_stats } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
return { req, res };
|
||||
};
|
||||
let router;
|
||||
|
||||
describe("/api", () => {
|
||||
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", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const { req, res } = faker({}, data_stats);
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await api(req, res);
|
||||
await router(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
});
|
||||
@@ -1,54 +1,42 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { bench, describe, vi } from "vitest";
|
||||
import { beforeAll, bench, describe, vi } from "vitest";
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
import { happy_path_gist_data } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||
|
||||
describe("test /api/gist", () => {
|
||||
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", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const req = {
|
||||
query: {
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
headers: {},
|
||||
url: "/api/gist?id=happy-gist-id",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await gist(req, res);
|
||||
await router(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
|
||||
@@ -1,53 +1,40 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { bench, describe, vi } from "vitest";
|
||||
import { beforeAll, bench, describe, vi } from "vitest";
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
import { data_user } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
describe("/api/pin", () => {
|
||||
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", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
repo: "convoychat",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
headers: {},
|
||||
url: "/api/pin?username=anuraghazra&repo=convoychat",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await pin(req, res);
|
||||
await router(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
|
||||
+244
-122
@@ -3,221 +3,343 @@
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
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";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const REPO = "curly-fiesta";
|
||||
const USER = "catelinemnemosyne";
|
||||
const STATS_CARD_USER = "e2eninja";
|
||||
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
|
||||
|
||||
const STATS_DATA = {
|
||||
name: "CodeNinja",
|
||||
totalPRs: 1,
|
||||
totalReviews: 0,
|
||||
totalCommits: 3,
|
||||
totalIssues: 1,
|
||||
totalStars: 1,
|
||||
contributedTo: 0,
|
||||
rank: {
|
||||
level: "C",
|
||||
percentile: 98.73972605284538,
|
||||
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 LANGS_DATA = {
|
||||
HTML: {
|
||||
color: "#e34c26",
|
||||
name: "HTML",
|
||||
size: 1721,
|
||||
},
|
||||
CSS: {
|
||||
color: "#663399",
|
||||
name: "CSS",
|
||||
size: 930,
|
||||
},
|
||||
JavaScript: {
|
||||
color: "#f1e05a",
|
||||
name: "JavaScript",
|
||||
size: 1912,
|
||||
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 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 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 REPOSITORY_DATA = {
|
||||
name: REPO,
|
||||
nameWithOwner: `${USER}/cra-test`,
|
||||
isPrivate: false,
|
||||
isArchived: false,
|
||||
isTemplate: false,
|
||||
stargazers: {
|
||||
totalCount: 1,
|
||||
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,
|
||||
},
|
||||
description: "Simple cra test repo.",
|
||||
primaryLanguage: {
|
||||
color: "#f1e05a",
|
||||
id: "MDg6TGFuZ3VhZ2UxNDA=",
|
||||
name: "JavaScript",
|
||||
},
|
||||
forkCount: 0,
|
||||
starCount: 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 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
|
||||
|
||||
describe("Fetch Cards", () => {
|
||||
let VERCEL_PREVIEW_URL = "https://github-stats-extended.vercel.app";
|
||||
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = "development";
|
||||
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";
|
||||
|
||||
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}/api?username=${STATS_CARD_USER}`),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local stats card.
|
||||
const localStatsCardSVG = renderStatsCard(STATS_DATA, {
|
||||
include_all_commits: true,
|
||||
});
|
||||
const localStatsCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview stats card response.
|
||||
const serverStatsSvg = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`,
|
||||
);
|
||||
const serverStatsSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
|
||||
|
||||
// Check if stats card from deployment matches the stats card from local.
|
||||
expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
|
||||
}, 15000);
|
||||
}, 20000);
|
||||
|
||||
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}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||
),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local language card.
|
||||
const localLanguageCardSVG = renderTopLanguages(LANGS_DATA);
|
||||
const localLanguageCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview language card response.
|
||||
const severLanguageSVG = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||
const serverLanguageSVG = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
||||
);
|
||||
|
||||
// Check if language card from deployment matches the local language card.
|
||||
expect(severLanguageSVG.data).toEqual(localLanguageCardSVG);
|
||||
}, 15000);
|
||||
expect(serverLanguageSVG.data).toEqual(localLanguageCardSVG);
|
||||
}, 20000);
|
||||
|
||||
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}/api/wakatime?username=${USER}`),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local WakaTime card.
|
||||
const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA);
|
||||
const localWakaCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview WakaTime card response.
|
||||
const serverWakaTimeSvg = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`,
|
||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
||||
);
|
||||
|
||||
// Check if WakaTime card from deployment matches the local WakaTime card.
|
||||
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
|
||||
}, 15000);
|
||||
}, 20000);
|
||||
|
||||
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}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
|
||||
),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local repo card.
|
||||
const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA);
|
||||
const localRepoCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview repo card response.
|
||||
const serverRepoSvg = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
|
||||
);
|
||||
const serverRepoSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
|
||||
|
||||
// Check if Repo card from deployment matches the local Repo card.
|
||||
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
|
||||
}, 15000);
|
||||
}, 20000);
|
||||
|
||||
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}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
|
||||
),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local gist card.
|
||||
const localGistCardSVG = renderGistCard(GIST_DATA);
|
||||
const localGistCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview gist card response.
|
||||
const serverGistSvg = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
|
||||
);
|
||||
const serverGistSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
|
||||
|
||||
// Check if Gist card from deployment matches the local Gist card.
|
||||
expect(serverGistSvg.data).toEqual(localGistCardSVG);
|
||||
}, 15000);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
+124
-136
@@ -1,161 +1,149 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getConfig, gist } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import gist from "../api-renamed/gist.js";
|
||||
import { renderGistCard } from "../src/cards/gist.js";
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
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("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
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);
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
await gist(req, res);
|
||||
const gistMock = vi.mocked(gist);
|
||||
const getConfigMock = vi.mocked(getConfig);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
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;
|
||||
});
|
||||
|
||||
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);
|
||||
it("should use the shorter error cache for temporary gist errors", async () => {
|
||||
gistMock.mockResolvedValue({
|
||||
status: "error - temporary",
|
||||
content: "temporary-error-svg",
|
||||
});
|
||||
|
||||
await gist(req, res);
|
||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
|
||||
const res = createResponse();
|
||||
|
||||
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 },
|
||||
),
|
||||
);
|
||||
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);
|
||||
it("should not persist permanent gist errors returned by core", async () => {
|
||||
gistMock.mockResolvedValue({
|
||||
status: "error - permanent",
|
||||
content: "permanent-error-svg",
|
||||
});
|
||||
|
||||
await gist(req, res);
|
||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
|
||||
const res = createResponse();
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({ message: "Gist not found" }),
|
||||
);
|
||||
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);
|
||||
it("should reject non-whitelisted gist ids before calling core logic", async () => {
|
||||
getConfigMock.mockReturnValue({ gistWhitelist: ["allowed-gist-id"] });
|
||||
|
||||
await gist(req, res);
|
||||
const req = createRequest("id=blocked-gist-id");
|
||||
const res = createResponse();
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await router(req, res);
|
||||
|
||||
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(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",
|
||||
);
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
@@ -57,20 +63,35 @@ 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")
|
||||
@@ -243,7 +264,6 @@ 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({}, {});
|
||||
|
||||
+71
-162
@@ -1,175 +1,84 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { pin } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import pin from "../api-renamed/pin.js";
|
||||
import { renderRepoCard } from "../src/cards/repo.js";
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
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("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
describe("Test /api/pin", () => {
|
||||
it("should test the request", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
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(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
repo: "convoychat",
|
||||
theme: "dark",
|
||||
},
|
||||
};
|
||||
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}`,
|
||||
"user-pat",
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// @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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// @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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
// @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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// @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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is blacklisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="450" height="195" viewBox="0 0 450 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId">Anurag Hazra's GitHub Stats, Rank: A+</title>
|
||||
<desc id="descId">Total Stars Earned: 14099, Total Commits (last year) : 200, Total PRs: 4000, Total Issues: 340, Contributed to (last year): 51</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;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58;
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #4c71f2;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #2f80ed;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #2f80ed;
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: 251.32741228718345;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 25.665014275610705;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" 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">Anurag Hazra's GitHub Stats</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<g data-testid="rank-circle" transform="translate(365, 47.5)">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40"/>
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40"/>
|
||||
<g class="rank-text">
|
||||
|
||||
<text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
|
||||
A+
|
||||
</text>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
<svg x="0" y="0">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Stars Earned:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="stars">14.1k</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Commits (last year):</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="commits">200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 50)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total PRs:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="prs">4k</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 75)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Issues:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="issues">340</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 100)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Contributed to (last year):</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="contribs">51</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="540" height="365" viewBox="0 0 540 365" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId">a custom title, Rank: A+</title>
|
||||
<desc id="descId">कुल अर्जित सितारे: 4100, कुल commits (2024) : 200, कुल PR: 4000, कुल PR का विलय: 3200, मर्ज किए गए PRs प्रतिशत: 80.0, कुल PRs की समीक्षा की गई: 1234, कुल चर्चाएँ शुरू हुईं: 222, कुल चर्चाओं के उत्तर: 111, (पिछले वर्ष) में योगदान दिया: 51</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
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: #abcdef;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef;
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #ff00aa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #654321;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #654321;
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: 251.32741228718345;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 26.378312814443284;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="539" fill="#0f172a" 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">a custom title</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<g data-testid="rank-circle" transform="translate(463.5, 132.5)">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40"/>
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40"/>
|
||||
<g class="rank-text">
|
||||
|
||||
<svg x="-38" y="-30" height="66" width="66" aria-hidden="true" viewBox="0 0 16 16" version="1.1" data-view-component="true" data-testid="github-rank-icon">
|
||||
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
<svg x="0" y="0">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<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"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल अर्जित सितारे:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="stars">4100</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 32)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<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"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल commits (2024):</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="commits">200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 64)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल PR:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs">4000</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 96)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल PR का विलय:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs_merged">3200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 128)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">मर्ज किए गए PRs प्रतिशत:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs_merged_percentage">80.0 %</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 160)">
|
||||
<g class="stagger" style="animation-delay: 1200ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल PRs की समीक्षा की गई:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="reviews">1234</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 192)">
|
||||
<g class="stagger" style="animation-delay: 1350ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल चर्चाएँ शुरू हुईं:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="discussions_started">222</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 224)">
|
||||
<g class="stagger" style="animation-delay: 1500ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल चर्चाओं के उत्तर:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="discussions_answered">111</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 256)">
|
||||
<g class="stagger" style="animation-delay: 1650ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">(पिछले वर्ष) में योगदान दिया:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="contribs">51</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,258 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/gist contract > should match the private missing-id response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This gist ID is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/gist contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="180" viewBox="0 0 400 180" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<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; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.icon { fill: #586069 }
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">countries.json</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">List of countries and territories in English and Spanish: name,</tspan><tspan dy="1.2em" x="25">continent, capital, dial code, country codes, TLD, and area in</tspan><tspan dy="1.2em" x="25">sq km. Lista de países y territorios en Inglés y Español:</tspan><tspan dy="1.2em" x="25">nombre, continente, capital, código de teléfono, códigos de</tspan><tspan dy="1.2em" x="25">país, dominio y área en km cuadrados. Updated 2023</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 105)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#292929"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">JSON</text>
|
||||
</g>
|
||||
</g><g transform="translate(53.67999954223633, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<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"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="starsCount" class="gray">33</text></g></g><g transform="translate(107.59999923706056, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forksCount" class="gray">11</text></g></g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/gist contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="180" viewBox="0 0 400 180" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.icon { fill: #ff00aa }
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="399" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">Yizack/countries.json</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">List of countries and territories in English and Spanish: name,</tspan><tspan dy="1.2em" x="25">continent, capital, dial code, country codes, TLD, and area in</tspan><tspan dy="1.2em" x="25">sq km. Lista de países y territorios en Inglés y Español:</tspan><tspan dy="1.2em" x="25">nombre, continente, capital, código de teléfono, códigos de</tspan><tspan dy="1.2em" x="25">país, dominio y área en km cuadrados. Updated 2023</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 105)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#292929"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">JSON</text>
|
||||
</g>
|
||||
</g><g transform="translate(53.67999954223633, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<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"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="starsCount" class="gray">33</text></g></g><g transform="translate(107.59999923706056, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forksCount" class="gray">11</text></g></g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\nquery gistInfo($gistName: String!) {\\n viewer {\\n gist(name: $gistName) {\\n description\\n owner {\\n login\\n }\\n stargazerCount\\n forks {\\n totalCount\\n }\\n files {\\n name\\n language {\\n name\\n }\\n size\\n }\\n }\\n }\\n}\\n","variables":{"gistName":"happy-gist-id"}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/gist contract > should match the public missing-id response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "id" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray">/api/gist?id=GIST_ID</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,412 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/pin contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the private non-whitelisted username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is blacklisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="150" viewBox="0 0 400 150" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<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; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #586069;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">convoychat</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable</tspan><tspan dy="1.2em" x="25">React, TypeScript and GraphQL chat app that has enough</tspan><tspan dy="1.2em" x="25">text to wrap across multiple lines in the repository card.</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 75)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
|
||||
</g>
|
||||
</g><g transform="translate(80.53999967575072, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<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"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(140.4199994087219, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
|
||||
</g>
|
||||
|
||||
<svg x="0" y="0"><g transform="translate(-3, 98)">
|
||||
|
||||
</g></svg>
|
||||
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="560" height="233" viewBox="0 0 560 233" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #ff00aa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="559" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">anuraghazra/convoychat</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable React, TypeScript and GraphQL...</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 45)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
|
||||
</g>
|
||||
</g><g transform="translate(80.53999967575072, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<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"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(140.4199994087219, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
|
||||
</g>
|
||||
|
||||
<svg x="0" y="0"><g transform="translate(-3, 68)">
|
||||
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20author%3Aanuraghazra&type=pullrequests">
|
||||
|
||||
<text class="stat bold" y="12.5">my created PRs:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_authored">1234</text></a>
|
||||
</g>
|
||||
</g><g transform="translate(210, 0)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20commenter%3Aanuraghazra+-author%3Aanuraghazra&type=pullrequests">
|
||||
|
||||
<text class="stat bold" y="12.5">my commented PRs:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_commented">2345</text></a>
|
||||
</g>
|
||||
</g></g><g transform="translate(0, 30)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20reviewed-by%3Aanuraghazra+-author%3Aanuraghazra&type=pullrequests">
|
||||
|
||||
<text class="stat bold" y="12.5">my reviewed PRs:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_reviewed">3456</text></a>
|
||||
</g>
|
||||
</g><g transform="translate(210, 0)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20author%3Aanuraghazra&type=issues">
|
||||
|
||||
<text class="stat bold" y="12.5">my created issues:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="issues_authored">4567</text></a>
|
||||
</g>
|
||||
</g></g><g transform="translate(0, 60)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20commenter%3Aanuraghazra+-author%3Aanuraghazra&type=issues">
|
||||
|
||||
<text class="stat bold" y="12.5">my commented issues:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="issues_commented">5678</text></a>
|
||||
</g>
|
||||
</g></g>
|
||||
</g></svg>
|
||||
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public missing-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username", "repo" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray">/api/pin?username=USERNAME&repo=REPO_NAME</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username", "repo" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray">/api/pin?username=USERNAME&repo=REPO_NAME</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,460 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the private non-whitelisted username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is blacklisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="300" height="285" viewBox="0 0 300 285" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<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; }
|
||||
}
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.bold { font-weight: 700 }
|
||||
.lang-name {
|
||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
||||
fill: #434d58;
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="299" 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">Most Used Languages</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<svg data-testid="lang-items" x="25">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">Rust</text>
|
||||
<text x="215" y="34" class="lang-name">70.92%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="70.92198581560284%">
|
||||
<rect height="8" fill="#dea584" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 40)">
|
||||
<g class="stagger" style="animation-delay: 600ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">HTML</text>
|
||||
<text x="215" y="34" class="lang-name">12.77%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="12.76595744680851%">
|
||||
<rect height="8" fill="#0f0" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 80)">
|
||||
<g class="stagger" style="animation-delay: 750ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">JavaScript</text>
|
||||
<text x="215" y="34" class="lang-name">8.51%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="8.51063829787234%">
|
||||
<rect height="8" fill="#0ff" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 120)">
|
||||
<g class="stagger" style="animation-delay: 900ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">TypeScript</text>
|
||||
<text x="215" y="34" class="lang-name">6.38%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="6.382978723404255%">
|
||||
<rect height="8" fill="#3178c6" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 160)">
|
||||
<g class="stagger" style="animation-delay: 1050ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">Java</text>
|
||||
<text x="215" y="34" class="lang-name">1.42%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="2%">
|
||||
<rect height="8" fill="#f00" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="420" height="110" viewBox="0 0 420 110" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.bold { font-weight: 700 }
|
||||
.lang-name {
|
||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
||||
fill: #abcdef;
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="419" fill="#0f172a" stroke-opacity="0"/>
|
||||
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 25)">
|
||||
|
||||
<svg data-testid="lang-items" x="25">
|
||||
|
||||
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width="370" height="8" fill="white" rx="5"/>
|
||||
</mask>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="256.98" height="8" fill="#3178c6"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="256.98" y="0" width="60.57" height="8" fill="#f00"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="317.55" y="0" width="52.45" height="8" fill="#00f"/>
|
||||
|
||||
|
||||
<g transform="translate(0, 25)">
|
||||
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms">
|
||||
<circle cx="5" cy="6" r="5" fill="#3178c6"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
TypeScript 19.0 B
|
||||
</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms">
|
||||
<circle cx="5" cy="6" r="5" fill="#f00"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
Java 4.5 B
|
||||
</text>
|
||||
</g>
|
||||
</g></g><g transform="translate(150, 0)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms">
|
||||
<circle cx="5" cy="6" r="5" fill="#00f"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
Python 3.9 B
|
||||
</text>
|
||||
</g>
|
||||
</g></g>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n # do not fetch forks\\n repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {\\n nodes {\\n name\\n languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {\\n edges {\\n size\\n node {\\n color\\n name\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,546 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the private non-whitelisted username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="495" height="195" viewBox="0 0 495 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
|
||||
<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 (last 7 days)</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)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="TypeScript">TypeScript:</text>
|
||||
<text class="stat" x="350" y="12.5">12 hrs</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="52%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="JavaScript">JavaScript:</text>
|
||||
<text class="stat" x="350" y="12.5">6 hrs 30 mins</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="28%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 50)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="Other">Other:</text>
|
||||
<text class="stat" x="350" y="12.5">3 hrs</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="13%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 75)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="YAML">YAML:</text>
|
||||
<text class="stat" x="350" y="12.5">1 hr</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="4%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 100)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="JSON">JSON:</text>
|
||||
<text class="stat" x="350" y="12.5">30 mins</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="3%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public inaccessible-profile response snapshot 1`] = `
|
||||
{
|
||||
"content": "<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"/>
|
||||
<desc id="descId"/>
|
||||
<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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
|
||||
<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>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="620" height="140" viewBox="0 0 620 140" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
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: #abcdef;
|
||||
}
|
||||
@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: #abcdef }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="619" fill="#0f172a" 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">a custom title</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<svg x="0" y="0" width="100%">
|
||||
|
||||
<mask id="rect-mask">
|
||||
<rect x="25" y="0" width="565" height="8" fill="white" rx="5"/>
|
||||
</mask>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="365.09200000000004" height="8" fill="#3178c6"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="365.09200000000004" y="0" width="196.588" height="8" fill="#f1e05a"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="561.6800000000001" y="0" width="28.084" height="8" fill="#cb171e"/>
|
||||
|
||||
|
||||
<g transform="translate(25, 25)">
|
||||
<circle cx="5" cy="6" r="5" fill="#3178c6"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
TypeScript - 61.88 %
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(292.5, 25)">
|
||||
<circle cx="5" cy="6" r="5" fill="#f1e05a"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
JavaScript - 33.32 %
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(25, 50)">
|
||||
<circle cx="5" cy="6" r="5" fill="#cb171e"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
YAML - 4.76 %
|
||||
</text>
|
||||
</g>
|
||||
|
||||
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,41 +1,196 @@
|
||||
// @ts-check
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
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";
|
||||
import { data_stats, normalizeSvg } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.unstubAllEnvs();
|
||||
// modules may cache environment variables, so we need to reset them
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Test /api/", () => {
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api contract", () => {
|
||||
it("should match the public happy-path response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
headers: {},
|
||||
url: "/api?username=anuraghazra",
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
show_icons: "true",
|
||||
card_width: "540",
|
||||
line_height: "32",
|
||||
title_color: "123456",
|
||||
ring_color: "654321",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
text_bold: "false",
|
||||
bg_color: "0f172a",
|
||||
exclude_repo: "repo-exclude-me",
|
||||
custom_title: "a custom title",
|
||||
locale: "hi",
|
||||
disable_animations: "true",
|
||||
border_radius: "12",
|
||||
number_format: "long",
|
||||
number_precision: "1",
|
||||
border_color: "fedcba",
|
||||
rank_icon: "github",
|
||||
commits_year: "2024",
|
||||
hide: "issues",
|
||||
role: "OWNER,COLLABORATOR",
|
||||
show: "reviews,prs_merged,prs_merged_percentage,discussions_started,discussions_answered",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api?${params.toString()}`,
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
|
||||
const res = createResponse();
|
||||
|
||||
await api(req, res);
|
||||
await router(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
graphqlRequest: mock.history.post[0].data,
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public blacklisted-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api?username=renovate-bot",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,143 @@
|
||||
// @ts-check
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
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";
|
||||
import { happy_path_gist_data, normalizeSvg } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
mock
|
||||
.onPost("https://api.github.com/graphql")
|
||||
.reply(200, happy_path_gist_data);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.unstubAllEnvs();
|
||||
// modules may cache environment variables, so we need to reset them
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Test /api/gist", () => {
|
||||
it("should render error if id is not provided", async () => {
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api/gist contract", () => {
|
||||
it("should match the public happy-path response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
query: {},
|
||||
headers: {},
|
||||
url: "/api/gist?id=happy-gist-id",
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
id: "happy-gist-id",
|
||||
title_color: "123456",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
show_owner: "true",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/gist?${params.toString()}`,
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||
const res = createResponse();
|
||||
|
||||
await gist(req, res);
|
||||
await router(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: 'Missing params "id" make sure you pass the parameters in URL',
|
||||
secondaryMessage: "/api/gist?id=GIST_ID",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
graphqlRequest: mock.history.post[0].data,
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-id response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/gist",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-id response snapshot", async () => {
|
||||
vi.stubEnv("GIST_WHITELIST", "allowed-gist-id");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/gist",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,65 +1,235 @@
|
||||
// @ts-check
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
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";
|
||||
import { data_user, normalizeSvg } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.unstubAllEnvs();
|
||||
// modules may cache environment variables, so we need to reset them
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Test /api/pin", () => {
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api/pin contract", () => {
|
||||
it("should match the public happy-path response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
repo: "convoychat",
|
||||
},
|
||||
headers: {},
|
||||
url: "/api/pin?username=anuraghazra&repo=convoychat",
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
const res = createResponse();
|
||||
|
||||
await pin(req, res);
|
||||
await router(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card if missing required parameters", async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
mock
|
||||
.onGet(
|
||||
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+author:anuraghazra+type:pr",
|
||||
)
|
||||
.reply(200, { total_count: 1234 });
|
||||
mock
|
||||
.onGet(
|
||||
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+commenter:anuraghazra+-author:anuraghazra+type:pr",
|
||||
)
|
||||
.reply(200, { total_count: 2345 });
|
||||
mock
|
||||
.onGet(
|
||||
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+reviewed-by:anuraghazra+-author:anuraghazra+type:pr",
|
||||
)
|
||||
.reply(200, { total_count: 3456 });
|
||||
mock
|
||||
.onGet(
|
||||
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+author:anuraghazra+type:issue",
|
||||
)
|
||||
.reply(200, { total_count: 4567 });
|
||||
mock
|
||||
.onGet(
|
||||
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+commenter:anuraghazra+-author:anuraghazra+type:issue",
|
||||
)
|
||||
.reply(200, { total_count: 5678 });
|
||||
|
||||
await pin(req, res);
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message:
|
||||
'Missing params "username", "repo" make sure you pass the parameters in URL',
|
||||
secondaryMessage: "/api/pin?username=USERNAME&repo=REPO_NAME",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
repo: "convoychat",
|
||||
title_color: "123456",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
card_width: "560",
|
||||
show_owner: "true",
|
||||
show: "prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented",
|
||||
show_icons: "false",
|
||||
number_format: "long",
|
||||
text_bold: "true",
|
||||
line_height: "30",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
description_lines_count: "1",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/pin?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
graphqlRequest: mock.history.post[0].data,
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-params response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public blacklisted-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=renovate-bot&repo=convoychat",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private non-whitelisted username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=martin-mfg",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,210 @@
|
||||
// @ts-check
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
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";
|
||||
import { data_langs, normalizeSvg } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.unstubAllEnvs();
|
||||
// modules may cache environment variables, so we need to reset them
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Test /api/top-langs", () => {
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api/top-langs contract", () => {
|
||||
it("should match the public happy-path response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
headers: {},
|
||||
url: "/api/top-langs?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
|
||||
await topLangs(req, res);
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
hide: "javascript,HTML",
|
||||
hide_title: "true",
|
||||
hide_border: "true",
|
||||
card_width: "420",
|
||||
title_color: "123456",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
layout: "compact",
|
||||
langs_count: "3",
|
||||
exclude_repo: "repo-hidden",
|
||||
size_weight: "0.5",
|
||||
count_weight: "1",
|
||||
role: "OWNER,COLLABORATOR",
|
||||
disable_animations: "true",
|
||||
stats_format: "bytes",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/top-langs?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
graphqlRequest: mock.history.post[0].data,
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public blacklisted-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs?username=renovate-bot",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private non-whitelisted username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs?username=martin-mfg",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// @ts-check
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { normalizeSvg, wakaTimeData } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const wakaTimeProfileNotPublicData = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
mock
|
||||
.onGet(
|
||||
"https://wakatime.com/api/v1/users/anuraghazra/stats?is_including_today=true",
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.unstubAllEnvs();
|
||||
// modules may cache environment variables, so we need to reset them
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api/wakatime contract", () => {
|
||||
it("should match the public happy-path response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
mock.reset(); // to verify api_domain param is working
|
||||
mock
|
||||
.onGet(
|
||||
"https://wakatime.local/api/v1/users/anuraghazra/stats?is_including_today=true",
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
title_color: "123456",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
card_width: "620",
|
||||
custom_title: "a custom title",
|
||||
layout: "compact",
|
||||
langs_count: "3",
|
||||
hide: "other",
|
||||
api_domain: "wakatime.local",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
display_format: "percent",
|
||||
disable_animations: "true",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/wakatime?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public inaccessible-profile response snapshot", async () => {
|
||||
mock.reset();
|
||||
mock
|
||||
.onGet(
|
||||
"https://wakatime.com/api/v1/users/anuraghazra/stats?is_including_today=true",
|
||||
)
|
||||
.reply(200, wakaTimeProfileNotPublicData);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private non-whitelisted username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?username=martin-mfg",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { queryByTestId } from "@testing-library/dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
describe("Test render.js", () => {
|
||||
it("should test renderError", () => {
|
||||
document.body.innerHTML = renderError({ message: "Something went wrong" });
|
||||
expect(
|
||||
queryByTestId(document.body, "message")?.children[0],
|
||||
).toHaveTextContent(/Something went wrong/gim);
|
||||
expect(
|
||||
queryByTestId(document.body, "message")?.children[1],
|
||||
).toBeEmptyDOMElement();
|
||||
|
||||
// Secondary message
|
||||
document.body.innerHTML = renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Secondary Message",
|
||||
});
|
||||
expect(
|
||||
queryByTestId(document.body, "message")?.children[1],
|
||||
).toHaveTextContent(/Secondary Message/gim);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { retryer } from "../src/common/retryer.js";
|
||||
|
||||
const fetcher = vi.fn((variables, token) => {
|
||||
logger.log(variables, token);
|
||||
return new Promise((res) => res({ data: "ok" }));
|
||||
});
|
||||
|
||||
const fetcherFail = vi.fn(() => {
|
||||
return new Promise((res) =>
|
||||
res({ data: { errors: [{ type: "RATE_LIMITED" }] } }),
|
||||
);
|
||||
});
|
||||
|
||||
const fetcherFailOnSecondTry = vi.fn((_vars, _token, retries) => {
|
||||
return new Promise((res) => {
|
||||
// faking rate limit
|
||||
// @ts-ignore
|
||||
if (retries < 1) {
|
||||
return res({ data: { errors: [{ type: "RATE_LIMITED" }] } });
|
||||
}
|
||||
return res({ data: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
const fetcherFailWithMessageBasedRateLimitErr = vi.fn(
|
||||
(_vars, _token, retries) => {
|
||||
return new Promise((res) => {
|
||||
// faking rate limit
|
||||
// @ts-ignore
|
||||
if (retries < 1) {
|
||||
return res({
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
type: "ASDF",
|
||||
message: "API rate limit already exceeded for user ID 11111111",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return res({ data: "ok" });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("Test Retryer", () => {
|
||||
it("retryer should return value and have zero retries on first try", async () => {
|
||||
let res = await retryer(fetcher, {});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(res).toStrictEqual({ data: "ok" });
|
||||
});
|
||||
|
||||
it("retryer should return value and have 2 retries", async () => {
|
||||
let res = await retryer(fetcherFailOnSecondTry, {});
|
||||
|
||||
expect(fetcherFailOnSecondTry).toHaveBeenCalledTimes(2);
|
||||
expect(res).toStrictEqual({ data: "ok" });
|
||||
});
|
||||
|
||||
it("retryer should return value and have 2 retries with message based rate limit error", async () => {
|
||||
let res = await retryer(fetcherFailWithMessageBasedRateLimitErr, {});
|
||||
|
||||
expect(fetcherFailWithMessageBasedRateLimitErr).toHaveBeenCalledTimes(2);
|
||||
expect(res).toStrictEqual({ data: "ok" });
|
||||
});
|
||||
|
||||
it("retryer should throw specific error if maximum retries reached", async () => {
|
||||
try {
|
||||
await retryer(fetcherFail, {});
|
||||
} catch (err) {
|
||||
expect(fetcherFail).toHaveBeenCalledTimes(2);
|
||||
// @ts-ignore
|
||||
expect(err.message).toBe("Downtime due to GitHub API rate limiting");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,18 @@
|
||||
* @file Tests for the status/up cloud function.
|
||||
*/
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import up, { RATE_LIMIT_SECONDS } from "../api-renamed/status/up.js";
|
||||
|
||||
@@ -55,10 +64,19 @@ const shields_down = {
|
||||
color: "red",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api/status/up", () => {
|
||||
it("should return `true` if request was successful", async () => {
|
||||
mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @type {import("../../src/fetchers/stats").StatsData}
|
||||
*/
|
||||
export 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: { level: "DEV", percentile: 0 },
|
||||
};
|
||||
|
||||
export 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
export 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
export const data_langs = {
|
||||
data: {
|
||||
user: {
|
||||
repositories: {
|
||||
nodes: [
|
||||
{
|
||||
languages: {
|
||||
edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 100, node: { color: "#0ff", name: "javascript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 100, node: { color: "#0ff", name: "javascript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
export 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,
|
||||
},
|
||||
};
|
||||
|
||||
export const data_user = {
|
||||
data: {
|
||||
user: { repository: data_repo.repository },
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
@@ -1,192 +1,84 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { topLangs } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import topLangs from "../api-renamed/top-langs.js";
|
||||
import { renderTopLanguages } from "../src/cards/top-languages.js";
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
import { data_langs } from "./test-data/langs-data.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const error = {
|
||||
errors: [
|
||||
{
|
||||
type: "NOT_FOUND",
|
||||
path: ["user"],
|
||||
locations: [],
|
||||
message: "Could not fetch user",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const langs = {
|
||||
HTML: {
|
||||
color: "#0f0",
|
||||
name: "HTML",
|
||||
size: 250,
|
||||
},
|
||||
javascript: {
|
||||
color: "#0ff",
|
||||
name: "javascript",
|
||||
size: 200,
|
||||
},
|
||||
};
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
describe("Test /api/top-langs", () => {
|
||||
it("should test the request", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
await topLangs(req, res);
|
||||
const topLangsMock = vi.mocked(topLangs);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(renderTopLanguages(langs));
|
||||
});
|
||||
const createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api/top-langs?${search}`,
|
||||
});
|
||||
|
||||
it("should work with the query options", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
hide_title: true,
|
||||
card_width: 100,
|
||||
title_color: "fff",
|
||||
icon_color: "fff",
|
||||
text_color: "fff",
|
||||
bg_color: "fff",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
await topLangs(req, res);
|
||||
const defaultCacheHeader =
|
||||
`max-age=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderTopLanguages(langs, {
|
||||
hide_title: true,
|
||||
card_width: 100,
|
||||
title_color: "fff",
|
||||
icon_color: "fff",
|
||||
text_color: "fff",
|
||||
bg_color: "fff",
|
||||
}),
|
||||
beforeEach(() => {
|
||||
topLangsMock.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/top-langs backend routing", () => {
|
||||
it("happy path should pass query params and user PAT, respond with top languages content and persist request", async () => {
|
||||
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
|
||||
topLangsMock.mockResolvedValue({
|
||||
status: "success",
|
||||
content: "mock-top-langs-svg",
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
"username=anuraghazra&layout=compact&langs_count=5",
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
it("should render error card on user data fetch error", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
await router(req, res);
|
||||
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(topLangsMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
layout: "compact",
|
||||
langs_count: "5",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, error);
|
||||
|
||||
await topLangs(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 on incorrect layout input", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
layout: ["pie"],
|
||||
},
|
||||
};
|
||||
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: "Something went wrong",
|
||||
secondaryMessage: "Incorrect layout input",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if wrong locale provided", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
locale: "asdf",
|
||||
},
|
||||
};
|
||||
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: "Something went wrong",
|
||||
secondaryMessage: "Locale not found",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should have proper cache", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
},
|
||||
};
|
||||
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.setHeader).toHaveBeenCalledWith(
|
||||
"Cache-Control",
|
||||
`max-age=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||
"user-pat",
|
||||
);
|
||||
expect(req.query).toEqual({
|
||||
username: "anuraghazra",
|
||||
layout: "compact",
|
||||
langs_count: "5",
|
||||
});
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-top-langs-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
});
|
||||
|
||||
+293
-35
@@ -1,41 +1,299 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Creates an asymmetric matcher for approximate numeric equality.
|
||||
*
|
||||
* This helper is intended for use in test frameworks (e.g., Jest) where
|
||||
* values need to be compared within a configurable decimal precision
|
||||
* instead of strict equality.
|
||||
*
|
||||
* The comparison succeeds when:
|
||||
*
|
||||
* |actual - expected| < 10^(-precision)
|
||||
*
|
||||
* For example, with `precision = 3`, values must be within `0.001`.
|
||||
*
|
||||
* @param {number} expected The expected numeric value to compare against.
|
||||
*
|
||||
* @param {number} [precision=10]
|
||||
* The number of decimal places of tolerance. Higher values mean stricter
|
||||
* comparison. Internally converted to epsilon = 10^-precision.
|
||||
*
|
||||
* @returns {{
|
||||
* asymmetricMatch(actual: unknown): boolean,
|
||||
* toAsymmetricMatcher(): string
|
||||
* }} An object implementing Jest-style asymmetric matcher methods.
|
||||
*
|
||||
*/
|
||||
export function approxNumber(expected, precision = 10) {
|
||||
return {
|
||||
asymmetricMatch(actual) {
|
||||
if (typeof actual !== "number" || typeof expected !== "number") {
|
||||
return false;
|
||||
}
|
||||
const epsilon = Math.pow(10, -precision);
|
||||
return Math.abs(actual - expected) < epsilon;
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const data_stats = {
|
||||
data: {
|
||||
user: {
|
||||
name: "Anurag Hazra",
|
||||
login: "anuraghazra",
|
||||
repositoriesContributedTo: { totalCount: 51 },
|
||||
commits: {
|
||||
totalCommitContributions: 200,
|
||||
},
|
||||
reviews: {
|
||||
totalPullRequestReviewContributions: 1234,
|
||||
},
|
||||
pullRequests: { totalCount: 4000 },
|
||||
mergedPullRequests: { totalCount: 3200 },
|
||||
openIssues: { totalCount: 300 },
|
||||
closedIssues: { totalCount: 40 },
|
||||
followers: { totalCount: 150 },
|
||||
repositoryDiscussions: { totalCount: 222 },
|
||||
repositoryDiscussionComments: {
|
||||
totalCount: 111,
|
||||
},
|
||||
repositories: {
|
||||
totalCount: 3,
|
||||
nodes: [
|
||||
{ name: "repo-keep-1", stargazers: { totalCount: 1500 } },
|
||||
{ name: "repo-exclude-me", stargazers: { totalCount: 9999 } },
|
||||
{ name: "repo-keep-2", stargazers: { totalCount: 2600 } },
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
},
|
||||
},
|
||||
},
|
||||
toAsymmetricMatcher() {
|
||||
return `≈ ${expected} (precision ${precision})`;
|
||||
},
|
||||
};
|
||||
|
||||
export const happy_path_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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const data_user = {
|
||||
data: {
|
||||
user: {
|
||||
repository: {
|
||||
username: "anuraghazra",
|
||||
name: "convoychat",
|
||||
nameWithOwner: "anuraghazra/convoychat",
|
||||
stargazers: {
|
||||
totalCount: 38000,
|
||||
},
|
||||
description:
|
||||
"Help us take over the world with a deeply customizable React, TypeScript and GraphQL chat app that has enough text to wrap across multiple lines in the repository card.",
|
||||
primaryLanguage: {
|
||||
color: "#2b7489",
|
||||
id: "MDg6TGFuZ3VhZ2UyODc=",
|
||||
name: "TypeScript",
|
||||
},
|
||||
forkCount: 100,
|
||||
isTemplate: false,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const data_langs = {
|
||||
data: {
|
||||
user: {
|
||||
repositories: {
|
||||
nodes: [
|
||||
{
|
||||
name: "repo-html",
|
||||
languages: {
|
||||
edges: [{ size: 180, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-javascript",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 120, node: { color: "#0ff", name: "JavaScript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-ts-1",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 45, node: { color: "#3178c6", name: "TypeScript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-ts-2",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 45, node: { color: "#3178c6", name: "TypeScript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-other-langs",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 20, node: { color: "#f00", name: "Java" } },
|
||||
{ size: 10, node: { color: "#0f0", name: "Cobol" } },
|
||||
{ size: 15, node: { color: "#00f", name: "Python" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-hidden",
|
||||
languages: {
|
||||
edges: [{ size: 1000, node: { color: "#dea584", name: "Rust" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const wakaTimeData = {
|
||||
data: {
|
||||
categories: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "Coding",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
daily_average: 16095,
|
||||
daily_average_including_other_language: 16329,
|
||||
days_including_holidays: 7,
|
||||
days_minus_holidays: 5,
|
||||
editors: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "VS Code",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
holidays: 2,
|
||||
human_readable_daily_average: "4 hrs 28 mins",
|
||||
human_readable_daily_average_including_other_language: "4 hrs 32 mins",
|
||||
human_readable_total: "22 hrs 21 mins",
|
||||
human_readable_total_including_other_language: "22 hrs 40 mins",
|
||||
id: "random hash",
|
||||
is_already_updating: false,
|
||||
is_coding_activity_visible: true,
|
||||
is_including_today: false,
|
||||
is_other_usage_visible: true,
|
||||
is_stuck: false,
|
||||
is_up_to_date: true,
|
||||
languages: [
|
||||
{
|
||||
digital: "12:00",
|
||||
hours: 12,
|
||||
minutes: 0,
|
||||
name: "TypeScript",
|
||||
percent: 52,
|
||||
text: "12 hrs",
|
||||
total_seconds: 43200,
|
||||
},
|
||||
{
|
||||
digital: "6:30",
|
||||
hours: 6,
|
||||
minutes: 30,
|
||||
name: "JavaScript",
|
||||
percent: 28,
|
||||
text: "6 hrs 30 mins",
|
||||
total_seconds: 23400,
|
||||
},
|
||||
{
|
||||
digital: "3:00",
|
||||
hours: 3,
|
||||
minutes: 0,
|
||||
name: "Other",
|
||||
percent: 13,
|
||||
text: "3 hrs",
|
||||
total_seconds: 10800,
|
||||
},
|
||||
{
|
||||
digital: "1:00",
|
||||
hours: 1,
|
||||
minutes: 0,
|
||||
name: "YAML",
|
||||
percent: 4,
|
||||
text: "1 hr",
|
||||
total_seconds: 3600,
|
||||
},
|
||||
{
|
||||
digital: "0:30",
|
||||
hours: 0,
|
||||
minutes: 30,
|
||||
name: "JSON",
|
||||
percent: 3,
|
||||
text: "30 mins",
|
||||
total_seconds: 1800,
|
||||
},
|
||||
],
|
||||
operating_systems: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "Mac",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
percent_calculated: 100,
|
||||
range: "last_7_days",
|
||||
status: "ok",
|
||||
timeout: 15,
|
||||
total_seconds: 80473.135716,
|
||||
total_seconds_including_other_language: 81643.570077,
|
||||
user_id: "random hash",
|
||||
username: "anuraghazra",
|
||||
writes_only: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** @typedef {import('@stats-organization/github-readme-stats-core')} CoreModule */
|
||||
|
||||
/**
|
||||
* Creates a mock module for @stats-organization/github-readme-stats-core.
|
||||
* @returns {CoreModule} Mocked core module.
|
||||
*/
|
||||
export function mockCore() {
|
||||
return {
|
||||
// @ts-expect-error no need to mock themes at the moment
|
||||
themes: {},
|
||||
request: vi.fn(),
|
||||
fetchWakatimeStats: vi.fn(),
|
||||
retryer: vi.fn(),
|
||||
dateDiff: vi.fn(),
|
||||
api: vi.fn(),
|
||||
gist: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
topLangs: vi.fn(),
|
||||
wakatime: vi.fn(),
|
||||
getConfig: vi.fn().mockReturnValue({}),
|
||||
renderError: ({ message }) => `render-error:${message}`,
|
||||
clampValue: (value, min, max) => Math.min(Math.max(value, min), max),
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes SVG code for stable test comparisons.
|
||||
* @param {string} svg SVG code to normalize.
|
||||
* @returns {string} Normalized SVG code.
|
||||
*/
|
||||
export function normalizeSvg(svg) {
|
||||
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
}
|
||||
|
||||
@@ -1,195 +1,78 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
// @ts-check
|
||||
|
||||
import wakatime from "../api-renamed/wakatime.js";
|
||||
import { renderWakatimeCard } from "../src/cards/wakatime.js";
|
||||
import { wakatime } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/index.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const wakaTimeData = {
|
||||
data: {
|
||||
categories: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "Coding",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
daily_average: 16095,
|
||||
daily_average_including_other_language: 16329,
|
||||
days_including_holidays: 7,
|
||||
days_minus_holidays: 5,
|
||||
editors: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "VS Code",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
holidays: 2,
|
||||
human_readable_daily_average: "4 hrs 28 mins",
|
||||
human_readable_daily_average_including_other_language: "4 hrs 32 mins",
|
||||
human_readable_total: "22 hrs 21 mins",
|
||||
human_readable_total_including_other_language: "22 hrs 40 mins",
|
||||
id: "random hash",
|
||||
is_already_updating: false,
|
||||
is_coding_activity_visible: true,
|
||||
is_including_today: false,
|
||||
is_other_usage_visible: true,
|
||||
is_stuck: false,
|
||||
is_up_to_date: true,
|
||||
languages: [
|
||||
{
|
||||
digital: "0:19",
|
||||
hours: 0,
|
||||
minutes: 19,
|
||||
name: "Other",
|
||||
percent: 1.43,
|
||||
text: "19 mins",
|
||||
total_seconds: 1170.434361,
|
||||
},
|
||||
{
|
||||
digital: "0:01",
|
||||
hours: 0,
|
||||
minutes: 1,
|
||||
name: "TypeScript",
|
||||
percent: 0.1,
|
||||
text: "1 min",
|
||||
total_seconds: 83.293809,
|
||||
},
|
||||
{
|
||||
digital: "0:00",
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
name: "YAML",
|
||||
percent: 0.07,
|
||||
text: "0 secs",
|
||||
total_seconds: 54.975151,
|
||||
},
|
||||
],
|
||||
operating_systems: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "Mac",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
percent_calculated: 100,
|
||||
range: "last_7_days",
|
||||
status: "ok",
|
||||
timeout: 15,
|
||||
total_seconds: 80473.135716,
|
||||
total_seconds_including_other_language: 81643.570077,
|
||||
user_id: "random hash",
|
||||
username: "anuraghazra",
|
||||
writes_only: false,
|
||||
},
|
||||
};
|
||||
|
||||
const wakaTimeNotFoundData = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
describe("Test /api/wakatime", () => {
|
||||
it("should test the request", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username } };
|
||||
const res = { setHeader: vi.fn(), send: vi.fn() };
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
await wakatime(req, res);
|
||||
const wakatimeMock = vi.mocked(wakatime);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderWakatimeCard(wakaTimeData.data, {}),
|
||||
);
|
||||
});
|
||||
const createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api/wakatime?${search}`,
|
||||
});
|
||||
|
||||
it("should render error if wrong locale is provided", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username, locale: "asdf" } };
|
||||
const res = { setHeader: vi.fn(), send: vi.fn() };
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
await wakatime(req, res);
|
||||
const defaultCacheHeader =
|
||||
`max-age=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
}),
|
||||
);
|
||||
});
|
||||
beforeEach(() => {
|
||||
wakatimeMock.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;
|
||||
});
|
||||
|
||||
it("should render error if user data is not accessible", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username } };
|
||||
const res = { setHeader: vi.fn(), send: vi.fn() };
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, wakaTimeNotFoundData);
|
||||
describe("Test /api/wakatime backend routing", () => {
|
||||
it("happy path should pass query params, respond with wakatime content and persist request", async () => {
|
||||
wakatimeMock.mockResolvedValue({
|
||||
status: "success",
|
||||
content: "mock-wakatime-svg",
|
||||
});
|
||||
|
||||
await wakatime(req, res);
|
||||
const req = createRequest("username=anuraghazra&theme=dark&layout=compact");
|
||||
const res = createResponse();
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(res.send.mock.calls[0][0]).toMatchSnapshot();
|
||||
});
|
||||
await router(req, res);
|
||||
|
||||
it("should have proper cache", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username } };
|
||||
const res = { setHeader: vi.fn(), send: vi.fn() };
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
|
||||
await wakatime(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
"Cache-Control",
|
||||
`max-age=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||
);
|
||||
expect(wakatimeMock).toHaveBeenCalledWith({
|
||||
username: "anuraghazra",
|
||||
theme: "dark",
|
||||
layout: "compact",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
expect(req.query).toEqual({
|
||||
username: "anuraghazra",
|
||||
theme: "dark",
|
||||
layout: "compact",
|
||||
});
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-wakatime-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"customConditions": []
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "cd ../../ && git clean ./apps -fx && pnpm --filter ./apps/backend/ --legacy deploy ./apps/deployment/ && mv ./apps/deployment/node_modules/ ./apps/backend/node_modules/ && ./vercel-preparation.sh",
|
||||
"buildCommand": "cd ../../ && git clean ./apps -fx && pnpm run build:packages && pnpm --filter ./apps/backend/ --legacy --prod deploy ./apps/deployment/ && mv ./apps/deployment/node_modules/ ./apps/backend/node_modules/ && ./vercel-preparation.sh",
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/",
|
||||
"destination": "https://github.com/stats-organization/github-stats-extended"
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/frontend/:match*",
|
||||
"destination": "https://gse-frontend.vercel.app/:match*"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
},
|
||||
test: {
|
||||
dir: "./tests/bench",
|
||||
environment: "jsdom",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
dir: "tests/e2e",
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
export default defineProject({
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
},
|
||||
test: {
|
||||
coverage: {
|
||||
enabled: true,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: "backend/public-instance",
|
||||
environment: "jsdom",
|
||||
dir: "tests",
|
||||
include: ["./*.test.{ts,js}", "./public-instance/*.test.{ts,js}"],
|
||||
setupFiles: ["./tests/_setup.js"],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: "backend/private-instance",
|
||||
environment: "jsdom",
|
||||
dir: "tests",
|
||||
include: ["./*.test.{ts,js}", "./private-instance/*.test.{ts,js}"],
|
||||
setupFiles: [
|
||||
"./tests/_setup.js",
|
||||
"./tests/_setup.private-instance.js",
|
||||
],
|
||||
},
|
||||
},
|
||||
environment: "jsdom",
|
||||
include: [
|
||||
"./tests/*.test.{ts,js}",
|
||||
"./tests/public-instance/*.test.{ts,js}",
|
||||
],
|
||||
setupFiles: ["./tests/_setup.js"],
|
||||
},
|
||||
});
|
||||
|
||||
+33
-36
@@ -1,48 +1,45 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"name": "@stats-organization/github-readme-stats-frontend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "2.11.2",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"axios": "^1",
|
||||
"axios-cache-interceptor": "^1",
|
||||
"daisyui": "5.5.19",
|
||||
"emoji-name-map": "^2.0.3",
|
||||
"github-username-regex": "^1.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-redux": "9.2.0",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "11.0.5",
|
||||
"redux": "5.0.1",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"uuid": "13.0.0",
|
||||
"word-wrap": "^1.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vitejs/plugin-react-swc": "4.2.3",
|
||||
"clsx": "2.1.1",
|
||||
"tailwindcss": "4.2.1",
|
||||
"vite": "catalog:default",
|
||||
"vite-plugin-node-polyfills": "0.25.0",
|
||||
"vite-plugin-string-replace": "1.1.5",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "catalog:default",
|
||||
"axios-cache-interceptor": "^1",
|
||||
"daisyui": "^5.5.23",
|
||||
"@stats-organization/github-readme-stats-backend": "workspace:^",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"redux": "^5.0.1",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"clsx": "2.1.1",
|
||||
"tailwindcss": "4.3.0",
|
||||
"vite": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"homepage": "/frontend",
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// @ts-expect-error type info should be added later
|
||||
import { router } from "@stats-organization/github-readme-stats-backend";
|
||||
import { loadConfigFromEnv } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
@@ -5,8 +8,6 @@ import Skeleton from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
|
||||
import { setShouldMock } from "../../axios-override.js";
|
||||
// @ts-expect-error will be solved by npm package
|
||||
import { default as router } from "../../backend/.vercel/output/functions/api.func/router.js";
|
||||
import { createMockRequest, createMockResponse } from "../../mock-http.js";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
@@ -45,7 +46,12 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
|
||||
let isCurrent = true;
|
||||
|
||||
const loadSvg = async () => {
|
||||
window.process.env.PAT_1 = userToken as string;
|
||||
const config: Record<string, string | undefined> = {
|
||||
FETCH_MULTI_PAGE_STARS: "10",
|
||||
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
loadConfigFromEnv(config);
|
||||
|
||||
setLoaded(false);
|
||||
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
const PROD = true as boolean;
|
||||
|
||||
export const USE_LOGGER = true as boolean;
|
||||
|
||||
export const CLIENT_ID = "Ov23liZSweT9LJrck9i8";
|
||||
|
||||
export const HOST = PROD
|
||||
? "github-stats-extended.vercel.app"
|
||||
: "github-stats-extended-preview.vercel.app";
|
||||
export const HOST = window.location.host;
|
||||
|
||||
const REDIRECT_URI = `https://${HOST}/frontend`;
|
||||
|
||||
export const GITHUB_PRIVATE_AUTH_URL = `https://github.com/login/oauth/authorize?scope=user,repo&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}?mode=private`;
|
||||
export const GITHUB_PUBLIC_AUTH_URL = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}?mode=public`;
|
||||
|
||||
export const DEMO_USER = "anuraghazra";
|
||||
export const DEMO_REPO = "anuraghazra/github-readme-stats";
|
||||
export const DEMO_GIST = "bbfce31e0217a3689c8d961a356cb10d";
|
||||
export const DEMO_WAKATIME_USER = "alan";
|
||||
export const DEMO_USER = "anuraghazra" as string;
|
||||
export const DEMO_REPO = "anuraghazra/github-readme-stats" as string;
|
||||
export const DEMO_GIST = "bbfce31e0217a3689c8d961a356cb10d" as string;
|
||||
export const DEMO_WAKATIME_USER = "alan" as string;
|
||||
|
||||
// imported backend code expects process.env to be defined
|
||||
window.process = {
|
||||
env: {
|
||||
FETCH_MULTI_PAGE_STARS: "10",
|
||||
PAT_1: "placeholderPAT", // so the backend's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
|
||||
},
|
||||
env: {},
|
||||
} as (typeof window)["process"];
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import BounceLoader from "react-spinners/BounceLoader";
|
||||
import { BounceLoader } from "react-spinners";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { authenticate } from "../../api/user";
|
||||
@@ -48,7 +48,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// for stage two
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>(userId);
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId);
|
||||
const [repo, setRepo] = useState(DEMO_REPO);
|
||||
const [gist, setGist] = useState(DEMO_GIST);
|
||||
const [wakatimeUser, setWakatimeUser] = useState(DEMO_WAKATIME_USER);
|
||||
@@ -76,6 +76,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
>();
|
||||
const [customTitle, setCustomTitle] = useState("");
|
||||
const [langsCount, setLangsCount] = useState<number | undefined>();
|
||||
const [hideValues, setHideValues] = useState(false);
|
||||
const [showAllStats, setShowAllStats] = useState(false);
|
||||
const [showIcons, setShowIcons] = useState(false);
|
||||
const [includeAllCommits, setIncludeAllCommits] = useState(true);
|
||||
@@ -121,6 +122,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
@@ -306,6 +308,8 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
setCustomTitle={setCustomTitle}
|
||||
langsCount={langsCount}
|
||||
setLangsCount={setLangsCount}
|
||||
hideValues={hideValues}
|
||||
setHideValues={setHideValues}
|
||||
showAllStats={showAllStats}
|
||||
setShowAllStats={setShowAllStats}
|
||||
showIcons={showIcons}
|
||||
|
||||
@@ -22,6 +22,7 @@ const baseOptions = {
|
||||
descriptionLines: undefined,
|
||||
customTitle: "",
|
||||
langsCount: undefined,
|
||||
hideValues: false,
|
||||
showAllStats: false,
|
||||
showIcons: false,
|
||||
includeAllCommits: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Options {
|
||||
descriptionLines: number | undefined;
|
||||
customTitle: string;
|
||||
langsCount: number | undefined;
|
||||
hideValues: boolean;
|
||||
showAllStats: boolean;
|
||||
showIcons: boolean;
|
||||
includeAllCommits: boolean;
|
||||
@@ -41,6 +42,7 @@ export function getFullSuffix({
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
@@ -138,6 +140,10 @@ export function getFullSuffix({
|
||||
fullSuffix += `&langs_count=${langsCount}`;
|
||||
}
|
||||
|
||||
if (hideValues && selectedCard === CardType.TOP_LANGS) {
|
||||
fullSuffix += `&hide_values=true`;
|
||||
}
|
||||
|
||||
if (showAllStats && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented`;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ interface CustomizeStageProps {
|
||||
setCustomTitle: Updater<string>;
|
||||
langsCount: number | undefined;
|
||||
setLangsCount: Updater<number | undefined>;
|
||||
hideValues: boolean;
|
||||
setHideValues: Updater<boolean>;
|
||||
showIcons: boolean;
|
||||
setShowIcons: Updater<boolean>;
|
||||
showAllStats: boolean;
|
||||
@@ -87,6 +89,8 @@ export function CustomizeStage({
|
||||
setCustomTitle,
|
||||
langsCount,
|
||||
setLangsCount,
|
||||
hideValues,
|
||||
setHideValues,
|
||||
showIcons,
|
||||
setShowIcons,
|
||||
showAllStats,
|
||||
@@ -321,6 +325,15 @@ export function CustomizeStage({
|
||||
max={20}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.TOP_LANGS && (
|
||||
<CheckboxSection
|
||||
title="Hide Values?"
|
||||
text="Hide language percentages or bytes while keeping the selected layout visible."
|
||||
question="Hide values?"
|
||||
checked={hideValues}
|
||||
onCheckedChange={setHideValues}
|
||||
/>
|
||||
)}
|
||||
{cardType === CardType.WAKATIME && (
|
||||
<CheckboxSection
|
||||
title="Show Percentages?"
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { themes } from "@stats-organization/github-readme-stats-core";
|
||||
import type { JSX } from "react";
|
||||
|
||||
// @ts-expect-error this will be provided by the npm package
|
||||
import { themes } from "../../../backend/themes/index";
|
||||
import { Card } from "../../../components/Card/Card";
|
||||
|
||||
// to be removed once npm package has been created
|
||||
type ThemeData = Record<
|
||||
string,
|
||||
{
|
||||
title_color: string;
|
||||
icon_color: string;
|
||||
text_color: string;
|
||||
bg_color: string;
|
||||
border_color: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const excludedThemes = [
|
||||
"merko",
|
||||
"blue-green",
|
||||
@@ -25,10 +12,9 @@ const excludedThemes = [
|
||||
"holi",
|
||||
];
|
||||
|
||||
const themeList = Object.keys(
|
||||
/* Needed until themes is typed correctly and retrieved from npm package */
|
||||
themes as ThemeData,
|
||||
).filter((myTheme) => !excludedThemes.includes(myTheme));
|
||||
const themeList = Object.keys(themes).filter(
|
||||
(myTheme) => !excludedThemes.includes(myTheme),
|
||||
);
|
||||
|
||||
interface ThemeStageProps {
|
||||
fullSuffix: string;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"customConditions": []
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"installCommand": "cd ../../ && git clean ./apps -fx && pnpm install && rm -rf ./apps/frontend/node_modules && ./vercel-preparation.sh && pnpm --filter ./apps/frontend/ --legacy deploy ./apps/deployment/ && mv ./apps/deployment/node_modules/ ./apps/frontend/node_modules/",
|
||||
"buildCommand": "pnpm run build",
|
||||
"outputDirectory": "build"
|
||||
}
|
||||
|
||||
@@ -1,69 +1,29 @@
|
||||
import path from "node:path";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
import StringReplace from "vite-plugin-string-replace";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineProject({
|
||||
base: "/frontend/",
|
||||
plugins: [
|
||||
StringReplace([
|
||||
{
|
||||
search: "process.env",
|
||||
replace: "window.process.env",
|
||||
fileName: ".*backend.*",
|
||||
},
|
||||
]),
|
||||
nodePolyfills({
|
||||
include: [
|
||||
"path",
|
||||
"querystring",
|
||||
"url",
|
||||
"http",
|
||||
"util",
|
||||
"stream",
|
||||
"buffer",
|
||||
],
|
||||
exclude: ["fs", "net"],
|
||||
}),
|
||||
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
||||
/**
|
||||
* mock pg (postgres) package in the browser to avoid runtime errors
|
||||
* @see apps/backend/src/common/database.js
|
||||
*/
|
||||
{
|
||||
name: "empty-pg-package",
|
||||
resolveId(id) {
|
||||
if (id === "pg") {
|
||||
return id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
load(id) {
|
||||
if (id === "pg") {
|
||||
return "export class Pool { constructor(config) {} }";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: true,
|
||||
|
||||
/** @todo use chunks to split bundle? */
|
||||
chunkSizeWarningLimit: 800,
|
||||
|
||||
rolldownOptions: {
|
||||
external: ["pg"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
alias: [
|
||||
{
|
||||
find: "../src/fetchers/wakatime.js",
|
||||
find: "../fetchers/wakatime.js",
|
||||
replacement: path.resolve(
|
||||
import.meta.dirname,
|
||||
"src/wakatime-override.ts",
|
||||
@@ -72,7 +32,6 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
test: {
|
||||
dir: "./src",
|
||||
exclude: ["**/backend/**"],
|
||||
dir: path.join(import.meta.dirname, "./src"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ GitHub Stats Extended comes with several built-in themes (e.g. `dark`, `radical`
|
||||
|
||||
<img src="https://res.cloudinary.com/anuraghazra/image/upload/v1595174536/grs-themes_l4ynja.png" alt="GitHub Stats Extended Themes" width="600px"/>
|
||||
|
||||
You can look at a preview for [all available themes](../backend/themes/README.md) or checkout the [theme config file](../backend/themes/index.js). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed.
|
||||
You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.js). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed.
|
||||
|
||||
#### Responsive Card Theme
|
||||
|
||||
@@ -85,33 +85,30 @@ You can look at a preview for [all available themes](../backend/themes/README.md
|
||||
|
||||
Since GitHub will re-upload the cards and serve them from their [CDN](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls), we can not infer the browser/GitHub theme on the server side. There are, however, four methods you can use to create dynamics themes on the client side.
|
||||
|
||||
##### Use the transparent theme
|
||||
##### Use GitHub's new media feature (recommended)
|
||||
|
||||
We have included a `transparent` theme that has a transparent background. This theme is optimized to look good on GitHub's dark and light default themes. You can enable this theme using the `&theme=transparent` parameter like so:
|
||||
You can use [GitHub's new media feature](https://github.blog/changelog/2022-05-19-specify-theme-context-for-images-in-markdown-beta/) in HTML to specify whether to display images for light or dark themes. This is done using the HTML `<picture>` element in combination with the `prefers-color-scheme` media feature.
|
||||
|
||||
```md
|
||||

|
||||
```html
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" /> <!-- light mode -->
|
||||
</picture>
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>:eyes: Show example</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
##### Add transparent alpha channel to a themes bg\_color
|
||||
|
||||
You can use the `bg_color` parameter to make any of [the available themes](../backend/themes/README.md) transparent. This is done by setting the `bg_color` to a color with a transparent alpha channel (i.e. `bg_color=00000000`):
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>:eyes: Show example</summary>
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" /> <!-- light mode -->
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -132,38 +129,33 @@ You can use [GitHub's theme context](https://github.blog/changelog/2021-11-24-sp
|
||||
|
||||
</details>
|
||||
|
||||
##### Use GitHub's new media feature
|
||||
##### Use the transparent theme
|
||||
|
||||
You can use [GitHub's new media feature](https://github.blog/changelog/2022-05-19-specify-theme-context-for-images-in-markdown-beta/) in HTML to specify whether to display images for light or dark themes. This is done using the HTML `<picture>` element in combination with the `prefers-color-scheme` media feature.
|
||||
We have included a `transparent` theme that has a transparent background. This theme is optimized to look good on GitHub's dark and light default themes. You can enable this theme using the `&theme=transparent` parameter like so:
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true"
|
||||
media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" />
|
||||
</picture>
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>:eyes: Show example</summary>
|
||||
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true"
|
||||
media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" />
|
||||
</picture>
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
##### Add transparent alpha channel to a themes bg\_color
|
||||
|
||||
You can use the `bg_color` parameter to make any of [the available themes](../packages/core/src/themes/README.md) transparent. This is done by setting the `bg_color` to a color with a transparent alpha channel (i.e. `bg_color=00000000`):
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>:eyes: Show example</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
@@ -181,7 +173,7 @@ You can customize the appearance of all your cards however you wish with URL par
|
||||
| `border_color` | Card's border color. Does not apply when `hide_border` is enabled. | string (hex color) | `e4e2e2` |
|
||||
| `bg_color` | Card's background color. | string (hex color or a gradient in the form of *angle,start,end*) | `fffefe` |
|
||||
| `hide_border` | Hides the card's border. | boolean | `false` |
|
||||
| `theme` | Name of the theme, choose from [all available themes](../backend/themes/README.md). | enum | `default` |
|
||||
| `theme` | Name of the theme, choose from [all available themes](../packages/core/src/themes/README.md). | enum | `default` |
|
||||
| `cache_seconds` | Sets the cache header manually (min: 21600, max: 86400). | integer | `21600` |
|
||||
| `locale` | Sets the language in the card, you can check full list of available locales [here](#available-locales). | enum | `en` |
|
||||
| `border_radius` | Corner rounding on the card. | number | `4.5` |
|
||||
@@ -323,6 +315,7 @@ You can customize the appearance and behavior of the pinned repository card usin
|
||||
| Name | Description | Type | Default value |
|
||||
| --- | --- | --- | --- |
|
||||
| `show_owner` | Shows the repo's owner name. | boolean | `false` |
|
||||
| `browser_rendering` | Compute text wrapping of repository description natively in the browser, instead of computing it server-side. | boolean | `false` |
|
||||
| `description_lines_count` | Manually set the number of lines for the description. Specified value will be clamped between 1 and 3. If this parameter is not specified, the number of lines will be automatically adjusted according to the actual length of the description. | number | `null` |
|
||||
| `card_width` | Sets the card's width manually. | number | `400px (approx.)` |
|
||||
| `show_icons` | Shows icons near all stats enabled via `show`. | boolean | `true` |
|
||||
@@ -368,6 +361,7 @@ You can customize the appearance and behavior of the gist card using the [common
|
||||
| Name | Description | Type | Default value |
|
||||
| --- | --- | --- | --- |
|
||||
| `show_owner` | Shows the gist's owner name. | boolean | `false` |
|
||||
| `browser_rendering` | Compute text wrapping of gist description natively in the browser, instead of computing it server-side. | boolean | `false` |
|
||||
|
||||
### Demo
|
||||
|
||||
@@ -417,6 +411,7 @@ You can customize the appearance and behavior of the top languages card using th
|
||||
| `disable_animations` | Disables all animations in the card. | boolean | `false` |
|
||||
| `prog_bar_bg_color` | Background color of the bars. (Applies only to `normal` layout.) | string (hex color) | `#ddd` |
|
||||
| `hide_progress` | Uses the compact layout option, hides percentages, and removes the bars. | boolean | `false` |
|
||||
| `hide_values` | Hides language percentages or bytes while keeping the progress bars or chart. | boolean | `false` |
|
||||
| `size_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `1` |
|
||||
| `count_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `0` |
|
||||
| `stats_format` | Switches between two available formats for language's stats `percentages` and `bytes`. | enum | `percentages` |
|
||||
|
||||
+20
-38
@@ -1,12 +1,12 @@
|
||||
# Deploy on your own
|
||||
# Run It Yourself
|
||||
|
||||
Since the GitHub API only allows a limited number of requests per hour, my `https://github-stats-extended.vercel.app/api` could possibly hit the rate limiter. If you deploy it yourself via GitHub Actions or your own hosted instance, then 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.
|
||||
We cache generated cards for a few hours to avoid potential rate-limiting in the GitHub API or on Vercel. If you want to set your own cache duration or you want to include private contributions in your stats without granting our hosted version of GitHub-Stats-Extended access to your private contributions, you can run GitHub-Stats-Extended on your own.
|
||||
|
||||
GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting takes more work and can serve fresher stats (with caching).
|
||||
GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting GitHub-Stats-Extended on Vercel takes more work and can serve fresher stats (with caching).
|
||||
|
||||
## GitHub Actions
|
||||
## GitHub Action
|
||||
|
||||
GitHub Actions generates static SVGs and avoids per-request API calls. By default it uses `GITHUB_TOKEN` (public stats only), for private stats, set a [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) as a secret and pass it to the action instead.
|
||||
With [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) you can generate static cards in your GitHub Actions workflow, commit them to your profile repository, and embed them directly from there. This avoids any per-request API calls.
|
||||
|
||||
Create `/.github/workflows/grs.yml` in your profile repo (`USERNAME/USERNAME`):
|
||||
|
||||
@@ -15,17 +15,21 @@ name: Update README cards
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
- cron: "0 0 * * *" # Runs once daily at midnight
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Generate stats card
|
||||
uses: readme-tools/github-readme-stats-action@v1
|
||||
uses: stats-organization/github-readme-stats-action@v2
|
||||
with:
|
||||
card: stats
|
||||
options: username=${{ github.repository_owner }}&show_icons=true
|
||||
@@ -34,14 +38,14 @@ jobs:
|
||||
|
||||
- name: Commit cards
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "github-actions@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add profile/*.svg
|
||||
git commit -m "Update README cards" || exit 0
|
||||
git push
|
||||
```
|
||||
|
||||
Then embed from your profile README:
|
||||
Then embed from your [profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme):
|
||||
|
||||
```md
|
||||

|
||||
@@ -49,7 +53,7 @@ Then embed from your profile README:
|
||||
|
||||
See more options and examples in the [GitHub Readme Stats Action README](https://github.com/stats-organization/github-readme-stats-action#readme).
|
||||
|
||||
## Self-hosted (Vercel/Other)
|
||||
## Self-hosted on Vercel
|
||||
|
||||
Running your own instance avoids public rate limits and gives you full control over caching, tokens, and private stats.
|
||||
|
||||
@@ -88,14 +92,11 @@ Selecting the right scopes for your token is important in case you want to displ
|
||||
|
||||
### On Vercel
|
||||
|
||||
<b>:film\_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107)</b>
|
||||
|
||||
Click on the deploy button to get started!
|
||||
|
||||
[](https://vercel.com/import/project?template=https://github.com/stats-organization/github-stats-extended)
|
||||
|
||||
<details>
|
||||
<summary><b>:hammer_and_wrench: Recommended: Step-by-step guide on setting up your own Vercel instance</b></summary>
|
||||
<b>Recommended: Step-by-step guide on setting up your own Vercel instance</b>
|
||||
|
||||
1. Go to [vercel.com](https://vercel.com/).
|
||||
2. Click on `Log in`.
|
||||
@@ -122,26 +123,7 @@ Click on the deploy button to get started!
|
||||
12. Click deploy, and you're good to go. See your domains to use the API!
|
||||
13. optional: add an SQL database; by using e.g. the ["Nile" integration](https://vercel.com/marketplace/nile) or by manually setting the environment variable `POSTGRES_URL`
|
||||
14. optional: [create your own OAuth App](https://github.com/settings/developers) and set environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly
|
||||
15. optional: in addition to the Vercel project based on the `apps/backend` folder, create a second project based on the `apps/frontend` folder. No environment variables needed.
|
||||
|
||||
</details>
|
||||
|
||||
### On other platforms
|
||||
|
||||
> [!WARNING]
|
||||
> This way of using GitHub-Stats-Extended is not officially supported and was added to cater to some particular use cases where Vercel could not be used (e.g. [#2341](https://github.com/anuraghazra/github-readme-stats/discussions/2341)). The support for this method, therefore, is limited.
|
||||
|
||||
<details>
|
||||
<summary><b>:hammer_and_wrench: Step-by-step guide for deploying on other platforms</b></summary>
|
||||
|
||||
1. Fork or clone this repo as per your needs
|
||||
2. Move `express` from the devDependencies to the dependencies section of `package.json`
|
||||
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L54-L61>
|
||||
3. Run `npm i` if needed (initial setup)
|
||||
4. Run `node express.js` to start the server, or set the entry point to `express.js` in `package.json` if you're deploying on a managed service
|
||||
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L11>
|
||||
5. You're done 🎉
|
||||
</details>
|
||||
15. optional: set the environment variable `TURBO_PLATFORM_ENV_DISABLED` to `true` to disable the build-time warning from [turbo](https://turborepo.dev/) about environment variables missing from "turbo.json" - This warning is not relevant in our project.
|
||||
|
||||
### Available environment variables
|
||||
|
||||
@@ -163,12 +145,12 @@ GitHub Stats Extended provides several environment variables that can be used to
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>UPDATE_AFTER_HOURS</code></td>
|
||||
<td>Sets the duration in hours after which the server <a href="fork.md#improved-performance-and-latency">proactively regenerates</a> a previously requested card.</td>
|
||||
<td>Sets the duration in hours after which the server <a href="fork.md#improved-performance-and-latency">proactively regenerates</a> a previously requested card. Defaults to 11 hours.</td>
|
||||
<td>Any int or float</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>DELETE_AFTER_HOURS</code></td>
|
||||
<td>Sets the duration in hours after which the server stops <a href="fork.md#improved-performance-and-latency">proactively regenerating</a> a previously requested card if it hasn't been requested again in the meantime.</td>
|
||||
<td>Sets the duration in hours after which the server stops <a href="fork.md#improved-performance-and-latency">proactively regenerating</a> a previously requested card if it hasn't been requested again in the meantime. Defaults to 8 days, i.e. 192 hours.</td>
|
||||
<td>Any int or float</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
+19
-9
@@ -1,6 +1,6 @@
|
||||
# Fork Information
|
||||
|
||||
This project is an actively maintained fork of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats).
|
||||
This project is an actively maintained fork and extension of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats).
|
||||
|
||||
## Key Differences
|
||||
|
||||
@@ -28,6 +28,11 @@ GitHub-Stats-Extended proactively precomputes and caches cards. This solves the
|
||||
### Multi-page fetching for accurate star counts
|
||||
GitHub-Stats-Extended fetches up to 1000 of your starred repositories to accurately compute your stars count. In github-readme-stats, this is limited to 100 repos because github-readme-stats doesn't have the above-mentioned performance improvements.
|
||||
|
||||
### Customization of top languages card
|
||||
GitHub-Stats-Extended allows you to display your top languages without any numbers via the `hide_values` parameter. And the `prog_bar_bg_color` parameter allows you to customize the background color of the progress bars, e.g. by setting it to white:
|
||||
|
||||

|
||||
|
||||
### Private contributions support
|
||||
GitHub-Stats-Extended can include private contributions in your stats cards. You no longer have to deploy your own instance for that. Just log into the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) via the "GitHub Private Access" button (or click "Upgrade to Private Access" if already logged in). This will allow GitHub-Stats-Extended to see your private contributions.
|
||||
|
||||
@@ -67,16 +72,21 @@ One of the valued maintainers [wrote](https://github.com/anuraghazra/github-read
|
||||
>
|
||||
> The volume is overwhelming for the small team, especially taking into account that right now I'm alone online and working only sometimes when I have a free hours, so it took some time to get to your PR.
|
||||
|
||||
So I decided to implement some of the highly requested features and make the enhanced project available to everyone.
|
||||
|
||||
## Upstream Sync Strategy
|
||||
|
||||
The maintainers of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) keep their project up to date with dependency upgrades and do occasionally merge some pull requests. I regularly merge the latest upstream changes into this fork to keep it in sync with the upstream repo.
|
||||
|
||||
I also try to structure my own changes in a way that minimizes merge conflicts with the upstream repo. So some of my changes might even be merged back into [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) if the upstream maintainers are interested.
|
||||
So [@martin-mfg](https://github.com/martin-mfg) decided to fork the project, implement some of the highly requested features and make the enhanced project available to everyone. Since the initial release of this fork @martin-mfg joined forces with the maintainers of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) and GitHub-Stats-Extended is now becoming the successor of github-readme-stats.
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
GitHub-Stats-Extended aims to be fully compatible with [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). Generally, additional functionality introduced in this fork has to be explicitly enabled via some parameter.
|
||||
GitHub-Stats-Extended aims to be fully compatible with [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). Additional functionality introduced in this fork has to be explicitly enabled via some parameter.
|
||||
|
||||
So you can change an existing stats card url from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) to GitHub-Stats-Extended simply by changing the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`. The card will look the same.
|
||||
|
||||
There is only one exception to this: GitHub-Stats-Extended improves line wrapping for multi-line gist and repository descriptions.
|
||||
This should be an improvement for existing cards, but it still changes their appearance a bit.
|
||||
|
||||
Previously, line wrapping happened simply after 59 characters, with special handling for Chinese characters:
|
||||
|
||||
<img width="400" height="140" alt="character-based" src="https://github.com/user-attachments/assets/1cf7edba-7f6c-4a37-89d7-334cbe54f0f1" />
|
||||
|
||||
GitHub-Stats-Extended now takes the actual width of each character into account:
|
||||
|
||||
<img width="400" height="150" alt="new-server-side-calculation" src="https://github.com/user-attachments/assets/277b92df-b2a9-48be-bd2a-d28c1d6764c5" />
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
|
||||
import { importX } from "eslint-plugin-import-x";
|
||||
import { default as jsdoc } from "eslint-plugin-jsdoc";
|
||||
import react from "eslint-plugin-react";
|
||||
@@ -18,6 +19,25 @@ export default defineConfig(
|
||||
|
||||
{
|
||||
extends: [importX.flatConfigs.recommended, importX.flatConfigs.typescript],
|
||||
settings: {
|
||||
"import-x/resolver-next": [
|
||||
createTypeScriptImportResolver({
|
||||
conditionNames: [
|
||||
/** Keep in sync with `tsconfig.base.json#customConditions` */
|
||||
"@stats/source",
|
||||
|
||||
"types",
|
||||
"import",
|
||||
|
||||
"require",
|
||||
"node",
|
||||
"node-addons",
|
||||
"browser",
|
||||
"default",
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
"import-x/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"import-x/order": [
|
||||
|
||||
+2
-13
@@ -2,21 +2,10 @@
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"workspaces": {
|
||||
"apps/backend": {
|
||||
"entry": ["api-renamed/*.js", "express.js", "tests/bench/*.bench.js"],
|
||||
"ignoreFiles": [
|
||||
"_dot_vercel_copy/**/*" // Hopefully by using a npm package this file will be removed
|
||||
]
|
||||
"entry": ["api-renamed/*.js", "express.js", "tests/bench/*.bench.js"]
|
||||
},
|
||||
"apps/frontend": {
|
||||
"entry": ["src/wakatime-override.ts"],
|
||||
|
||||
"ignoreDependencies": [
|
||||
// below dependencies are added because backend folder is copied inside frontend folder,
|
||||
// so some of his dependencies must be present here
|
||||
"github-username-regex",
|
||||
"emoji-name-map",
|
||||
"word-wrap"
|
||||
]
|
||||
"entry": ["src/wakatime-override.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-20
@@ -1,35 +1,44 @@
|
||||
{
|
||||
"name": "root",
|
||||
"name": "@stats-organization/root",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "2.0.2",
|
||||
"@eslint/js": "9.39.3",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/node": "24.10.13",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import-x": "4.16.1",
|
||||
"eslint-plugin-jsdoc": "62.7.1",
|
||||
"@eslint/compat": "2.1.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@types/node": "24.13.2",
|
||||
"@vitest/coverage-v8": "catalog:default",
|
||||
"eslint": "10.4.1",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-jsdoc": "63.0.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"globals": "17.3.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.6.0",
|
||||
"husky": "9.1.7",
|
||||
"knip": "5.85.0",
|
||||
"lint-staged": "16.2.7",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.56.1"
|
||||
"knip": "6.16.1",
|
||||
"lint-staged": "17.0.7",
|
||||
"prettier": "3.8.4",
|
||||
"turbo": "2.9.18",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.61.0",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"build:packages": "turbo run build --filter=./packages/*",
|
||||
"build:frontend": "turbo run build --filter=./apps/frontend",
|
||||
"dev:frontend": "pnpm run --filter=./apps/frontend dev",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --config vitest.config.coverage.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"lint": "turbo run lint",
|
||||
"lint:eslint": "eslint",
|
||||
"lint:eslint:fix": "eslint --fix",
|
||||
"lint:knip": "knip",
|
||||
"typecheck": "tsc --build --noEmit"
|
||||
"typecheck": "turbo run typecheck"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,css,json,jsonc,yaml,yml}": "prettier --write"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Core library powering [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) and [GitHub Readme Stats Action](https://github.com/stats-organization/github-readme-stats-action).
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "@stats-organization/github-readme-stats-core",
|
||||
"version": "2.1.3",
|
||||
"type": "module",
|
||||
"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",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"author": {
|
||||
"name": "Anurag Hazra",
|
||||
"url": "https://github.com/anuraghazra/"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Rick Staa",
|
||||
"url": "https://github.com/rickstaa"
|
||||
},
|
||||
{
|
||||
"name": "Alexandr Garbuzov",
|
||||
"url": "https://github.com/qwerty541"
|
||||
},
|
||||
{
|
||||
"name": "Abhijit Gupta",
|
||||
"url": "https://github.com/avgupta456"
|
||||
},
|
||||
{
|
||||
"name": "Marco Pasqualetti",
|
||||
"url": "https://github.com/marcalexiei"
|
||||
},
|
||||
{
|
||||
"name": "martin-mfg",
|
||||
"url": "https://github.com/martin-mfg"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
},
|
||||
"description": "Dynamically generate stats for your GitHub readme",
|
||||
"keywords": [
|
||||
"github-readme-stats",
|
||||
"readme-stats",
|
||||
"cards",
|
||||
"card-generator",
|
||||
"github-stats",
|
||||
"github-stats-extended",
|
||||
"github-readme-stats-extended"
|
||||
],
|
||||
"main": "./build/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"@stats/source": "./src/index.ts",
|
||||
"default": "./build/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"build",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"prepack": "pnpm run build",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@uppercod/css-to-object": "1.1.1",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"jsdom": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "catalog:default",
|
||||
"emoji-name-map": "^2.0.3",
|
||||
"github-username-regex": "^1.0.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user