Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84835caea1 | ||
|
|
dc9c926800 | ||
|
|
986eaad1a8 | ||
|
|
5403eefbd3 | ||
|
|
12b27566be | ||
|
|
40097713b9 | ||
|
|
2168c1f91e | ||
|
|
fa82216dfe | ||
|
|
9cf716c85f | ||
|
|
ef3f207dbf | ||
|
|
35fbec8004 | ||
|
|
3ef27f872c | ||
|
|
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)"
|
||||
|
||||
+38
-30
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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,44 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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
|
||||
|
||||
# Only install chromium since it is the only browser enabled in
|
||||
# apps/frontend/playwright.config.ts. Update this if more projects are added.
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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 +108,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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
|
||||
@@ -10,17 +10,23 @@ on:
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "45 * * * *"
|
||||
- cron: "15,45 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
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@f98bb26551cd1af7d3eb2674578a80754ece88db # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- 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
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"prettier.prettier-vscode",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
|
||||
Vendored
+1
-2
@@ -3,8 +3,7 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": ["Wakatime"]
|
||||
"cSpell.words": ["retryer", "Wakatime"]
|
||||
}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"expiration": 255600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"axios": "catalog:default",
|
||||
"pg": "^8.22.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,275 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { flexLayout } from "./render.js";
|
||||
|
||||
class Card {
|
||||
/**
|
||||
* Creates a new card instance.
|
||||
*
|
||||
* @param {object} args Card arguments.
|
||||
* @param {number=} args.width Card width.
|
||||
* @param {number=} args.height Card height.
|
||||
* @param {number=} args.border_radius Card border radius.
|
||||
* @param {string=} args.customTitle Card custom title.
|
||||
* @param {string=} args.defaultTitle Card default title.
|
||||
* @param {string=} args.titlePrefixIcon Card title prefix icon.
|
||||
* @param {object} [args.colors={}] Card colors arguments.
|
||||
* @param {string=} args.colors.titleColor Card title color.
|
||||
* @param {string=} args.colors.textColor Card text color.
|
||||
* @param {string=} args.colors.iconColor Card icon color.
|
||||
* @param {string|string[]=} args.colors.bgColor Card background color.
|
||||
* @param {string=} args.colors.borderColor Card border color.
|
||||
*/
|
||||
constructor({
|
||||
width = 100,
|
||||
height = 100,
|
||||
border_radius = 4.5,
|
||||
colors = {},
|
||||
customTitle,
|
||||
defaultTitle = "",
|
||||
titlePrefixIcon,
|
||||
}) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
this.hideBorder = false;
|
||||
this.hideTitle = false;
|
||||
|
||||
this.border_radius = border_radius;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
this.colors = colors;
|
||||
this.title =
|
||||
customTitle === undefined
|
||||
? encodeHTML(defaultTitle)
|
||||
: encodeHTML(customTitle);
|
||||
|
||||
this.css = "";
|
||||
|
||||
this.paddingX = 25;
|
||||
this.paddingY = 35;
|
||||
this.titlePrefixIcon = titlePrefixIcon;
|
||||
this.animations = true;
|
||||
this.a11yTitle = "";
|
||||
this.a11yDesc = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
disableAnimations() {
|
||||
this.animations = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} props The props object.
|
||||
* @param {string} props.title Accessibility title.
|
||||
* @param {string} props.desc Accessibility description.
|
||||
* @returns {void}
|
||||
*/
|
||||
setAccessibilityLabel({ title, desc }) {
|
||||
this.a11yTitle = title;
|
||||
this.a11yDesc = desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value The CSS to add to the card.
|
||||
* @returns {void}
|
||||
*/
|
||||
setCSS(value) {
|
||||
this.css = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value Whether to hide the border or not.
|
||||
* @returns {void}
|
||||
*/
|
||||
setHideBorder(value) {
|
||||
this.hideBorder = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value Whether to hide the title or not.
|
||||
* @returns {void}
|
||||
*/
|
||||
setHideTitle(value) {
|
||||
this.hideTitle = value;
|
||||
if (value) {
|
||||
this.height -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text The title to set.
|
||||
* @returns {void}
|
||||
*/
|
||||
setTitle(text) {
|
||||
this.title = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The rendered card title.
|
||||
*/
|
||||
renderTitle() {
|
||||
const titleText = `
|
||||
<text
|
||||
x="0"
|
||||
y="0"
|
||||
class="header"
|
||||
data-testid="header"
|
||||
>${this.title}</text>
|
||||
`;
|
||||
|
||||
const prefixIcon = `
|
||||
<svg
|
||||
class="icon"
|
||||
x="0"
|
||||
y="-13"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
${this.titlePrefixIcon}
|
||||
</svg>
|
||||
`;
|
||||
return `
|
||||
<g
|
||||
data-testid="card-title"
|
||||
transform="translate(${this.paddingX}, ${this.paddingY})"
|
||||
>
|
||||
${flexLayout({
|
||||
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
|
||||
gap: 25,
|
||||
}).join("")}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The rendered card gradient.
|
||||
*/
|
||||
renderGradient() {
|
||||
if (typeof this.colors.bgColor !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const gradients = this.colors.bgColor.slice(1);
|
||||
return typeof this.colors.bgColor === "object"
|
||||
? `
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gradient"
|
||||
gradientTransform="rotate(${this.colors.bgColor[0]})"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
${gradients.map((grad, index) => {
|
||||
let offset = (index * 100) / (gradients.length - 1);
|
||||
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
|
||||
})}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves css animations for a card.
|
||||
*
|
||||
* @returns {string} Animation css.
|
||||
*/
|
||||
getAnimations = () => {
|
||||
return `
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} body The inner body of the card.
|
||||
* @returns {string} The rendered card.
|
||||
*/
|
||||
render(body) {
|
||||
return `
|
||||
<svg
|
||||
width="${this.width}"
|
||||
height="${this.height}"
|
||||
viewBox="0 0 ${this.width} ${this.height}"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-labelledby="descId"
|
||||
>
|
||||
<title id="titleId">${this.a11yTitle}</title>
|
||||
<desc id="descId">${this.a11yDesc}</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${this.colors.titleColor};
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
${this.css}
|
||||
|
||||
${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
|
||||
${
|
||||
this.animations === false
|
||||
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
||||
: ""
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.renderGradient()}
|
||||
|
||||
<rect
|
||||
data-testid="card-bg"
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
rx="${this.border_radius}"
|
||||
height="99%"
|
||||
stroke="${this.colors.borderColor}"
|
||||
width="${this.width - 1}"
|
||||
fill="${
|
||||
typeof this.colors.bgColor === "object"
|
||||
? "url(#gradient)"
|
||||
: this.colors.bgColor
|
||||
}"
|
||||
stroke-opacity="${this.hideBorder ? 0 : 1}"
|
||||
/>
|
||||
|
||||
${this.hideTitle ? "" : this.renderTitle()}
|
||||
|
||||
<g
|
||||
data-testid="main-card-body"
|
||||
transform="translate(0, ${
|
||||
this.hideTitle ? this.paddingX : this.paddingY + 20
|
||||
})"
|
||||
>
|
||||
${body}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export { Card };
|
||||
export default Card;
|
||||
@@ -1,42 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
const FALLBACK_LOCALE = "en";
|
||||
|
||||
/**
|
||||
* I18n translation class.
|
||||
*/
|
||||
class I18n {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {Object} options Options.
|
||||
* @param {string=} options.locale Locale.
|
||||
* @param {any} options.translations Translations.
|
||||
*/
|
||||
constructor({ locale, translations }) {
|
||||
this.locale = locale || FALLBACK_LOCALE;
|
||||
this.translations = translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation.
|
||||
*
|
||||
* @param {string} str String to translate.
|
||||
* @returns {string} Translated string.
|
||||
*/
|
||||
t(str) {
|
||||
if (!this.translations[str]) {
|
||||
throw new Error(`${str} Translation string not found`);
|
||||
}
|
||||
|
||||
if (!this.translations[str][this.locale]) {
|
||||
throw new Error(
|
||||
`'${str}' translation not found for locale '${this.locale}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.translations[str][this.locale];
|
||||
}
|
||||
}
|
||||
|
||||
export { I18n };
|
||||
@@ -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,145 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { themes } from "../../themes/index.js";
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid hex color.
|
||||
*
|
||||
* @param {string} hexColor String to check.
|
||||
* @returns {boolean} True if the given string is a valid hex color.
|
||||
*/
|
||||
const isValidHexColor = (hexColor) => {
|
||||
return new RegExp(
|
||||
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
|
||||
).test(hexColor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given string is a valid gradient.
|
||||
*
|
||||
* @param {string[]} colors Array of colors.
|
||||
* @returns {boolean} True if the given string is a valid gradient.
|
||||
*/
|
||||
const isValidGradient = (colors) => {
|
||||
return (
|
||||
colors.length > 2 &&
|
||||
colors.slice(1).every((color) => isValidHexColor(color))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a gradient if color has more than one valid hex codes else a single color.
|
||||
*
|
||||
* @param {string} color The color to parse.
|
||||
* @param {string | string[]} fallbackColor The fallback color.
|
||||
* @returns {string | string[]} The gradient or color.
|
||||
*/
|
||||
const fallbackColor = (color, fallbackColor) => {
|
||||
let gradient = null;
|
||||
|
||||
let colors = color ? color.split(",") : [];
|
||||
if (colors.length > 1 && isValidGradient(colors)) {
|
||||
gradient = colors;
|
||||
}
|
||||
|
||||
return (
|
||||
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
|
||||
fallbackColor
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Object containing card colors.
|
||||
* @typedef {{
|
||||
* titleColor: string;
|
||||
* iconColor: string;
|
||||
* textColor: string;
|
||||
* bgColor: string | string[];
|
||||
* borderColor: string;
|
||||
* ringColor: string;
|
||||
* }} CardColors
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns theme based colors with proper overrides and defaults.
|
||||
*
|
||||
* @param {Object} args Function arguments.
|
||||
* @param {string=} args.title_color Card title color.
|
||||
* @param {string=} args.text_color Card text color.
|
||||
* @param {string=} args.icon_color Card icon color.
|
||||
* @param {string=} args.bg_color Card background color.
|
||||
* @param {string=} args.border_color Card border color.
|
||||
* @param {string=} args.ring_color Card ring color.
|
||||
* @param {string=} args.theme Card theme.
|
||||
* @returns {CardColors} Card colors.
|
||||
*/
|
||||
const getCardColors = ({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color,
|
||||
theme,
|
||||
}) => {
|
||||
const defaultTheme = themes["default"];
|
||||
const isThemeProvided =
|
||||
theme !== null && theme !== undefined && theme in themes;
|
||||
|
||||
// @ts-ignore
|
||||
const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
|
||||
|
||||
const defaultBorderColor =
|
||||
"border_color" in selectedTheme
|
||||
? selectedTheme.border_color
|
||||
: // @ts-ignore
|
||||
defaultTheme.border_color;
|
||||
|
||||
// get the color provided by the user else the theme color
|
||||
// finally if both colors are invalid fallback to default theme
|
||||
const titleColor = fallbackColor(
|
||||
title_color || selectedTheme.title_color,
|
||||
"#" + defaultTheme.title_color,
|
||||
);
|
||||
|
||||
// get the color provided by the user else the theme color
|
||||
// finally if both colors are invalid we use the titleColor
|
||||
const ringColor = fallbackColor(
|
||||
// @ts-ignore
|
||||
ring_color || selectedTheme.ring_color,
|
||||
titleColor,
|
||||
);
|
||||
const iconColor = fallbackColor(
|
||||
icon_color || selectedTheme.icon_color,
|
||||
"#" + defaultTheme.icon_color,
|
||||
);
|
||||
const textColor = fallbackColor(
|
||||
text_color || selectedTheme.text_color,
|
||||
"#" + defaultTheme.text_color,
|
||||
);
|
||||
const bgColor = fallbackColor(
|
||||
bg_color || selectedTheme.bg_color,
|
||||
"#" + defaultTheme.bg_color,
|
||||
);
|
||||
|
||||
const borderColor = fallbackColor(
|
||||
border_color || defaultBorderColor,
|
||||
"#" + defaultBorderColor,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof titleColor !== "string" ||
|
||||
typeof textColor !== "string" ||
|
||||
typeof ringColor !== "string" ||
|
||||
typeof iconColor !== "string" ||
|
||||
typeof borderColor !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"Unexpected behavior, all colors except background should be string.",
|
||||
);
|
||||
}
|
||||
|
||||
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
|
||||
};
|
||||
|
||||
export { fallbackColor, getCardColors };
|
||||
@@ -1,9 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Valid owner affiliations for GitHub API queries.
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];
|
||||
|
||||
export { OWNER_AFFILIATIONS };
|
||||
@@ -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,91 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import wrap from "word-wrap";
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
|
||||
/**
|
||||
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
||||
*
|
||||
* @param {number} num The number to format.
|
||||
* @param {number=} precision The number of decimal places to include.
|
||||
* @returns {string|number} The formatted number.
|
||||
*/
|
||||
const kFormatter = (num, precision) => {
|
||||
const abs = Math.abs(num);
|
||||
const sign = Math.sign(num);
|
||||
|
||||
if (typeof precision === "number" && !isNaN(precision)) {
|
||||
return (sign * (abs / 1000)).toFixed(precision) + "k";
|
||||
}
|
||||
|
||||
if (abs < 1000) {
|
||||
return sign * abs;
|
||||
}
|
||||
|
||||
return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert bytes to a human-readable string representation.
|
||||
*
|
||||
* @param {number} bytes The number of bytes to convert.
|
||||
* @returns {string} The human-readable representation of bytes.
|
||||
* @throws {Error} If bytes is negative or too large.
|
||||
*/
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes < 0) {
|
||||
throw new Error("Bytes must be a non-negative number");
|
||||
}
|
||||
|
||||
if (bytes === 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
|
||||
const base = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(base));
|
||||
|
||||
if (i >= sizes.length) {
|
||||
throw new Error("Bytes is too large to convert to a human-readable string");
|
||||
}
|
||||
|
||||
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split text over multiple lines based on the card width.
|
||||
*
|
||||
* @param {string} text Text to split.
|
||||
* @param {number} width Line width in number of characters.
|
||||
* @param {number} maxLines Maximum number of lines.
|
||||
* @returns {string[]} Array of lines.
|
||||
*/
|
||||
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
|
||||
const fullWidthComma = ",";
|
||||
const encoded = encodeHTML(text);
|
||||
const isChinese = encoded.includes(fullWidthComma);
|
||||
|
||||
let wrapped = [];
|
||||
|
||||
if (isChinese) {
|
||||
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
|
||||
} else {
|
||||
wrapped = wrap(encoded, {
|
||||
width,
|
||||
}).split("\n"); // Split wrapped lines to get an array of lines
|
||||
}
|
||||
|
||||
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
|
||||
|
||||
// Add "..." to the last line if the text exceeds maxLines
|
||||
if (wrapped.length > maxLines) {
|
||||
lines[maxLines - 1] += "...";
|
||||
}
|
||||
|
||||
// Remove empty lines if text fits in less than maxLines lines
|
||||
const multiLineText = lines.filter(Boolean);
|
||||
return multiLineText;
|
||||
};
|
||||
|
||||
export { kFormatter, formatBytes, wrapTextMultiline };
|
||||
@@ -1,21 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Send GraphQL request to GitHub API.
|
||||
*
|
||||
* @param {import('axios').AxiosRequestConfig['data']} data Request data.
|
||||
* @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
|
||||
* @returns {Promise<any>} Request response.
|
||||
*/
|
||||
const request = (data, headers) => {
|
||||
return axios({
|
||||
url: "https://api.github.com/graphql",
|
||||
method: "post",
|
||||
headers,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export { request };
|
||||
@@ -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,113 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { getUserAccessByName } from "./database.js";
|
||||
import { CustomError } from "./error.js";
|
||||
import { logger } from "./log.js";
|
||||
|
||||
/**
|
||||
* Returns a random integer from 0 (inclusive) to `max` (exclusive).
|
||||
*
|
||||
* The value is generated using `Math.random()` and uniformly distributed
|
||||
* across the range.
|
||||
*
|
||||
* @param {number} max The upper bound (exclusive). Must be a positive number.
|
||||
*
|
||||
* @returns {number} A random integer `n` such that `0 <= n < max`.
|
||||
*/
|
||||
function getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import("axios").AxiosResponse} AxiosResponse Axios response.
|
||||
* @typedef {(variables: any, token: string, retriesForTests?: number) => Promise<AxiosResponse>} FetcherFunction Fetcher function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
|
||||
*
|
||||
* @param {FetcherFunction} fetcher The fetcher function.
|
||||
* @param {string?} username GitHub username of the user whose PAT to use, if available
|
||||
* @param {any} variables Object with arguments to pass to the fetcher function.
|
||||
* @returns {Promise<any>} The response from the fetcher function.
|
||||
*/
|
||||
const retryer = async (fetcher, username, variables) => {
|
||||
let userPAT;
|
||||
if (username) {
|
||||
userPAT = await getUserAccessByName(username);
|
||||
}
|
||||
|
||||
let PATs;
|
||||
if (userPAT?.token) {
|
||||
PATs = [{ name: `USER_${username}`, value: userPAT.token }];
|
||||
} else {
|
||||
const patNames = Object.keys(process.env).filter((key) =>
|
||||
/PAT_\d*$/.exec(key),
|
||||
);
|
||||
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
|
||||
}
|
||||
|
||||
if (!PATs.length) {
|
||||
throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS);
|
||||
}
|
||||
const startPAT = getRandomInt(PATs.length);
|
||||
|
||||
for (let retries = 0; retries < PATs.length; retries++) {
|
||||
const currentPAT = PATs[(startPAT + retries) % PATs.length];
|
||||
|
||||
try {
|
||||
let response = await fetcher(
|
||||
variables,
|
||||
// @ts-ignore
|
||||
currentPAT.value,
|
||||
// used in tests for faking rate limit
|
||||
retries,
|
||||
);
|
||||
|
||||
// react on both type and message-based rate-limit signals.
|
||||
// https://github.com/anuraghazra/github-readme-stats/issues/4425
|
||||
const errors = response?.data?.errors;
|
||||
const errorType = errors?.[0]?.type;
|
||||
const errorMsg = errors?.[0]?.message || "";
|
||||
const isRateLimited =
|
||||
(errors && errorType === "RATE_LIMITED") ||
|
||||
/rate limit/i.test(errorMsg);
|
||||
|
||||
if (isRateLimited) {
|
||||
logger.log(`${currentPAT.name} Failed due to rate limiting`);
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
} catch (err) {
|
||||
/** @type {any} */
|
||||
const e = err;
|
||||
|
||||
// network/unexpected error → let caller treat as failure
|
||||
if (!e?.response) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
// also checking for bad credentials if any tokens gets invalidated
|
||||
const isBadCredential =
|
||||
e?.response?.data?.message === "Bad credentials";
|
||||
const isAccountSuspended =
|
||||
e?.response?.data?.message === "Sorry. Your account was suspended.";
|
||||
|
||||
if (isBadCredential || isAccountSuspended) {
|
||||
logger.log(`${currentPAT.name} Failed due to bad credentials`);
|
||||
} else {
|
||||
// HTTP error with a response → return it for caller-side handling
|
||||
return e.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new CustomError(
|
||||
"Downtime due to GitHub API rate limiting",
|
||||
CustomError.MAX_RETRY,
|
||||
);
|
||||
};
|
||||
|
||||
export { retryer };
|
||||
export default retryer;
|
||||
@@ -1,113 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { MissingParamError } from "../common/error.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
const QUERY = `
|
||||
query gistInfo($gistName: String!) {
|
||||
viewer {
|
||||
gist(name: $gistName) {
|
||||
description
|
||||
owner {
|
||||
login
|
||||
}
|
||||
stargazerCount
|
||||
forks {
|
||||
totalCount
|
||||
}
|
||||
files {
|
||||
name
|
||||
language {
|
||||
name
|
||||
}
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Gist data fetcher.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const fetcher = async (variables, token) => {
|
||||
return await request(
|
||||
{ query: QUERY, variables },
|
||||
{ Authorization: `token ${token}` },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function calculates the primary language of a gist by files size.
|
||||
*
|
||||
* @param {GistFile[]} files Files.
|
||||
* @returns {string} Primary language.
|
||||
*/
|
||||
const calculatePrimaryLanguage = (files) => {
|
||||
/** @type {Record<string, number>} */
|
||||
const languages = {};
|
||||
|
||||
for (const file of files) {
|
||||
if (file.language) {
|
||||
if (languages[file.language.name]) {
|
||||
languages[file.language.name] += file.size;
|
||||
} else {
|
||||
languages[file.language.name] = file.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let primaryLanguage = Object.keys(languages)[0];
|
||||
for (const language in languages) {
|
||||
if (languages[language] > languages[primaryLanguage]) {
|
||||
primaryLanguage = language;
|
||||
}
|
||||
}
|
||||
|
||||
return primaryLanguage;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').GistData} GistData Gist data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch GitHub gist information by given username and ID.
|
||||
*
|
||||
* @param {string} id GitHub gist ID.
|
||||
* @returns {Promise<GistData>} Gist data.
|
||||
*/
|
||||
const fetchGist = async (id) => {
|
||||
if (!id) {
|
||||
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
|
||||
}
|
||||
const res = await retryer(fetcher, null, { gistName: id });
|
||||
if (res.data.errors) {
|
||||
throw new Error(res.data.errors[0].message);
|
||||
}
|
||||
if (!res.data.data.viewer.gist) {
|
||||
throw new Error("Gist not found");
|
||||
}
|
||||
const data = res.data.data.viewer.gist;
|
||||
return {
|
||||
name: data.files[Object.keys(data.files)[0]].name,
|
||||
nameWithOwner: `${data.owner.login}/${
|
||||
data.files[Object.keys(data.files)[0]].name
|
||||
}`,
|
||||
description: data.description,
|
||||
language: calculatePrimaryLanguage(data.files),
|
||||
starsCount: data.stargazerCount,
|
||||
forksCount: data.forks.totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
export { fetchGist };
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./common/index.js";
|
||||
export * from "./cards/index.js";
|
||||
@@ -66,6 +66,6 @@ export async function repeatRecentRequests() {
|
||||
if (urls.length === 0) {
|
||||
console.log("No recent requests found.");
|
||||
} else {
|
||||
await makeRequests(urls, 5);
|
||||
await makeRequests(urls, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCardColors } from "../src/common/color";
|
||||
|
||||
describe("Test color.js", () => {
|
||||
it("getCardColors: should return expected values", () => {
|
||||
let colors = getCardColors({
|
||||
title_color: "f00",
|
||||
text_color: "0f0",
|
||||
ring_color: "0000ff",
|
||||
icon_color: "00f",
|
||||
bg_color: "fff",
|
||||
border_color: "fff",
|
||||
theme: "dark",
|
||||
});
|
||||
expect(colors).toStrictEqual({
|
||||
titleColor: "#f00",
|
||||
textColor: "#0f0",
|
||||
iconColor: "#00f",
|
||||
ringColor: "#0000ff",
|
||||
bgColor: "#fff",
|
||||
borderColor: "#fff",
|
||||
});
|
||||
});
|
||||
|
||||
it("getCardColors: should fallback to default colors if color is invalid", () => {
|
||||
let colors = getCardColors({
|
||||
title_color: "invalidcolor",
|
||||
text_color: "0f0",
|
||||
icon_color: "00f",
|
||||
bg_color: "fff",
|
||||
border_color: "invalidColor",
|
||||
theme: "dark",
|
||||
});
|
||||
expect(colors).toStrictEqual({
|
||||
titleColor: "#2f80ed",
|
||||
textColor: "#0f0",
|
||||
iconColor: "#00f",
|
||||
ringColor: "#2f80ed",
|
||||
bgColor: "#fff",
|
||||
borderColor: "#e4e2e2",
|
||||
});
|
||||
});
|
||||
|
||||
it("getCardColors: should fallback to specified theme colors if is not defined", () => {
|
||||
let colors = getCardColors({
|
||||
theme: "dark",
|
||||
});
|
||||
expect(colors).toStrictEqual({
|
||||
titleColor: "#fff",
|
||||
textColor: "#9f9f9f",
|
||||
ringColor: "#fff",
|
||||
iconColor: "#79ff97",
|
||||
bgColor: "#151515",
|
||||
borderColor: "#e4e2e2",
|
||||
});
|
||||
});
|
||||
|
||||
it("getCardColors: should return ring color equal to title color if not ring color is defined", () => {
|
||||
let colors = getCardColors({
|
||||
title_color: "f00",
|
||||
text_color: "0f0",
|
||||
icon_color: "00f",
|
||||
bg_color: "fff",
|
||||
border_color: "fff",
|
||||
theme: "dark",
|
||||
});
|
||||
expect(colors).toStrictEqual({
|
||||
titleColor: "#f00",
|
||||
textColor: "#0f0",
|
||||
iconColor: "#00f",
|
||||
ringColor: "#f00",
|
||||
bgColor: "#fff",
|
||||
borderColor: "#fff",
|
||||
});
|
||||
});
|
||||
|
||||
it("getCardColors: should fallback to default theme if theme is invalid", () => {
|
||||
let colors = getCardColors({
|
||||
theme: "invalidTheme",
|
||||
});
|
||||
expect(colors).toStrictEqual({
|
||||
titleColor: "#2f80ed",
|
||||
textColor: "#434d58",
|
||||
iconColor: "#4c71f2",
|
||||
ringColor: "#2f80ed",
|
||||
bgColor: "#fffefe",
|
||||
borderColor: "#e4e2e2",
|
||||
});
|
||||
});
|
||||
});
|
||||
+242
-122
@@ -3,221 +3,341 @@
|
||||
*/
|
||||
|
||||
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, stargazerCount: 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,
|
||||
stargazerCount: 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { encodeHTML } from "../src/common/html.js";
|
||||
|
||||
describe("Test html.js", () => {
|
||||
it("should test encodeHTML", () => {
|
||||
expect(encodeHTML(`<html>hello world<,.#4^&^@%!))`)).toBe(
|
||||
"<html>hello world<,.#4^&^@%!))",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 stargazerCount\\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 stargazerCount\\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);
|
||||
});
|
||||
});
|
||||
|
||||
+291
-35
@@ -1,41 +1,297 @@
|
||||
// @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", stargazerCount: 1500 },
|
||||
{ name: "repo-exclude-me", stargazerCount: 9999 },
|
||||
{ name: "repo-keep-2", stargazerCount: 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",
|
||||
stargazerCount: 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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Puts the SPA into an authenticated state without contacting GitHub by
|
||||
* stubbing the OAuth code exchange and the follow-up user-access lookup:
|
||||
* `authenticate` returns a userId (which flips `isAuthenticated` to true), and
|
||||
* `user-access` returns metadata so AppTrends does not immediately log back out.
|
||||
* @param page - The Playwright page to install the route handlers on.
|
||||
*/
|
||||
async function mockAuthEndpoints(page: Page): Promise<void> {
|
||||
await page.route("**/api/authenticate**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ userId: "octocat", needDowngrade: false }),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/user-access**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ token: "test-token", privateAccess: "false" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("AppTrends auth-driven stage transition", () => {
|
||||
test("auto-advances from Login to Select a Card once authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthEndpoints(page);
|
||||
|
||||
// Land on the app as GitHub does after the OAuth redirect (URL carries `code`).
|
||||
await page.goto("?code=test-oauth-code");
|
||||
|
||||
// AppTrends starts unauthenticated on stage 0 ("Login"). When the code
|
||||
// exchange flips `isAuthenticated` false -> true, the render-phase
|
||||
// transition must advance the stepper to stage 1 ("Select a Card").
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Select a Card",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a manually selected earlier step instead of re-forcing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthEndpoints(page);
|
||||
await page.goto("?code=test-oauth-code");
|
||||
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await expect(heading).toContainText("Select a Card");
|
||||
|
||||
// Going back to "Login" while still authenticated must stick: the transition
|
||||
// fires only on an auth *change*, so a later render must not snap the user
|
||||
// forward again. A bug that dropped the previous-value guard would bounce
|
||||
// the heading straight back to "Select a Card" here.
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(heading).toContainText("Login");
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => {
|
||||
// Logo exists
|
||||
await expect(page.locator('img[alt="logo"]')).toBeVisible();
|
||||
|
||||
// Star on GitHub button
|
||||
const starButton = page.getByRole("button", { name: /star on/i });
|
||||
// Star on GitHub link
|
||||
const starButton = page.getByRole("link", { name: /star on/i });
|
||||
await expect(starButton).toBeVisible();
|
||||
|
||||
const githubLink = page.locator(
|
||||
@@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => {
|
||||
await expect(guestBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test("theme picker switches and persists the theme", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
const html = page.locator("html");
|
||||
const trigger = page.getByRole("button", { name: /choose theme/i });
|
||||
const options = page.getByRole("menuitemradio");
|
||||
|
||||
// The menu is closed initially.
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(options).toHaveCount(0);
|
||||
|
||||
// Opening it reveals the options (portaled to body, above the stepper).
|
||||
await trigger.click();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Read the themes off the options instead of hardcoding their names.
|
||||
const firstTheme = await options.first().getAttribute("data-theme-value");
|
||||
const lastTheme = await options.last().getAttribute("data-theme-value");
|
||||
|
||||
// Selecting an option applies the theme, persists it, and closes the menu.
|
||||
await options.last().click();
|
||||
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
|
||||
await expect(options).toHaveCount(0);
|
||||
await expect(
|
||||
page.evaluate(() => localStorage.getItem("theme")),
|
||||
).resolves.toBe(lastTheme);
|
||||
|
||||
// The choice survives a reload and is marked as selected on reopen.
|
||||
await page.reload();
|
||||
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
|
||||
await trigger.click();
|
||||
await expect(options.last()).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Switching to another option works too.
|
||||
await options.first().click();
|
||||
await expect(html).toHaveAttribute("data-theme", firstTheme ?? "");
|
||||
});
|
||||
|
||||
test("navigates between steps", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("selecting 'None' progress style hides the rank circle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("");
|
||||
|
||||
// Stage 0 -> 2: go to customization
|
||||
await page.getByRole("button", { name: "Modify Parameters" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Modify Card Parameters",
|
||||
);
|
||||
|
||||
const preview = page.locator("#svgWrapper");
|
||||
const rankCircle = preview.locator('[data-testid="rank-circle"]');
|
||||
|
||||
// Default "Rank" progress style renders the rank circle.
|
||||
await expect(preview).toBeAttached();
|
||||
await expect(rankCircle).toHaveCount(1);
|
||||
|
||||
await page.getByRole("combobox").selectOption({ label: "None" });
|
||||
|
||||
await expect(rankCircle).toHaveCount(0);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user