Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b652f300 | ||
|
|
c7630f2ae4 | ||
|
|
480c26b717 | ||
|
|
3090adee7f | ||
|
|
c86492f9c2 | ||
|
|
c177bd9ec5 | ||
|
|
944857b2a5 | ||
|
|
a9cfee5809 | ||
|
|
c7b6e66dea | ||
|
|
6a35f03faa | ||
|
|
34d08882a7 | ||
|
|
4edf85bdc2 | ||
|
|
ad10495810 | ||
|
|
fbc030f475 | ||
|
|
100090de31 | ||
|
|
ba04189700 | ||
|
|
1abd91eace | ||
|
|
dd28696356 | ||
|
|
ecce10198c | ||
|
|
5d3d3af4da | ||
|
|
5156202340 | ||
|
|
28bb41a488 | ||
|
|
3ff8364844 | ||
|
|
df66b24c6a | ||
|
|
fbd292a03d | ||
|
|
68eddf0f81 | ||
|
|
58d941d63a | ||
|
|
372eaaf058 | ||
|
|
09a72df953 | ||
|
|
587f4589b8 | ||
|
|
259fc7c5bc | ||
|
|
caccc4d69a | ||
|
|
1c6b4c31d9 | ||
|
|
2992f63de2 | ||
|
|
9201b65d82 | ||
|
|
90508f02f3 | ||
|
|
3bda3abee7 | ||
|
|
8f7cc5824b | ||
|
|
1d7ea960f5 | ||
|
|
2b908ad146 | ||
|
|
63ca91e1b1 | ||
|
|
2007f51dc0 | ||
|
|
3258ad09b0 | ||
|
|
4ccec4604b | ||
|
|
b81abe07c4 | ||
|
|
b0100ca48e | ||
|
|
7c7fca420b | ||
|
|
1d9db07ed4 | ||
|
|
4043488ee0 | ||
|
|
807aa453d8 | ||
|
|
6f1ad4b732 | ||
|
|
536199bf4a | ||
|
|
a2bb443789 | ||
|
|
5da671b171 | ||
|
|
ed9d284973 | ||
|
|
748d8c5430 | ||
|
|
fef8e90c86 | ||
|
|
fa82216dfe | ||
|
|
ef3f207dbf | ||
|
|
35fbec8004 | ||
|
|
3ef27f872c | ||
|
|
5557e9c772 | ||
|
|
570fc4aaed | ||
|
|
2ac454a177 | ||
|
|
b837c50885 | ||
|
|
dfb07a69b9 | ||
|
|
41b51dcfd9 | ||
|
|
7687c47d85 | ||
|
|
7917d54fdf | ||
|
|
7771a34790 | ||
|
|
1c266ad51e | ||
|
|
6258d90604 | ||
|
|
6d857d0f34 | ||
|
|
e15deeac76 | ||
|
|
b5d50fc28f | ||
|
|
1d394c7fad | ||
|
|
76e09ce3b0 | ||
|
|
96d141e8c7 | ||
|
|
6310073428 | ||
|
|
1d8aaf0fc8 | ||
|
|
4a2785e286 | ||
|
|
16ea9afebe | ||
|
|
c707e6b7b1 | ||
|
|
9dc4910d4b | ||
|
|
5d1978011e | ||
|
|
d84cc27b9c | ||
|
|
e43e55c2b7 | ||
|
|
f884df6312 | ||
|
|
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 |
@@ -21,4 +21,4 @@ If you believe someone is violating the code of conduct, we ask that you report
|
||||
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
|
||||
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
|
||||
|
||||
Based on the [Django Code of Conduct](https://www.djangoproject.com/conduct/).
|
||||
Based on the [Django Code of Conduct](https://www.djangoproject.com/conduct/).
|
||||
|
||||
+35
-21
@@ -5,13 +5,45 @@
|
||||
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).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pnpm run test # unit tests
|
||||
pnpm run lint # eslint
|
||||
pnpm run typecheck # tsc
|
||||
```
|
||||
|
||||
The **Backend E2E test** in CI compares the cards your branch renders against the ones served by the preview deployment, which is still on the last commit merged to master.
|
||||
So if your PR changes card output at all, that job goes red until the preview catches up.
|
||||
It's marked `continue-on-error`, so it won't block your PR, but do open it and check the diff is only what you expected.
|
||||
|
||||
If the change to card markup was intentional, update the snapshots:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./packages/core/ run test:update:snapshot
|
||||
pnpm --filter ./apps/backend/ run test:update:snapshot
|
||||
```
|
||||
|
||||
## GraphQL Queries
|
||||
|
||||
The GraphQL queries live in `packages/core/src/graphql/queries/*.graphql`,
|
||||
and their TypeScript types are generated from GitHub's published schema into `packages/core/src/graphql/generated/`.
|
||||
Those generated files are committed, so if you change a query, regenerate them and include the result in your PR:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./packages/core/ run generate-graphql-types
|
||||
```
|
||||
|
||||
CI runs `pnpm --filter ./packages/core/ run check-graphql-types`, which fails if the committed types no longer match the queries.
|
||||
Never edit the generated files by hand — change the `.graphql` file and regenerate.
|
||||
|
||||
## Themes Contribution
|
||||
|
||||
We have stopped the addition of new themes to decrease maintenance efforts. If you are considering contributing your theme just because you are using it personally, then instead of adding it to our theme collection, you can use card [customization options](../docs/advanced_documentation.md#customization).
|
||||
@@ -30,25 +62,7 @@ In short, when you submit changes, your submissions are understood to be under t
|
||||
|
||||
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/stats-organization/github-stats-extended/issues/new/choose). If there is already an open issue for your bug in the upstream repo [github-readme-stats](https://github.com/anuraghazra/github-readme-stats/issues) you don't need to report it here.
|
||||
|
||||
## Frequently Asked Questions (FAQs)
|
||||
|
||||
**Q:** How to hide Jupyter Notebook?
|
||||
|
||||
> **Ans:** &hide=jupyter%20notebook
|
||||
|
||||
**Q:** Language Card is incorrect
|
||||
|
||||
> **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats:
|
||||
>
|
||||
> - <https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174>
|
||||
>
|
||||
> - <https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181>
|
||||
|
||||
**Q:** How to count private stats?
|
||||
|
||||
> see [here](../docs/fork.md#private-contributions-support)
|
||||
|
||||
### Feature Request
|
||||
## Feature Request
|
||||
|
||||
**Great Feature Requests** tend to have:
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Security Policy
|
||||
|
||||
Please report any security vulnerabilities to github.stats.extended@gmail.com. I will try to respond as quickly as possible, but since I am maintaining this project in my free time, please allow some time for a response.
|
||||
Please report any security vulnerabilities to github.stats.extended@gmail.com. I will try to respond as quickly as possible, but since I am maintaining this project in my free time, please allow some time for a response.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+54
-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,61 @@ 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-*"
|
||||
graphql:
|
||||
patterns:
|
||||
- "graphql"
|
||||
- "@graphql-codegen/*"
|
||||
- "@octokit/graphql-schema"
|
||||
|
||||
# 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 +79,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)"
|
||||
|
||||
+48
-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,48 @@ 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
|
||||
|
||||
# Compares the cards we render here against the preview deployment,
|
||||
# which is still on the last commit merged on master.
|
||||
# So any PR that changes card output turns this red until the preview catches up.
|
||||
# Worth a look, just not always a bug.
|
||||
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 +112,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
|
||||
@@ -114,5 +126,11 @@ jobs:
|
||||
- name: Lint (knip)
|
||||
run: pnpm run lint:knip
|
||||
|
||||
- name: Lint (deduped deps)
|
||||
run: pnpm run lint:deps
|
||||
|
||||
- name: Lint (generated GraphQL types)
|
||||
run: pnpm --filter ./packages/core/ run check-graphql-types
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
+12
-25
@@ -11,50 +11,37 @@ on:
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "0 0 */30 * *"
|
||||
- cron: "45 22 */7 * *"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: write
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
update-languages:
|
||||
if: |
|
||||
github.repository == 'anuraghazra/github-readme-stats' ||
|
||||
github.repository == 'stats-organization/github-readme-stats' ||
|
||||
github.repository == 'stats-organization/github-stats-extended'
|
||||
name: Update supported languages
|
||||
if: github.repository == 'stats-organization/github-stats-extended'
|
||||
name: Generate Language Colors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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-language-colors
|
||||
|
||||
- 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.1.1
|
||||
with:
|
||||
commit-message: "feat(backend): update languages JSON"
|
||||
commit-message: "feat(core): update language colors"
|
||||
branch: "update_langs/patch"
|
||||
delete-branch: true
|
||||
title: Update languages JSON
|
||||
body: "The
|
||||
[update-langs](https://github.com/stats-organization/github-stats-extended/actions/workflows/update-langs.yml)
|
||||
action found new/updated languages in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)."
|
||||
title: Update languages colors
|
||||
body: >-
|
||||
The [generate-language-colors](https://github.com/stats-organization/github-stats-extended/actions/workflows/generate-language-colors.yml)
|
||||
workflow detected changes in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml) and regenerated the local language colors JSON file.
|
||||
labels: "ci"
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Generate Theme Readme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "apps/backend/themes/index.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
generate-theme-doc:
|
||||
runs-on: ubuntu-latest
|
||||
name: Generate theme doc
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: write
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
run: |
|
||||
pnpm --filter github-readme-stats run theme-readme-gen
|
||||
|
||||
- name: Create Pull Request if themes README has changed
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
commit-message: "feat(backend): update themes README"
|
||||
branch: "update_themes_readme/patch"
|
||||
delete-branch: true
|
||||
title: Update themes README
|
||||
body: "The
|
||||
[generate-theme-doc](https://github.com/stats-organization/github-stats-extended/actions/workflows/generate-theme-doc.yml)
|
||||
action found new/updated languages in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)."
|
||||
labels: "ci"
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Generate Theme Readme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "packages/core/src/themes/index.ts"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "45 22 */7 * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
generate-theme-doc:
|
||||
runs-on: ubuntu-latest
|
||||
name: Generate Theme Readme
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
run: pnpm --filter ./packages/core/ run generate-theme-readme
|
||||
|
||||
- name: Create Pull Request if themes README has changed
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "feat(core): update themes README"
|
||||
branch: "update_themes_readme/patch"
|
||||
delete-branch: true
|
||||
title: Update themes README
|
||||
body: >-
|
||||
The [generate-theme-readme](https://github.com/stats-organization/github-stats-extended/actions/workflows/generate-theme-readme.yml)
|
||||
workflow detected changes in
|
||||
[`packages/core/src/themes/index.ts`](https://github.com/stats-organization/github-stats-extended/blob/master/packages/core/src/themes/index.ts)
|
||||
and regenerated the themes README.
|
||||
labels: "ci"
|
||||
@@ -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,22 +10,29 @@ on:
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "45 * * * *"
|
||||
- cron: "15,45 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
triggerRepeatRecent:
|
||||
repeat-recent-requests:
|
||||
if: 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
|
||||
with:
|
||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||
method: "POST"
|
||||
timeout: "300000"
|
||||
- name: Show Response
|
||||
run: |
|
||||
echo ${{ steps.myRequest.outputs.response }}
|
||||
echo ${{ steps.myRequest.outputs.status }}
|
||||
args=(
|
||||
--silent --show-error # no progress bar, but still report errors
|
||||
--request POST # the only thing the endpoint checks
|
||||
--location # a redirect would otherwise pass the status check
|
||||
--max-redirs 5 # bounded well below curl's default of 50
|
||||
--speed-limit 1 --speed-time 300 # give up after 300s of no traffic
|
||||
--output response.txt # keep the body out of the status capture
|
||||
--write-out "%{http_code}" # status is all we capture on stdout
|
||||
)
|
||||
status=$(curl "${args[@]}" "https://github-stats-extended.vercel.app/api/repeat-recent")
|
||||
cat response.txt
|
||||
printf '\nstatus: %s\n' "$status"
|
||||
test "$status" -lt 400
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
pnpm-lock.yaml
|
||||
|
||||
# https://github.com/stats-organization/github-stats-extended/pull/26#discussion_r2709033732
|
||||
# Avoid prettier format on md files to simplify merge on upstream repo
|
||||
*.md
|
||||
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"]
|
||||
}
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
<img src="docs/appIcon.svg" width="100px" alt="GitHub Stats Extended Logo" />
|
||||
<h1>GitHub Stats Extended</h1>
|
||||
<p>Dynamically generate GitHub stats for your READMEs.</p>
|
||||
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a>
|
||||
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=light_github" />
|
||||
</picture>
|
||||
</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
|
||||
|
||||
# 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)
|
||||
|
||||
# Quick Start
|
||||
## Quick Start
|
||||
|
||||
- Copy and paste this into your markdown:
|
||||
```markdown
|
||||
[](https://github.com/stats-organization/github-stats-extended)
|
||||
@@ -23,45 +34,97 @@ This project is an [extended version](docs/fork.md) of [github-readme-stats](htt
|
||||
- Change the `?username=` value to your GitHub username.
|
||||
- Done!
|
||||
|
||||
-----------------------
|
||||
---
|
||||
|
||||
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!
|
||||
|
||||
# Card Types
|
||||
## 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:
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=light_github" alt="Anurag's GitHub stats" />
|
||||
</picture>
|
||||
|
||||
- ...your top languages...:
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/top-langs/?username=anuraghazra&langs_count=4&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/top-langs/?username=anuraghazra&langs_count=4&theme=light_github" alt="Top Langs" />
|
||||
</picture>
|
||||
|
||||
- ...and development time:
|
||||
|
||||
[](https://wakatime.com/@alan)
|
||||
<a href="https://wakatime.com/@alan">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/wakatime?username=alan&langs_count=6&theme=dark_github"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/wakatime?username=alan&langs_count=6&theme=light_github" alt="Alan's WakaTime stats" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- Pin more than 6 repos in your GitHub profile:
|
||||
|
||||
[](https://github.com/anuraghazra/github-readme-stats)
|
||||
<a href="https://github.com/anuraghazra/github-readme-stats">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&theme=dark_github_repocard"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&theme=light_github_repocard" alt="Readme Card" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- Pin Gists in your GitHub profile:
|
||||
|
||||
[](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d)
|
||||
<a href="https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d">
|
||||
<picture>
|
||||
<source
|
||||
srcset="https://github-stats-extended.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=dark_github_repocard"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img src="https://github-stats-extended.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=light_github_repocard" alt="Gist Card" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- Customize all the cards:
|
||||
|
||||
[](https://github-stats-extended.vercel.app/api/?username=anuraghazra&show_icons=true&theme=calm&rank_icon=github&include_all_commits=true&custom_title=Anurag's+Stats&disable_animations=true&number_format=long&show=prs_merged_percentage,prs_reviewed)
|
||||
|
||||
# Advanced Customization
|
||||
## 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).
|
||||
|
||||
# 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! ❤️
|
||||
## Run It Yourself
|
||||
|
||||
# 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.
|
||||
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 [Deploy on your own](docs/deploy.md) for various deployment options.
|
||||
See [Run It Yourself](docs/deploy.md) for detailed instructions.
|
||||
|
||||
# Contributing
|
||||
Contributions are welcome!
|
||||
## Acknowledgements
|
||||
|
||||
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
||||
|
||||
## 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderRepoCard } from "../src/cards/repo.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchRepo } from "../src/fetchers/repo.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
repo,
|
||||
hide_border,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
card_width,
|
||||
theme,
|
||||
show_owner,
|
||||
show,
|
||||
show_icons,
|
||||
number_format,
|
||||
text_bold,
|
||||
line_height,
|
||||
cache_seconds,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
description_lines_count,
|
||||
} = req.query;
|
||||
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id: username,
|
||||
type: "username",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w/.,]+$/;
|
||||
if (
|
||||
(username && !safePattern.test(username)) ||
|
||||
(repo && !safePattern.test(repo))
|
||||
) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Username or repository contains unsafe characters",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const showStats = parseArray(show);
|
||||
const repoData = await fetchRepo(
|
||||
username,
|
||||
repo,
|
||||
showStats.includes("prs_authored"),
|
||||
showStats.includes("prs_commented"),
|
||||
showStats.includes("prs_reviewed"),
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
);
|
||||
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.PIN_CARD.DEFAULT,
|
||||
min: CACHE_TTL.PIN_CARD.MIN,
|
||||
max: CACHE_TTL.PIN_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderRepoCard(repoData, {
|
||||
hide_border: parseBoolean(hide_border),
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
border_radius,
|
||||
border_color,
|
||||
card_width_input: parseInt(card_width, 10),
|
||||
show_owner: parseBoolean(show_owner),
|
||||
show: showStats,
|
||||
show_icons: parseBoolean(show_icons),
|
||||
number_format,
|
||||
text_bold: parseBoolean(text_bold),
|
||||
line_height,
|
||||
username,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
description_lines_count,
|
||||
}),
|
||||
);
|
||||
} 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,194 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderTopLanguages } from "../src/cards/top-languages.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
hide,
|
||||
hide_title,
|
||||
hide_border,
|
||||
card_width,
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
prog_bar_bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
layout,
|
||||
langs_count,
|
||||
exclude_repo,
|
||||
size_weight,
|
||||
count_weight,
|
||||
custom_title,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
role,
|
||||
disable_animations,
|
||||
hide_progress,
|
||||
stats_format,
|
||||
} = req.query;
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id: username,
|
||||
type: "username",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Locale not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
layout !== undefined &&
|
||||
(typeof layout !== "string" ||
|
||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
||||
) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect layout input",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
stats_format !== undefined &&
|
||||
(typeof stats_format !== "string" ||
|
||||
!["bytes", "percentages"].includes(stats_format))
|
||||
) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect stats_format input",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const topLangs = await fetchTopLanguages(
|
||||
username,
|
||||
parseArray(exclude_repo),
|
||||
size_weight,
|
||||
count_weight,
|
||||
parseArray(role),
|
||||
);
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
|
||||
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
|
||||
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderTopLanguages(topLangs, {
|
||||
custom_title,
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide: parseArray(hide),
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
prog_bar_bg_color,
|
||||
theme,
|
||||
layout,
|
||||
langs_count,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
hide_progress: parseBoolean(hide_progress),
|
||||
stats_format,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,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,141 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { default as Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||
import { parseEmojis } from "../common/ops.js";
|
||||
import {
|
||||
createLanguageNode,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
measureText,
|
||||
} from "../common/render.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const CARD_DEFAULT_WIDTH = 400;
|
||||
const HEADER_MAX_LENGTH = 35;
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
|
||||
* @typedef {import('../fetchers/types').GistData} GistData Gist data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render gist card.
|
||||
*
|
||||
* @param {GistData} gistData Gist data.
|
||||
* @param {Partial<GistCardOptions>} options Gist card options.
|
||||
* @returns {string} Gist card.
|
||||
*/
|
||||
const renderGistCard = (gistData, options = {}) => {
|
||||
const { name, nameWithOwner, description, language, starsCount, forksCount } =
|
||||
gistData;
|
||||
const {
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme = "default_repocard",
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner = false,
|
||||
hide_border = false,
|
||||
} = options;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, iconColor, bgColor, borderColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const lineWidth = 59;
|
||||
const linesLimit = 10;
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
|
||||
const descriptionLines = multiLineDescription.length;
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const lineHeight = descriptionLines > 3 ? 12 : 10;
|
||||
const height =
|
||||
(descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight;
|
||||
|
||||
const totalStars = kFormatter(starsCount);
|
||||
const totalForks = kFormatter(forksCount);
|
||||
const svgStars = iconWithLabel(
|
||||
icons.star,
|
||||
totalStars,
|
||||
"starsCount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
const svgForks = iconWithLabel(
|
||||
icons.fork,
|
||||
totalForks,
|
||||
"forksCount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
|
||||
const languageName = language || "Unspecified";
|
||||
// @ts-ignore
|
||||
const languageColor = languageColors[languageName] || "#858585";
|
||||
|
||||
const svgLanguage = createLanguageNode(languageName, languageColor);
|
||||
|
||||
const starAndForkCount = flexLayout({
|
||||
items: [svgLanguage, svgStars, svgForks],
|
||||
sizes: [
|
||||
measureText(languageName, 12),
|
||||
ICON_SIZE + measureText(`${totalStars}`, 12),
|
||||
ICON_SIZE + measureText(`${totalForks}`, 12),
|
||||
],
|
||||
gap: 25,
|
||||
}).join("");
|
||||
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
|
||||
const card = new Card({
|
||||
defaultTitle:
|
||||
header.length > HEADER_MAX_LENGTH
|
||||
? `${header.slice(0, HEADER_MAX_LENGTH)}...`
|
||||
: header,
|
||||
titlePrefixIcon: icons.gist,
|
||||
width: CARD_DEFAULT_WIDTH,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
card.setCSS(`
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.icon { fill: ${iconColor} }
|
||||
`);
|
||||
card.setHideBorder(hide_border);
|
||||
|
||||
return card.render(`
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75})">
|
||||
${starAndForkCount}
|
||||
</g>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderGistCard };
|
||||
@@ -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,324 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
|
||||
import {
|
||||
createLanguageNode,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
measureText,
|
||||
} from "../common/render.js";
|
||||
import { repoCardLocales } from "../translations.js";
|
||||
|
||||
import { createTextNode } from "./stats.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const DESCRIPTION_LINE_WIDTH = 59;
|
||||
const DESCRIPTION_MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* Retrieves the repository description and wraps it to fit the card width.
|
||||
*
|
||||
* @param {string} label The repository description.
|
||||
* @param {string} textColor The color of the text.
|
||||
* @returns {string} Wrapped repo description SVG object.
|
||||
*/
|
||||
const getBadgeSVG = (label, textColor, xOffset = 0) => `
|
||||
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
||||
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
||||
<text
|
||||
x="23" y="-5"
|
||||
alignment-baseline="central"
|
||||
dominant-baseline="central"
|
||||
text-anchor="middle"
|
||||
fill="${textColor}"
|
||||
>
|
||||
${label}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
|
||||
/**
|
||||
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
|
||||
* @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders repository card details.
|
||||
*
|
||||
* @param {RepositoryData} repo Repository data.
|
||||
* @param {Partial<RepoCardOptions>} options Card options.
|
||||
* @returns {string} Repository card SVG object.
|
||||
*/
|
||||
const renderRepoCard = (repo, options = {}) => {
|
||||
const {
|
||||
name,
|
||||
nameWithOwner,
|
||||
description,
|
||||
primaryLanguage,
|
||||
isArchived,
|
||||
isTemplate,
|
||||
starCount,
|
||||
forkCount,
|
||||
totalPRsAuthored,
|
||||
totalPRsCommented,
|
||||
totalPRsReviewed,
|
||||
totalIssuesAuthored,
|
||||
totalIssuesCommented,
|
||||
} = repo;
|
||||
const {
|
||||
hide_border = false,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
card_width_input,
|
||||
show_owner = false,
|
||||
show = [],
|
||||
show_icons = true,
|
||||
number_format = "short",
|
||||
text_bold = false,
|
||||
line_height = 22,
|
||||
username,
|
||||
theme = "default_repocard",
|
||||
border_radius,
|
||||
border_color,
|
||||
locale,
|
||||
description_lines_count,
|
||||
} = options;
|
||||
|
||||
const card_width =
|
||||
card_width_input && !isNaN(card_width_input)
|
||||
? card_width_input
|
||||
: show.length >= 2
|
||||
? 430
|
||||
: 400;
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: repoCardLocales,
|
||||
});
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
|
||||
const STATS = {};
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("repocard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
id: "prs_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_commented")) {
|
||||
STATS.prs_commented = {
|
||||
icon: icons.comments,
|
||||
label: i18n.t("repocard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
id: "prs_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_reviewed")) {
|
||||
STATS.prs_reviewed = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("repocard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
id: "prs_reviewed",
|
||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_authored")) {
|
||||
STATS.issues_authored = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("repocard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
id: "issues_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_commented")) {
|
||||
STATS.issues_commented = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("repocard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
id: "issues_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
|
||||
const statItems = Object.keys(STATS).map((key, index) =>
|
||||
// create the text nodes, and pass index so that we can calculate the line spacing
|
||||
createTextNode({
|
||||
icon: STATS[key].icon,
|
||||
label: STATS[key].label,
|
||||
value: STATS[key].value,
|
||||
id: STATS[key].id,
|
||||
unitSymbol: STATS[key].unitSymbol,
|
||||
index,
|
||||
showIcons: show_icons,
|
||||
shiftValuePos: 14.01,
|
||||
bold: text_bold,
|
||||
numberFormat: number_format,
|
||||
link: STATS[key].link,
|
||||
labelXOffset: 23,
|
||||
}),
|
||||
);
|
||||
|
||||
const extraLHeight = parseInt(String(line_height), 10);
|
||||
const lineHeight = 10;
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
|
||||
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
|
||||
const descriptionMaxLines = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: DESCRIPTION_MAX_LINES;
|
||||
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(
|
||||
desc,
|
||||
Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
|
||||
descriptionMaxLines,
|
||||
);
|
||||
const descriptionLinesCount = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: multiLineDescription.length;
|
||||
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const extraHeight = Object.keys(STATS).length
|
||||
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
|
||||
: 0;
|
||||
const height =
|
||||
(descriptionLinesCount > 1 ? 120 : 110) +
|
||||
descriptionLinesCount * lineHeight +
|
||||
extraHeight;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const colors = getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const svgLanguage = primaryLanguage
|
||||
? createLanguageNode(langName, langColor)
|
||||
: "";
|
||||
|
||||
const totalStars = kFormatter(starCount);
|
||||
const totalForks = kFormatter(forkCount);
|
||||
const svgStars = iconWithLabel(
|
||||
icons.star,
|
||||
totalStars,
|
||||
"stargazers",
|
||||
ICON_SIZE,
|
||||
);
|
||||
const svgForks = iconWithLabel(
|
||||
icons.fork,
|
||||
totalForks,
|
||||
"forkcount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
|
||||
const starAndForkCount = flexLayout({
|
||||
items: [svgLanguage, svgStars, svgForks],
|
||||
sizes: [
|
||||
measureText(langName, 12),
|
||||
ICON_SIZE + measureText(`${totalStars}`, 12),
|
||||
ICON_SIZE + measureText(`${totalForks}`, 12),
|
||||
],
|
||||
gap: 25,
|
||||
}).join("");
|
||||
|
||||
let extraRows = [];
|
||||
for (let i = 0; i < statItems.length; i += 2) {
|
||||
extraRows.push(
|
||||
flexLayout({
|
||||
items: statItems.slice(i, i + 2),
|
||||
gap: 210,
|
||||
direction: "row",
|
||||
}).join(""),
|
||||
);
|
||||
}
|
||||
const extraItems = `
|
||||
<svg x="0" y="0"><g transform="translate(-3, ${height - 52 - extraHeight})">
|
||||
${flexLayout({
|
||||
items: extraRows,
|
||||
gap: extraLHeight,
|
||||
direction: "column",
|
||||
}).join("")}
|
||||
</g></svg>
|
||||
`;
|
||||
|
||||
const card = new Card({
|
||||
defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header,
|
||||
titlePrefixIcon: icons.contribs,
|
||||
width: card_width,
|
||||
height,
|
||||
border_radius,
|
||||
colors,
|
||||
});
|
||||
|
||||
card.disableAnimations();
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(false);
|
||||
card.setCSS(`
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: ${colors.iconColor};
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
return card.render(`
|
||||
${
|
||||
isTemplate
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.template"),
|
||||
colors.textColor,
|
||||
card_width - 400,
|
||||
)
|
||||
: isArchived
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.archived"),
|
||||
colors.textColor,
|
||||
card_width - 400,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75 - extraHeight})">
|
||||
${starAndForkCount}
|
||||
</g>
|
||||
${extraItems}
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderRepoCard };
|
||||
Vendored
-75
@@ -1,75 +0,0 @@
|
||||
type ThemeNames = keyof typeof import("../../themes/index.js");
|
||||
type RankIcon = "default" | "github" | "percentile";
|
||||
|
||||
type CommonOptions = {
|
||||
title_color: string;
|
||||
icon_color: string;
|
||||
text_color: string;
|
||||
bg_color: string;
|
||||
theme: ThemeNames;
|
||||
border_radius: number;
|
||||
border_color: string;
|
||||
locale: string;
|
||||
hide_border: boolean;
|
||||
};
|
||||
|
||||
export type StatCardOptions = CommonOptions & {
|
||||
hide: string[];
|
||||
show_icons: boolean;
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide_rank: boolean;
|
||||
include_all_commits: boolean;
|
||||
commits_year: number;
|
||||
line_height: number | string;
|
||||
custom_title: string;
|
||||
disable_animations: boolean;
|
||||
number_format: string;
|
||||
number_precision: number;
|
||||
ring_color: string;
|
||||
text_bold: boolean;
|
||||
rank_icon: RankIcon;
|
||||
show: string[];
|
||||
};
|
||||
|
||||
export type RepoCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
description_lines_count: number;
|
||||
card_width_input;
|
||||
show: string[];
|
||||
show_icons: boolean;
|
||||
number_format: string;
|
||||
text_bold: boolean;
|
||||
line_height: number | string;
|
||||
username;
|
||||
};
|
||||
|
||||
export type TopLangOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide: string[];
|
||||
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
|
||||
custom_title: string;
|
||||
langs_count: number;
|
||||
disable_animations: boolean;
|
||||
hide_progress: boolean;
|
||||
prog_bar_bg_color: string;
|
||||
stats_format: "percentages" | "bytes";
|
||||
};
|
||||
|
||||
export type WakaTimeOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
hide: string[];
|
||||
card_width: number;
|
||||
line_height: string;
|
||||
hide_progress: boolean;
|
||||
custom_title: string;
|
||||
layout: "compact" | "normal";
|
||||
langs_count: number;
|
||||
display_format: "time" | "percent";
|
||||
disable_animations: boolean;
|
||||
};
|
||||
|
||||
export type GistCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
};
|
||||
@@ -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,162 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { MissingParamError } from "../common/error.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
import { fetchRepoUserStats } from "./stats.js";
|
||||
|
||||
/**
|
||||
* Repo data fetcher.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
fragment RepoInfo on Repository {
|
||||
name
|
||||
nameWithOwner
|
||||
isPrivate
|
||||
isArchived
|
||||
isTemplate
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
description
|
||||
primaryLanguage {
|
||||
color
|
||||
id
|
||||
name
|
||||
}
|
||||
forkCount
|
||||
}
|
||||
query getRepo($login: String!, $repo: String!) {
|
||||
user(login: $login) {
|
||||
repository(name: $repo) {
|
||||
...RepoInfo
|
||||
}
|
||||
}
|
||||
organization(login: $login) {
|
||||
repository(name: $repo) {
|
||||
...RepoInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME";
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").RepositoryData} RepositoryData Repository data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch repository data.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {string} reponame GitHub repository name.
|
||||
* @returns {Promise<RepositoryData>} Repository data.
|
||||
*/
|
||||
const fetchRepo = async (
|
||||
username,
|
||||
reponame,
|
||||
include_prs_authored = false,
|
||||
include_prs_commented = false,
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
) => {
|
||||
let owner = username;
|
||||
if (reponame && reponame.includes("/")) {
|
||||
const [parsed_owner, parsed_repo] = reponame.split("/");
|
||||
owner = parsed_owner;
|
||||
reponame = parsed_repo;
|
||||
}
|
||||
|
||||
if (owner && !username) {
|
||||
username = owner;
|
||||
}
|
||||
if (username && !owner) {
|
||||
owner = username;
|
||||
}
|
||||
if (!username && !reponame) {
|
||||
throw new MissingParamError(["username", "repo"], urlExample);
|
||||
}
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"], urlExample);
|
||||
}
|
||||
if (!reponame) {
|
||||
throw new MissingParamError(["repo"], urlExample);
|
||||
}
|
||||
|
||||
let res = await retryer(fetcher, username, { login: owner, repo: reponame });
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
if (!data.user && !data.organization) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const isUser = data.organization === null && data.user;
|
||||
const isOrg = data.user === null && data.organization;
|
||||
|
||||
if (isUser) {
|
||||
if (!data.user.repository || data.user.repository.isPrivate) {
|
||||
throw new Error("User Repository Not found");
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
[owner + "/" + reponame],
|
||||
[],
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
...data.user.repository,
|
||||
starCount: data.user.repository.stargazers.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (isOrg) {
|
||||
if (
|
||||
!data.organization.repository ||
|
||||
data.organization.repository.isPrivate
|
||||
) {
|
||||
throw new Error("Organization Repository Not found");
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
[owner + "/" + reponame],
|
||||
[],
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
...data.organization.repository,
|
||||
starCount: data.organization.repository.stargazers.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unexpected behavior");
|
||||
};
|
||||
|
||||
export { fetchRepo };
|
||||
@@ -1,457 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import githubUsernameRegex from "github-username-regex";
|
||||
|
||||
import { calculateRank } from "../calculateRank.js";
|
||||
import { excludeRepositories } from "../common/envs.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { logger } from "../common/log.js";
|
||||
import { buildSearchFilter, parseOwnerAffiliations } from "../common/ops.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
// GraphQL queries.
|
||||
const GRAPHQL_REPOS_FIELD = `
|
||||
repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {
|
||||
totalCount
|
||||
nodes {
|
||||
name
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GRAPHQL_REPOS_QUERY = `
|
||||
query userInfo($login: String!, $after: String, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
${GRAPHQL_REPOS_FIELD}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GRAPHQL_STATS_QUERY = `
|
||||
query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
name
|
||||
login
|
||||
commits: contributionsCollection (from: $startTime) {
|
||||
totalCommitContributions,
|
||||
}
|
||||
reviews: contributionsCollection {
|
||||
totalPullRequestReviewContributions
|
||||
}
|
||||
repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
|
||||
totalCount
|
||||
}
|
||||
pullRequests(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {
|
||||
totalCount
|
||||
}
|
||||
openIssues: issues(states: OPEN) {
|
||||
totalCount
|
||||
}
|
||||
closedIssues: issues(states: CLOSED) {
|
||||
totalCount
|
||||
}
|
||||
followers {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussions @include(if: $includeDiscussions) {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {
|
||||
totalCount
|
||||
}
|
||||
${GRAPHQL_REPOS_FIELD}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Stats fetcher object.
|
||||
*
|
||||
* @param {object & { after: string | null }} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY;
|
||||
return request(
|
||||
{
|
||||
query,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `bearer ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch stats information for a given username.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} variables.username GitHub username.
|
||||
* @param {boolean} variables.includeMergedPullRequests Include merged pull requests.
|
||||
* @param {boolean} variables.includeDiscussions Include discussions.
|
||||
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
||||
* @param {string|undefined} variables.startTime Time to start the count of total commits.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*
|
||||
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
|
||||
*/
|
||||
const statsFetcher = async ({
|
||||
username,
|
||||
includeMergedPullRequests,
|
||||
includeDiscussions,
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
}) => {
|
||||
let stats;
|
||||
let hasNextPage = true;
|
||||
let endCursor = null;
|
||||
let fetchedPages = 0;
|
||||
while (hasNextPage) {
|
||||
const variables = {
|
||||
login: username,
|
||||
first: 100,
|
||||
after: endCursor,
|
||||
includeMergedPullRequests,
|
||||
includeDiscussions,
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
};
|
||||
let res = await retryer(fetcher, username, variables);
|
||||
if (res.data.errors) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Store stats data.
|
||||
const repoNodes = res.data.data.user.repositories.nodes;
|
||||
if (stats) {
|
||||
if (fetchedPages === 1) {
|
||||
// make deep copy of relevant stats fields to avoid altering the cached response object in frontend
|
||||
stats = structuredClone({
|
||||
data: stats.data,
|
||||
statusText: stats.statusText,
|
||||
});
|
||||
}
|
||||
stats.data.data.user.repositories.nodes.push(...repoNodes);
|
||||
} else {
|
||||
stats = res;
|
||||
}
|
||||
|
||||
// Disable multi page fetching on public Vercel instance due to rate limits.
|
||||
fetchedPages++;
|
||||
const repoNodesWithStars = repoNodes.filter(
|
||||
(node) => node.stargazers.totalCount !== 0,
|
||||
);
|
||||
|
||||
hasNextPage =
|
||||
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
|
||||
process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
|
||||
repoNodes.length === repoNodesWithStars.length &&
|
||||
res.data.data.user.repositories.pageInfo.hasNextPage;
|
||||
|
||||
endCursor = res.data.data.user.repositories.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch total commits using the REST API.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*
|
||||
* @see https://developer.github.com/v3/search/#search-commits
|
||||
*/
|
||||
const fetchTotalItems = (variables, token) => {
|
||||
return axios({
|
||||
method: "get",
|
||||
url:
|
||||
`https://api.github.com/search/` +
|
||||
variables.type +
|
||||
`?per_page=1&q=` +
|
||||
buildSearchFilter(variables.repo, variables.owner).replaceAll(" ", "+") +
|
||||
variables.filter,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.cloak-preview",
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all the commits for all the repositories of a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @returns {Promise<number>} Total commits.
|
||||
*
|
||||
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
|
||||
* #92#issuecomment-661026467 and #211 for more information.
|
||||
*/
|
||||
const totalItemsFetcher = async (username, repo, owner, type, filter) => {
|
||||
if (!githubUsernameRegex.test(username)) {
|
||||
logger.log("Invalid username provided.");
|
||||
throw new Error("Invalid username provided.");
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await retryer(fetchTotalItems, username, {
|
||||
login: username,
|
||||
repo,
|
||||
owner,
|
||||
type,
|
||||
filter,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.log(err);
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
const totalCount = res.data.total_count;
|
||||
if (isNaN(totalCount)) {
|
||||
logger.error("GitHub error: " + JSON.stringify(res.data));
|
||||
throw new CustomError(
|
||||
"Could not fetch data from GitHub REST API.",
|
||||
CustomError.GITHUB_REST_API_ERROR,
|
||||
);
|
||||
}
|
||||
return totalCount;
|
||||
};
|
||||
|
||||
const fetchRepoUserStats = async (
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
) => {
|
||||
let stats = {};
|
||||
if (include_prs_authored) {
|
||||
stats.totalPRsAuthored = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:pr`,
|
||||
);
|
||||
}
|
||||
if (include_prs_commented) {
|
||||
stats.totalPRsCommented = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:pr`,
|
||||
);
|
||||
}
|
||||
if (include_prs_reviewed) {
|
||||
stats.totalPRsReviewed = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`reviewed-by:${username}+-author:${username}+type:pr`,
|
||||
);
|
||||
}
|
||||
if (include_issues_authored) {
|
||||
stats.totalIssuesAuthored = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:issue`,
|
||||
);
|
||||
}
|
||||
if (include_issues_commented) {
|
||||
stats.totalIssuesCommented = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:issue`,
|
||||
);
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch stats for a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {boolean} include_all_commits Include all commits.
|
||||
* @param {string[]} exclude_repo Repositories to exclude.
|
||||
* @param {boolean} include_merged_pull_requests Include merged pull requests.
|
||||
* @param {boolean} include_discussions Include discussions.
|
||||
* @param {boolean} include_discussions_answers Include discussions answers.
|
||||
* @param {number|undefined} commits_year Year to count total commits
|
||||
* @param {string[]} ownerAffiliations Owner affiliations. Default: OWNER.
|
||||
* @returns {Promise<import("./types").StatsData>} Stats data.
|
||||
*/
|
||||
const fetchStats = async (
|
||||
username,
|
||||
include_all_commits = false,
|
||||
exclude_repo = [],
|
||||
include_merged_pull_requests = false,
|
||||
include_discussions = false,
|
||||
include_discussions_answers = false,
|
||||
commits_year,
|
||||
repo = [],
|
||||
owner = [],
|
||||
include_prs_authored = false,
|
||||
include_prs_commented = false,
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
ownerAffiliations = [],
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
name: "",
|
||||
totalPRs: 0,
|
||||
totalPRsMerged: 0,
|
||||
mergedPRsPercentage: 0,
|
||||
totalReviews: 0,
|
||||
totalCommits: 0,
|
||||
totalIssues: 0,
|
||||
totalStars: 0,
|
||||
totalDiscussionsStarted: 0,
|
||||
totalDiscussionsAnswered: 0,
|
||||
contributedTo: 0,
|
||||
totalPRsAuthored: 0,
|
||||
totalPRsCommented: 0,
|
||||
totalPRsReviewed: 0,
|
||||
totalIssuesAuthored: 0,
|
||||
totalIssuesCommented: 0,
|
||||
rank: { level: "C", percentile: 100 },
|
||||
};
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
let res = await statsFetcher({
|
||||
username,
|
||||
includeMergedPullRequests: include_merged_pull_requests,
|
||||
includeDiscussions: include_discussions,
|
||||
includeDiscussionsAnswers: include_discussions_answers,
|
||||
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
||||
ownerAffiliations,
|
||||
});
|
||||
|
||||
// Catch GraphQL errors.
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
if (res.data.errors[0].type === "NOT_FOUND") {
|
||||
throw new CustomError(
|
||||
res.data.errors[0].message || "Could not fetch user.",
|
||||
CustomError.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new CustomError(
|
||||
"Something went wrong while trying to retrieve the stats data using the GraphQL API.",
|
||||
CustomError.GRAPHQL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const user = res.data.data.user;
|
||||
|
||||
stats.name = user.name || user.login;
|
||||
|
||||
// if include_all_commits, fetch all commits using the REST API.
|
||||
if (include_all_commits) {
|
||||
stats.totalCommits = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"commits",
|
||||
`author:${username}`,
|
||||
);
|
||||
} else {
|
||||
stats.totalCommits = user.commits.totalCommitContributions;
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
);
|
||||
Object.assign(stats, repoUserStats);
|
||||
|
||||
stats.totalPRs = user.pullRequests.totalCount;
|
||||
if (include_merged_pull_requests) {
|
||||
stats.totalPRsMerged = user.mergedPullRequests.totalCount;
|
||||
stats.mergedPRsPercentage =
|
||||
(user.mergedPullRequests.totalCount / user.pullRequests.totalCount) *
|
||||
100 || 0;
|
||||
}
|
||||
stats.totalReviews = user.reviews.totalPullRequestReviewContributions;
|
||||
stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
|
||||
if (include_discussions) {
|
||||
stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount;
|
||||
}
|
||||
if (include_discussions_answers) {
|
||||
stats.totalDiscussionsAnswered =
|
||||
user.repositoryDiscussionComments.totalCount;
|
||||
}
|
||||
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
||||
|
||||
// Retrieve stars while filtering out repositories to be hidden.
|
||||
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||
let repoToHide = new Set(allExcludedRepos);
|
||||
|
||||
stats.totalStars = user.repositories.nodes
|
||||
.filter((data) => {
|
||||
return !repoToHide.has(data.name);
|
||||
})
|
||||
.reduce((prev, curr) => {
|
||||
return prev + curr.stargazers.totalCount;
|
||||
}, 0);
|
||||
|
||||
stats.rank = calculateRank({
|
||||
all_commits: include_all_commits,
|
||||
commits: stats.totalCommits,
|
||||
prs: stats.totalPRs,
|
||||
reviews: stats.totalReviews,
|
||||
issues: stats.totalIssues,
|
||||
repos: user.repositories.totalCount,
|
||||
stars: stats.totalStars,
|
||||
followers: user.followers.totalCount,
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export { fetchStats, fetchRepoUserStats };
|
||||
@@ -1,168 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { excludeRepositories } from "../common/envs.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { logger } from "../common/log.js";
|
||||
import { parseOwnerAffiliations } from "../common/ops.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
/**
|
||||
* Top languages fetcher object.
|
||||
*
|
||||
* @param {any} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import("axios").AxiosResponse>} Languages fetcher response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
# do not fetch forks
|
||||
repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {
|
||||
nodes {
|
||||
name
|
||||
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
|
||||
edges {
|
||||
size
|
||||
node {
|
||||
color
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").TopLangData} TopLangData Top languages data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch top languages for a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {string[]} exclude_repo List of repositories to exclude. Default: [].
|
||||
* @param {number} size_weight Weightage to be given to size.
|
||||
* @param {number} count_weight Weightage to be given to count.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @returns {Promise<TopLangData>} Top languages data.
|
||||
*/
|
||||
const fetchTopLanguages = async (
|
||||
username,
|
||||
exclude_repo = [],
|
||||
size_weight = 1,
|
||||
count_weight = 0,
|
||||
ownerAffiliations = [],
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
const res = await retryer(fetcher, username, {
|
||||
login: username,
|
||||
ownerAffiliations,
|
||||
});
|
||||
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
if (res.data.errors[0].type === "NOT_FOUND") {
|
||||
throw new CustomError(
|
||||
res.data.errors[0].message || "Could not fetch user.",
|
||||
CustomError.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new CustomError(
|
||||
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
|
||||
CustomError.GRAPHQL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
let repoNodes = res.data.data.user.repositories.nodes;
|
||||
/** @type {Record<string, boolean>} */
|
||||
let repoToHide = {};
|
||||
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||
|
||||
// populate repoToHide map for quick lookup
|
||||
// while filtering out
|
||||
if (allExcludedRepos) {
|
||||
allExcludedRepos.forEach((repoName) => {
|
||||
repoToHide[repoName] = true;
|
||||
});
|
||||
}
|
||||
|
||||
// filter out repositories to be hidden
|
||||
repoNodes = repoNodes
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.filter((name) => !repoToHide[name.name]);
|
||||
|
||||
let repoCount = 0;
|
||||
|
||||
repoNodes = repoNodes
|
||||
.filter((node) => node.languages.edges.length > 0)
|
||||
// flatten the list of language nodes
|
||||
.reduce((acc, curr) => curr.languages.edges.concat(acc), [])
|
||||
.reduce((acc, prev) => {
|
||||
// get the size of the language (bytes)
|
||||
let langSize = prev.size;
|
||||
|
||||
// if we already have the language in the accumulator
|
||||
// & the current language name is same as previous name
|
||||
// add the size to the language size and increase repoCount.
|
||||
if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) {
|
||||
langSize = prev.size + acc[prev.node.name].size;
|
||||
repoCount += 1;
|
||||
} else {
|
||||
// reset repoCount to 1
|
||||
// language must exist in at least one repo to be detected
|
||||
repoCount = 1;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[prev.node.name]: {
|
||||
name: prev.node.name,
|
||||
color: prev.node.color,
|
||||
size: langSize,
|
||||
count: repoCount,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
Object.keys(repoNodes).forEach((name) => {
|
||||
// comparison index calculation
|
||||
repoNodes[name].size =
|
||||
Math.pow(repoNodes[name].size, size_weight) *
|
||||
Math.pow(repoNodes[name].count, count_weight);
|
||||
});
|
||||
|
||||
const topLangs = Object.keys(repoNodes)
|
||||
.sort((a, b) => repoNodes[b].size - repoNodes[a].size)
|
||||
.reduce((result, key) => {
|
||||
result[key] = repoNodes[key];
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return topLangs;
|
||||
};
|
||||
|
||||
export { fetchTopLanguages };
|
||||
@@ -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 topLanguages") && 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,770 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api contract > should match the gradient-background 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: #ffffff;
|
||||
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: #ffffff;
|
||||
}
|
||||
@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: #ffffff;
|
||||
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: #ffffff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #ffffff;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #ffffff;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #2f80ed; }
|
||||
.card-bg { fill: url(#gradient-dark); stroke: #e4e2e2; }
|
||||
|
||||
.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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
<linearGradient id="gradient" gradientTransform="rotate(35)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4158d0"/>,<stop offset="50%" stop-color="#c850c0"/>,<stop offset="100%" stop-color="#ffcc70"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="gradient-dark" gradientTransform="rotate(45)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#60A5FA"/>,<stop offset="100%" stop-color="#4ADE80"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" fill="url(#gradient)" 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 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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":"\\nquery 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(\\n first: 1\\n contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]\\n ) {\\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 ...RepoStars\\n }\\n}\\nfragment RepoStars on User {\\n repositories(\\n first: 100\\n after: $after\\n ownerAffiliations: $ownerAffiliations\\n orderBy: {direction: DESC, field: STARGAZERS}\\n ) {\\n totalCount\\n nodes {\\n ...RepoNode\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\\nfragment RepoNode on Repository {\\n name\\n stargazerCount\\n}","variables":{"login":"anuraghazra","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,282 @@
|
||||
// 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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: #ffabcd;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #ffabcd }
|
||||
.icon { fill: #ff00aa }
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #123456; }
|
||||
.card-bg { fill: #0f172a; stroke: #fedcba; }
|
||||
|
||||
.description {
|
||||
fill: #abcdef;
|
||||
|
||||
}
|
||||
.gray { fill: #abcdef }
|
||||
.icon { fill: #aa00ff }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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 ...GistFileInfo\\n }\\n }\\n }\\n}\\nfragment GistFileInfo on GistFile {\\n name\\n language {\\n name\\n }\\n size\\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,575 @@
|
||||
// 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 archived-repo 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; stroke: #434d58 }
|
||||
.badge text { fill: #434d58 }
|
||||
|
||||
.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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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)">
|
||||
|
||||
|
||||
<g data-testid="badge" class="badge" transform="translate(320, -18)">
|
||||
<rect stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"/>
|
||||
<text x="23" y="-5" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">
|
||||
Archived
|
||||
</text>
|
||||
</g>
|
||||
|
||||
|
||||
<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 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; stroke: #434d58 }
|
||||
.badge text { fill: #434d58 }
|
||||
|
||||
.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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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: #a9fef7;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #a9fef7 }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2; stroke: #a9fef7 }
|
||||
.badge text { fill: #a9fef7 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #a9fef7 }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #f8d847;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #123456; }
|
||||
.card-bg { fill: #193549; stroke: #fedcba; }
|
||||
|
||||
.description {
|
||||
fill: #75eeb2;
|
||||
|
||||
}
|
||||
.gray { fill: #75eeb2 }
|
||||
.badge rect { stroke: #75eeb2 }
|
||||
.badge text { fill: #75eeb2 }
|
||||
.stat { fill: #75eeb2 }
|
||||
.icon { fill: #0480ef; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="559" fill="#141321" 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":"\\nquery 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}\\nfragment 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}","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,483 @@
|
||||
// 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;
|
||||
}
|
||||
.progress-background { fill: #ddd; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<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" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<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" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<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" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<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" class="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8"/>
|
||||
<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;
|
||||
}
|
||||
.progress-background { fill: #ddd; }
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #123456; }
|
||||
.card-bg { fill: #0f172a; stroke: #e4e2e2; }
|
||||
|
||||
.stat { fill: #abcdef; }
|
||||
.lang-name { fill: #abcdef; }
|
||||
.progress-background { fill: #ddd; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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":"\\nquery topLanguages($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {\\n nodes {\\n ...TopLanguagesRepository\\n }\\n }\\n }\\n}\\nfragment TopLanguagesRepository on Repository {\\n name\\n languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {\\n edges {\\n ...TopLanguage\\n }\\n }\\n}\\nfragment TopLanguage on LanguageEdge {\\n size\\n node {\\n color\\n name\\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,594 @@
|
||||
// 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;
|
||||
fill: #2f80ed;
|
||||
}
|
||||
.progress-background { fill: #434d58; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="52%">
|
||||
<rect height="8" 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" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="28%">
|
||||
<rect height="8" 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" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="13%">
|
||||
<rect height="8" 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" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="4%">
|
||||
<rect height="8" 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" class="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8"/>
|
||||
<svg data-testid="lang-progress" width="3%">
|
||||
<rect height="8" 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;
|
||||
fill: #2f80ed;
|
||||
}
|
||||
.progress-background { fill: #434d58; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="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">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: #ff3456;
|
||||
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;
|
||||
fill: #ff3456;
|
||||
}
|
||||
.progress-background { fill: #abcdef; }
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header { fill: #ff6543; }
|
||||
.card-bg { fill: #193549; stroke: #fedcba; }
|
||||
|
||||
|
||||
.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 }
|
||||
|
||||
.lang-name { fill: #abcdef }
|
||||
.lang-progress { fill: #ff6543; }
|
||||
.progress-background { fill: #abcdef; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 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>
|
||||
|
||||
|
||||
<defs>
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
|
||||
<rect data-testid="card-bg" class="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="619" fill="#141321" 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,221 @@
|
||||
// @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 gradient-background response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
theme_light: "ambient_gradient",
|
||||
bg_color_dark: "45,60A5FA,4ADE80",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api?${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",
|
||||
};
|
||||
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,145 @@
|
||||
// @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_light: "ff00aa",
|
||||
icon_color_dark: "aa00ff",
|
||||
text_color_light: "ffabcd",
|
||||
text_color_dark: "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,260 @@
|
||||
// @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 () => {
|
||||
it("should match the public archived-repo response snapshot", async () => {
|
||||
let archived_data_user = structuredClone(data_user);
|
||||
archived_data_user.data.user.repository.isArchived = true;
|
||||
|
||||
mock
|
||||
.onPost("https://api.github.com/graphql")
|
||||
.reply(200, archived_data_user);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
headers: {},
|
||||
url: "/api/pin?username=anuraghazra&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 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",
|
||||
theme_light: "radical",
|
||||
theme_dark: "cobalt",
|
||||
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,211 @@
|
||||
// @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",
|
||||
theme_dark: "cobalt",
|
||||
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,235 @@
|
||||
// @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",
|
||||
theme: "vue",
|
||||
theme_light: "radical",
|
||||
theme_dark: "cobalt",
|
||||
title_color: "123456",
|
||||
title_color_light: "ff3456",
|
||||
title_color_dark: "ff6543",
|
||||
text_color: "abcdef",
|
||||
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,241 +0,0 @@
|
||||
import { queryByTestId } from "@testing-library/dom";
|
||||
import { cssToObject } from "@uppercod/css-to-object";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderGistCard } from "../src/cards/gist.js";
|
||||
import { themes } from "../themes/index.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
/**
|
||||
* @type {import("../src/fetchers/gist").GistData}
|
||||
*/
|
||||
const data = {
|
||||
name: "test",
|
||||
nameWithOwner: "anuraghazra/test",
|
||||
description: "Small test repository with different Python programs.",
|
||||
language: "Python",
|
||||
starsCount: 163,
|
||||
forksCount: 19,
|
||||
};
|
||||
|
||||
describe("test renderGistCard", () => {
|
||||
it("should render correctly", () => {
|
||||
document.body.innerHTML = renderGistCard(data);
|
||||
|
||||
const [header] = document.getElementsByClassName("header");
|
||||
|
||||
expect(header).toHaveTextContent("test");
|
||||
expect(header).not.toHaveTextContent("anuraghazra");
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"Small test repository with different Python programs.",
|
||||
);
|
||||
expect(queryByTestId(document.body, "starsCount")).toHaveTextContent("163");
|
||||
expect(queryByTestId(document.body, "forksCount")).toHaveTextContent("19");
|
||||
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
|
||||
"Python",
|
||||
);
|
||||
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
|
||||
"fill",
|
||||
"#3572A5",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display username in title if show_owner is true", () => {
|
||||
document.body.innerHTML = renderGistCard(data, { show_owner: true });
|
||||
const [header] = document.getElementsByClassName("header");
|
||||
expect(header).toHaveTextContent("anuraghazra/test");
|
||||
});
|
||||
|
||||
it("should trim header if name is too long", () => {
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
name: "some-really-long-repo-name-for-test-purposes",
|
||||
});
|
||||
const [header] = document.getElementsByClassName("header");
|
||||
expect(header).toHaveTextContent("some-really-long-repo-name-for-test...");
|
||||
});
|
||||
|
||||
it("should trim description if description os too long", () => {
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
description:
|
||||
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
|
||||
});
|
||||
expect(
|
||||
document.getElementsByClassName("description")[0].children[0].textContent,
|
||||
).toBe("The quick brown fox jumps over the lazy dog is an");
|
||||
|
||||
expect(
|
||||
document.getElementsByClassName("description")[0].children[1].textContent,
|
||||
).toBe("English-language pangram—a sentence that contains all");
|
||||
});
|
||||
|
||||
it("should not trim description if it is short", () => {
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
description: "Small text should not trim",
|
||||
});
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"Small text should not trim",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render emojis in description", () => {
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
description: "This is a test gist description with :heart: emoji.",
|
||||
});
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"This is a test gist description with ❤️ emoji.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom colors properly", () => {
|
||||
const customColors = {
|
||||
title_color: "5a0",
|
||||
icon_color: "1b998b",
|
||||
text_color: "9991",
|
||||
bg_color: "252525",
|
||||
};
|
||||
|
||||
document.body.innerHTML = renderGistCard(data, {
|
||||
...customColors,
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
"#252525",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render with all the themes", () => {
|
||||
Object.keys(themes).forEach((name) => {
|
||||
document.body.innerHTML = renderGistCard(data, {
|
||||
theme: name,
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(
|
||||
`#${themes[name].title_color}`,
|
||||
);
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
|
||||
const backgroundElement = queryByTestId(document.body, "card-bg");
|
||||
const backgroundElementFill = backgroundElement.getAttribute("fill");
|
||||
expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain(
|
||||
backgroundElementFill,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom colors with themes", () => {
|
||||
document.body.innerHTML = renderGistCard(data, {
|
||||
title_color: "5a0",
|
||||
theme: "radical",
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe("#5a0");
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
`#${themes.radical.bg_color}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom colors with themes and fallback to default colors if invalid", () => {
|
||||
document.body.innerHTML = renderGistCard(data, {
|
||||
title_color: "invalid color",
|
||||
text_color: "invalid color",
|
||||
theme: "radical",
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(
|
||||
`#${themes.default.title_color}`,
|
||||
);
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
`#${themes.radical.bg_color}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not render star count or fork count if either of the are zero", () => {
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
starsCount: 0,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "starsCount")).toBeNull();
|
||||
expect(queryByTestId(document.body, "forksCount")).toBeInTheDocument();
|
||||
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
starsCount: 1,
|
||||
forksCount: 0,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "starsCount")).toBeInTheDocument();
|
||||
expect(queryByTestId(document.body, "forksCount")).toBeNull();
|
||||
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
starsCount: 0,
|
||||
forksCount: 0,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "starsCount")).toBeNull();
|
||||
expect(queryByTestId(document.body, "forksCount")).toBeNull();
|
||||
});
|
||||
|
||||
it("should render without rounding", () => {
|
||||
document.body.innerHTML = renderGistCard(data, {
|
||||
border_radius: "0",
|
||||
});
|
||||
expect(document.querySelector("rect")).toHaveAttribute("rx", "0");
|
||||
document.body.innerHTML = renderGistCard(data, {});
|
||||
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
|
||||
});
|
||||
|
||||
it("should fallback to default description", () => {
|
||||
document.body.innerHTML = renderGistCard({
|
||||
...data,
|
||||
description: undefined,
|
||||
});
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"No description provided",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,373 +0,0 @@
|
||||
import { queryByTestId } from "@testing-library/dom";
|
||||
import { cssToObject } from "@uppercod/css-to-object";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderRepoCard } from "../src/cards/repo.js";
|
||||
import { themes } from "../themes/index.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const data_repo = {
|
||||
repository: {
|
||||
nameWithOwner: "anuraghazra/convoychat",
|
||||
name: "convoychat",
|
||||
description: "Help us take over the world! React + TS + GraphQL Chat App",
|
||||
primaryLanguage: {
|
||||
color: "#2b7489",
|
||||
id: "MDg6TGFuZ3VhZ2UyODc=",
|
||||
name: "TypeScript",
|
||||
},
|
||||
starCount: 38000,
|
||||
forkCount: 100,
|
||||
},
|
||||
};
|
||||
|
||||
describe("Test renderRepoCard", () => {
|
||||
it("should render correctly", () => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository);
|
||||
|
||||
const [header] = document.getElementsByClassName("header");
|
||||
|
||||
expect(header).toHaveTextContent("convoychat");
|
||||
expect(header).not.toHaveTextContent("anuraghazra");
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"Help us take over the world! React + TS + GraphQL Chat App",
|
||||
);
|
||||
expect(queryByTestId(document.body, "stargazers")).toHaveTextContent("38k");
|
||||
expect(queryByTestId(document.body, "forkcount")).toHaveTextContent("100");
|
||||
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
|
||||
"TypeScript",
|
||||
);
|
||||
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
|
||||
"fill",
|
||||
"#2b7489",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display username in title (full repo name)", () => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
show_owner: true,
|
||||
});
|
||||
expect(document.getElementsByClassName("header")[0]).toHaveTextContent(
|
||||
"anuraghazra/convoychat",
|
||||
);
|
||||
});
|
||||
|
||||
it("should trim header", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
name: "some-really-long-repo-name-for-test-purposes",
|
||||
});
|
||||
|
||||
expect(document.getElementsByClassName("header")[0].textContent).toBe(
|
||||
"some-really-long-repo-name-for-test...",
|
||||
);
|
||||
});
|
||||
|
||||
it("should trim description", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
description:
|
||||
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
|
||||
});
|
||||
|
||||
expect(
|
||||
document.getElementsByClassName("description")[0].children[0].textContent,
|
||||
).toBe("The quick brown fox jumps over the lazy dog is an");
|
||||
|
||||
expect(
|
||||
document.getElementsByClassName("description")[0].children[1].textContent,
|
||||
).toBe("English-language pangram—a sentence that contains all");
|
||||
|
||||
// Should not trim
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
description: "Small text should not trim",
|
||||
});
|
||||
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"Small text should not trim",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render emojis", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
description: "This is a text with a :poop: poo emoji",
|
||||
});
|
||||
|
||||
// poop emoji may not show in all editors but it's there between "a" and "poo"
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"This is a text with a 💩 poo emoji",
|
||||
);
|
||||
});
|
||||
|
||||
it("should hide language if primaryLanguage is null & fallback to correct values", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
primaryLanguage: null,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "primary-lang")).toBeNull();
|
||||
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
primaryLanguage: { color: null, name: null },
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "primary-lang")).toBeInTheDocument();
|
||||
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
|
||||
"fill",
|
||||
"#333",
|
||||
);
|
||||
|
||||
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
|
||||
"Unspecified",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render default colors properly", () => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository);
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe("#2f80ed");
|
||||
expect(descClassStyles.fill.trim()).toBe("#434d58");
|
||||
expect(iconClassStyles.fill.trim()).toBe("#586069");
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
"#fffefe",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom colors properly", () => {
|
||||
const customColors = {
|
||||
title_color: "5a0",
|
||||
icon_color: "1b998b",
|
||||
text_color: "9991",
|
||||
bg_color: "252525",
|
||||
};
|
||||
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
...customColors,
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
"#252525",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render with all the themes", () => {
|
||||
Object.keys(themes).forEach((name) => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
theme: name,
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(
|
||||
`#${themes[name].title_color}`,
|
||||
);
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
|
||||
const backgroundElement = queryByTestId(document.body, "card-bg");
|
||||
const backgroundElementFill = backgroundElement.getAttribute("fill");
|
||||
expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain(
|
||||
backgroundElementFill,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom colors with themes", () => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
title_color: "5a0",
|
||||
theme: "radical",
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe("#5a0");
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
`#${themes.radical.bg_color}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom colors with themes and fallback to default colors if invalid", () => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
title_color: "invalid color",
|
||||
text_color: "invalid color",
|
||||
theme: "radical",
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const descClassStyles = stylesObject[":host"][".description "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(
|
||||
`#${themes.default.title_color}`,
|
||||
);
|
||||
expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
`#${themes.radical.bg_color}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not render star count or fork count if either of the are zero", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
starCount: 0,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "stargazers")).toBeNull();
|
||||
expect(queryByTestId(document.body, "forkcount")).toBeInTheDocument();
|
||||
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
starCount: 1,
|
||||
forkCount: 0,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "stargazers")).toBeInTheDocument();
|
||||
expect(queryByTestId(document.body, "forkcount")).toBeNull();
|
||||
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
starCount: 0,
|
||||
forkCount: 0,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "stargazers")).toBeNull();
|
||||
expect(queryByTestId(document.body, "forkcount")).toBeNull();
|
||||
});
|
||||
|
||||
it("should render badges", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
isArchived: true,
|
||||
});
|
||||
|
||||
expect(queryByTestId(document.body, "badge")).toHaveTextContent("Archived");
|
||||
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
isTemplate: true,
|
||||
});
|
||||
expect(queryByTestId(document.body, "badge")).toHaveTextContent("Template");
|
||||
});
|
||||
|
||||
it("should not render template", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
});
|
||||
expect(queryByTestId(document.body, "badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("should render translated badges", () => {
|
||||
document.body.innerHTML = renderRepoCard(
|
||||
{
|
||||
...data_repo.repository,
|
||||
isArchived: true,
|
||||
},
|
||||
{
|
||||
locale: "cn",
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(document.body, "badge")).toHaveTextContent("已归档");
|
||||
|
||||
document.body.innerHTML = renderRepoCard(
|
||||
{
|
||||
...data_repo.repository,
|
||||
isTemplate: true,
|
||||
},
|
||||
{
|
||||
locale: "cn",
|
||||
},
|
||||
);
|
||||
expect(queryByTestId(document.body, "badge")).toHaveTextContent("模板");
|
||||
});
|
||||
|
||||
it("should render without rounding", () => {
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
border_radius: "0",
|
||||
});
|
||||
expect(document.querySelector("rect")).toHaveAttribute("rx", "0");
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {});
|
||||
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
|
||||
});
|
||||
|
||||
it("should fallback to default description", () => {
|
||||
document.body.innerHTML = renderRepoCard({
|
||||
...data_repo.repository,
|
||||
description: undefined,
|
||||
isArchived: true,
|
||||
});
|
||||
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
|
||||
"No description provided",
|
||||
);
|
||||
});
|
||||
|
||||
it("should have correct height with specified `description_lines_count` parameter", () => {
|
||||
// Testing short description
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
description_lines_count: 1,
|
||||
});
|
||||
expect(document.querySelector("svg")).toHaveAttribute("height", "120");
|
||||
document.body.innerHTML = renderRepoCard(data_repo.repository, {
|
||||
description_lines_count: 3,
|
||||
});
|
||||
expect(document.querySelector("svg")).toHaveAttribute("height", "150");
|
||||
|
||||
// Testing long description
|
||||
const longDescription =
|
||||
"A tool that will make a lot of iPhone/iPad developers' life easier. It shares your app over-the-air in a WiFi network. Bonjour is used and no configuration is needed.";
|
||||
document.body.innerHTML = renderRepoCard(
|
||||
{ ...data_repo.repository, description: longDescription },
|
||||
{
|
||||
description_lines_count: 3,
|
||||
},
|
||||
);
|
||||
expect(document.querySelector("svg")).toHaveAttribute("height", "150");
|
||||
document.body.innerHTML = renderRepoCard(
|
||||
{ ...data_repo.repository, description: longDescription },
|
||||
{
|
||||
description_lines_count: 1,
|
||||
},
|
||||
);
|
||||
expect(document.querySelector("svg")).toHaveAttribute("height", "120");
|
||||
});
|
||||
});
|
||||
@@ -1,468 +0,0 @@
|
||||
import {
|
||||
getByTestId,
|
||||
queryAllByTestId,
|
||||
queryByTestId,
|
||||
} from "@testing-library/dom";
|
||||
import { cssToObject } from "@uppercod/css-to-object";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderStatsCard } from "../src/cards/stats.js";
|
||||
import { CustomError } from "../src/common/error.js";
|
||||
import { themes } from "../themes/index.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const stats = {
|
||||
name: "Anurag Hazra",
|
||||
totalStars: 100,
|
||||
totalCommits: 200,
|
||||
totalIssues: 300,
|
||||
totalPRs: 400,
|
||||
totalPRsMerged: 320,
|
||||
mergedPRsPercentage: 80,
|
||||
totalReviews: 50,
|
||||
totalDiscussionsStarted: 10,
|
||||
totalDiscussionsAnswered: 50,
|
||||
contributedTo: 500,
|
||||
rank: { level: "A+", percentile: 40 },
|
||||
};
|
||||
|
||||
describe("Test renderStatsCard", () => {
|
||||
it("should render correctly", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats);
|
||||
|
||||
expect(document.getElementsByClassName("header")[0].textContent).toBe(
|
||||
"Anurag Hazra's GitHub Stats",
|
||||
);
|
||||
|
||||
expect(
|
||||
document.body.getElementsByTagName("svg")[0].getAttribute("height"),
|
||||
).toBe("195");
|
||||
expect(getByTestId(document.body, "stars").textContent).toBe("100");
|
||||
expect(getByTestId(document.body, "commits").textContent).toBe("200");
|
||||
expect(getByTestId(document.body, "issues").textContent).toBe("300");
|
||||
expect(getByTestId(document.body, "prs").textContent).toBe("400");
|
||||
expect(getByTestId(document.body, "contribs").textContent).toBe("500");
|
||||
expect(queryByTestId(document.body, "card-bg")).toBeInTheDocument();
|
||||
expect(queryByTestId(document.body, "rank-circle")).toBeInTheDocument();
|
||||
|
||||
// Default hidden stats
|
||||
expect(queryByTestId(document.body, "reviews")).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(document.body, "discussions_started"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(document.body, "discussions_answered"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(queryByTestId(document.body, "prs_merged")).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(document.body, "prs_merged_percentage"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have proper name apostrophe", () => {
|
||||
document.body.innerHTML = renderStatsCard({ ...stats, name: "Anil Das" });
|
||||
|
||||
expect(document.getElementsByClassName("header")[0].textContent).toBe(
|
||||
"Anil Das' GitHub Stats",
|
||||
);
|
||||
|
||||
document.body.innerHTML = renderStatsCard({ ...stats, name: "Felix" });
|
||||
|
||||
expect(document.getElementsByClassName("header")[0].textContent).toBe(
|
||||
"Felix's GitHub Stats",
|
||||
);
|
||||
});
|
||||
|
||||
it("should hide individual stats", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
hide: ["issues", "prs", "contribs"],
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.getElementsByTagName("svg")[0].getAttribute("height"),
|
||||
).toBe("150"); // height should be 150 because we clamped it.
|
||||
|
||||
expect(queryByTestId(document.body, "stars")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "commits")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "issues")).toBeNull();
|
||||
expect(queryByTestId(document.body, "prs")).toBeNull();
|
||||
expect(queryByTestId(document.body, "contribs")).toBeNull();
|
||||
expect(queryByTestId(document.body, "reviews")).toBeNull();
|
||||
expect(queryByTestId(document.body, "discussions_started")).toBeNull();
|
||||
expect(queryByTestId(document.body, "discussions_answered")).toBeNull();
|
||||
expect(queryByTestId(document.body, "prs_merged")).toBeNull();
|
||||
expect(queryByTestId(document.body, "prs_merged_percentage")).toBeNull();
|
||||
});
|
||||
|
||||
it("should show additional stats", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
show: [
|
||||
"reviews",
|
||||
"discussions_started",
|
||||
"discussions_answered",
|
||||
"prs_merged",
|
||||
"prs_merged_percentage",
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.getElementsByTagName("svg")[0].getAttribute("height"),
|
||||
).toBe("320");
|
||||
|
||||
expect(queryByTestId(document.body, "stars")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "commits")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "issues")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "prs")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "contribs")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "reviews")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "discussions_started")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "discussions_answered")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "prs_merged")).toBeDefined();
|
||||
expect(queryByTestId(document.body, "prs_merged_percentage")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should hide_rank", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, { hide_rank: true });
|
||||
|
||||
expect(queryByTestId(document.body, "rank-circle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with custom width set", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats);
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "450");
|
||||
|
||||
document.body.innerHTML = renderStatsCard(stats, { card_width: 500 });
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "500");
|
||||
});
|
||||
|
||||
it("should render with custom width set and limit minimum width", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, { card_width: 1 });
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
|
||||
|
||||
// Test default minimum card width without rank circle.
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
card_width: 1,
|
||||
hide_rank: true,
|
||||
});
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
|
||||
|
||||
// Test minimum card width with rank and icons.
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
card_width: 1,
|
||||
hide_rank: true,
|
||||
show_icons: true,
|
||||
});
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
|
||||
|
||||
// Test minimum card width with icons but without rank.
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
card_width: 1,
|
||||
hide_rank: false,
|
||||
show_icons: true,
|
||||
});
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
|
||||
|
||||
// Test minimum card width without icons or rank.
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
card_width: 1,
|
||||
hide_rank: false,
|
||||
show_icons: false,
|
||||
});
|
||||
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
|
||||
});
|
||||
|
||||
it("should render default colors properly", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats);
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.textContent);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const statClassStyles = stylesObject[":host"][".stat "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe("#2f80ed");
|
||||
expect(statClassStyles.fill.trim()).toBe("#434d58");
|
||||
expect(iconClassStyles.fill.trim()).toBe("#4c71f2");
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
"#fffefe",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom colors properly", () => {
|
||||
const customColors = {
|
||||
title_color: "5a0",
|
||||
icon_color: "1b998b",
|
||||
text_color: "9991",
|
||||
bg_color: "252525",
|
||||
};
|
||||
|
||||
document.body.innerHTML = renderStatsCard(stats, { ...customColors });
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const statClassStyles = stylesObject[":host"][".stat "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
|
||||
expect(statClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
"#252525",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom colors with themes", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
title_color: "5a0",
|
||||
theme: "radical",
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const statClassStyles = stylesObject[":host"][".stat "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe("#5a0");
|
||||
expect(statClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
`#${themes.radical.bg_color}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should render with all the themes", () => {
|
||||
Object.keys(themes).forEach((name) => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
theme: name,
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const statClassStyles = stylesObject[":host"][".stat "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(
|
||||
`#${themes[name].title_color}`,
|
||||
);
|
||||
expect(statClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
|
||||
const backgroundElement = queryByTestId(document.body, "card-bg");
|
||||
const backgroundElementFill = backgroundElement.getAttribute("fill");
|
||||
expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain(
|
||||
backgroundElementFill,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom colors with themes and fallback to default colors if invalid", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
title_color: "invalid color",
|
||||
text_color: "invalid color",
|
||||
theme: "radical",
|
||||
});
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const statClassStyles = stylesObject[":host"][".stat "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(
|
||||
`#${themes.default.title_color}`,
|
||||
);
|
||||
expect(statClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
`#${themes.radical.bg_color}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should render custom ring_color properly", () => {
|
||||
const customColors = {
|
||||
title_color: "5a0",
|
||||
ring_color: "0000ff",
|
||||
icon_color: "1b998b",
|
||||
text_color: "9991",
|
||||
bg_color: "252525",
|
||||
};
|
||||
|
||||
document.body.innerHTML = renderStatsCard(stats, { ...customColors });
|
||||
|
||||
const styleTag = document.querySelector("style");
|
||||
const stylesObject = cssToObject(styleTag.innerHTML);
|
||||
|
||||
const headerClassStyles = stylesObject[":host"][".header "];
|
||||
const statClassStyles = stylesObject[":host"][".stat "];
|
||||
const iconClassStyles = stylesObject[":host"][".icon "];
|
||||
const rankCircleStyles = stylesObject[":host"][".rank-circle "];
|
||||
const rankCircleRimStyles = stylesObject[":host"][".rank-circle-rim "];
|
||||
|
||||
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
|
||||
expect(statClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
|
||||
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
|
||||
expect(rankCircleStyles.stroke.trim()).toBe(`#${customColors.ring_color}`);
|
||||
expect(rankCircleRimStyles.stroke.trim()).toBe(
|
||||
`#${customColors.ring_color}`,
|
||||
);
|
||||
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
|
||||
"fill",
|
||||
"#252525",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render icons correctly", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
show_icons: true,
|
||||
});
|
||||
|
||||
expect(queryAllByTestId(document.body, "icon")[0]).toBeDefined();
|
||||
expect(queryByTestId(document.body, "stars")).toBeDefined();
|
||||
expect(
|
||||
queryByTestId(document.body, "stars").previousElementSibling, // the label
|
||||
).toHaveAttribute("x", "25");
|
||||
});
|
||||
|
||||
it("should not have icons if show_icons is false", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, { show_icons: false });
|
||||
|
||||
expect(queryAllByTestId(document.body, "icon")[0]).not.toBeDefined();
|
||||
expect(queryByTestId(document.body, "stars")).toBeDefined();
|
||||
expect(
|
||||
queryByTestId(document.body, "stars").previousElementSibling, // the label
|
||||
).not.toHaveAttribute("x");
|
||||
});
|
||||
|
||||
it("should auto resize if hide_rank is true", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
hide_rank: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.getElementsByTagName("svg")[0].getAttribute("width"),
|
||||
).toBe("305.81250000000006");
|
||||
});
|
||||
|
||||
it("should auto resize if hide_rank is true & custom_title is set", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
hide_rank: true,
|
||||
custom_title: "Hello world",
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.getElementsByTagName("svg")[0].getAttribute("width"),
|
||||
).toBe("287");
|
||||
});
|
||||
|
||||
it("should render translations", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, { locale: "cn" });
|
||||
expect(document.getElementsByClassName("header")[0].textContent).toBe(
|
||||
"Anurag Hazra 的 GitHub 统计数据",
|
||||
);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'g[transform="translate(0, 0)"]>.stagger>.stat.bold',
|
||||
).textContent,
|
||||
).toMatchInlineSnapshot(`"获标星数:"`);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'g[transform="translate(0, 25)"]>.stagger>.stat.bold',
|
||||
).textContent,
|
||||
).toMatchInlineSnapshot(`"累计提交总数 (去年):"`);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'g[transform="translate(0, 50)"]>.stagger>.stat.bold',
|
||||
).textContent,
|
||||
).toMatchInlineSnapshot(`"发起的 PR 总数:"`);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'g[transform="translate(0, 75)"]>.stagger>.stat.bold',
|
||||
).textContent,
|
||||
).toMatchInlineSnapshot(`"提出的 issue 总数:"`);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'g[transform="translate(0, 100)"]>.stagger>.stat.bold',
|
||||
).textContent,
|
||||
).toMatchInlineSnapshot(`"贡献的项目数(去年):"`);
|
||||
});
|
||||
|
||||
it("should render without rounding", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, { border_radius: "0" });
|
||||
expect(document.querySelector("rect")).toHaveAttribute("rx", "0");
|
||||
document.body.innerHTML = renderStatsCard(stats, {});
|
||||
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
|
||||
});
|
||||
|
||||
it("should shorten values", () => {
|
||||
stats["totalCommits"] = 1999;
|
||||
|
||||
document.body.innerHTML = renderStatsCard(stats);
|
||||
expect(getByTestId(document.body, "commits").textContent).toBe("2k");
|
||||
document.body.innerHTML = renderStatsCard(stats, { number_format: "long" });
|
||||
expect(getByTestId(document.body, "commits").textContent).toBe("1999");
|
||||
document.body.innerHTML = renderStatsCard(stats, { number_precision: 2 });
|
||||
expect(getByTestId(document.body, "commits").textContent).toBe("2.00k");
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
number_format: "long",
|
||||
number_precision: 2,
|
||||
});
|
||||
expect(getByTestId(document.body, "commits").textContent).toBe("1999");
|
||||
});
|
||||
|
||||
it("should render default rank icon with level A+", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
rank_icon: "default",
|
||||
});
|
||||
expect(queryByTestId(document.body, "level-rank-icon")).toBeDefined();
|
||||
expect(
|
||||
queryByTestId(document.body, "level-rank-icon").textContent.trim(),
|
||||
).toBe("A+");
|
||||
});
|
||||
|
||||
it("should render github rank icon", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
rank_icon: "github",
|
||||
});
|
||||
expect(queryByTestId(document.body, "github-rank-icon")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should show the rank percentile", () => {
|
||||
document.body.innerHTML = renderStatsCard(stats, {
|
||||
rank_icon: "percentile",
|
||||
});
|
||||
expect(queryByTestId(document.body, "percentile-top-header")).toBeDefined();
|
||||
expect(
|
||||
queryByTestId(document.body, "percentile-top-header").textContent.trim(),
|
||||
).toBe("Top");
|
||||
expect(queryByTestId(document.body, "rank-percentile-text")).toBeDefined();
|
||||
expect(
|
||||
queryByTestId(document.body, "percentile-rank-value").textContent.trim(),
|
||||
).toBe(stats.rank.percentile.toFixed(1) + "%");
|
||||
});
|
||||
|
||||
it("should throw error if all stats and rank icon are hidden", () => {
|
||||
expect(() =>
|
||||
renderStatsCard(stats, {
|
||||
hide: ["stars", "commits", "prs", "issues", "contribs"],
|
||||
hide_rank: true,
|
||||
}),
|
||||
).toThrow(
|
||||
new CustomError(
|
||||
"Could not render stats card.",
|
||||
"Either stats or rank are required.",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user